Z ZPI 文档 v0.3.8
介绍页

源码

packages/desktop/test/projects.test.ts

typescript218 行查看原文
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, it } from "vitest";
import { chunk, done, fakeServer, send } from "../../../tests/fake-server.ts";
import { SessionHost } from "../src/main/session-host.ts";
import { SettingsStore } from "../src/main/storage.ts";

const codec = {
  isEncryptionAvailable: () => true,
  encryptString: (v: string) => Buffer.from(v),
  decryptString: (v: Buffer) => v.toString(),
};
const cleanup: (() => Promise<unknown>)[] = [];
afterEach(async () => {
  for (const f of cleanup.splice(0).reverse()) await f();
});
async function fixture(
  handler: Parameters<typeof fakeServer>[0] = (_, r) => {
    send(r, chunk({ content: "ok" }));
    done(r);
  },
) {
  const dir = await mkdtemp(join(tmpdir(), "zpi-projects-"));
  cleanup.push(() => rm(dir, { recursive: true, force: true }));
  const server = await fakeServer(handler);
  cleanup.push(server.close);
  const settings = new SettingsStore(dir, codec);
  settings.saveProvider({
    id: "p",
    name: "P",
    baseUrl: server.url,
    apiKey: "fixture-key",
    models: [{ id: "new" }, { id: "plain", reasoning: false }],
  });
  const workspace = join(dir, "workspace");
  const host = new SessionHost(dir, settings, join(dir, "resources"), workspace, undefined);
  await host.init();
  cleanup.push(() => host.close());
  return { dir, workspace, host, settings, server };
}

it("persists project order when adding and restoring a hidden project", async () => {
  const f = await fixture();
  const aPath = join(f.dir, "a"),
    bPath = join(f.dir, "b");
  await mkdir(aPath);
  await mkdir(bPath);
  const a = await f.host.addProject(aPath),
    b = await f.host.addProject(bPath);
  f.host.removeProject(a.id);
  await f.host.addProject(aPath);
  expect(f.host.listProjects().map((project) => project.id)).toEqual([b.id, a.id]);
  await f.host.close();
  const reopened = new SessionHost(f.dir, f.settings, join(f.dir, "resources"), f.workspace);
  cleanup.push(() => reopened.close());
  await reopened.init();
  expect(reopened.listProjects().map((project) => project.id)).toEqual([b.id, a.id]);
});

it("nullable ownership uses the real workspace for AGENTS, skills, init and tools, and reloads the reserved partition", async () => {
  let wrote = false;
  const f = await fixture((body, r) => {
    if (JSON.stringify(body).includes("create or edit ONLY") && !wrote) {
      wrote = true;
      send(
        r,
        chunk({
          tool_calls: [
            {
              index: 0,
              id: "init-write",
              type: "function",
              function: {
                name: "write",
                arguments: JSON.stringify({ path: "AGENTS.md", content: "workspace instructions" }),
              },
            },
          ],
        }),
      );
      done(r, "tool_calls");
    } else if (
      JSON.stringify(body).includes("$inspect inspect") &&
      !JSON.stringify(body).includes("WORKSPACE SKILL")
    ) {
      send(
        r,
        chunk({
          tool_calls: [
            {
              index: 0,
              id: "read-skill",
              type: "function",
              function: {
                name: "read",
                arguments: JSON.stringify({ path: ".zpi/skills/inspect/SKILL.md" }),
              },
            },
          ],
        }),
      );
      done(r, "tool_calls");
    } else {
      send(r, chunk({ content: "done" }));
      done(r);
    }
  });
  expect(existsSync(f.workspace)).toBe(false);
  const s = await f.host.createSession(null);
  expect(s).toMatchObject({ projectId: null, cwd: join(f.workspace, `draft-${s.id}`) });
  expect(f.host.listProjects()).toEqual([]);
  await mkdir(join(String(s.cwd), ".zpi", "skills", "inspect"), { recursive: true });
  await writeFile(
    join(String(s.cwd), ".zpi", "skills", "inspect", "SKILL.md"),
    "---\nname: inspect\ndescription: Workspace\n---\nWORKSPACE SKILL",
  );
  expect((await f.host.listSessionSkills(s.id)).skills.map((s) => s.name)).toContain("inspect");
  await f.host.setSessionSelection(s.id, { provider: "p", modelId: "new", reasoning: "none" });
  await f.host.startRun({ sessionId: s.id, text: "/init" });
  await f.host.activeRuns.get(s.id)?.done;
  expect(await readFile(join(String(s.cwd), "AGENTS.md"), "utf8")).toBe("workspace instructions");
  expect(f.server.requests.every((r) => r.reasoning_effort === "none")).toBe(true);
  await f.host.startRun({ sessionId: s.id, text: "$inspect inspect" });
  await f.host.activeRuns.get(s.id)?.done;
  const skillRequest = f.server.requests.find((request) =>
    JSON.stringify(request).includes("$inspect inspect"),
  );
  expect(JSON.stringify(skillRequest)).toContain(
    join(String(s.cwd), ".zpi", "skills", "inspect", "SKILL.md"),
  );
  expect(JSON.stringify(skillRequest)).not.toContain("WORKSPACE SKILL");
  expect(JSON.stringify(f.server.requests.at(-1))).toContain("WORKSPACE SKILL");
  expect(JSON.stringify(f.server.requests.at(-1))).toContain("workspace instructions");
  const file = join(f.dir, "agent", "sessions", "_unassigned", `${s.id}.jsonl`);
  expect(f.settings.state.getTask(s.id)?.projectId).toBeNull();
  expect(await readFile(file, "utf8")).not.toContain('"projectId"');
  const restarted = new SessionHost(f.dir, f.settings, join(f.dir, "resources"), f.workspace, undefined);
  await restarted.init();
  cleanup.push(() => restarted.close());
  expect(restarted.getSessionSnapshot(s.id).session.cwd).toBe(s.cwd);
  expect(restarted.listRecentSessions().map((s) => s.id)).toEqual([s.id]);
  const p = await f.host.addProject(f.workspace);
  const explicit = await f.host.createSession(p.id);
  expect(explicit.projectId).toBe(p.id);
  expect(f.host.listSessions(p.id).map((s) => s.id)).toEqual([explicit.id]);
  await expect(f.host.createSession("missing")).rejects.toThrow("项目不存在");
});

