Z ZPI 文档 v0.3.8
介绍页

源码

packages/builtin-plugins/test/long-memory.test.ts

typescript943 行查看原文
import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
  LongMemoryService,
  type LongMemorySource,
  type LongMemoryState,
  type MemoryRunRequest,
  type MemoryRunResult,
} from "@zpi-dev/builtin-plugins/memory/long-memory";
import { memoryNotesDirectory } from "@zpi-dev/builtin-plugins/memory/workspace";
import { afterEach, expect, test, vi } from "vitest";
import { defaultMemorySettings, type MemorySettings } from "../src/memory/types.ts";
import type { CombinedSelection } from "../src/types.ts";

const directories: string[] = [];
const services: LongMemoryService[] = [];
const HOUR = 3_600_000;
const DAY = 24 * HOUR;
const model: CombinedSelection = { provider: "fake", modelId: "memory", reasoning: "medium" };
const enabled: MemorySettings = { ...defaultMemorySettings, short: false, long: true };

afterEach(async () => {
  await Promise.all(services.splice(0).map((service) => service.close()));
  await Promise.all(
    directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
  );
});

const validSummary = (text = "") =>
  `v1\n## User Profile\n\n## User preferences\n\n## General Tips\n\n## What's in Memory\n${text}`;

async function fixture() {
  const directory = await mkdtemp(join(tmpdir(), "zpi-long-memory-"));
  directories.push(directory);
  let now = Date.UTC(2026, 9, 23, 10);
  let state: LongMemoryState | undefined;
  let failPublication = false;
  let sources: LongMemorySource[] = [];
  const requests: MemoryRunRequest[] = [];
  const inputs: string[] = [];
  const run = vi.fn(async (request: MemoryRunRequest): Promise<MemoryRunResult> => {
    if (request.stage === "extract")
      return {
        text: JSON.stringify({
          rollout_slug: "test-task",
          rollout_summary: "User asked to preserve API; verified tests passed.",
        }),
        usage: { tokens: 10, cost: 0.001 },
      };
    request.signal.throwIfAborted();
    await writeFile(
      join(request.directory as string, "memory_summary.md"),
      validSummary("## Memory index\nProject a: preserve existing API."),
    );
    return { text: "Updated.", usage: { tokens: 10, cost: 0.001 } };
  });
  const service = new LongMemoryService({
    directory,
    getState: () => state,
    setState: (next) => {
      if (failPublication && next.activeFingerprint !== state?.activeFingerprint)
        throw new Error("storage: publication unavailable");
      state = next;
    },
    sources: async () => sources,
    run: async (request) => {
      requests.push(request);
      inputs.push(
        request.directory
          ? (
              await Promise.all(
                (request.readFiles ?? []).map((path) =>
                  readFile(join(request.directory as string, path), "utf8"),
                ),
              )
            ).join("\n")
          : request.prompt,
      );
      return run(request);
    },
    now: () => now,
  });
  services.push(service);
  const source = (id = "session-a", changes: Partial<LongMemorySource> = {}): LongMemorySource => ({
    id,
    cwd: "/workspace/a",
    title: `Task ${id}`,
    createdAt: now - 2 * DAY,
    updatedAt: now - 7 * HOUR,
    boundary: "1:10",
    evidencePath: join(directory, "history", `${id}.md`),
    read: async () =>
      "[human user] Please preserve the API.\n[tool] Tests passed.\n[assistant proposal] Try another framework.",
    ...changes,
  });
  return {
    service,
    directory,
    run,
    requests,
    inputs,
    source,
    get state() {
      if (!state) throw new Error("Expected persisted memory state");
      return state;
    },
    set state(value) {
      state = value;
    },
    get now() {
      return now;
    },
    set now(value) {
      now = value;
    },
    get sources() {
      return sources;
    },
    set sources(value) {
      sources = value;
    },
    set failPublication(value: boolean) {
      failPublication = value;
    },
  };
}

test.each([
  { short: false, long: false, calls: 0 },
  { short: true, long: false, calls: 0 },
  { short: false, long: true, calls: 2 },
])("exclusive modes: short=$short long=$long", async ({ short, long, calls }) => {
  const f = await fixture();
  f.sources = [f.source()];
  await f.service.check(model, { ...enabled, short, long });
  expect(f.run).toHaveBeenCalledTimes(calls);
  expect(f.service.status().sources.length > 0).toBe(long);
});

test("eligibility uses idle age, ignores active sources, and bounds one batch", async () => {
  const f = await fixture();
  f.sources = [
    f.source("fresh", { updatedAt: f.now - HOUR }),
    f.source("old", { updatedAt: f.now - 11 * DAY }),
    f.source("active", { active: true }),
    f.source("a"),
    f.source("b"),
    f.source("c"),
  ];
  await f.service.check(model, enabled);
  expect(f.requests.filter(({ stage }) => stage === "extract")).toHaveLength(2);
  expect(Object.keys(f.state.jobs)).toEqual(["a", "b"]);
  expect(f.state.records.active).toBeUndefined();
});

