Files
my-pi/pi-subagents/test/worktree-isolation-e2e.test.ts
T

190 lines
8.2 KiB
TypeScript

/**
* worktree-isolation-e2e.test.ts — `isolation: "worktree"` through the real
* stack: a real git repo, a real pi-mono session, the real extension, real
* `createWorktree`/`cleanupWorktree`, on a faux model.
*
* Coverage sat on either side of this seam and never joined it.
* test/worktree.test.ts drives real git but calls createWorktree/cleanupWorktree
* directly, with no agent anywhere. test/agent-manager.test.ts (and -gc, and
* agent-startup-error) mock ../src/worktree.js entirely and assert only the gate
* and the fail-loud throw. So the chain the feature actually promises was never
* pinned end to end: spawn → the child's cwd IS the copy → its edits stay out of
* the main checkout → cleanup commits them to a branch the result names → the
* copy is gone. Every link was tested; the chain was not.
*
* Deliberately faux, not live: a live model may decline to spawn at all, which
* would look like a pass. Each run pins `live: false` rather than trusting the
* env var to leave it alone — the pre-publish smoke sets PI_E2E_LIVE globally.
*/
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Context } from "@earendil-works/pi-ai";
import { fauxToolCall } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it, vi } from "vitest";
import { setWorktreeIsolationEnabled } from "../src/worktree.js";
import { agentCall, type FauxReply, type PrintModeRun, runPrintMode } from "./helpers/print-mode-runner.js";
vi.setConfig({ testTimeout: 30_000 });
/** The child creates this in whatever cwd it was actually given. */
const MARKER_FILE = "agent-work.txt";
/** Emitted by the child once its edit has landed. */
const CHILD_MARKER = "CHILD-EDITED-ITS-TREE";
const CHILD_PROMPT = "Create the marker file.";
function git(cwd: string, ...args: string[]): string {
return execFileSync("git", args, { cwd, stdio: "pipe", timeout: 10_000 }).toString().trim();
}
/** A real repo with one commit — `git worktree add` needs a HEAD to branch from. */
function initGitRepo(): string {
const dir = mkdtempSync(join(tmpdir(), "wt-iso-e2e-"));
git(dir, "init");
git(dir, "config", "user.email", "test@test.com");
git(dir, "config", "user.name", "Test");
writeFileSync(join(dir, "README.md"), "# Test repo");
git(dir, "add", "README.md");
git(dir, "commit", "-m", "initial");
return dir;
}
function firstUserText(context: Context): string {
const first = context.messages.find((m) => m.role === "user");
const content = first?.content;
if (typeof content === "string") return content;
return ((content ?? []) as Array<{ text?: string }>).map((b) => b.text ?? "").join("");
}
function toolResultNames(context: Context): string[] {
return context.messages
.filter((m) => m.role === "toolResult")
.map((m) => (m as { toolName?: string }).toolName ?? "");
}
/** Every Agent tool result the parent session received, concatenated. */
function agentResultText(session: Context): string {
return session.messages
.filter((m) => m.role === "toolResult" && (m as { toolName?: string }).toolName === "Agent")
.flatMap((m) => ((m.content ?? []) as Array<{ text?: string }>).map((b) => b.text ?? ""))
.join("\n");
}
/**
* One responder for both sessions, split on the first user message.
*
* The child writes through the REAL bash tool rather than having the test touch
* the filesystem for it: the whole question is which directory the agent's own
* tools resolve against, and writing the file from the test would answer it by
* assumption.
*/
function respondSpawning(isolation: "worktree" | undefined): (context: Context) => FauxReply {
return (context: Context): FauxReply => {
if (firstUserText(context).includes(CHILD_PROMPT)) {
if (!toolResultNames(context).includes("bash")) {
return fauxToolCall("bash", { command: `echo isolated > ${MARKER_FILE}` });
}
return CHILD_MARKER;
}
// Parent: spawn once, then echo the tool result so a lost one fails loudly.
if (toolResultNames(context).includes("Agent")) {
return `parent saw: ${agentResultText(context)}`;
}
return agentCall({
// Foreground: this test reads the child's marker out of the parent's
// inline Agent tool result, which a background spawn replaces with a
// "started in background" receipt.
run_in_background: false,
description: "worktree work",
prompt: CHILD_PROMPT,
...(isolation ? { isolation } : {}),
});
};
}
describe("worktree isolation e2e (real git, real pi-mono, faux model)", () => {
let run: PrintModeRun | undefined;
const repos: string[] = [];
afterEach(async () => {
await run?.dispose();
run = undefined;
// Module-level switch: applySettings only applies keys that are PRESENT, so
// a later run without the key would otherwise inherit whatever this one set.
setWorktreeIsolationEnabled(true);
for (const dir of repos.splice(0)) {
try { git(dir, "worktree", "prune"); } catch { /* repo may be gone */ }
rmSync(dir, { recursive: true, force: true });
}
});
it("runs the child in the copy and lands its changes on a branch, not the main checkout", async () => {
const repo = initGitRepo();
repos.push(repo);
run = await runPrintMode({
prompt: "Delegate the work.",
cwd: repo,
respond: respondSpawning("worktree"),
live: false,
});
// The child's own tools resolved against the copy — the main checkout never
// saw the file. This is the guarantee; everything below is its bookkeeping.
expect(existsSync(join(repo, MARKER_FILE))).toBe(false);
const result = agentResultText(run.parentSession);
expect(result).toContain(CHILD_MARKER);
// The result names a branch and the command to merge it — the only artifact,
// since the worktree directory does not survive.
const branch = /Changes saved to branch `(pi-agent-[^`]+)`/.exec(result)?.[1];
expect(branch).toBeTruthy();
expect(result).toContain(`git merge ${branch}`);
// That branch exists in the MAIN repo and carries the child's file.
expect(git(repo, "branch", "--list", branch!)).toContain(branch!);
expect(git(repo, "ls-tree", "--name-only", branch!)).toContain(MARKER_FILE);
// And the copy is gone: `git worktree list` is down to the main checkout.
expect(git(repo, "worktree", "list").split("\n")).toHaveLength(1);
});
it("downgrades to the main checkout when the project set worktreeIsolation: false", async () => {
const repo = initGitRepo();
repos.push(repo);
mkdirSync(join(repo, ".pi"), { recursive: true });
writeFileSync(join(repo, ".pi", "subagents.json"), JSON.stringify({ worktreeIsolation: false }));
// The caller passes `isolation: "worktree"` even though the setting drops
// the parameter from the schema — exactly what a model holding a cached tool
// spec does, and the case the downgrade (rather than a throw) exists for.
//
// Mutation note: the resolver gate (invocation-config) and the manager gate
// (agent-manager) are redundant on THIS path, so removing either one alone
// leaves this test green — verified, not assumed. That is the point of the
// second gate, which exists for cross-extension RPC, where options skip the
// resolver entirely. This test pins the behaviour and goes red when both are
// gone; each gate is pinned individually by its own unit test.
run = await runPrintMode({
prompt: "Delegate the work.",
cwd: repo,
respond: respondSpawning("worktree"),
live: false,
});
const result = agentResultText(run.parentSession);
expect(result).toContain(CHILD_MARKER);
// Ran in the main checkout: the file is right there, and no branch was made.
expect(existsSync(join(repo, MARKER_FILE))).toBe(true);
expect(git(repo, "branch", "--list", "pi-agent-*")).toBe("");
expect(git(repo, "worktree", "list").split("\n")).toHaveLength(1);
// Silent by design — no per-result note, which is why the tool description
// drops the isolation bullet alongside the parameter (see index.ts).
expect(result).not.toContain("Changes saved to branch");
});
});