Z ZPI 文档 v0.3.8
介绍页

源码

packages/desktop/test/worktrees.test.ts

typescript533 行查看原文
import { execFile } from "node:child_process";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { afterEach, expect, it, vi } from "vitest";
import { chunk, deferred, done, send } from "../../../tests/fake-server.ts";
import { SessionHost } from "../src/main/session-host.ts";
import * as worktrees from "../src/main/worktrees.ts";
import { createWorktree, gitEnvironment, resolveWorktreeBase } from "../src/main/worktrees.ts";
import { cleanup, fixture } from "./helpers/host-fixture.ts";

const exec = promisify(execFile);
afterEach(() => vi.restoreAllMocks());
async function git(cwd: string, ...args: string[]) {
  return (await exec("git", ["-C", cwd, ...args])).stdout.trim();
}
async function repo(cwd: string) {
  await git(cwd, "init", "-b", "main");
  await git(cwd, "config", "user.email", "test@example.test");
  await git(cwd, "config", "user.name", "Test");
  await writeFile(join(cwd, "tracked.txt"), "committed");
  await git(cwd, "add", ".");
  await git(cwd, "commit", "-m", "Initial");
}

it("creates detached worktrees from HEAD without adding branches or changing uncommitted source files", async () => {
  const f = await fixture((_, response) => done(response));
  await repo(f.a.path);
  const head = await git(f.a.path, "rev-parse", "HEAD");
  await writeFile(join(f.a.path, "tracked.txt"), "dirty");
  const branches = await git(f.a.path, "for-each-ref", "--format=%(refname)", "refs/heads/");
  const tree = await createWorktree(f.a.path, join(f.dir, "trees"));
  expect(tree.id).toMatch(/^[a-z0-9]{4}$/);
  expect(tree.path).toBe(join(f.dir, "trees", tree.id, "a"));
  expect(await git(tree.path, "rev-parse", "HEAD")).toBe(head);
  expect(await git(tree.path, "branch", "--show-current")).toBe("");
  expect(await readFile(join(tree.path, "tracked.txt"), "utf8")).toBe("committed");
  expect(await readFile(join(f.a.path, "tracked.txt"), "utf8")).toBe("dirty");
  const second = await createWorktree(f.a.path, join(f.dir, "trees"));
  expect(second.id).not.toBe(tree.id);
  expect(await git(f.a.path, "for-each-ref", "--format=%(refname)", "refs/heads/")).toBe(branches);
  expect(await gitEnvironment(f.b.path)).toBeNull();
});

it("lists local and fetched remote branches separately and creates detached worktrees from either", async () => {
  const f = await fixture((_, response) => done(response));
  await repo(f.a.path);
  const initial = await git(f.a.path, "rev-parse", "HEAD");
  await git(f.a.path, "checkout", "-b", "feature/topic");
  await writeFile(join(f.a.path, "tracked.txt"), "local feature");
  await git(f.a.path, "commit", "-am", "Feature");
  const local = await git(f.a.path, "rev-parse", "HEAD");
  // Fetch from another repository so the list represents actual remote-tracking refs.
  await repo(f.b.path);
  await git(f.b.path, "checkout", "-b", "feature/topic");
  await writeFile(join(f.b.path, "tracked.txt"), "remote feature");
  await git(f.b.path, "commit", "-am", "Remote feature");
  const remote = await git(f.b.path, "rev-parse", "HEAD");
  await git(f.a.path, "remote", "add", "origin", f.b.path);
  await git(f.a.path, "fetch", "origin");
  await git(f.a.path, "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main");
  await git(f.a.path, "checkout", "main");
  await writeFile(join(f.a.path, "tracked.txt"), "dirty");
  const environment = await gitEnvironment(f.a.path);
  expect(environment).toMatchObject({ branch: "main", head: initial, currentRef: "refs/heads/main" });
  expect(environment?.branches).toContainEqual({
    ref: "refs/heads/feature/topic",
    name: "feature/topic",
    remote: false,
  });
  expect(environment?.branches).toContainEqual({
    ref: "refs/remotes/origin/feature/topic",
    name: "origin/feature/topic",
    remote: true,
  });
  expect(environment?.branches.some((branch) => branch.ref === "refs/remotes/origin/HEAD")).toBe(false);
  const branches = await git(f.a.path, "for-each-ref", "--format=%(refname)", "refs/heads/");
  for (const [ref, head, content] of [
    ["refs/heads/feature/topic", local, "local feature"],
    ["refs/remotes/origin/feature/topic", remote, "remote feature"],
  ]) {
    const tree = await createWorktree(f.a.path, join(f.dir, "trees"), undefined, ref);
    expect(tree.id).toMatch(/^[a-z0-9]{4}$/);
    expect(await git(tree.path, "rev-parse", "HEAD")).toBe(head);
    expect(await git(tree.path, "branch", "--show-current")).toBe("");
    expect(await readFile(join(tree.path, "tracked.txt"), "utf8")).toBe(content);
  }
  expect(await git(f.a.path, "for-each-ref", "--format=%(refname)", "refs/heads/")).toBe(branches);
  expect(await git(f.a.path, "branch", "--show-current")).toBe("main");
  expect(await readFile(join(f.a.path, "tracked.txt"), "utf8")).toBe("dirty");
  expect(await git(f.a.path, "rev-parse", "refs/heads/feature/topic")).toBe(local);
  expect(await git(f.a.path, "rev-parse", "refs/remotes/origin/feature/topic")).toBe(remote);
});

