testMcpSessionsAndUpgrades

tcmts.cli.test.e2e.mcp.testMcpSessionsAndUpgrades · #nazylzp7 · term

An agent works on a branch of its own, as work on a git branch is, unless it's told to work on the codebase's own (--direct). And when tcm is replaced (a new version, a new known-good build), the server starts again: the client is told its tools changed, and keeps working.

import { plainEnv, tcmBin, tempDir, withChildren } from "#codebase/tcmts/cli/test/e2e/harness";
import { session, sessionOf } from "#codebase/tcmts/cli/test/e2e/mcp";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";

export const testMcpSessionsAndUpgrades = () =>
  withChildren(async (keep) => {
    const dir = tempDir("mcp upgrade");
    assert.equal(
      spawnSync(process.execPath, [tcmBin(), "init"], { cwd: dir, env: plainEnv() }).status,
      0,
    );
    // (something that takes a while to answer)
    fs.writeFileSync(
      path.join(dir, "scratch.ts"),
      `// tcm: namespace lib\nexport const slow = async () => {\n  await new Promise((r) => setTimeout(r, 12000));\n  console.log("slow done");\n};\n`,
    );
    assert.equal(
      spawnSync(process.execPath, [tcmBin(), "add"], { cwd: dir, env: plainEnv() }).status,
      0,
    );
    // tcm run through a program of its own, which "upgrading" replaces
    const bin = path.join(dir, "tcm-bin.mjs");
    fs.writeFileSync(bin, `import ${JSON.stringify(pathToFileURL(tcmBin()).href)};\n`);
    const hello = {
      protocolVersion: "2025-06-18",
      capabilities: {},
      clientInfo: { name: "test", version: "0" },
    };
    const a = sessionOf(dir, keep, [bin, "mcp", "--direct"]);
    assert.match(
      String((await a.request("initialize", hello)).result!.instructions),
      /works on the codebase's own branch directly/,
    );
    const b = session(dir, keep);
    assert.match(
      String((await b.request("initialize", hello)).result!.instructions),
      /This is a session on its own branch, session\//,
    );
    b.close();
    // replaced while a call is still being answered: started again at once (that call doesn't hold it back), the
    // client told, the old server still answering the call, and the new one the rest
    let slowDone = false;
    const slow = a.call("tcm_run", { name: "lib.slow" }).finally(() => (slowDone = true));
    await new Promise((r) => setTimeout(r, 500));
    fs.writeFileSync(bin, `${fs.readFileSync(bin, "utf8")}// v2\n`);
    for (
      const until = Date.now() + 30_000;
      !a.notes.some((n) => n.method === "notifications/tools/list_changed");
      await new Promise((r) => setTimeout(r, 100))
    ) {
      if (Date.now() > until) assert.fail("the server wasn't started again");
    }
    assert.equal(slowDone, false, "the restart waited for the call in flight");
    assert.match((await slow).text, /slow done/);
    assert.match((await a.call("tcm_status")).text, /\b1 named definitions?\b[\s\S]*lib {2}1/);
    a.close();
  })

uses