test("reports no candidates separately from not yet idle", async () => {
  const f = await fixture();
  await f.service.check(model, enabled);
  expect(f.service.status().status).toBe("no-candidates");
  f.sources = [f.source("fresh", { updatedAt: f.now - HOUR })];
  await f.service.check(model, enabled);
  expect(f.service.status().status).toBe("waiting-idle");
  expect(f.run).not.toHaveBeenCalled();
});

test("two phases publish source-linked files, inject only summary, and skip unchanged inputs", async () => {
  const f = await fixture();
  f.sources = [f.source()];
  await f.service.check(model, enabled);
  expect(f.requests.map(({ stage }) => stage)).toEqual(["extract", "consolidate"]);
  const status = f.service.status();
  expect(status.tokens).toBe(20);
  expect(status.cost).toBe(0.002);
  expect((await readdir(status.path)).sort()).toEqual([
    ".git",
    "extensions",
    "memory_summary.md",
    "rollout_summaries",
  ]);
  const recap = await readFile(status.sources[0].summaryPath ?? "", "utf8");
  expect(recap).toContain('session_id: "session-a"');
  expect(recap).toContain('source_boundary: "1:10"');
  expect(recap).toContain('workspace: "/workspace/a"');
  expect(recap).toContain("evidence_path:");
  const context = await f.service.context();
  expect(context.instructions.join("\n")).toContain("Project a: preserve existing API.");
  expect(context.instructions.join("\n")).not.toContain("User asked to preserve API; verified tests passed.");
  expect(context.sources).toHaveLength(1);
  await f.service.check(model, enabled);
  expect(f.run).toHaveBeenCalledTimes(2);
  expect(f.service.status().status).toBe("unchanged");
});

test("empty extraction persists the boundary and does not repeatedly invoke either model", async () => {
  const f = await fixture();
  f.sources = [f.source()];
  f.run.mockResolvedValue({ text: '{"rollout_slug":"","rollout_summary":""}' });
  await f.service.check(model, enabled);
  await f.service.check(model, enabled);
  expect(f.run).toHaveBeenCalledTimes(1);
  expect(f.state.jobs["session-a"].completed).toBe(true);
  expect(f.state.activeFingerprint).toBeUndefined();
});

test("failed extraction backs off, caps retries, and retries a changed source boundary", async () => {
  const f = await fixture();
  f.sources = [f.source()];
  f.run.mockRejectedValue(
    Object.assign(new Error("Invalid model credentials"), { usage: { tokens: 7, cost: null } }),
  );
  await f.service.check(model, enabled);
  await f.service.check(model, enabled);
  expect(f.run).toHaveBeenCalledTimes(1);
  expect(f.service.status().message).toContain("Invalid model credentials");
  f.now += HOUR;
  await f.service.check(model, enabled);
  f.now += HOUR;
  await f.service.check(model, enabled);
  f.now += HOUR;
  await f.service.check(model, enabled);
  expect(f.run).toHaveBeenCalledTimes(3);
  expect(f.state.jobs["session-a"].completed).toBe(false);
  expect(f.service.status().tokens).toBe(21);
  expect(f.service.status().cost).toBeNull();
  f.sources[0].boundary = "1:20";
  await f.service.check(model, enabled);
  expect(f.run).toHaveBeenCalledTimes(4);
  expect(f.state.jobs["session-a"].attempts).toBe(1);
});

test("concurrent checks deduplicate and freeze triggering model and reasoning for both stages", async () => {
  const f = await fixture();
  f.sources = [f.source()];
  let release!: (value: MemoryRunResult) => void;
  const gate = new Promise<MemoryRunResult>((resolve) => {
    release = resolve;
  });
  f.run.mockImplementationOnce(async () => {
    return gate;
  });
  const trigger = { ...model };
  const first = f.service.check(trigger, enabled);
  expect(f.service.check({ ...model, modelId: "other" }, enabled)).toBe(first);
  trigger.modelId = "changed-later";
  trigger.reasoning = "low";
  await vi.waitFor(() => expect(f.run).toHaveBeenCalledTimes(1));
  release({ text: '{"rollout_slug":"test-task","rollout_summary":"evidence"}' });
  await first;
  expect(f.requests.map(({ model: selection }) => selection)).toEqual([model, model]);
});

test("manual model and stage overrides are explicit; missing manual selection fails visibly", async () => {
  const f = await fixture();
  f.sources = [f.source()];
  await f.service.check(model, { ...enabled, modelMode: "manual" });
  expect(f.service.status().status).toBe("failed");
  expect(f.run).not.toHaveBeenCalled();
  const manual = { provider: "custom", modelId: "base", reasoning: "high" };
  const extraction = { provider: "custom", modelId: "cheap", reasoning: "low" };
  const consolidation = { provider: "custom", modelId: "strong", reasoning: "max" };
  await f.service.check(model, {
    ...enabled,
    modelMode: "manual",
    selection: manual,
    extractSelection: extraction,
    consolidateSelection: consolidation,
  });
  expect(f.requests.map(({ model: selection }) => selection)).toEqual([extraction, consolidation]);
});