it("rejects missing branches and revision expressions and supports a detached starting commit", async () => {
  const f = await fixture((_, response) => done(response));
  await repo(f.a.path);
  for (const ref of ["main", "--help", "refs/heads/main~1", "refs/heads/main^{commit}"])
    await expect(resolveWorktreeBase(f.a.path, ref)).rejects.toThrow("无效起始分支");
  await expect(
    createWorktree(f.a.path, join(f.dir, "trees"), undefined, "refs/heads/deleted"),
  ).rejects.toThrow("起始分支不存在");
  const head = await git(f.a.path, "rev-parse", "HEAD");
  await git(f.a.path, "checkout", "--detach");
  expect(await gitEnvironment(f.a.path)).toMatchObject({ currentRef: null, head });
  const tree = await createWorktree(f.a.path, join(f.dir, "trees"), undefined, "HEAD");
  expect(await git(tree.path, "rev-parse", "HEAD")).toBe(head);
});

it("keeps commits in detached worktrees and allows creating a named branch later", async () => {
  const f = await fixture((_, response) => done(response));
  await repo(f.a.path);
  const sourceHead = await git(f.a.path, "rev-parse", "HEAD");
  const tree = await createWorktree(f.a.path, join(f.dir, "trees"));
  await writeFile(join(tree.path, "tracked.txt"), "worktree commit");
  await git(tree.path, "commit", "-am", "Detached work");
  const head = await git(tree.path, "rev-parse", "HEAD");
  expect(head).not.toBe(sourceHead);
  expect(await git(tree.path, "branch", "--show-current")).toBe("");
  expect(await git(f.a.path, "rev-parse", "HEAD")).toBe(sourceHead);
  expect(await git(f.a.path, "for-each-ref", "--format=%(refname)", "refs/heads/")).toBe("refs/heads/main");
  await git(tree.path, "switch", "-c", "feature/saved-work");
  expect(await git(tree.path, "rev-parse", "refs/heads/feature/saved-work")).toBe(head);
  expect(await readFile(join(tree.path, "tracked.txt"), "utf8")).toBe("worktree commit");
});

it("restores existing named worktrees without detaching them or allocating another checkout", async () => {
  const f = await fixture((_, response) => done(response));
  await repo(f.a.path);
  const path = join(f.dir, "legacy-tree");
  await git(f.a.path, "worktree", "add", "-b", "legacy", path);
  const session = await f.host.createSession(f.a.id, { cwd: path });
  await f.host.close();
  const worktree = { branch: "legacy", path, sourceCwd: f.a.path };
  const persisted = f.settings.state.getTask(session.id);
  if (!persisted) throw new Error("Missing task fixture");
  f.settings.state.upsertTask({ ...persisted, newWorktree: true, worktree });
  const host = new SessionHost(f.dir, f.settings, f.dir);
  await host.init();
  cleanup.push(() => host.close());
  expect(host.getSessionSnapshot(session.id).session.worktree).toEqual(worktree);
  expect(host.workspaceInfo(session.id).cwd).toBe(path);
  await host.startRun({ sessionId: session.id, text: "continue" });
  await host.activeRuns.get(session.id)?.done;
  expect(host.workspaceInfo(session.id).cwd).toBe(path);
  expect(await git(path, "branch", "--show-current")).toBe("legacy");
  expect((await git(f.a.path, "worktree", "list", "--porcelain")).match(/^worktree /gm)).toHaveLength(2);
});

