Z ZPI 文档 v0.3.8
介绍页

源码

packages/desktop/test/attachments.test.ts

typescript280 行查看原文
import { randomFillSync } from "node:crypto";
import { existsSync } from "node:fs";
import { mkdir, readFile, realpath, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createReadTool } from "@zpi-dev/coding-agent";
import sharp from "sharp";
import { expect, test } from "vitest";
import { AttachmentStore } from "../src/main/attachments.ts";
import { SessionHost } from "../src/main/session-host.ts";
import { SettingsStore } from "../src/main/storage.ts";
import { cleanup, codec, fixture, png, run } from "./helpers/context-fixture.ts";

test("invalid cache directories and damaged indexes do not block startup or cleanup of other sessions", async () => {
  const f = await fixture();
  const root = join(f.dir, "agent", "attachments");
  await mkdir(join(root, "invalid directory"));
  await mkdir(join(root, "damaged"));
  await writeFile(join(root, "damaged", "index.json"), "{broken");
  await writeFile(join(root, "damaged", "keep.png"), "preserve");
  const image = await f.host.importImage(f.session.id, "unsent.png", await png());
  await f.host.close();
  const reopened = new SessionHost(f.dir, f.settings, f.resources, f.workspace, undefined);
  await reopened.init();
  cleanup.push(() => reopened.close());
  expect(reopened.listRecentSessions().map((record) => record.id)).toContain(f.session.id);
  expect(await readFile(join(root, "damaged", "index.json"), "utf8")).toBe("{broken");
  expect(existsSync(join(root, "damaged", "keep.png"))).toBe(true);
  expect(existsSync(join(root, f.session.id, `${image.id}.png`))).toBe(false);
});

test("attachment IDs loaded from disk cannot escape the owning session during reads or copies", async () => {
  const f = await fixture();
  const root = join(f.dir, "agent", "attachments");
  await mkdir(join(root, "source"));
  await writeFile(join(root, "outside.png"), "private");
  await writeFile(
    join(root, "source", "index.json"),
    JSON.stringify([
      { id: "../outside", name: "image", mimeType: "image/png", width: 1, height: 1, size: 7, used: true },
    ]),
  );
  const store = new AttachmentStore(root);
  await expect(store.read("source", "../outside")).rejects.toThrow("无效附件 ID");
  await expect(store.copyTo("source", "target", ["../outside"])).rejects.toThrow("无效附件 ID");
  expect(await readFile(join(root, "outside.png"), "utf8")).toBe("private");
  expect(existsSync(join(root, "target", "index.json"))).toBe(false);
});

test("opaque image ownership, actual image_url, raw visible references, restart preview and no base64 in projection", async () => {
  const f = await fixture();
  await writeFile(join(f.workspace, "hello world.ts"), "let value=1");
  const image = await f.host.importImage(f.session.id, "original.jpg", await png());
  const other = await f.host.createSession(null);
  await expect(f.host.readAttachment(other.id, image.id)).rejects.toThrow("不属于");
  await run(f, "Inspect", { attachments: [image.id], fileReferences: ["hello world.ts"] });
  const body = JSON.stringify(f.server.requests[0]);
  expect(body).toContain("image_url");
  expect(body).toContain("data:image/png;base64,");
  expect(body).toContain("Referenced project files");
  const snapshot = f.host.getSessionSnapshot(f.session.id);
  expect(snapshot.view.runs[0]).toMatchObject({
    userMessage: "Inspect",
    attachments: [{ id: image.id, width: 3, height: 2 }],
    fileReferences: ["hello world.ts"],
  });
  expect(JSON.stringify(snapshot)).not.toContain("base64");
  expect(JSON.stringify(snapshot)).not.toContain((await f.host.readAttachment(f.session.id, image.id)).data);
  await f.host.removeAttachment(f.session.id, image.id);
  expect((await f.host.readAttachment(f.session.id, image.id)).data).toBeTruthy();
  await f.host.close();
  const restarted = new SessionHost(
    f.dir,
    new SettingsStore(f.dir, codec),
    f.resources,
    f.workspace,
    undefined,
  );
  await restarted.init();
  cleanup.push(() => restarted.close());
  expect(restarted.getSessionSnapshot(f.session.id).view.runs[0].attachments?.[0].id).toBe(image.id);
  expect((await restarted.readAttachment(f.session.id, image.id)).metadata.name).toBe("original.jpg");
  await restarted.deleteSession(f.session.id);
  expect(existsSync(join(f.dir, "agent", "attachments", f.session.id))).toBe(false);
});