test.each(["bad format", "", "oversized"])(
  "invalid summary restores prior publication: %s",
  async (badOutput) => {
    const f = await fixture();
    f.sources = [f.source()];
    await f.service.check(model, enabled);
    const previous = f.state.activeFingerprint;
    const previousSummary = (await f.service.context()).instructions;
    f.sources[0].boundary = "1:20";
    f.run.mockImplementation(async (request) => {
      if (request.stage === "extract")
        return { text: '{"rollout_slug":"test-task","rollout_summary":"new evidence"}' };
      await writeFile(
        join(request.directory as string, "memory_summary.md"),
        badOutput === "oversized" ? validSummary("x".repeat(10000)) : badOutput,
      );
      return { text: "Updated." };
    });
    await f.service.check(model, enabled);
    expect(f.state.activeFingerprint).toBe(previous);
    expect((await f.service.context()).instructions).toEqual(previousSummary);
    expect(f.service.status().status).toBe("failed");
  },
);

test("pause and disable abort a running task even if runner resolves late; pause retains reading", async () => {
  const f = await fixture();
  f.sources = [f.source()];
  await f.service.check(model, enabled);
  const previous = f.state.activeFingerprint;
  f.sources[0].boundary = "1:20";
  let release!: (value: MemoryRunResult) => void;
  f.run.mockImplementationOnce(
    async () =>
      new Promise((resolve) => {
        release = resolve;
      }),
  );
  const task = f.service.check(model, enabled);
  await vi.waitFor(() => expect(release).toBeTypeOf("function"));
  f.service.configure({ ...enabled, generate: false });
  await task;
  release({ text: '{"rollout_slug":"test-task","rollout_summary":"late"}' });
  expect(f.state.activeFingerprint).toBe(previous);
  expect(f.service.status().status).toBe("paused");
  expect((await f.service.context()).sources).toHaveLength(1);
  f.service.configure({ ...enabled, long: false });
  expect((await f.service.context()).sources).toHaveLength(1);
});

test("clear cancels pending tasks, clears artifacts and notes, and tombstones existing source versions", async () => {
  const f = await fixture();
  f.sources = [f.source()];
  await f.service.check(model, enabled);
  await writeFile(join(f.directory, memoryNotesDirectory, "request.md"), "Forget the API preference.");
  f.sources[0].boundary = "1:20";
  let release!: (value: MemoryRunResult) => void;
  f.run.mockImplementationOnce(
    async () =>
      new Promise((resolve) => {
        release = resolve;
      }),
  );
  const task = f.service.check(model, enabled);
  await vi.waitFor(() => expect(release).toBeTypeOf("function"));
  await f.service.clear();
  await task;
  release({ text: '{"rollout_slug":"test-task","rollout_summary":"late"}' });
  expect(f.state.activeFingerprint).toBeUndefined();
  expect(f.state.tombstones["session-a"]).toBe("1:20");
  await expect(readdir(f.directory)).rejects.toMatchObject({ code: "ENOENT" });
  const calls = f.run.mock.calls.length;
  await f.service.check(model, enabled);
  expect(f.run).toHaveBeenCalledTimes(calls);
  f.sources[0].boundary = "1:21";
  await f.service.check(model, enabled);
  expect(f.run).toHaveBeenCalledTimes(calls + 2);
});

test("deleted source is removed from reconstruction and stale summary stays hidden until consolidation", async () => {
  const f = await fixture();
  f.sources = [f.source()];
  await f.service.check(model, enabled);
  const previous = f.state.activeFingerprint;
  await f.service.invalidateSource("session-a");
  expect((await f.service.context()).sources).toEqual([]);
  expect((await f.service.context()).instructions.join("\n")).not.toContain(
    "Project a: preserve existing API.",
  );
  expect(f.state.activeFingerprint).toBe(previous);
  f.sources = [];
  await f.service.check(model, enabled);
  const prompt = f.inputs.at(-1);
  expect(prompt).toContain("deleted file mode");
  expect(f.state.activeSources).toEqual([]);
  expect(await readdir(join(f.service.status().path, "rollout_summaries"))).toEqual([]);
});

test("persistent forget notes change the fingerprint and remain an input after another source update", async () => {
  const f = await fixture();
  f.sources = [f.source()];
  await f.service.check(model, enabled);
  const note = "2026-10-23: Forget the API preference for /workspace/a.";
  await writeFile(join(f.directory, memoryNotesDirectory, "request.md"), note);
  await f.service.check(model, enabled);
  expect(f.requests.at(-1)?.stage).toBe("consolidate");
  expect(f.inputs.at(-1)).toContain(note);
  f.sources[0].boundary = "1:20";
  await f.service.check(model, enabled);
  expect(f.inputs.at(-1)).toContain(note);
  expect(await readFile(join(f.directory, memoryNotesDirectory, "request.md"), "utf8")).toBe(note);
});