it("restores the chosen starting branch and resolves its latest commit at first send", async () => {
  const f = await fixture((_, response) => done(response));
  await repo(f.a.path);
  await git(f.a.path, "branch", "feature/topic");
  const session = await f.host.createSession(f.a.id);
  expect(await f.host.setNewWorktree(session.id, true)).toMatchObject({ worktreeBaseRef: "refs/heads/main" });
  await f.host.setNewWorktree(session.id, true, "refs/heads/feature/topic");
  await f.host.setNewWorktree(session.id, false);
  expect(await f.host.setNewWorktree(session.id, true)).toMatchObject({
    worktreeBaseRef: "refs/heads/feature/topic",
  });
  expect((await git(f.a.path, "worktree", "list", "--porcelain")).match(/^worktree /gm)).toHaveLength(1);
  expect(f.host.getSessionSnapshot(session.id).session.worktree).toBeUndefined();
  await f.host.close();
  const host = new SessionHost(
    f.dir,
    f.settings,
    f.dir,
    undefined,
    undefined,
    undefined,
    join(f.dir, "trees"),
  );
  await host.init();
  cleanup.push(() => host.close());
  expect(host.getSessionSnapshot(session.id).session).toMatchObject({
    draft: true,
    worktreeBaseRef: "refs/heads/feature/topic",
  });
  await git(f.a.path, "checkout", "feature/topic");
  await writeFile(join(f.a.path, "tracked.txt"), "latest feature");
  await git(f.a.path, "commit", "-am", "Latest feature");
  const head = await git(f.a.path, "rev-parse", "HEAD");
  await git(f.a.path, "checkout", "main");
  await host.startRun({ sessionId: session.id, text: "work" });
  await host.activeRuns.get(session.id)?.done;
  const record = host.getSessionSnapshot(session.id).session;
  expect(await git(record.cwd as string, "rev-parse", "HEAD")).toBe(head);
  expect(await readFile(join(record.cwd as string, "tracked.txt"), "utf8")).toBe("latest feature");
  expect(await git(f.a.path, "branch", "--show-current")).toBe("main");
  await expect(host.setNewWorktree(session.id, true, "refs/heads/main")).rejects.toThrow("首次发送前");
  await writeFile(join(record.cwd as string, "tracked.txt"), "saved detached commit");
  await git(record.cwd as string, "commit", "-am", "Saved detached commit");
  const committedHead = await git(record.cwd as string, "rev-parse", "HEAD");
  await host.close();
  const restored = new SessionHost(f.dir, f.settings, f.dir);
  await restored.init();
  cleanup.push(() => restored.close());
  expect(restored.getSessionSnapshot(session.id).session).toMatchObject({
    worktreeBaseRef: "refs/heads/feature/topic",
    worktree: record.worktree,
    cwd: record.cwd,
  });
  expect(await git(restored.workspaceInfo(session.id).cwd, "rev-parse", "HEAD")).toBe(committedHead);
  expect(await git(restored.workspaceInfo(session.id).cwd, "branch", "--show-current")).toBe("");
});

it("reports a deleted starting branch without running the model and retries that branch when restored", async () => {
  const f = await fixture((_, response) => done(response));
  await repo(f.a.path);
  await git(f.a.path, "branch", "feature/topic");
  const session = await f.host.createSession(f.a.id);
  await expect(f.host.setNewWorktree(session.id, true, "refs/heads/missing")).rejects.toThrow(
    "起始分支不存在",
  );
  expect(f.host.getSessionSnapshot(session.id).session.newWorktree).toBeUndefined();
  await f.host.setNewWorktree(session.id, true, "refs/heads/feature/topic");
  await git(f.a.path, "branch", "-D", "feature/topic");
  await f.host.submitInput({ sessionId: session.id, text: "work" });
  await f.host.activeRuns.get(session.id)?.done;
  expect(f.host.getSessionSnapshot(session.id).view.runs[0]).toMatchObject({
    status: "error",
    error: expect.stringContaining("起始分支不存在"),
  });
  expect(f.server.requests).toHaveLength(0);
  expect(f.host.getSessionSnapshot(session.id).session.worktree).toBeUndefined();
  await git(f.a.path, "branch", "feature/topic");
  const actual = worktrees.createWorktree;
  vi.spyOn(worktrees, "createWorktree").mockImplementation((cwd, _root, onProgress, baseRef) =>
    actual(cwd, join(f.dir, "trees"), onProgress, baseRef),
  );
  await f.host.submitInput({ sessionId: session.id, text: "retry" });
  await f.host.activeRuns.get(session.id)?.done;
  expect(f.host.getSessionSnapshot(session.id).view.runs.map((run) => run.status)).toEqual([
    "error",
    "completed",
  ]);
});