test("file attachments work with text-only models, include bounded text and preserve binary files by path", async () => {
  const f = await fixture();
  await f.host.setSessionSelection(f.session.id, { provider: "p", modelId: "plain", reasoning: "none" });
  const textPath = join(f.dir, "outside-workspace.txt");
  const pdfPath = join(f.dir, "document.pdf");
  await writeFile(textPath, "attachment content\n".repeat(3000));
  await writeFile(pdfPath, "%PDF-1.7\n\0binary payload");
  const text = await f.host.importFile(f.session.id, textPath);
  const pdf = await f.host.importFile(f.session.id, pdfPath);
  expect(text).toMatchObject({
    name: "outside-workspace.txt",
    path: await realpath(textPath),
    mimeType: "text/plain",
  });
  expect(pdf).toMatchObject({
    name: "document.pdf",
    path: await realpath(pdfPath),
    mimeType: "application/pdf",
  });
  const other = await f.host.createSession(null);
  await expect(f.host.readAttachment(other.id, pdf.id)).rejects.toThrow("不属于");
  await run(f, "Read the attachments", { attachments: [text.id, pdf.id] });
  const body = JSON.stringify(f.server.requests[0]);
  expect(body).toContain("attachment content");
  expect(body).toContain("Showing lines 1-2000");
  expect(body).toContain(await realpath(pdfPath));
  expect(body).not.toContain("binary payload");
  expect(body).not.toContain("image_url");
  expect(body).not.toContain("base64");
  expect(f.host.getSessionSnapshot(f.session.id).view.runs[0].attachments).toHaveLength(2);
  await f.host.deleteSession(f.session.id);
  expect(await readFile(pdfPath, "utf8")).toContain("%PDF");
  expect(await readFile(textPath, "utf8")).toContain("attachment content");
});

test("file picker imports are bounded by attachment count, validate files and never remove originals", async () => {
  const f = await fixture();
  await expect(f.host.importFile(f.session.id, f.workspace)).rejects.toThrow("文件");
  await expect(f.host.importFile(f.session.id, join(f.dir, "missing.txt"))).rejects.toThrow("ENOENT");
  for (let i = 0; i < 8; i++) {
    const path = join(f.dir, `file-${i}.json`);
    await writeFile(path, "{}");
    await f.host.importFile(f.session.id, path);
  }
  const path = join(f.dir, "ninth.csv");
  await writeFile(path, "a,b");
  await expect(f.host.importFile(f.session.id, path)).rejects.toThrow("8");
  await f.host.attachments.cleanUnsent();
  expect(await readFile(join(f.dir, "file-0.json"), "utf8")).toBe("{}");
});