test("retention propagates removal but keeps success boundary to avoid regenerating old evidence", async () => {
  const f = await fixture();
  f.sources = [f.source()];
  await f.service.check(model, enabled);
  f.now += 31 * DAY;
  await f.service.check(model, { ...enabled, maxAgeDays: 90 });
  expect(f.requests.filter(({ stage }) => stage === "extract")).toHaveLength(1);
  expect(f.state.records["session-a"]).toBeUndefined();
  expect(f.state.jobs["session-a"].completed).toBe(true);
  expect(f.state.activeSources).toEqual([]);
});

test("actual source use extends retention without forcing consolidation for rank-only changes", async () => {
  const f = await fixture();
  f.sources = [f.source()];
  await f.service.check(model, enabled);
  f.now += 29 * DAY;
  f.service.recordUsage(["session-a", "session-a"]);
  f.now += 2 * DAY;
  await f.service.check(model, { ...enabled, maxAgeDays: 90 });
  expect(f.state.records["session-a"].usageCount).toBe(1);
  expect(f.state.activeSources).toHaveLength(1);
  expect(f.run).toHaveBeenCalledTimes(2);
});

test("evidence provenance instructions and secret redaction apply before upload and storage", async () => {
  const f = await fixture();
  f.sources = [
    f.source("secret", { read: async () => "[human user] Please remember my api_key=sk-1234567890123456" }),
  ];
  const normal = f.run.getMockImplementation();
  if (!normal) throw new Error("Missing fake runner");
  f.run.mockImplementation(async (request) =>
    request.stage === "extract"
      ? {
          text: JSON.stringify({
            rollout_slug: "test-task",
            rollout_summary: "User proposed a key sk-1234567890123456",
          }),
        }
      : normal(request),
  );
  await f.service.check(model, enabled);
  expect(f.requests[0].prompt).not.toContain("sk-1234567890123456");
  expect(f.requests[0].instructions).toContain("injected memories");
  expect(f.requests[0].instructions).toContain("not new evidence");
  expect(JSON.stringify(f.state)).not.toContain("sk-1234567890123456");
});

test("close cancels background work and prevents new requests", async () => {
  const f = await fixture();
  f.sources = [f.source()];
  f.run.mockImplementationOnce(async () => new Promise(() => {}));
  const task = f.service.check(model, enabled);
  await vi.waitFor(() => expect(f.run).toHaveBeenCalledTimes(1));
  await f.service.close();
  await task;
  await f.service.check(model, enabled);
  expect(f.run).toHaveBeenCalledTimes(1);
  expect(f.state.activeFingerprint).toBeUndefined();
});

test("fixed paths survive successive updates while injected summary text is frozen per turn", async () => {
  const f = await fixture();
  f.sources = [f.source()];
  await f.service.check(model, enabled);
  const path = f.service.status().path;
  const context = await f.service.context();
  f.sources[0].boundary = "1:20";
  await f.service.check(model, enabled);
  expect(f.service.status().path).toBe(path);
  expect(await readFile(context.sources[0].summaryPath as string, "utf8")).toContain("1:20");
  expect(await readFile(join(path, "memory_summary.md"), "utf8")).toContain("Memory index");
  expect(await readdir(path)).not.toContain("generations");
});

test("active history keeps its existing memory and deleting an unrelated task does not invalidate it", async () => {
  const f = await fixture();
  f.sources = [f.source()];
  await f.service.check(model, enabled);
  f.sources[0].active = true;
  f.sources[0].boundary = "1:20";
  await f.service.check(model, enabled);
  await f.service.invalidateSource("unrelated-subagent");
  expect(f.state.invalidated).toBe(false);
  expect(f.state.activeSources).toHaveLength(1);
  expect(f.run).toHaveBeenCalledTimes(2);
});

test("a consolidation referencing a deleted source is rejected, keeping old artifacts but hiding stale context", async () => {
  const f = await fixture();
  f.sources = [f.source()];
  await f.service.check(model, enabled);
  const original = f.state.activeFingerprint;
  await f.service.invalidateSource("session-a");
  f.sources = [];
  f.run.mockImplementation(async (request) => {
    await writeFile(
      join(request.directory as string, "memory_summary.md"),
      validSummary("See rollout_summaries/deleted.md"),
    );
    return { text: "Updated." };
  });
  await f.service.check(model, enabled);
  expect(f.service.status().status).toBe("failed");
  expect(f.service.status().message).toContain("已删除的来源");
  expect(f.state.activeFingerprint).toBe(original);
  expect((await f.service.context()).sources).toEqual([]);
});

