test / m3.test.ts
test / m3.test.ts
// Tests for M3: recall, cleanup after deleted chats, undo and handoff.
import * as assert from "node:assert/strict";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { test } from "node:test";
const HOME = fs.mkdtempSync(path.join(os.tmpdir(), "throughline-m3-"));
const CONVERSATIONS = fs.mkdtempSync(path.join(os.tmpdir(), "throughline-conv-"));
process.env.THROUGHLINE_HOME = HOME;
process.env.THROUGHLINE_CONVERSATIONS = CONVERSATIONS;
import { loadChatState } from "../src/chat";
import { cleanup } from "../src/cleanup";
import { listProjects, loadLedger, loadMeta, readEvents } from "../src/ledger";
import { hasIndex, search, splitPassages } from "../src/recall";
import { Session } from "./helpers";
const filler = (n: number, word: string) => Array.from({ length: n }, (_, i) => `Paragraph ${i} about ${word} and nothing else of note.`).join("\n\n");
const exists = (id: string) => fs.writeFileSync(path.join(CONVERSATIONS, `${id}.conversation.json`), "{}");
test("recall starts only in a long chat, finds early wording, and skips what is still in view", async () => {
const s = new Session("3000000000001");
s.config = { recallThresholdTokens: 3000, verbatimWindowTokens: 1500 };
await s.send("Hello.");
s.reply("Hi.");
const short = await s.send("Short so far.");
assert.equal(short.text, "Short so far.");
assert.equal(hasIndex("3000000000001"), false, "nothing copied below the threshold");
assert.deepEqual(await s.tools(), []);
const statuses: string[] = [];
statuses.push(...(await s.send("Remember this: the access code for the north gate is 7741-ALPHA.")).statuses);
s.reply("<think>The user wants me to keep a code; secret reasoning here.</think>Noted.");
for (let i = 0; i < 4; i++) {
statuses.push(...(await s.send(`Tell me about topic ${i}.`)).statuses);
s.reply(filler(80, `weather pattern ${i}`));
}
statuses.push(...(await s.send("The south gate code is 1234-BRAVO.")).statuses);
s.reply("Got it.");
const long = await s.send("Now, what was the north gate code?");
statuses.push(...long.statuses);
assert.equal(statuses.filter((t) => /recall is on/.test(t)).length, 1, "announced once, when it starts");
assert.ok(hasIndex("3000000000001"));
assert.equal(long.text, "Now, what was the north gate code?", "recall adds nothing to messages");
const tools = await s.tools();
assert.deepEqual(tools.map((t) => t.name), ["recall"]);
const found = await s.call("recall", { query: "north gate access code" });
assert.match(found, /^status: ok/);
assert.match(found, /\[turn 3, the user, [0-9-]+ [0-9:]+ UTC\]\nRemember this: the access code for the north gate is 7741-ALPHA\./);
assert.ok(!found.includes("1234-BRAVO"), "recent messages, still in view, are not returned");
assert.ok(!search("3000000000001", "secret reasoning").length, "the model's reasoning is not indexed");
assert.match(await s.call("recall", { query: "zebra" }), /^status: no_results/);
});
test("the recall index grows with the chat and is rebuilt when earlier messages change", async () => {
const s = new Session("3000000000002");
s.config = { recallThresholdTokens: 500, verbatimWindowTokens: 100 };
await s.send(`First topic is copper. ${filler(60, "copper")}`);
s.reply("OK.");
await s.send("Second.");
s.reply("OK.");
await s.send("Third.");
assert.ok(search("3000000000002", "copper").length > 0);
s.reply(`Tin is next. ${filler(60, "tin")}`);
await s.send("Fourth.");
s.reply("OK.");
await s.send("Fifth.");
assert.ok(search("3000000000002", "tin").length > 0, "new messages are added");
// The user edits the first message: the index notices and rebuilds.
const stored = (s.history as any).data.messages;
stored[1].content = [{ type: "text", text: `First topic is zinc. ${filler(60, "zinc")}` }];
stored[stored.length - 1].content = [{ type: "text", text: "Fifth, edited." }];
s.reply("OK.");
await s.send("Sixth.");
assert.ok(search("3000000000002", "zinc").length > 0);
assert.equal(search("3000000000002", "copper").length, 0);
});
test("/tl recall off deletes the copy and stops recall; on resumes it", async () => {
const s = new Session("3000000000003");
s.config = { recallThresholdTokens: 500 };
await s.send(`Long start. ${filler(60, "granite")}`);
s.reply("OK.");
await s.send("Next.");
assert.ok(hasIndex("3000000000003"));
const off = await s.send("/tl recall off");
assert.match(off.text, /Recall is off in this chat, and its search copy of the chat was deleted\./);
assert.equal(hasIndex("3000000000003"), false);
s.reply("OK.");
await s.send("Still long.");
assert.equal(hasIndex("3000000000003"), false, "not rebuilt while off");
assert.deepEqual(await s.tools(), []);
await s.send("/tl recall on");
s.reply("OK.");
await s.send("Again.");
assert.ok(hasIndex("3000000000003"));
});
test("passages split on paragraphs and stay near the target size", () => {
const parts = splitPassages(filler(40, "basalt"));
assert.ok(parts.length > 1);
assert.ok(parts.every((p) => p.length <= 1000));
assert.ok(parts.join(" ").includes("Paragraph 39 about basalt"));
});
test("cleanup removes a deleted chat's data at once, and orphaned projects after the grace period", async () => {
const keep = new Session("3000000000010");
const gone = new Session("3000000000011");
const alsoGone = new Session("3000000000012");
await keep.send("/tl start Shared project.");
const shared = loadChatState("3000000000010")!.project!;
await gone.send(`/tl open ${shared}`);
await alsoGone.send("/tl start Lonely project.");
const lonely = loadChatState("3000000000012")!.project!;
// An empty conversations folder is not evidence of anything: nothing is touched.
assert.match(cleanup().skipped ?? "", /missing, unreadable or empty/);
assert.ok(fs.existsSync(path.join(HOME, "chats", "3000000000011")));
exists("3000000000010");
exists("3000000000099"); // some other chat
const now = Date.now();
const r = cleanup(now);
assert.ok(r.chatsRemoved.includes("3000000000011") && r.chatsRemoved.includes("3000000000012"));
assert.ok(!r.chatsRemoved.includes("3000000000010"));
assert.ok(!fs.existsSync(path.join(HOME, "chats", "3000000000011")));
assert.deepEqual(loadLedger(shared)!.chats, ["3000000000010"], "a project still used elsewhere is kept");
assert.deepEqual(r.orphaned, [lonely]);
assert.ok(loadMeta(lonely).orphanedSince);
assert.ok(readEvents(lonely, 0).some((e) => e.op === "chat_deleted" && e.actor === "plugin"));
// Within the grace period nothing more happens; after it, the project moves to the trash.
assert.deepEqual(cleanup(now + 6 * 86400000).trashed, []);
const later = cleanup(now + 8 * 86400000);
assert.deepEqual(later.trashed, [lonely]);
assert.ok(!listProjects().includes(lonely));
// The trash is emptied after its retention period.
const purge = cleanup(now + 8 * 86400000 + 31 * 86400000);
assert.equal(purge.purged.length, 1);
assert.equal(fs.readdirSync(path.join(HOME, "trash")).length, 0);
});
test("opening an orphaned project clears its orphan mark", async () => {
exists("3000000000020");
const s = new Session("3000000000021");
await s.send("/tl start Orphan then rescue.");
const slug = loadChatState("3000000000021")!.project!;
const r = cleanup();
assert.ok(r.orphaned.includes(slug));
const rescuer = new Session("3000000000020");
await rescuer.send(`/tl open ${slug}`);
assert.equal(loadMeta(slug).orphanedSince, undefined);
assert.deepEqual(cleanup(Date.now() + 30 * 86400000).trashed, []);
});
test("/tl undo steps back one change at a time and never reuses an id", async () => {
const s = new Session("3000000000030");
await s.send("/tl start Undo things.");
s.reply("OK.");
await s.send("Go.");
await s.call("ledger_plan", { action: "add", steps: ["One"], status: "doing" });
await s.call("ledger_record", { kind: "finding", text: "Kept.", source: "user" });
await s.call("ledger_record", { kind: "finding", text: "Mistake.", source: "user" });
await s.call("ledger_plan", { action: "update", step: 1, status: "done" });
s.reply("Done.");
const slug = loadChatState("3000000000030")!.project!;
const u1 = await s.send("/tl undo");
assert.match(u1.text, /Undid step 1 now done\./);
assert.equal(loadLedger(slug)!.plan[0].status, "doing");
const u2 = await s.send("/tl undo");
assert.match(u2.text, /Undid F2 added\./);
assert.deepEqual(loadLedger(slug)!.findings.map((f) => f.id), ["F1"]);
await s.call("ledger_record", { kind: "finding", text: "Fresh.", source: "user" });
assert.deepEqual(loadLedger(slug)!.findings.map((f) => f.id), ["F1", "F3"], "F2 is not handed out again");
const u3 = await s.send("/tl undo");
assert.match(u3.text, /Undid F3 added\./, "a new change starts the undo run again");
assert.deepEqual(loadLedger(slug)!.findings.map((f) => f.id), ["F1"]);
assert.equal(loadLedger(slug)!.name, slug);
});
test("/tl handoff writes the three layers with the recent exchanges", async () => {
const s = new Session("3000000000040");
await s.send("Find the default port of two web servers.");
s.reply("Server A uses 8080; server B uses 3000.");
await s.send("/tl start");
s.reply("Started.");
await s.send("Record them.");
await s.call("ledger_record", { kind: "finding", text: "Server A defaults to port 8080.", source: "https://example.org/a" });
await s.call("ledger_record", { kind: "decision", text: "Report the default only.", reason: "the user asked for defaults" });
s.reply("<think>internal</think>Recorded both.");
const out = await s.send("/tl handoff");
assert.match(out.text, /Wrote the handoff for "find-default-port-two-web-servers" to /);
const md = fs.readFileSync(path.join(HOME, "projects", "find-default-port-two-web-servers", "handoff.md"), "utf8");
for (const h of ["## Permanent core", "### Goal", "### Decisions", "## Current state", "### Plan", "### Findings", "## Recent conversation"]) assert.ok(md.includes(h), h);
assert.match(md, /- D1: Report the default only\. Reason: the user asked for defaults/);
assert.match(md, /- F1: Server A defaults to port 8080\. Source: https:\/\/example\.org\/a \(unverified\)/);
assert.match(md, /\*\*User:\*\* Find the default port of two web servers\./);
assert.match(md, /\*\*Assistant:\*\* Recorded both\./);
assert.ok(!md.includes("[throughLine"), "no throughLine blocks in the conversation");
assert.ok(!md.includes("internal"), "no reasoning in the conversation");
});
test("an edit saved just before a command is applied first, never overwritten", async () => {
const s = new Session("3000000000050");
await s.send("/tl start Edits before commands.");
s.reply("OK.");
await s.send("Go.");
await s.call("ledger_record", { kind: "finding", text: "A fact.", source: "user" });
s.reply("Recorded.");
await s.send("Thanks.");
const slug = loadChatState("3000000000050")!.project!;
const file = path.join(HOME, "projects", slug, "ledger.md");
// The user edits the file, then sends a command before any ordinary message.
fs.writeFileSync(file, fs.readFileSync(file, "utf8").replace("## Constraints\n\n(none)", "## Constraints\n\n- Every finding should be in plain words."));
const handoff = await s.send("/tl handoff");
assert.match(handoff.text, /Your edit to ledger\.md was applied first: constraints/);
assert.deepEqual(loadLedger(slug)!.constraints, ["Every finding should be in plain words."]);
// Edited again, then undo: the undo reverts the user's latest edit, which was applied, not lost.
fs.writeFileSync(file, fs.readFileSync(file, "utf8").replace("- Every finding should be in plain words.", "- Every finding should be in plain words.\n- Keep it short."));
const undo = await s.send("/tl undo");
assert.match(undo.text, /Undid the hand edit \(constraints\)\./);
assert.match(undo.text, /Your edit to ledger\.md was applied first: constraints/);
assert.deepEqual(loadLedger(slug)!.constraints, ["Every finding should be in plain words."]);
assert.ok(readEvents(slug, 0).filter((e) => e.op === "hand_edit").length === 2, "both edits are in the log");
// A tool write between messages also merges the pending edit first.
fs.writeFileSync(file, fs.readFileSync(file, "utf8").replace("- F1: A fact.", "- F1: A corrected fact."));
await s.call("ledger_record", { kind: "finding", text: "Another fact.", source: "user" });
assert.deepEqual(loadLedger(slug)!.findings.map((f) => f.text), ["A corrected fact.", "Another fact."]);
});
test("recall finds compound words by their parts and names the tool behind a result", async () => {
const s = new Session("3000000000060");
s.config = { recallThresholdTokens: 500, verbatimWindowTokens: 100 };
await s.send("Search for it.");
s.toolRound([{ name: "web_search", args: { query: "x" }, result: "status: browser_unavailable\nnext: The browser did not connect." }]);
s.toolRound([{ name: "read_webpage", args: { url: "https://example.org" }, result: `status: ok\n${filler(60, "lava")}` }]);
s.reply("The first search failed, then the read worked.");
await s.send("More.");
s.reply(filler(20, "pumice"));
await s.send("Even more.");
const r = await s.call("recall", { query: "browser problem" });
assert.match(r, /\[turn 1, tool web_search, [^\]]+\]\nstatus: browser_unavailable/);
});
// Tests for M3: recall, cleanup after deleted chats, undo and handoff.
import * as assert from "node:assert/strict";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { test } from "node:test";
const HOME = fs.mkdtempSync(path.join(os.tmpdir(), "throughline-m3-"));
const CONVERSATIONS = fs.mkdtempSync(path.join(os.tmpdir(), "throughline-conv-"));
process.env.THROUGHLINE_HOME = HOME;
process.env.THROUGHLINE_CONVERSATIONS = CONVERSATIONS;
import { loadChatState } from "../src/chat";
import { cleanup } from "../src/cleanup";
import { listProjects, loadLedger, loadMeta, readEvents } from "../src/ledger";
import { hasIndex, search, splitPassages } from "../src/recall";
import { Session } from "./helpers";
const filler = (n: number, word: string) => Array.from({ length: n }, (_, i) => `Paragraph ${i} about ${word} and nothing else of note.`).join("\n\n");
const exists = (id: string) => fs.writeFileSync(path.join(CONVERSATIONS, `${id}.conversation.json`), "{}");
test("recall starts only in a long chat, finds early wording, and skips what is still in view", async () => {
const s = new Session("3000000000001");
s.config = { recallThresholdTokens: 3000, verbatimWindowTokens: 1500 };
await s.send("Hello.");
s.reply("Hi.");
const short = await s.send("Short so far.");
assert.equal(short.text, "Short so far.");
assert.equal(hasIndex("3000000000001"), false, "nothing copied below the threshold");
assert.deepEqual(await s.tools(), []);
const statuses: string[] = [];
statuses.push(...(await s.send("Remember this: the access code for the north gate is 7741-ALPHA.")).statuses);
s.reply("<think>The user wants me to keep a code; secret reasoning here.</think>Noted.");
for (let i = 0; i < 4; i++) {
statuses.push(...(await s.send(`Tell me about topic ${i}.`)).statuses);
s.reply(filler(80, `weather pattern ${i}`));
}
statuses.push(...(await s.send("The south gate code is 1234-BRAVO.")).statuses);
s.reply("Got it.");
const long = await s.send("Now, what was the north gate code?");
statuses.push(...long.statuses);
assert.equal(statuses.filter((t) => /recall is on/.test(t)).length, 1, "announced once, when it starts");
assert.ok(hasIndex("3000000000001"));
assert.equal(long.text, "Now, what was the north gate code?", "recall adds nothing to messages");
const tools = await s.tools();
assert.deepEqual(tools.map((t) => t.name), ["recall"]);
const found = await s.call("recall", { query: "north gate access code" });
assert.match(found, /^status: ok/);
assert.match(found, /\[turn 3, the user, [0-9-]+ [0-9:]+ UTC\]\nRemember this: the access code for the north gate is 7741-ALPHA\./);
assert.ok(!found.includes("1234-BRAVO"), "recent messages, still in view, are not returned");
assert.ok(!search("3000000000001", "secret reasoning").length, "the model's reasoning is not indexed");
assert.match(await s.call("recall", { query: "zebra" }), /^status: no_results/);
});
test("the recall index grows with the chat and is rebuilt when earlier messages change", async () => {
const s = new Session("3000000000002");
s.config = { recallThresholdTokens: 500, verbatimWindowTokens: 100 };
await s.send(`First topic is copper. ${filler(60, "copper")}`);
s.reply("OK.");
await s.send("Second.");
s.reply("OK.");
await s.send("Third.");
assert.ok(search("3000000000002", "copper").length > 0);
s.reply(`Tin is next. ${filler(60, "tin")}`);
await s.send("Fourth.");
s.reply("OK.");
await s.send("Fifth.");
assert.ok(search("3000000000002", "tin").length > 0, "new messages are added");
// The user edits the first message: the index notices and rebuilds.
const stored = (s.history as any).data.messages;
stored[1].content = [{ type: "text", text: `First topic is zinc. ${filler(60, "zinc")}` }];
stored[stored.length - 1].content = [{ type: "text", text: "Fifth, edited." }];
s.reply("OK.");
await s.send("Sixth.");
assert.ok(search("3000000000002", "zinc").length > 0);
assert.equal(search("3000000000002", "copper").length, 0);
});
test("/tl recall off deletes the copy and stops recall; on resumes it", async () => {
const s = new Session("3000000000003");
s.config = { recallThresholdTokens: 500 };
await s.send(`Long start. ${filler(60, "granite")}`);
s.reply("OK.");
await s.send("Next.");
assert.ok(hasIndex("3000000000003"));
const off = await s.send("/tl recall off");
assert.match(off.text, /Recall is off in this chat, and its search copy of the chat was deleted\./);
assert.equal(hasIndex("3000000000003"), false);
s.reply("OK.");
await s.send("Still long.");
assert.equal(hasIndex("3000000000003"), false, "not rebuilt while off");
assert.deepEqual(await s.tools(), []);
await s.send("/tl recall on");
s.reply("OK.");
await s.send("Again.");
assert.ok(hasIndex("3000000000003"));
});
test("passages split on paragraphs and stay near the target size", () => {
const parts = splitPassages(filler(40, "basalt"));
assert.ok(parts.length > 1);
assert.ok(parts.every((p) => p.length <= 1000));
assert.ok(parts.join(" ").includes("Paragraph 39 about basalt"));
});
test("cleanup removes a deleted chat's data at once, and orphaned projects after the grace period", async () => {
const keep = new Session("3000000000010");
const gone = new Session("3000000000011");
const alsoGone = new Session("3000000000012");
await keep.send("/tl start Shared project.");
const shared = loadChatState("3000000000010")!.project!;
await gone.send(`/tl open ${shared}`);
await alsoGone.send("/tl start Lonely project.");
const lonely = loadChatState("3000000000012")!.project!;
// An empty conversations folder is not evidence of anything: nothing is touched.
assert.match(cleanup().skipped ?? "", /missing, unreadable or empty/);
assert.ok(fs.existsSync(path.join(HOME, "chats", "3000000000011")));
exists("3000000000010");
exists("3000000000099"); // some other chat
const now = Date.now();
const r = cleanup(now);
assert.ok(r.chatsRemoved.includes("3000000000011") && r.chatsRemoved.includes("3000000000012"));
assert.ok(!r.chatsRemoved.includes("3000000000010"));
assert.ok(!fs.existsSync(path.join(HOME, "chats", "3000000000011")));
assert.deepEqual(loadLedger(shared)!.chats, ["3000000000010"], "a project still used elsewhere is kept");
assert.deepEqual(r.orphaned, [lonely]);
assert.ok(loadMeta(lonely).orphanedSince);
assert.ok(readEvents(lonely, 0).some((e) => e.op === "chat_deleted" && e.actor === "plugin"));
// Within the grace period nothing more happens; after it, the project moves to the trash.
assert.deepEqual(cleanup(now + 6 * 86400000).trashed, []);
const later = cleanup(now + 8 * 86400000);
assert.deepEqual(later.trashed, [lonely]);
assert.ok(!listProjects().includes(lonely));
// The trash is emptied after its retention period.
const purge = cleanup(now + 8 * 86400000 + 31 * 86400000);
assert.equal(purge.purged.length, 1);
assert.equal(fs.readdirSync(path.join(HOME, "trash")).length, 0);
});
test("opening an orphaned project clears its orphan mark", async () => {
exists("3000000000020");
const s = new Session("3000000000021");
await s.send("/tl start Orphan then rescue.");
const slug = loadChatState("3000000000021")!.project!;
const r = cleanup();
assert.ok(r.orphaned.includes(slug));
const rescuer = new Session("3000000000020");
await rescuer.send(`/tl open ${slug}`);
assert.equal(loadMeta(slug).orphanedSince, undefined);
assert.deepEqual(cleanup(Date.now() + 30 * 86400000).trashed, []);
});
test("/tl undo steps back one change at a time and never reuses an id", async () => {
const s = new Session("3000000000030");
await s.send("/tl start Undo things.");
s.reply("OK.");
await s.send("Go.");
await s.call("ledger_plan", { action: "add", steps: ["One"], status: "doing" });
await s.call("ledger_record", { kind: "finding", text: "Kept.", source: "user" });
await s.call("ledger_record", { kind: "finding", text: "Mistake.", source: "user" });
await s.call("ledger_plan", { action: "update", step: 1, status: "done" });
s.reply("Done.");
const slug = loadChatState("3000000000030")!.project!;
const u1 = await s.send("/tl undo");
assert.match(u1.text, /Undid step 1 now done\./);
assert.equal(loadLedger(slug)!.plan[0].status, "doing");
const u2 = await s.send("/tl undo");
assert.match(u2.text, /Undid F2 added\./);
assert.deepEqual(loadLedger(slug)!.findings.map((f) => f.id), ["F1"]);
await s.call("ledger_record", { kind: "finding", text: "Fresh.", source: "user" });
assert.deepEqual(loadLedger(slug)!.findings.map((f) => f.id), ["F1", "F3"], "F2 is not handed out again");
const u3 = await s.send("/tl undo");
assert.match(u3.text, /Undid F3 added\./, "a new change starts the undo run again");
assert.deepEqual(loadLedger(slug)!.findings.map((f) => f.id), ["F1"]);
assert.equal(loadLedger(slug)!.name, slug);
});
test("/tl handoff writes the three layers with the recent exchanges", async () => {
const s = new Session("3000000000040");
await s.send("Find the default port of two web servers.");
s.reply("Server A uses 8080; server B uses 3000.");
await s.send("/tl start");
s.reply("Started.");
await s.send("Record them.");
await s.call("ledger_record", { kind: "finding", text: "Server A defaults to port 8080.", source: "https://example.org/a" });
await s.call("ledger_record", { kind: "decision", text: "Report the default only.", reason: "the user asked for defaults" });
s.reply("<think>internal</think>Recorded both.");
const out = await s.send("/tl handoff");
assert.match(out.text, /Wrote the handoff for "find-default-port-two-web-servers" to /);
const md = fs.readFileSync(path.join(HOME, "projects", "find-default-port-two-web-servers", "handoff.md"), "utf8");
for (const h of ["## Permanent core", "### Goal", "### Decisions", "## Current state", "### Plan", "### Findings", "## Recent conversation"]) assert.ok(md.includes(h), h);
assert.match(md, /- D1: Report the default only\. Reason: the user asked for defaults/);
assert.match(md, /- F1: Server A defaults to port 8080\. Source: https:\/\/example\.org\/a \(unverified\)/);
assert.match(md, /\*\*User:\*\* Find the default port of two web servers\./);
assert.match(md, /\*\*Assistant:\*\* Recorded both\./);
assert.ok(!md.includes("[throughLine"), "no throughLine blocks in the conversation");
assert.ok(!md.includes("internal"), "no reasoning in the conversation");
});
test("an edit saved just before a command is applied first, never overwritten", async () => {
const s = new Session("3000000000050");
await s.send("/tl start Edits before commands.");
s.reply("OK.");
await s.send("Go.");
await s.call("ledger_record", { kind: "finding", text: "A fact.", source: "user" });
s.reply("Recorded.");
await s.send("Thanks.");
const slug = loadChatState("3000000000050")!.project!;
const file = path.join(HOME, "projects", slug, "ledger.md");
// The user edits the file, then sends a command before any ordinary message.
fs.writeFileSync(file, fs.readFileSync(file, "utf8").replace("## Constraints\n\n(none)", "## Constraints\n\n- Every finding should be in plain words."));
const handoff = await s.send("/tl handoff");
assert.match(handoff.text, /Your edit to ledger\.md was applied first: constraints/);
assert.deepEqual(loadLedger(slug)!.constraints, ["Every finding should be in plain words."]);
// Edited again, then undo: the undo reverts the user's latest edit, which was applied, not lost.
fs.writeFileSync(file, fs.readFileSync(file, "utf8").replace("- Every finding should be in plain words.", "- Every finding should be in plain words.\n- Keep it short."));
const undo = await s.send("/tl undo");
assert.match(undo.text, /Undid the hand edit \(constraints\)\./);
assert.match(undo.text, /Your edit to ledger\.md was applied first: constraints/);
assert.deepEqual(loadLedger(slug)!.constraints, ["Every finding should be in plain words."]);
assert.ok(readEvents(slug, 0).filter((e) => e.op === "hand_edit").length === 2, "both edits are in the log");
// A tool write between messages also merges the pending edit first.
fs.writeFileSync(file, fs.readFileSync(file, "utf8").replace("- F1: A fact.", "- F1: A corrected fact."));
await s.call("ledger_record", { kind: "finding", text: "Another fact.", source: "user" });
assert.deepEqual(loadLedger(slug)!.findings.map((f) => f.text), ["A corrected fact.", "Another fact."]);
});
test("recall finds compound words by their parts and names the tool behind a result", async () => {
const s = new Session("3000000000060");
s.config = { recallThresholdTokens: 500, verbatimWindowTokens: 100 };
await s.send("Search for it.");
s.toolRound([{ name: "web_search", args: { query: "x" }, result: "status: browser_unavailable\nnext: The browser did not connect." }]);
s.toolRound([{ name: "read_webpage", args: { url: "https://example.org" }, result: `status: ok\n${filler(60, "lava")}` }]);
s.reply("The first search failed, then the read worked.");
await s.send("More.");
s.reply(filler(20, "pumice"));
await s.send("Even more.");
const r = await s.call("recall", { query: "browser problem" });
assert.match(r, /\[turn 1, tool web_search, [^\]]+\]\nstatus: browser_unavailable/);
});