test("pasted files survive restart and copies, clean up their own cache and do not inject disguised binary text", async () => {
  const f = await fixture();
  const text = await f.host.importAttachment(f.session.id, "pasted.txt", Buffer.from("pasted file content"));
  const binary = await f.host.importAttachment(f.session.id, "binary.txt", Buffer.from("\0binary payload"));
  const unsent = await f.host.importAttachment(f.session.id, "unsent.txt", Buffer.from("unsent"));
  const cached = (await f.host.readAttachment(f.session.id, text.id)).metadata;
  if (!("path" in cached)) throw Error("Expected file attachment");
  await run(f, "Inspect files", { attachments: [text.id, binary.id] });
  expect(JSON.stringify(f.server.requests[0])).toContain("pasted file content");
  expect(JSON.stringify(f.server.requests[0])).not.toContain("binary payload");
  const root = join(f.dir, "agent", "attachments");
  const store = new AttachmentStore(root);
  await store.cleanUnsent();
  expect(existsSync(join(root, f.session.id, `${unsent.id}.file`))).toBe(false);
  expect(existsSync(cached.path)).toBe(true);
  await store.copyTo(f.session.id, "fork", [text.id, binary.id]);
  await store.deleteSession(f.session.id);
  const reopened = new AttachmentStore(root);
  const copied = (await reopened.read("fork", text.id)).metadata;
  if (!("path" in copied)) throw Error("Expected file attachment");
  expect(copied.path).toBe(join(root, "fork", `${text.id}.file`));
  expect(await readFile(copied.path, "utf8")).toBe("pasted file content");
  expect((await reopened.load("fork", [text.id])).fileContext).toContain("pasted file content");
  await reopened.deleteSession("fork");
  expect(existsSync(copied.path)).toBe(false);
});

test("large native text attachments use bounded previews while the original remains readable", async () => {
  const f = await fixture();
  const path = join(f.dir, "large.log");
  await writeFile(path, `${"中文 log\n".repeat(40_000)}last line`);
  const file = await f.host.importFile(f.session.id, path);
  const loaded = await f.host.attachments.load(f.session.id, [file.id]);
  expect(loaded.fileContext).toContain("中文 log");
  expect(loaded.fileContext).toContain("Partial attachment preview");
  expect(loaded.fileContext).not.toContain("last line");
  expect(Buffer.byteLength(loaded.fileContext)).toBeLessThan(55 * 1024);
  expect(await readFile(path, "utf8")).toContain("last line");
});

test("image previews retain originals while sending the same bounded payload as read", async () => {
  const f = await fixture();
  const original = await sharp({ create: { width: 3000, height: 2, channels: 3, background: "red" } })
    .jpeg()
    .toBuffer();
  const path = join(f.dir, "wide.jpg");
  await writeFile(path, original);
  const image = await f.host.importFile(f.session.id, path);
  expect(image).toMatchObject({ mimeType: "image/jpeg", width: 3000, height: 2, size: original.length });
  const root = join(f.dir, "agent", "attachments");
  const indexPath = join(root, f.session.id, "index.json");
  const index = JSON.parse(await readFile(indexPath, "utf8"));
  index[0].width = 1;
  index[0].height = 1;
  index[0].size = 1;
  await writeFile(indexPath, JSON.stringify(index));
  const loaded = await f.host.attachments.load(f.session.id, [image.id]);
  expect(loaded.metadata[0]).toMatchObject({ width: 3000, height: 2, size: original.length });
  expect(loaded.fileContext).toContain("original 3000x2, displayed at 2000x1");
  expect(loaded.fileContext).toContain("1.50 horizontally and 2.00 vertically");
  const read = await createReadTool(f.workspace).execute("image", { path });
  expect(loaded.images).toEqual(read.content.filter((part) => part.type === "image"));
  expect((await f.host.readAttachment(f.session.id, image.id)).data).toBe(original.toString("base64"));
  await f.host.attachments.copyTo(f.session.id, "image-copy", [image.id]);
  const reopened = new AttachmentStore(root);
  expect((await reopened.read("image-copy", image.id)).data).toBe(original.toString("base64"));
  expect((await reopened.load("image-copy", [image.id])).images).toEqual(loaded.images);
  await reopened.cleanUnsent();
  expect(existsSync(join(root, f.session.id, `${image.id}.jpg`))).toBe(false);
  expect(existsSync(join(root, "image-copy", `${image.id}.jpg`))).toBe(true);
  expect((await readFile(path)).equals(original)).toBe(true);
});