test("empty regenerated extraction removes a previously retained source from consolidation", async () => {
  const f = await fixture();
  f.sources = [f.source()];
  await f.service.check(model, enabled);
  f.sources[0].boundary = "1:20";
  f.run.mockResolvedValueOnce({ text: '{"rollout_slug":"","rollout_summary":""}' });
  await f.service.check(model, enabled);
  expect(f.state.records["session-a"]).toBeUndefined();
  expect(f.state.activeSources).toEqual([]);
  expect(f.inputs.at(-1)).toContain("deleted file mode");
});

test.each([
  { rollout_summary: "partial", unexpected: "bad" },
  { rollout_slug: "bad path/", rollout_summary: "evidence" },
])("invalid extraction preserves previous valid record: %j", async (partial) => {
  const f = await fixture();
  f.sources = [f.source()];
  await f.service.check(model, enabled);
  const original = structuredClone(f.state.records["session-a"]);
  f.sources[0].boundary = "1:20";
  f.run.mockResolvedValueOnce({ text: JSON.stringify(partial) });
  await f.service.check(model, enabled);
  expect(f.service.status().status).toBe("failed");
  expect(f.state.records["session-a"]).toEqual(original);
  expect(f.state.jobs["session-a"].completed).toBe(false);
});

test("clear supplies a durable evidence cutoff and empty post-clear history requires no model call", async () => {
  const f = await fixture();
  const read = vi.fn(async (_afterBoundary?: string) => "");
  f.sources = [f.source("session-a", { read })];
  await f.service.clear();
  f.sources[0].boundary = "2:1";
  await f.service.check(model, enabled);
  expect(read).toHaveBeenCalledWith("1:10");
  expect(f.run).not.toHaveBeenCalled();
  expect(f.state.jobs["session-a"].completed).toBe(true);
  expect(f.state.activeFingerprint).toBeUndefined();
});

test("an empty rewritten generation rebases the clear cutoff so later appended evidence is eligible", async () => {
  const f = await fixture();
  const read = vi.fn(async (_afterBoundary?: string) => "");
  f.sources = [f.source("session-a", { read })];
  await f.service.clear();
  f.sources[0].boundary = "new:generation:3";
  await f.service.check(model, enabled);
  expect(read).toHaveBeenLastCalledWith("1:10");
  expect(f.state.tombstones["session-a"]).toBe("new:generation:3");
  expect(f.state.jobs["session-a"].completed).toBe(true);
  expect(f.run).not.toHaveBeenCalled();
  f.sources[0].boundary = "new:generation:4";
  read.mockResolvedValueOnce("[human user] A new explicitly reusable preference.");
  await f.service.check(model, enabled);
  expect(read).toHaveBeenLastCalledWith("new:generation:3");
  expect(f.run).toHaveBeenCalledTimes(2);
  expect(f.state.records["session-a"].boundary).toBe("new:generation:4");
});

test("cancelled calls can settle usage separately without publishing their late result", async () => {
  const f = await fixture();
  f.sources = [f.source()];
  let release!: (value: MemoryRunResult) => void;
  f.run.mockImplementationOnce(
    async () =>
      new Promise((resolve) => {
        release = resolve;
      }),
  );
  const work = f.service.check(model, enabled);
  await vi.waitFor(() => expect(release).toBeTypeOf("function"));
  await f.service.stop();
  release({
    text: '{"rollout_slug":"test-task","rollout_summary":"late"}',
    usage: { tokens: 42, cost: null },
  });
  await work;
  await vi.waitFor(() => expect(f.service.status().tokens).toBe(42));
  expect(f.state.activeFingerprint).toBeUndefined();
  expect(f.run).toHaveBeenCalledTimes(1);
});

test("a failed state commit restores the previous summary in the fixed directory", async () => {
  const f = await fixture();
  f.sources = [f.source()];
  await f.service.check(model, enabled);
  const generation = f.state.activeFingerprint;
  f.sources[0].boundary = "1:20";
  f.failPublication = true;
  await f.service.check(model, enabled);
  expect(f.service.status().status).toBe("failed");
  expect(f.state.activeFingerprint).toBe(generation);
  expect(await readdir(f.directory)).not.toContain("generations");
  expect(await readFile(join(f.service.status().path, "memory_summary.md"), "utf8")).toContain(
    "Memory index",
  );
});

test("startup restores the summary journal and drain waits for cancelled transport settlement", async () => {
  const f = await fixture();
  await writeFile(join(f.directory, "memory_summary.md"), "incomplete summary");
  await writeFile(
    join(f.directory, ".summary-backup.json"),
    JSON.stringify({ exists: true, content: validSummary("previous valid summary") }),
  );
  f.sources = [f.source()];
  let release!: (value: MemoryRunResult) => void;
  f.run.mockImplementationOnce(
    async () =>
      new Promise((resolve) => {
        release = resolve;
      }),
  );
  const task = f.service.check(model, enabled);
  await vi.waitFor(() => expect(release).toBeTypeOf("function"));
  expect(await readFile(join(f.directory, "memory_summary.md"), "utf8")).toContain("previous valid summary");
  await f.service.close();
  await task;
  let drained = false;
  const drain = f.service.drain().then(() => {
    drained = true;
  });
  await Promise.resolve();
  expect(drained).toBe(false);
  release({ text: '{"rollout_slug":"test-task","rollout_summary":"late"}' });
  await drain;
  expect(drained).toBe(true);
  expect(f.state.activeFingerprint).toBeUndefined();
});

