mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
190 lines
8.2 KiB
TypeScript
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");
|
|
});
|
|
});
|