it("defers creation until sending and restores the actual worktree for prompts, tools and subsequent sessions", async () => {
  const f = await fixture((_, response, index) => {
    send(
      response,
      chunk(
        index === 0
          ? {
              tool_calls: [
                {
                  index: 0,
                  id: "write",
                  type: "function",
                  function: {
                    name: "write",
                    arguments: JSON.stringify({ path: "tracked.txt", content: "agent result" }),
                  },
                },
              ],
            }
          : { content: "ok" },
      ),
    );
    done(response, index === 0 ? "tool_calls" : "stop");
  });
  await repo(f.a.path);
  await f.host.close();
  const root = join(f.dir, "trees");
  const host = new SessionHost(f.dir, f.settings, f.dir, undefined, undefined, undefined, root);
  await host.init();
  cleanup.push(() => host.close());
  const session = await host.createSession(f.a.id);
  await host.setNewWorktree(session.id, true);
  expect(host.workspaceInfo(session.id).cwd).toBe(f.a.path);
  await writeFile(join(f.a.path, "tracked.txt"), "second");
  await git(f.a.path, "commit", "-am", "Second");
  const head = await git(f.a.path, "rev-parse", "HEAD");
  await host.startRun({ sessionId: session.id, text: "work" });
  await host.activeRuns.get(session.id)?.done;
  const record = host.getSessionSnapshot(session.id).session;
  expect(record.worktree?.id).toMatch(/^[a-z0-9]{4}$/);
  expect(record.cwd).toBe(join(root, record.worktree?.id as string, "a"));
  expect(record.cwd).toBe(record.worktree?.path);
  expect(await git(record.cwd as string, "rev-parse", "HEAD")).toBe(head);
  expect(JSON.stringify(f.server.requests.at(-1)?.messages)).toContain(record.cwd);
  expect(host.sessions.get(session.id)?.systemPrompt).toContain(record.cwd);
  expect(await readFile(join(record.cwd as string, "tracked.txt"), "utf8")).toBe("agent result");
  expect(await readFile(join(f.a.path, "tracked.txt"), "utf8")).toBe("second");
  await expect(host.setNewWorktree(session.id, false)).rejects.toThrow("首次发送前");
  await host.close();
  const restored = new SessionHost(f.dir, f.settings, f.dir, undefined, undefined, undefined, root);
  await restored.init();
  cleanup.push(() => restored.close());
  expect(restored.workspaceInfo(session.id).cwd).toBe(record.cwd);
  await restored.startRun({ sessionId: session.id, text: "continue" });
  await restored.activeRuns.get(session.id)?.done;
  expect(JSON.stringify(f.server.requests.at(-1)?.messages)).toContain(record.cwd);
  const side = await restored.createSideChat(session.id);
  expect(restored.workspaceInfo(side.id).cwd).toBe(record.cwd);
  const runId = restored.getSessionSnapshot(session.id).view.runs.at(-1)?.runId;
  const fork = await restored.forkSession(session.id, runId as string);
  expect(fork.worktree).toEqual(record.worktree);
  expect(restored.workspaceInfo(fork.id).cwd).toBe(record.cwd);
});

it("resolves relative line references in the new worktree while preserving explicit absolute references", async () => {
  const f = await fixture((_, response) => {
    send(response, chunk({ content: "ok" }));
    done(response);
  });
  await repo(f.a.path);
  await f.host.close();
  const host = new SessionHost(
    f.dir,
    f.settings,
    f.dir,
    undefined,
    undefined,
    undefined,
    join(f.dir, "trees"),
  );
  await host.init();
  cleanup.push(() => host.close());
  const session = await host.createSession(f.a.id);
  await host.setNewWorktree(session.id, true);
  const sourceFile = join(f.a.path, "tracked.txt");
  const references = ["tracked.txt", "tracked.txt:1", "tracked.txt#L1", `${sourceFile}:1`];
  await host.startRun({ sessionId: session.id, text: "check references", fileReferences: references });
  await host.activeRuns.get(session.id)?.done;
  const snapshot = host.getSessionSnapshot(session.id);
  const messages = f.server.requests[0]?.messages as unknown as { role: string; content: string }[];
  const user = messages.find((message) => message.role === "user");
  expect(user?.content).toContain(
    ["tracked.txt", join(snapshot.session.cwd as string, "tracked.txt"), sourceFile]
      .map((path) => `- ${JSON.stringify(path)}`)
      .join("\n"),
  );
  expect(snapshot.view.runs[0].fileReferences).toEqual(references);
});