test("invalidating a no-output job leaves unrelated published memory visible", async () => {
  const f = await fixture();
  f.sources = [f.source("published"), f.source("empty", { read: async () => "" })];
  await f.service.check(model, enabled);
  expect(f.state.jobs.empty.completed).toBe(true);
  expect(f.state.records.empty).toBeUndefined();
  await f.service.invalidateSource("empty");
  f.sources = f.sources.filter(({ id }) => id !== "empty");
  expect(f.state.invalidated).toBe(false);
  expect((await f.service.context()).sources.map(({ sessionId }) => sessionId)).toEqual(["published"]);
  await f.service.check(model, enabled);
  expect(f.service.status().status).toBe("unchanged");
  expect(f.run).toHaveBeenCalledTimes(2);
});

test("identical valid published inputs clear a stale invalidation without another model call", async () => {
  const f = await fixture();
  f.sources = [f.source()];
  await f.service.check(model, enabled);
  f.state = { ...f.state, invalidated: true };
  await f.service.check(model, enabled);
  expect(f.state.invalidated).toBe(false);
  expect((await f.service.context()).sources).toHaveLength(1);
  expect(f.run).toHaveBeenCalledTimes(2);
});

test("archived sources are ineligible but clear also tombstones them before later restoration", async () => {
  const f = await fixture();
  const read = vi.fn(async (_afterBoundary?: string) => "[human user] Newly appended evidence.");
  f.sources = [f.source("archived", { eligible: false, read })];
  await f.service.check(model, enabled);
  expect(f.run).not.toHaveBeenCalled();
  expect(read).not.toHaveBeenCalled();
  await f.service.clear();
  expect(f.state.tombstones.archived).toBe("1:10");
  f.sources[0].eligible = true;
  await f.service.check(model, enabled);
  expect(f.run).not.toHaveBeenCalled();
  f.sources[0].boundary = "1:11";
  await f.service.check(model, enabled);
  expect(read).toHaveBeenLastCalledWith("1:10");
  expect(f.run).toHaveBeenCalledTimes(2);
});

test("archiving a previously published source removes it from the next consolidation", async () => {
  const f = await fixture();
  f.sources = [f.source()];
  await f.service.check(model, enabled);
  f.sources[0].eligible = false;
  await f.service.check(model, enabled);
  expect(f.requests.filter(({ stage }) => stage === "extract")).toHaveLength(1);
  expect(f.state.activeSources).toEqual([]);
  expect(f.inputs.at(-1)).toContain("deleted file mode");
});

test("normal transcript rewrite invalidation permits full extraction without creating a clear cutoff", async () => {
  const f = await fixture();
  const read = vi.fn(async (_afterBoundary?: string) => "[human user] Source evidence.");
  f.sources = [f.source("rewritten", { read })];
  await f.service.check(model, enabled);
  await f.service.invalidateSource("rewritten");
  f.sources[0].boundary = "2:10";
  await f.service.check(model, enabled);
  expect(read).toHaveBeenLastCalledWith(undefined);
  expect(f.state.tombstones.rewritten).toBeUndefined();
  expect(f.state.records.rewritten.boundary).toBe("2:10");
  expect(f.run).toHaveBeenCalledTimes(4);
});

test("slow extraction failures back off from the failure time", async () => {
  const f = await fixture();
  f.sources = [f.source()];
  f.run.mockImplementationOnce(async () => {
    f.now += 2 * HOUR;
    throw new Error("slow extraction failed");
  });
  await f.service.check(model, enabled);
  expect(f.state.jobs["session-a"].retryAt).toBe(f.now + HOUR);
  await f.service.check(model, enabled);
  expect(f.run).toHaveBeenCalledTimes(1);
  f.now += HOUR;
  await f.service.check(model, enabled);
  expect(f.service.status().status).toBe("success");
});

test("slow consolidation failures back off from the failure time", async () => {
  const f = await fixture();
  f.sources = [f.source()];
  const normal = f.run.getMockImplementation();
  f.run.mockImplementation(async (request) => {
    if (request.stage === "consolidate") {
      f.now += 2 * HOUR;
      throw new Error("slow consolidation failed");
    }
    if (!normal) throw new Error("missing model fixture");
    return normal(request);
  });
  await f.service.check(model, enabled);
  expect(f.state.consolidationRetryAt).toBe(f.now + HOUR);
  await f.service.check(model, enabled);
  expect(f.run).toHaveBeenCalledTimes(2);
});