it("five pins are enforced globally, idempotently and persist through unpin/archive/restart", async () => {
  const f = await fixture();
  const project = await f.host.addProject(f.dir);
  const tasks = await Promise.all(
    Array.from({ length: 7 }, (_, i) => f.host.createSession(i % 2 ? project.id : null)),
  );
  for (const task of tasks.slice(0, 5)) f.host.setSessionPinned(task.id, true);
  const initial = f.host.listRecentSessions().filter((r) => r.pinnedAt != null);
  expect(() => f.host.setSessionPinned(tasks[5].id, true)).toThrow("最多置顶 5 个");
  expect(f.host.setSessionPinned(tasks[0].id, true).pinnedAt).toBe(initial[0].pinnedAt);
  f.host.setSessionPinned(tasks[0].id, false);
  f.host.setSessionPinned(tasks[5].id, true);
  await f.host.archiveSession(tasks[1].id);
  f.host.setSessionPinned(tasks[6].id, true);
  expect(() => f.host.setSessionPinned(tasks[1].id, true)).toThrow();
  await f.host.close();
  const restarted = new SessionHost(f.dir, f.settings, join(f.dir, "resources"), f.workspace, undefined);
  await restarted.init();
  cleanup.push(() => restarted.close());
  expect(
    restarted
      .listRecentSessions()
      .filter((r) => r.pinnedAt != null)
      .map((r) => r.id),
  ).toEqual(tasks.slice(2).map((r) => r.id));
  expect(() => restarted.setSessionPinned(tasks[0].id, true)).toThrow("最多置顶 5 个");
});

it("keeps manually ordered slots through reopen, hidden projects, stale commands and restart", async () => {
  const f = await fixture();
  const projects = [];
  for (const name of ["a", "b", "c"]) {
    const path = join(f.dir, name);
    await mkdir(path);
    projects.push(await f.host.addProject(path));
  }
  const [a, b, c] = projects;
  expect(f.host.listProjects().map((project) => project.id)).toEqual([c.id, b.id, a.id]);
  f.host.reorderProjects([a.id, c.id, b.id]);
  await f.host.addProject(c.path);
  expect(f.host.listProjects().map((project) => project.id)).toEqual([a.id, c.id, b.id]);
  f.host.removeProject(c.id);
  expect(() => f.host.reorderProjects([a.id, c.id, b.id])).toThrow("项目列表已变化");
  expect(() => f.host.reorderProjects([a.id, a.id])).toThrow("项目列表已变化");
  f.host.reorderProjects([b.id, a.id]);
  await f.host.addProject(c.path);
  expect(f.host.listProjects().map((project) => project.id)).toEqual([b.id, c.id, a.id]);
  await f.host.close();
  const reopened = new SessionHost(f.dir, f.settings, join(f.dir, "resources"), f.workspace);
  cleanup.push(() => reopened.close());
  await reopened.init();
  expect(reopened.listProjects().map((project) => project.id)).toEqual([b.id, c.id, a.id]);
});

it("keeps hidden project ownership and cwd when an archived task is restored", async () => {
  const f = await fixture();
  const project = await f.host.addProject(f.dir);
  const task = await f.host.createSession(project.id);
  await f.host.archiveSession(task.id);
  f.host.removeProject(project.id);
  const restored = f.host.restoreSession(task.id);
  expect(restored).toMatchObject({ projectId: project.id, cwd: f.dir, archivedAt: null });
  expect(f.host.listProjects()).toEqual([]);
  expect(f.host.listRecentSessions().find((record) => record.id === task.id)?.projectId).toBe(project.id);
  expect((await f.host.addProject(f.dir)).id).toBe(project.id);
  expect(f.host.listProjects()).toHaveLength(1);
});