for (const reference of ["tracked.txt", "tracked.txt:1"]) {
  it(`submits ${reference} even when absent from the selected worktree branch`, async () => {
    const f = await fixture((_, response) => done(response));
    await repo(f.a.path);
    await git(f.a.path, "checkout", "-b", "without-file");
    await git(f.a.path, "rm", "tracked.txt");
    await git(f.a.path, "commit", "-m", "Remove file");
    await git(f.a.path, "checkout", "main");
    await f.host.close();
    const host = new SessionHost(
      f.dir,
      f.settings,
      f.dir,
      undefined,
      undefined,
      undefined,
      join(f.dir, "trees"),
    );
    await host.init();
    cleanup.push(() => host.close());
    const session = await host.createSession(f.a.id);
    await host.setNewWorktree(session.id, true, "refs/heads/without-file");
    await host.startRun({ sessionId: session.id, text: "read reference", fileReferences: [reference] });
    await host.activeRuns.get(session.id)?.done;
    expect(f.server.requests).toHaveLength(1);
    const snapshot = host.getSessionSnapshot(session.id);
    expect(snapshot.view.runs[0]).toMatchObject({
      status: "completed",
      fileReferences: [reference],
    });
    const expected =
      reference === "tracked.txt" ? reference : join(snapshot.session.cwd as string, "tracked.txt");
    const messages = f.server.requests[0].messages as unknown as { role: string; content: string }[];
    expect(messages.find((message) => message.role === "user")?.content).toContain(
      `- ${JSON.stringify(expected)}`,
    );
  });
}

for (const workspaceMode of ["preserve", "rewind"] as const) {
  it(`preserves worktree ownership after editing the first turn with ${workspaceMode} and restarting`, async () => {
    const f = await fixture((_, response, index) => {
      send(
        response,
        chunk(
          index === 0
            ? {
                tool_calls: [
                  {
                    index: 0,
                    id: "write",
                    type: "function",
                    function: {
                      name: "write",
                      arguments: JSON.stringify({ path: "tracked.txt", content: "agent result" }),
                    },
                  },
                ],
              }
            : { content: "ok" },
        ),
      );
      done(response, index === 0 ? "tool_calls" : "stop");
    });
    await repo(f.a.path);
    await f.host.close();
    const root = join(f.dir, "trees");
    const host = new SessionHost(f.dir, f.settings, f.dir, undefined, undefined, undefined, root);
    await host.init();
    cleanup.push(() => host.close());
    const session = await host.createSession(f.a.id);
    await host.setNewWorktree(session.id, true);
    const first = await host.startRun({ sessionId: session.id, text: "first message" });
    await host.activeRuns.get(session.id)?.done;
    const before = host.getSessionSnapshot(session.id).session;
    expect(before.worktree?.path).toBe(before.cwd);
    const edited = await host.editUserMessage(session.id, first.runId, {
      text: "replacement message",
      workspaceMode,
    });
    expect(edited).not.toHaveProperty("conflicts");
    await host.activeRuns.get(session.id)?.done;
    expect(await readFile(join(before.cwd as string, "tracked.txt"), "utf8")).toBe(
      workspaceMode === "rewind" ? "committed" : "agent result",
    );
    await host.close();
    const restored = new SessionHost(f.dir, f.settings, f.dir, undefined, undefined, undefined, root);
    await restored.init();
    cleanup.push(() => restored.close());
    expect(restored.getSessionSnapshot(session.id).session).toMatchObject({
      cwd: before.cwd,
      worktree: before.worktree,
      newWorktree: true,
      worktreeBaseRef: "refs/heads/main",
    });
    await restored.startRun({ sessionId: session.id, text: "continue after restart" });
    await restored.activeRuns.get(session.id)?.done;
    expect(restored.getSessionSnapshot(session.id).session.cwd).toBe(before.cwd);
    expect(JSON.stringify(f.server.requests.at(-1)?.messages)).toContain(before.cwd);
    expect((await git(f.a.path, "worktree", "list", "--porcelain")).match(/^worktree /gm)).toHaveLength(2);
  });
}

