testSessionsWorkApart

tcmts.cli.test.e2e.mcp.testSessionsWorkApart · #l5hb2r5v · term

Agents in sessions (tcm mcp --branch) work in one codebase at once, each on its own branch with its own scratch files, out of the mirror's files and each other's way, until the user has tcm_merge land their work on main (and only then): main merged in, the tests it reaches passing, then onto main, which the files follow. A branch an earlier session left is seen by a new one, and can be landed from it.

import { okIn, tempDir, withChildren } from "#codebase/tcmts/cli/test/e2e/harness";
import { session } from "#codebase/tcmts/cli/test/e2e/mcp";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";

export const testSessionsWorkApart = () =>
  withChildren(async (keep) => {
    const root = tempDir("sessions");
    const proj = path.join(root, "proj");
    const dir = path.join(root, "cb");
    for (const d of ["src", "test"]) fs.mkdirSync(path.join(proj, d), { recursive: true });
    fs.mkdirSync(dir);
    fs.writeFileSync(path.join(proj, "package.json"), JSON.stringify({ type: "module" }));
    fs.writeFileSync(
      path.join(proj, "src", "math.ts"),
      "export const square = (x: number): number => x * x;\n",
    );
    fs.writeFileSync(
      path.join(proj, "test", "math.ts"),
      `import assert from "node:assert/strict";\nimport { square } from "../src/math.ts";\n\nexport const testSquare = () => assert.equal(square(3), 9);\n`,
    );
    const tcm = (...args: string[]) => okIn(dir, ...args);
    const source = () => fs.readFileSync(path.join(proj, "src", "math.ts"), "utf8");
    tcm("init");
    fs.rmSync(path.join(dir, "scratch.ts"));
    tcm("import", proj, "--into", "lib", "--tests", "--mirror");

    const a = session(dir, keep, "--branch", "agent/a");
    const b = session(dir, keep, "--branch", "agent/b");
    const init = await a.request("initialize", {
      protocolVersion: "2025-06-18",
      capabilities: {},
      clientInfo: { name: "test", version: "0" },
    });
    assert.match(
      String(init.result!.instructions),
      // (landed only when the user says to: until then it stays on its branch)
      /a session on its own branch, agent\/a[\s\S]*ask the user whether to land it[\s\S]*tcm_merge/,
    );

    // a changes square, in its own scratch file, on its own branch
    const edited = await a.call("tcm_edit", { names: ["lib.math.square"] });
    assert.match(edited.text, /sessions\/agent_a\/scratch\.ts/);
    const file = path.join(dir, ".tcm", "sessions", "agent_a", "scratch.ts");
    fs.writeFileSync(file, fs.readFileSync(file, "utf8").replace("x * x", "x ** 2"));
    assert.match((await a.call("tcm_update")).text, /~ update +lib\.math\.square/);
    // b, main and the files don't see it
    assert.match((await b.call("tcm_view", { names: ["lib.math.square"] })).text, /x \* x/);
    assert.match(tcm("view", "lib.math.square"), /x \* x/);
    assert.match(source(), /x \* x/);
    // and it stays off main
    assert.match(
      (await a.call("tcm_cli", { args: ["switch", "main"] })).text,
      /main is the codebase's own branch: a session's work lands there with tcm_merge/,
    );

    // b's scratch files are its own too; it adds a test that fails
    assert.match(
      (await b.call("tcm_edit", { names: ["lib.math.square"] })).text,
      /sessions\/agent_b\/scratch\.ts/,
    );
    fs.writeFileSync(
      path.join(dir, ".tcm", "sessions", "agent_b", "scratch.ts"),
      `// tcm: namespace lib.math\nexport const testCube = () => {\n  throw new Error("not yet");\n};\n`,
    );
    assert.match((await b.call("tcm_update")).text, /\+ new +lib\.math\.testCube/);

    // not told to by the user, nothing lands: a's work stays on its branch
    const unasked = await a.call("tcm_merge");
    assert.equal(unasked.isError, true, unasked.text);
    assert.match(
      unasked.text,
      /Not landed: tcm_merge lands a branch only when the user has told you to/,
    );
    assert.match(tcm("view", "lib.math.square"), /x \* x/);

    // told to, but not what it's for: main's history would say nothing of it
    const unsaid = await a.call("tcm_merge", { approved: true });
    assert.equal(unsaid.isError, true, unsaid.text);
    assert.match(unsaid.text, /Not landed: say what agent\/a's work is for in `message`/);

    // told to: its change is on main, and in the files, and main's history says what it's for
    const landed = await a.call("tcm_merge", {
      approved: true,
      message: "lib.math.square: x ** 2\n\nSquares read as powers.",
    });
    assert.equal(landed.isError, false, landed.text);
    assert.match(
      tcm("history", "1"),
      // (a's one step, landed with nothing on main since: the same change from the same point, so the same entry)
      /^#\w{8} +0 .* lib\.math\.square: x \*\* 2\n {4}Squares read as powers\.\n {4}update lib\.math\.square\n/,
    );
    assert.match(landed.text, /agent\/a is on main/);
    // (what landed by name, not its code again: a made it)
    assert.match(landed.text, /edited: lib\.math\.square/);
    assert.doesNotMatch(landed.text, /x \*\* 2/);
    assert.match(tcm("view", "lib.math.square"), /x \*\* 2/);
    assert.match(source(), /x \*\* 2/);

    // b brings main in, but its test fails: nothing lands
    const refused = await b.call("tcm_merge", { approved: true, message: "lib.math: testCube" });
    assert.equal(refused.isError, true, refused.text);
    assert.match(refused.text, /testCube[\s\S]*nothing landed on main/);
    // (only what failed)
    assert.doesNotMatch(refused.text, /✓/);
    assert.doesNotMatch(tcm("ls", "lib.math"), /testCube/);

    // with no flags, each server is a session on a new branch of its own
    const c = session(dir, keep);
    const cInit = await c.request("initialize", {
      protocolVersion: "2025-06-18",
      capabilities: {},
      clientInfo: { name: "test", version: "0" },
    });
    const cBranch = /own branch, (session\/\d{4}-\d{2}-\d{2}-[0-9a-f]{6}):/.exec(
      String(cInit.result!.instructions),
    )?.[1];
    assert.ok(cBranch, String(cInit.result!.instructions));
    assert.match(tcm("branch"), new RegExp(cBranch));
    // b fixes its test, then stops (a reconnect, say): its work is on its branch, which a new session sees, and lands
    fs.writeFileSync(
      path.join(dir, ".tcm", "sessions", "agent_b", "scratch.ts"),
      `// tcm: namespace lib.math\nexport const testCube = () => {};\n`,
    );
    assert.match((await b.call("tcm_update")).text, /~ update +lib\.math\.testCube/);
    b.close();
    assert.match(
      (await c.call("tcm_status")).text,
      /branches with work main doesn't have[\s\S]*agent\/b/,
    );
    // (in the background: the tests it runs can outlast a call, and tcm_job says how it's going)
    const started = await c.call("tcm_merge", {
      approved: true,
      branch: "agent/b",
      message: "lib.math: testCube",
      background: true,
    });
    const job = /started job (\d+): tcm_merge/.exec(started.text)?.[1];
    assert.ok(job, started.text);
    const resumed = await c.call("tcm_job", { id: Number(job), wait: 300 });
    assert.equal(resumed.isError, false, resumed.text);
    assert.match(resumed.text, /\(tcm_merge\) ended[\s\S]*agent\/b is on main/);
    assert.match(tcm("ls", "lib.math"), /testCube/);
    c.close();
    // c changed nothing: its branch goes with it; a's and b's, named, stay
    for (const until = Date.now() + 20_000; new RegExp(cBranch).test(tcm("branch"));) {
      if (Date.now() > until) assert.fail(`${cBranch} wasn't deleted: ${tcm("branch")}`);
      await new Promise((r) => setTimeout(r, 200));
    }
    assert.match(tcm("branch"), /agent\/a[\s\S]*agent\/b|agent\/b[\s\S]*agent\/a/);
  })

uses