test("cancelling extraction repeatedly does not exhaust the failure budget", async () => {
  const f = await fixture();
  f.sources = [f.source()];
  const normal = f.run.getMockImplementation();
  f.run.mockImplementation(
    async (request) =>
      new Promise((_, reject) => {
        request.signal.addEventListener("abort", () => reject(new Error("cancelled")), { once: true });
      }),
  );
  for (let attempt = 1; attempt <= 3; attempt++) {
    const work = f.service.check(model, enabled);
    await vi.waitFor(() => expect(f.run).toHaveBeenCalledTimes(attempt));
    await f.service.stop();
    await work;
    expect(f.state.jobs["session-a"].attempts).toBe(0);
  }
  if (!normal) throw new Error("missing model fixture");
  f.run.mockImplementation(normal);
  await f.service.check(model, enabled);
  expect(f.service.status().status).toBe("success");
});

test("unchanged inputs restore missing generated source files without another model call", async () => {
  const f = await fixture();
  f.sources = [f.source()];
  await f.service.check(model, enabled);
  const previous = f.service.status();
  await rm(previous.sources[0].summaryPath as string);
  await f.service.check(model, enabled);
  expect(f.run).toHaveBeenCalledTimes(2);
  const current = f.service.status();
  expect(current.path).toBe(previous.path);
  expect(await readFile(current.sources[0].summaryPath as string, "utf8")).toContain("session-a");
});

test("an existing library distinguishes newly waiting history from unchanged evidence", async () => {
  const f = await fixture();
  f.sources = [f.source()];
  await f.service.check(model, enabled);
  f.sources.push(f.source("fresh", { updatedAt: f.now - HOUR }));
  await f.service.check(model, enabled);
  expect(f.service.status().status).toBe("waiting-idle");
  expect(f.service.status().sources).toHaveLength(1);
  expect(f.run).toHaveBeenCalledTimes(2);
});

test("v2 bounds complete per-session summaries to 9,000 UTF-8 bytes without generating raw memories", async () => {
  const f = await fixture();
  f.sources = [f.source()];
  f.run.mockResolvedValueOnce({
    text: JSON.stringify({ rollout_summary: "中文经验".repeat(3000), rollout_slug: "long-summary" }),
  });
  await f.service.check(model, enabled);
  expect(f.service.status().status).toBe("success");
  expect(Buffer.byteLength(f.state.records["session-a"].rolloutSummary, "utf8")).toBeLessThanOrEqual(9000);
  expect(f.state.records["session-a"].rolloutSummary).toContain("truncated");
  expect(f.state.records["session-a"]).not.toHaveProperty("rawMemory");
  expect(await readdir(f.directory)).not.toContain("MEMORY.md");
  expect(await readdir(f.directory)).not.toContain("raw_memories.md");
});

test("the v2 processing version restores retry eligibility for old capped failures", async () => {
  const f = await fixture();
  f.sources = [f.source()];
  await f.service.check(model, enabled);
  const state = f.state;
  state.jobs["session-a"] = {
    boundary: "1:10",
    version: "zpi-memory-1",
    attempts: 3,
    retryAt: f.now + DAY,
    completed: false,
    lastError: "length",
  };
  f.state = state;
  await f.service.check(model, enabled);
  expect(f.requests.filter(({ stage }) => stage === "extract")).toHaveLength(2);
  expect(f.state.jobs["session-a"].version).toBe("zpi-memory-v2");
  expect(f.state.jobs["session-a"].completed).toBe(true);
});

test("a cancelled file worker settles before rollback and the next writer is allowed", async () => {
  const f = await fixture();
  f.sources = [f.source()];
  await f.service.check(model, enabled);
  const old = await readFile(join(f.directory, "memory_summary.md"), "utf8");
  f.sources[0].boundary = "1:20";
  const original = f.run.getMockImplementation();
  if (!original) throw new Error("Missing fake runner");
  let release!: () => void;
  f.run.mockImplementation(async (request) => {
    if (request.stage !== "consolidate") return original(request);
    await writeFile(join(f.directory, "memory_summary.md"), "partial worker output");
    await new Promise<void>((resolve) => {
      release = resolve;
    });
    request.signal.throwIfAborted();
    return { text: "" };
  });
  const work = f.service.check(model, enabled);
  await vi.waitFor(() => expect(release).toBeTypeOf("function"));
  let stopped = false;
  const stop = f.service.stop().then(() => {
    stopped = true;
  });
  await Promise.resolve();
  expect(stopped).toBe(false);
  release();
  await stop;
  await work;
  expect(await readFile(join(f.directory, "memory_summary.md"), "utf8")).toBe(old);
});