it("acknowledges a send and publishes preparation before checkout while keeping other tasks usable", async () => {
  const gate = deferred();
  const f = await fixture((_, response) => {
    send(response, chunk({ content: "ready" }));
    done(response);
  });
  cleanup.push(async () => gate.resolve());
  await repo(f.a.path);
  const actual = worktrees.createWorktree;
  vi.spyOn(worktrees, "createWorktree").mockImplementation(async (cwd, _root, onProgress) => {
    onProgress?.("正在创建工作树 · abcd…");
    await gate.promise;
    return actual(cwd, join(f.dir, "trees"), onProgress);
  });
  const { host } = f;
  const session = await host.createSession(f.a.id);
  await host.setNewWorktree(session.id, true);
  const result = await host.submitInput({ sessionId: session.id, text: "work" });
  if (!("runId" in result)) throw new Error("Expected an accepted run");
  const snapshot = host.getSessionSnapshot(session.id);
  expect(snapshot.session).toMatchObject({ draft: false, running: true });
  expect(snapshot.view.runs).toMatchObject([
    { runId: result.runId, userMessage: "work", status: "running", preparation: "正在创建工作树 · abcd…" },
  ]);
  expect(f.server.requests).toHaveLength(0);
  await expect(host.createSideChat(session.id)).rejects.toThrow("工作树尚未创建");
  expect(host.getSessionSnapshot((await host.createSession(f.b.id)).id).session.cwd).toBe(f.b.path);
  await expect(host.startRun({ sessionId: session.id, text: "duplicate" })).rejects.toThrow("正在运行");
  const finished = host.activeRuns.get(session.id)?.done;
  gate.resolve();
  await finished;
  const completed = host.getSessionSnapshot(session.id);
  expect(completed.view.runs).toHaveLength(1);
  expect(completed.view.runs[0]).toMatchObject({ status: "completed", preparation: null });
  expect(completed.session.cwd).toBe(completed.session.worktree?.path);
  const side = await host.createSideChat(session.id);
  expect(host.workspaceInfo(side.id).cwd).toBe(completed.session.cwd);
  expect(JSON.stringify(f.server.requests[0].messages)).toContain(completed.session.cwd);
});

it("shows checkout failures in the accepted task and retries creating the worktree before using tools", async () => {
  const f = await fixture((_, response) => done(response));
  await repo(f.a.path);
  const actual = worktrees.createWorktree;
  vi.spyOn(worktrees, "createWorktree")
    .mockRejectedValueOnce(new Error("checkout failed"))
    .mockImplementation((cwd, _root, onProgress) => actual(cwd, join(f.dir, "trees"), onProgress));
  const session = await f.host.createSession(f.a.id);
  await f.host.setNewWorktree(session.id, true);
  await f.host.submitInput({ sessionId: session.id, text: "work" });
  await f.host.activeRuns.get(session.id)?.done;
  const failed = f.host.getSessionSnapshot(session.id);
  expect(failed.view.runs[0]).toMatchObject({
    userMessage: "work",
    status: "error",
    error: "checkout failed",
    preparation: null,
  });
  expect(failed.session.draft).toBe(false);
  expect(f.server.requests).toHaveLength(0);
  expect(await readFile(f.host.sessionPath(session.id), "utf8")).toContain("checkout failed");
  await f.host.submitInput({ sessionId: session.id, text: "retry" });
  await f.host.activeRuns.get(session.id)?.done;
  const retried = f.host.getSessionSnapshot(session.id);
  expect(retried.session.worktree?.path).toBe(retried.session.cwd);
  expect(retried.view.runs.map((run) => run.status)).toEqual(["error", "completed"]);
  expect(JSON.stringify(f.server.requests[0].messages)).toContain(retried.session.cwd);
});

it("does not call the model after stopping during checkout", async () => {
  const gate = deferred();
  const f = await fixture((_, response) => done(response));
  cleanup.push(async () => gate.resolve());
  await repo(f.a.path);
  const actual = worktrees.createWorktree;
  vi.spyOn(worktrees, "createWorktree").mockImplementation(async (cwd, _root, onProgress) => {
    await gate.promise;
    return actual(cwd, join(f.dir, "trees"), onProgress);
  });
  const session = await f.host.createSession(f.a.id);
  await f.host.setNewWorktree(session.id, true);
  const { runId } = await f.host.startRun({ sessionId: session.id, text: "work" });
  const stopped = f.host.abortRun({ sessionId: session.id, runId });
  gate.resolve();
  await stopped;
  expect(f.host.getSessionSnapshot(session.id).view.runs[0]).toMatchObject({
    status: "aborted",
    preparation: null,
  });
  expect(f.server.requests).toHaveLength(0);
  expect(f.host.activeRuns.has(session.id)).toBe(false);
});