test / flow.test.ts
test / flow.test.ts
// End-to-end tests of the preprocessor, commands and tools, using the SDK's real Chat and
// ChatMessage classes and a fake controller in place of LM Studio.
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-test-"));
process.env.THROUGHLINE_HOME = HOME;
import { loadChatState } from "../src/chat";
import { BLOCK_CLOSE, FULL_OPEN, HEADER_OPEN } from "../src/digest";
import { loadLedger, listProjects } from "../src/ledger";
import { estimateTokens, stripThroughLineBlocks } from "../src/text";
import { Session } from "./helpers";
import { firstUserText } from "../src/commands";
test("a prediction with no chat attached is left alone and gets no tools", async () => {
const s = new Session(null);
const out = await s.send("Hello.");
assert.equal(out.text, "Hello.");
assert.deepEqual(await s.tools(), []);
});
test("a chat with no project receives nothing and gets no tools", async () => {
const s = new Session("1000000000001");
const out = await s.send("What is the capital of Portugal?");
assert.equal(out.text, "What is the capital of Portugal?");
assert.deepEqual(out.statuses, []);
assert.deepEqual(await s.tools(), []);
assert.equal(loadChatState("1000000000001"), null, "nothing written for an ordinary chat");
});
test("a full project lifecycle in one chat, then continued in another", async () => {
const s = new Session("1000000000002");
await s.send("Compare the licenses and default context lengths of three inference runtimes, citing the source for each.");
s.reply("Sure.");
// Start without a goal: the first message of the chat becomes the goal, word for word.
const started = await s.send("/tl start");
assert.match(started.text, /^\[throughLine handled a command/);
assert.match(started.text, /Started the project "compare-licenses-default-context-lengths/);
s.reply("Started.");
const slug = loadChatState("1000000000002")!.project!;
const ledger = loadLedger(slug)!;
assert.equal(ledger.goal, "Compare the licenses and default context lengths of three inference runtimes, citing the source for each.");
assert.equal(ledger.goalSource, "user_message");
assert.equal((await s.tools()).length, 4);
// First message after the start carries the full state.
const first = await s.send("Go ahead.");
assert.ok(first.text.includes(FULL_OPEN));
assert.ok(first.text.endsWith(BLOCK_CLOSE));
assert.match(first.statuses[0], /full state shown/);
// The model plans and records through its tools; results stay short.
let r = await s.call("ledger_plan", { action: "add", steps: ["Find each license", "Find each default context length", "Draft the table"] });
assert.match(r, /^status: ok\nchanged: steps 1 to 3 added/);
r = await s.call("ledger_plan", { action: "update", step: 1, status: "doing" });
assert.match(r, /now: 1\. Find each license \(doing\)/);
r = await s.call("ledger_record", { kind: "finding", text: "Runtime A is MIT licensed.", source: "https://example.org/a/LICENSE" });
assert.match(r, /F1 added: Runtime A is MIT licensed\. \(source: https:\/\/example\.org\/a\/LICENSE\)/);
assert.ok(estimateTokens(r) < 100, `tool result is short (${estimateTokens(r)} tokens)`);
r = await s.call("ledger_record", { kind: "finding", text: "The user lives in a small town and likes jazz.", source: "user" });
assert.match(r, /^status: error\nreason: This reads like a personal fact about the user/);
r = await s.call("ledger_record", { kind: "question", text: "Does runtime C publish a default at all?" });
assert.match(r, /Q1 opened/);
r = await s.call("ledger_resolve", { question: "Q1", answer: "No; it uses the model's maximum.", record_as_finding: true, source: "turn 7" });
assert.match(r, /Q1 resolved; F2 added/);
r = await s.call("ledger_record", { kind: "decision", text: "Cite primary sources only.", reason: "secondary sites were stale" });
assert.match(r, /D1 added/);
r = await s.call("ledger_plan", { action: "update", step: 1, status: "done" });
r = await s.call("ledger_plan", { action: "update", step: 9, status: "done" });
assert.match(r, /^status: error\nreason: There is no step 9/);
s.reply("Licenses found.");
// Next turn: the full state is still recent, so only the short header, with what changed.
const second = await s.send("Continue.");
assert.ok(second.text.includes(HEADER_OPEN));
assert.ok(!second.text.includes(FULL_OPEN));
assert.match(second.text, /Changed since the last header: steps 1 to 3 added; F1 added; Q1 opened; Q1 resolved; F2 added; D1 added; step 1 now done\n/);
const header = second.text.slice(second.text.indexOf(HEADER_OPEN));
assert.ok(estimateTokens(header) < 150, `header is small (${estimateTokens(header)} tokens)`);
// Third turn with nothing changed: no change line at all.
s.reply("Working.");
const third = await s.send("Keep going.");
assert.ok(!third.text.includes("Changed since"));
// Once more than the verbatim window has passed since the last full state, it comes back.
s.toolOutput(30000);
const fourth = await s.send("And now?");
assert.ok(fourth.text.includes(FULL_OPEN));
const full = fourth.text.slice(fourth.text.indexOf(FULL_OPEN));
assert.ok(estimateTokens(full) <= 600, `full state within cap (${estimateTokens(full)} tokens)`);
assert.match(full, /F2 Does runtime C publish a default at all\? No; it uses the model's maximum\./);
// Commands from the user change the goal and constraints; the model cannot.
await s.send("/tl constraint Use only primary sources.");
assert.deepEqual(loadLedger(slug)!.constraints, ["Use only primary sources."]);
// A new chat continues the project; its first message gets the full state with everything.
const s2 = new Session("1000000000003");
assert.deepEqual(await s2.tools(), []);
const opened = await s2.send(`/tl open ${slug.slice(0, 12)}`);
assert.match(opened.text, /now continues the project/);
assert.equal((await s2.tools()).length, 4);
const cont = await s2.send("Where were we?");
assert.ok(cont.text.includes(FULL_OPEN));
assert.match(cont.text, /Constraints: Use only primary sources\./);
assert.match(cont.text, /F1 Runtime A is MIT licensed\./);
assert.match(cont.text, /D1 Cite primary sources only\./);
assert.deepEqual(loadLedger(slug)!.chats.sort(), ["1000000000002", "1000000000003"]);
// /tl off stops the second chat: nothing appended, no tools.
await s2.send("/tl off");
const after = await s2.send("Thanks.");
assert.equal(after.text, "Thanks.");
assert.deepEqual(await s2.tools(), []);
// Every change is in the event log, and versions are kept.
const events = fs.readFileSync(path.join(HOME, "projects", slug, "events.jsonl"), "utf8").trim().split("\n").map((l) => JSON.parse(l));
assert.ok(events.some((e) => e.op === "goal_set" || e.op === "project_created"));
assert.ok(events.filter((e) => e.op === "constraint_added").every((e) => e.actor === "user"));
assert.ok(fs.readdirSync(path.join(HOME, "projects", slug, "versions")).length > 5);
assert.match(fs.readFileSync(path.join(HOME, "projects", slug, "ledger.md"), "utf8"), /## Findings\n\n- F1: Runtime A is MIT licensed\./);
// /tl forget needs the exact name, then moves the project to the trash and unbinds chats.
const askFirst = await s.send("/tl forget");
assert.match(askFirst.text, new RegExp(`send /tl forget ${slug}`));
await s.send(`/tl forget ${slug}`);
assert.ok(!listProjects().includes(slug));
assert.equal(fs.readdirSync(path.join(HOME, "trash")).length, 1);
assert.equal(loadChatState("1000000000002")!.project, undefined);
const gone = await s.send("Hello again.");
assert.equal(gone.text, "Hello again.");
});
test("a chat with documents gets the state once, then nothing added", async () => {
const s = new Session("1000000000004");
await s.send("/tl start Summarize the attached notes into a plan.");
s.reply("Started.");
const first = await s.send("Here they are.", { files: true });
assert.ok(first.text.includes(FULL_OPEN), "first turn of the project in the chat still shows the full state");
assert.ok(first.text.includes("not repeated on later messages"));
s.reply("Read them.");
const next = await s.send("Next part.");
assert.equal(next.text, "Next part.", "nothing added, so rag-v1 searches with the user's own words");
assert.match(next.statuses[0], /quiet: documents attached/);
assert.equal((await s.tools()).length, 4, "the ledger tools stay available");
const diag = fs.readFileSync(path.join(HOME, "diagnostics.jsonl"), "utf8").trim().split("\n").map((l) => JSON.parse(l));
assert.ok(diag.every((d) => d.event === "preprocessor_order" && d.ragRanFirst === false && Object.keys(d).length === 3));
});
test("starting twice, unknown commands and help", async () => {
const s = new Session("1000000000005");
const empty = await s.send("/tl start");
assert.match(empty.text, /No goal yet\. Set one with \/tl goal/);
const again = await s.send("/tl start Something else");
assert.match(again.text, /already tracking/);
const unknown = await s.send("/tl frobnicate");
assert.match(unknown.text, /Unknown command/);
const help = await s.send("/tl");
assert.match(help.text, /\/tl start \[goal\]/);
assert.equal(loadChatState("1000000000005")!.project, "project");
const goal = await s.send("/tl goal Write a short comparison.");
assert.match(goal.text, /Goal set, and the project is now named "write-short-comparison"/);
const renamed = loadChatState("1000000000005")!.project!;
assert.equal(renamed, "write-short-comparison");
assert.equal(loadLedger(renamed)!.goalSource, "command");
assert.ok(!listProjects().includes("project"));
const again2 = await s.send("/tl goal Write a longer comparison.");
assert.match(again2.text, /Goal of "write-short-comparison" set to/, "only a placeholder name is replaced");
});
test("the goal is never taken from an earlier command or its stored reply", () => {
assert.equal(firstUserText(["/tl list", "[throughLine handled a command from the user. Reply...]\nresult", "Plan my week."]), "Plan my week.");
assert.equal(firstUserText(["/tl help"]), "");
});
test("text taken from history has throughLine's blocks removed", () => {
const withHeader = `Do the thing.\n\n${HEADER_OPEN}\nProject: x | Goal: y\nNow: no plan yet.\n${BLOCK_CLOSE}`;
assert.equal(stripThroughLineBlocks(withHeader), "Do the thing.");
assert.equal(stripThroughLineBlocks("Next.\n\n[throughLine: project x, no plan yet. Background state for the assistant; ledger_view has the rest.]"), "Next.");
});
// End-to-end tests of the preprocessor, commands and tools, using the SDK's real Chat and
// ChatMessage classes and a fake controller in place of LM Studio.
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-test-"));
process.env.THROUGHLINE_HOME = HOME;
import { loadChatState } from "../src/chat";
import { BLOCK_CLOSE, FULL_OPEN, HEADER_OPEN } from "../src/digest";
import { loadLedger, listProjects } from "../src/ledger";
import { estimateTokens, stripThroughLineBlocks } from "../src/text";
import { Session } from "./helpers";
import { firstUserText } from "../src/commands";
test("a prediction with no chat attached is left alone and gets no tools", async () => {
const s = new Session(null);
const out = await s.send("Hello.");
assert.equal(out.text, "Hello.");
assert.deepEqual(await s.tools(), []);
});
test("a chat with no project receives nothing and gets no tools", async () => {
const s = new Session("1000000000001");
const out = await s.send("What is the capital of Portugal?");
assert.equal(out.text, "What is the capital of Portugal?");
assert.deepEqual(out.statuses, []);
assert.deepEqual(await s.tools(), []);
assert.equal(loadChatState("1000000000001"), null, "nothing written for an ordinary chat");
});
test("a full project lifecycle in one chat, then continued in another", async () => {
const s = new Session("1000000000002");
await s.send("Compare the licenses and default context lengths of three inference runtimes, citing the source for each.");
s.reply("Sure.");
// Start without a goal: the first message of the chat becomes the goal, word for word.
const started = await s.send("/tl start");
assert.match(started.text, /^\[throughLine handled a command/);
assert.match(started.text, /Started the project "compare-licenses-default-context-lengths/);
s.reply("Started.");
const slug = loadChatState("1000000000002")!.project!;
const ledger = loadLedger(slug)!;
assert.equal(ledger.goal, "Compare the licenses and default context lengths of three inference runtimes, citing the source for each.");
assert.equal(ledger.goalSource, "user_message");
assert.equal((await s.tools()).length, 4);
// First message after the start carries the full state.
const first = await s.send("Go ahead.");
assert.ok(first.text.includes(FULL_OPEN));
assert.ok(first.text.endsWith(BLOCK_CLOSE));
assert.match(first.statuses[0], /full state shown/);
// The model plans and records through its tools; results stay short.
let r = await s.call("ledger_plan", { action: "add", steps: ["Find each license", "Find each default context length", "Draft the table"] });
assert.match(r, /^status: ok\nchanged: steps 1 to 3 added/);
r = await s.call("ledger_plan", { action: "update", step: 1, status: "doing" });
assert.match(r, /now: 1\. Find each license \(doing\)/);
r = await s.call("ledger_record", { kind: "finding", text: "Runtime A is MIT licensed.", source: "https://example.org/a/LICENSE" });
assert.match(r, /F1 added: Runtime A is MIT licensed\. \(source: https:\/\/example\.org\/a\/LICENSE\)/);
assert.ok(estimateTokens(r) < 100, `tool result is short (${estimateTokens(r)} tokens)`);
r = await s.call("ledger_record", { kind: "finding", text: "The user lives in a small town and likes jazz.", source: "user" });
assert.match(r, /^status: error\nreason: This reads like a personal fact about the user/);
r = await s.call("ledger_record", { kind: "question", text: "Does runtime C publish a default at all?" });
assert.match(r, /Q1 opened/);
r = await s.call("ledger_resolve", { question: "Q1", answer: "No; it uses the model's maximum.", record_as_finding: true, source: "turn 7" });
assert.match(r, /Q1 resolved; F2 added/);
r = await s.call("ledger_record", { kind: "decision", text: "Cite primary sources only.", reason: "secondary sites were stale" });
assert.match(r, /D1 added/);
r = await s.call("ledger_plan", { action: "update", step: 1, status: "done" });
r = await s.call("ledger_plan", { action: "update", step: 9, status: "done" });
assert.match(r, /^status: error\nreason: There is no step 9/);
s.reply("Licenses found.");
// Next turn: the full state is still recent, so only the short header, with what changed.
const second = await s.send("Continue.");
assert.ok(second.text.includes(HEADER_OPEN));
assert.ok(!second.text.includes(FULL_OPEN));
assert.match(second.text, /Changed since the last header: steps 1 to 3 added; F1 added; Q1 opened; Q1 resolved; F2 added; D1 added; step 1 now done\n/);
const header = second.text.slice(second.text.indexOf(HEADER_OPEN));
assert.ok(estimateTokens(header) < 150, `header is small (${estimateTokens(header)} tokens)`);
// Third turn with nothing changed: no change line at all.
s.reply("Working.");
const third = await s.send("Keep going.");
assert.ok(!third.text.includes("Changed since"));
// Once more than the verbatim window has passed since the last full state, it comes back.
s.toolOutput(30000);
const fourth = await s.send("And now?");
assert.ok(fourth.text.includes(FULL_OPEN));
const full = fourth.text.slice(fourth.text.indexOf(FULL_OPEN));
assert.ok(estimateTokens(full) <= 600, `full state within cap (${estimateTokens(full)} tokens)`);
assert.match(full, /F2 Does runtime C publish a default at all\? No; it uses the model's maximum\./);
// Commands from the user change the goal and constraints; the model cannot.
await s.send("/tl constraint Use only primary sources.");
assert.deepEqual(loadLedger(slug)!.constraints, ["Use only primary sources."]);
// A new chat continues the project; its first message gets the full state with everything.
const s2 = new Session("1000000000003");
assert.deepEqual(await s2.tools(), []);
const opened = await s2.send(`/tl open ${slug.slice(0, 12)}`);
assert.match(opened.text, /now continues the project/);
assert.equal((await s2.tools()).length, 4);
const cont = await s2.send("Where were we?");
assert.ok(cont.text.includes(FULL_OPEN));
assert.match(cont.text, /Constraints: Use only primary sources\./);
assert.match(cont.text, /F1 Runtime A is MIT licensed\./);
assert.match(cont.text, /D1 Cite primary sources only\./);
assert.deepEqual(loadLedger(slug)!.chats.sort(), ["1000000000002", "1000000000003"]);
// /tl off stops the second chat: nothing appended, no tools.
await s2.send("/tl off");
const after = await s2.send("Thanks.");
assert.equal(after.text, "Thanks.");
assert.deepEqual(await s2.tools(), []);
// Every change is in the event log, and versions are kept.
const events = fs.readFileSync(path.join(HOME, "projects", slug, "events.jsonl"), "utf8").trim().split("\n").map((l) => JSON.parse(l));
assert.ok(events.some((e) => e.op === "goal_set" || e.op === "project_created"));
assert.ok(events.filter((e) => e.op === "constraint_added").every((e) => e.actor === "user"));
assert.ok(fs.readdirSync(path.join(HOME, "projects", slug, "versions")).length > 5);
assert.match(fs.readFileSync(path.join(HOME, "projects", slug, "ledger.md"), "utf8"), /## Findings\n\n- F1: Runtime A is MIT licensed\./);
// /tl forget needs the exact name, then moves the project to the trash and unbinds chats.
const askFirst = await s.send("/tl forget");
assert.match(askFirst.text, new RegExp(`send /tl forget ${slug}`));
await s.send(`/tl forget ${slug}`);
assert.ok(!listProjects().includes(slug));
assert.equal(fs.readdirSync(path.join(HOME, "trash")).length, 1);
assert.equal(loadChatState("1000000000002")!.project, undefined);
const gone = await s.send("Hello again.");
assert.equal(gone.text, "Hello again.");
});
test("a chat with documents gets the state once, then nothing added", async () => {
const s = new Session("1000000000004");
await s.send("/tl start Summarize the attached notes into a plan.");
s.reply("Started.");
const first = await s.send("Here they are.", { files: true });
assert.ok(first.text.includes(FULL_OPEN), "first turn of the project in the chat still shows the full state");
assert.ok(first.text.includes("not repeated on later messages"));
s.reply("Read them.");
const next = await s.send("Next part.");
assert.equal(next.text, "Next part.", "nothing added, so rag-v1 searches with the user's own words");
assert.match(next.statuses[0], /quiet: documents attached/);
assert.equal((await s.tools()).length, 4, "the ledger tools stay available");
const diag = fs.readFileSync(path.join(HOME, "diagnostics.jsonl"), "utf8").trim().split("\n").map((l) => JSON.parse(l));
assert.ok(diag.every((d) => d.event === "preprocessor_order" && d.ragRanFirst === false && Object.keys(d).length === 3));
});
test("starting twice, unknown commands and help", async () => {
const s = new Session("1000000000005");
const empty = await s.send("/tl start");
assert.match(empty.text, /No goal yet\. Set one with \/tl goal/);
const again = await s.send("/tl start Something else");
assert.match(again.text, /already tracking/);
const unknown = await s.send("/tl frobnicate");
assert.match(unknown.text, /Unknown command/);
const help = await s.send("/tl");
assert.match(help.text, /\/tl start \[goal\]/);
assert.equal(loadChatState("1000000000005")!.project, "project");
const goal = await s.send("/tl goal Write a short comparison.");
assert.match(goal.text, /Goal set, and the project is now named "write-short-comparison"/);
const renamed = loadChatState("1000000000005")!.project!;
assert.equal(renamed, "write-short-comparison");
assert.equal(loadLedger(renamed)!.goalSource, "command");
assert.ok(!listProjects().includes("project"));
const again2 = await s.send("/tl goal Write a longer comparison.");
assert.match(again2.text, /Goal of "write-short-comparison" set to/, "only a placeholder name is replaced");
});
test("the goal is never taken from an earlier command or its stored reply", () => {
assert.equal(firstUserText(["/tl list", "[throughLine handled a command from the user. Reply...]\nresult", "Plan my week."]), "Plan my week.");
assert.equal(firstUserText(["/tl help"]), "");
});
test("text taken from history has throughLine's blocks removed", () => {
const withHeader = `Do the thing.\n\n${HEADER_OPEN}\nProject: x | Goal: y\nNow: no plan yet.\n${BLOCK_CLOSE}`;
assert.equal(stripThroughLineBlocks(withHeader), "Do the thing.");
assert.equal(stripThroughLineBlocks("Next.\n\n[throughLine: project x, no plan yet. Background state for the assistant; ledger_view has the rest.]"), "Next.");
});