test("application close waits for initialization before storage ownership ends", async () => {
  const f = await fixture();
  const events: string[] = [];
  const initialization = f.service.init().then(() => events.push("initialized"));
  const closing = f.service.close().then(() => events.push("closed"));
  await Promise.all([initialization, closing]);
  expect(events).toEqual(["initialized", "closed"]);
  await expect(f.service.init()).rejects.toThrow("已关闭");
});

test("clear waits for initialization so a late initializer cannot recreate the library", async () => {
  const f = await fixture();
  const initialization = f.service.init();
  await f.service.clear();
  await initialization;
  await expect(readdir(f.directory)).rejects.toMatchObject({ code: "ENOENT" });
  expect(f.state.version).toBe(2);
});

test("credential-bearing notes are diagnosed before the Git diff is sent to the model", async () => {
  const f = await fixture();
  f.sources = [f.source()];
  await f.service.check(model, enabled);
  const note = "api_key=sk-1234567890123456";
  await writeFile(join(f.directory, memoryNotesDirectory, "request.md"), note);
  await f.service.check(model, enabled);
  expect(f.run).toHaveBeenCalledTimes(2);
  expect(f.service.status().status).toBe("failed");
  expect(f.service.status().message).toContain("包含可识别凭据");
  expect(f.service.status().message).not.toContain("sk-1234567890123456");
  expect(await readFile(join(f.directory, memoryNotesDirectory, "request.md"), "utf8")).toBe(note);
});

test("notes alone trigger consolidation; new and edited requests retain their provenance and old notes", async () => {
  const f = await fixture();
  await f.service.init();
  const directory = join(f.directory, memoryNotesDirectory);
  const addition = join(directory, "remember.md");
  const correction = join(directory, "correct.md");
  await writeFile(addition, "Remember Chinese responses for project A. NOTE_BODY_ONLY");
  await f.service.check(model, enabled);
  expect(f.requests.map(({ stage }) => stage)).toEqual(["consolidate"]);
  expect(f.inputs.at(-1)).toContain("+Remember Chinese responses");
  expect(f.inputs.at(-1)).toContain("Never delete a note file");
  expect(f.inputs.at(-1)).toContain("[ad-hoc note]");
  const context = (await f.service.context()).instructions.join("\n");
  expect(context).toContain("extensions/ad_hoc/notes/");
  expect(context).toContain("separate, uniquely named note");
  expect(context).not.toContain("NOTE_BODY_ONLY");
  expect(context).not.toContain("Never delete a note file");
  expect(context).not.toContain("memory_notes.md");
  await f.service.check(model, enabled);
  expect(f.run).toHaveBeenCalledTimes(1);
  await writeFile(correction, "Correction: English responses for project A.");
  await f.service.check(model, enabled);
  expect(f.run).toHaveBeenCalledTimes(2);
  expect(f.inputs.at(-1)).toContain("Correction: English responses");
  await writeFile(correction, "Correction: French responses for project A.");
  await f.service.check(model, enabled);
  expect(f.run).toHaveBeenCalledTimes(3);
  expect(f.inputs.at(-1)).toContain("-Correction: English responses");
  expect(f.inputs.at(-1)).toContain("+Correction: French responses");
  expect(await readFile(addition, "utf8")).toContain("Remember Chinese responses");
  expect(await readFile(correction, "utf8")).toContain("French responses");
});

test("a failed note consolidation keeps the previous summary, request and uncommitted Git diff for retry", async () => {
  const f = await fixture();
  f.sources = [f.source()];
  await f.service.check(model, enabled);
  const previous = await readFile(join(f.directory, "memory_summary.md"), "utf8");
  const note = join(f.directory, memoryNotesDirectory, "forget.md");
  await writeFile(note, "Forget the project A procedure.");
  f.run.mockImplementationOnce(async (request) => {
    await writeFile(join(request.directory as string, "memory_summary.md"), "Incomplete");
    throw new Error("provider: interrupted");
  });
  await f.service.check(model, enabled);
  expect(f.service.status().status).toBe("failed");
  expect(await readFile(join(f.directory, "memory_summary.md"), "utf8")).toBe(previous);
  expect(await readFile(note, "utf8")).toContain("Forget the project A procedure");
  f.now += HOUR + 1;
  await f.service.check(model, enabled);
  expect(f.inputs.at(-1)).toContain("+Forget the project A procedure");
  expect(f.service.status().status).toBe("success");
});

test("a note removed externally is propagated through the next consolidation diff", async () => {
  const f = await fixture();
  await f.service.init();
  const path = join(f.directory, memoryNotesDirectory, "obsolete.md");
  await writeFile(path, "Old requested preference.");
  await f.service.check(model, enabled);
  await rm(path);
  await f.service.check(model, enabled);
  expect(f.run).toHaveBeenCalledTimes(2);
  expect(f.inputs.at(-1)).toContain("deleted file mode");
  expect(f.requests.at(-1)?.instructions).toContain("Remove claims supported only by deleted");
  expect(await readdir(join(f.directory, memoryNotesDirectory))).toEqual([]);
});