test("cached legacy PNGs are revalidated and resized instead of trusting stale indexes", async () => {
  const f = await fixture();
  const original = await sharp({ create: { width: 2400, height: 1200, channels: 3, background: "red" } })
    .png()
    .toBuffer();
  const image = await f.host.importImage(f.session.id, "legacy.png", original);
  const root = join(f.dir, "agent", "attachments", f.session.id);
  const indexPath = join(root, "index.json");
  const index = JSON.parse(await readFile(indexPath, "utf8"));
  index[0].width = 100_000;
  index[0].height = 100_000;
  index[0].warning = "stale animation warning";
  await writeFile(indexPath, JSON.stringify(index));
  const loaded = await f.host.attachments.load(f.session.id, [image.id]);
  expect(loaded.metadata[0]).toMatchObject({ width: 2400, height: 1200 });
  expect(loaded.metadata[0]).not.toHaveProperty("warning");
  expect(await sharp(Buffer.from(loaded.images[0].data, "base64")).metadata()).toMatchObject({
    width: 2000,
    height: 1000,
  });
  await writeFile(join(root, `${image.id}.png`), original.subarray(0, 40));
  await expect(f.host.attachments.load(f.session.id, [image.id])).rejects.toThrow();
});

test("the provider receives a bounded encoding while the attachment cache keeps a large original", async () => {
  const f = await fixture();
  const original = await sharp(randomFillSync(Buffer.alloc(1600 * 1600 * 3)), {
    raw: { width: 1600, height: 1600, channels: 3 },
  })
    .png()
    .toBuffer();
  expect(original.toString("base64").length).toBeGreaterThan(5 * 1024 * 1024);
  const image = await f.host.importImage(f.session.id, "large.png", original);
  await run(f, "Inspect the large image", { attachments: [image.id] });
  expect(f.server.requests).toHaveLength(1);
  const body = JSON.stringify(f.server.requests[0]);
  const payload = /data:image\/jpeg;base64,([A-Za-z0-9+/=]+)/.exec(body)?.[1];
  expect(payload).toBeDefined();
  if (!payload) return;
  expect(payload.length).toBeLessThanOrEqual(5 * 1024 * 1024);
  expect(await sharp(Buffer.from(payload, "base64")).metadata()).toMatchObject({ width: 1600, height: 1600 });
  expect((await f.host.readAttachment(f.session.id, image.id)).data).toBe(original.toString("base64"));
});

test("image imports and cached reads bound source bytes and preserve originals", async () => {
  const f = await fixture();
  const root = join(f.dir, "agent", "attachments");
  const store = new AttachmentStore(root);
  const original = await png();
  const path = join(f.workspace, "source.png");
  await writeFile(path, original);
  const image = await store.importFile(f.session.id, path);
  const cached = join(root, f.session.id, `${image.id}.png`);
  expect(await readFile(cached)).toEqual(original);
  expect(Buffer.from((await store.read(f.session.id, image.id)).data, "base64")).toEqual(original);
  const loaded = await store.load(f.session.id, [image.id]);
  expect(loaded.images).toHaveLength(1);
  expect(Buffer.from(loaded.images[0].data, "base64").length).toBeLessThan(5 * 1024 * 1024);
  const oversized = Buffer.alloc(11 * 1024 * 1024);
  original.copy(oversized);
  await writeFile(path, oversized);
  await expect(store.importFile(f.session.id, path)).rejects.toThrow("10 MiB");
  // Cached index sizes are not trusted: the actual file may have changed after import.
  await writeFile(cached, oversized);
  await expect(store.read(f.session.id, image.id)).rejects.toThrow("byte size limit");
  await expect(store.load(f.session.id, [image.id])).rejects.toThrow("byte size limit");
  await expect(store.copyTo(f.session.id, "copy-target", [image.id])).rejects.toThrow("byte size limit");
  await writeFile(cached, original.subarray(0, 40));
  await expect(store.read(f.session.id, image.id)).rejects.toThrow();
});