feat: auto-delete old conversations, and stop a tool call that repeats (#15)

This commit was merged in pull request #15.
This commit is contained in:
2026-10-05 09:05:15 +00:00
parent 576c5946f7
commit 05fd9ff13b
25 changed files with 2372 additions and 59 deletions
+167 -21
View File
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import path from "node:path";
import { readFileSync } from "node:fs";
import { pickToolCalls, runAgent } from "../lib/agent";
import { MAX_TOOL_ITERATIONS } from "../lib/constants";
import { DEFAULT_MAX_TOOL_ROUNDS } from "../lib/constants";
import { OllamaError } from "../lib/ollama";
import { toolsFor } from "../lib/tools";
import type { ApiMessage, ToolCall } from "../lib/types";
@@ -28,6 +28,19 @@ function answer(text: string): Record<string, unknown>[] {
return [{ message: { role: "assistant", content: text }, done: true, done_reason: "stop" }];
}
/**
* One tool call that is distinct every round.
*
* The arguments have to differ, or the repeat guard in `agent.ts` ends the turn
* before the round cap the tests are about ever gets there. `get_datetime` is the
* tool to use because it never touches the network, so the only `fetch` calls a
* test sees are the ones to the chat endpoint. (`web_search` with a real `query`
* fetches, which quietly ate the queued mock responses.)
*/
function distinctCall(index: number): Record<string, unknown> {
return toolCall("get_datetime", { attempt: index });
}
const fetchMock = vi.fn();
beforeEach(() => {
@@ -137,15 +150,15 @@ describe("runAgent", () => {
it("stops after the iteration cap instead of looping forever", async () => {
// Every response asks for another tool call, so only the cap can end this.
for (let i = 0; i < MAX_TOOL_ITERATIONS + 2; i++) {
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime")]));
for (let i = 0; i < DEFAULT_MAX_TOOL_ROUNDS + 2; i++) {
fetchMock.mockResolvedValueOnce(ndjsonResponse([distinctCall(i)]));
}
const result = await runAgent(baseOptions([{ role: "user", content: "hi" }]));
expect(result.toolCalls).toHaveLength(MAX_TOOL_ITERATIONS);
expect(result.toolCalls).toHaveLength(DEFAULT_MAX_TOOL_ROUNDS);
// Every tool round, plus one closing round with the tools taken away.
expect(fetchMock).toHaveBeenCalledTimes(MAX_TOOL_ITERATIONS + 1);
expect(fetchMock).toHaveBeenCalledTimes(DEFAULT_MAX_TOOL_ROUNDS + 1);
// The closing round produced no text either, so the notice is the last resort.
expect(result.content).toContain("Ran out of tool calls");
});
@@ -153,8 +166,8 @@ describe("runAgent", () => {
it("gives the model a closing round so a spent budget still ends in an answer", async () => {
// The reported failure: a broad research question used up the budget and the
// turn ended with a notice about the loop instead of an answer.
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("web_search")]));
for (let i = 0; i < DEFAULT_MAX_TOOL_ROUNDS; i++) {
fetchMock.mockResolvedValueOnce(ndjsonResponse([distinctCall(i)]));
}
fetchMock.mockResolvedValueOnce(
ndjsonResponse(answer("Meadows have Perch, at 2 fish each. I could not confirm the others.")),
@@ -167,15 +180,15 @@ describe("runAgent", () => {
});
it("takes the tools away for the closing round, so it writes rather than searches again", async () => {
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("web_search")]));
for (let i = 0; i < DEFAULT_MAX_TOOL_ROUNDS; i++) {
fetchMock.mockResolvedValueOnce(ndjsonResponse([distinctCall(i)]));
}
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("Here is what I found.")));
await runAgent(baseOptions([{ role: "user", content: "hi" }]));
const first = JSON.parse(fetchMock.mock.calls[0][1].body as string) as { tools?: unknown };
const last = JSON.parse(fetchMock.mock.calls[MAX_TOOL_ITERATIONS][1].body as string) as {
const last = JSON.parse(fetchMock.mock.calls[DEFAULT_MAX_TOOL_ROUNDS][1].body as string) as {
tools?: unknown;
};
@@ -184,14 +197,14 @@ describe("runAgent", () => {
});
it("asks the closing round to name what it could not confirm", async () => {
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("web_search")]));
for (let i = 0; i < DEFAULT_MAX_TOOL_ROUNDS; i++) {
fetchMock.mockResolvedValueOnce(ndjsonResponse([distinctCall(i)]));
}
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("ok")));
await runAgent(baseOptions([{ role: "user", content: "hi" }]));
const last = JSON.parse(fetchMock.mock.calls[MAX_TOOL_ITERATIONS][1].body as string) as {
const last = JSON.parse(fetchMock.mock.calls[DEFAULT_MAX_TOOL_ROUNDS][1].body as string) as {
messages: ApiMessage[];
};
const nudge = last.messages[last.messages.length - 1];
@@ -204,17 +217,17 @@ describe("runAgent", () => {
it("falls back to the last preamble when the closing round comes back empty", async () => {
// A preamble is not normally the answer, but a turn that gathered things
// should still say more than a notice about its own loop.
const preamble = {
const preamble = (i: number) => ({
message: {
role: "assistant",
content: "Let me look that up.",
tool_calls: [{ function: { name: "web_search", arguments: {} } }],
tool_calls: [{ function: { name: "get_datetime", arguments: { attempt: i } } }],
},
done: true,
};
});
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
fetchMock.mockResolvedValueOnce(ndjsonResponse([preamble]));
for (let i = 0; i < DEFAULT_MAX_TOOL_ROUNDS; i++) {
fetchMock.mockResolvedValueOnce(ndjsonResponse([preamble(i)]));
}
fetchMock.mockResolvedValueOnce(ndjsonResponse([{ message: { role: "assistant", content: "" }, done: true }]));
@@ -224,14 +237,14 @@ describe("runAgent", () => {
});
it("still reports the tools it used when the budget runs out", async () => {
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime")]));
for (let i = 0; i < DEFAULT_MAX_TOOL_ROUNDS; i++) {
fetchMock.mockResolvedValueOnce(ndjsonResponse([distinctCall(i)]));
}
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("done")));
const result = await runAgent(baseOptions([{ role: "user", content: "hi" }]));
expect(result.toolCalls).toHaveLength(MAX_TOOL_ITERATIONS);
expect(result.toolCalls).toHaveLength(DEFAULT_MAX_TOOL_ROUNDS);
expect(result.toolCalls.every((call) => call.name === "get_datetime")).toBe(true);
});
@@ -248,6 +261,138 @@ describe("runAgent", () => {
});
});
/** Queues `count` rounds that all ask for the identical call. */
function queueRepeats(count: number): void {
for (let i = 0; i < count; i++) {
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { tz: "UTC" })]));
}
}
it("stops when the same call comes round a third time instead of running it again", async () => {
// The rut this guards against: the model decides the first search did not
// answer the question and asks the identical question again, and again.
// Two executions is a retry; a third is not going to learn anything new.
queueRepeats(3);
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("It is 03:04 UTC.")));
const result = await runAgent(baseOptions([{ role: "user", content: "hi" }]));
// Two executions, plus the refusal that ended the turn.
expect(result.toolCalls).toHaveLength(3);
expect(result.toolCalls.filter((call) => call.repeated)).toHaveLength(1);
expect(result.content).toBe("It is 03:04 UTC.");
// Two tool rounds, the refusal round, and the closing round.
expect(fetchMock).toHaveBeenCalledTimes(4);
});
it("does not count a different call with the same tool as a repeat", async () => {
// Searching for three different things is a model doing its job, not a loop.
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { tz: "UTC" })]));
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { tz: "CET" })]));
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { tz: "JST" })]));
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("Three zones.")));
const result = await runAgent(baseOptions([{ role: "user", content: "hi" }]));
expect(result.toolCalls).toHaveLength(3);
expect(result.toolCalls.every((call) => !call.repeated)).toBe(true);
});
it("treats reordered arguments as the same call", async () => {
// Models are not consistent about key order, and a guard that only catches
// byte-identical calls would miss the ordinary shape of a repeat.
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { tz: "UTC", unit: "h" })]));
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { unit: "h", tz: "UTC" })]));
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { tz: "UTC", unit: "h" })]));
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("done")));
const result = await runAgent(baseOptions([{ role: "user", content: "hi" }]));
expect(result.toolCalls.filter((call) => call.repeated)).toHaveLength(1);
});
it("tells the model the call was repeating rather than that a budget ran out", async () => {
queueRepeats(3);
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("ok")));
await runAgent(baseOptions([{ role: "user", content: "hi" }]));
const last = JSON.parse(fetchMock.mock.calls[3][1].body as string) as { messages: ApiMessage[] };
const nudge = last.messages[last.messages.length - 1];
expect(nudge.role).toBe("user");
expect(nudge.content).toContain("identical arguments 3 times");
expect(nudge.content).not.toContain("reached the limit");
});
it("answers from what it gathered when a repeat ends the turn", async () => {
queueRepeats(3);
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("I could not get past the time check.")));
const result = await runAgent(baseOptions([{ role: "user", content: "hi" }]));
expect(result.content).toBe("I could not get past the time check.");
expect(result.content).not.toContain("kept repeating");
});
it("says the turn was cut short when a repeat leaves the closing round speechless", async () => {
queueRepeats(3);
fetchMock.mockResolvedValueOnce(
ndjsonResponse([{ message: { role: "assistant", content: "" }, done: true }]),
);
const result = await runAgent(baseOptions([{ role: "user", content: "hi" }]));
expect(result.content).toContain("kept repeating the same call");
});
it("honours a caller's round cap in place of the default", async () => {
for (let i = 0; i < 4; i++) {
fetchMock.mockResolvedValueOnce(ndjsonResponse([distinctCall(i)]));
}
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("done")));
const result = await runAgent({ ...baseOptions([{ role: "user", content: "hi" }]), maxToolRounds: 4 });
expect(result.toolCalls).toHaveLength(4);
expect(result.content).toBe("done");
});
it("runs past the default cap when the caller asks for no cap at all", async () => {
// The preference can be switched to Unlimited, and the only difference that
// should make is that the cap stops arriving.
const rounds = DEFAULT_MAX_TOOL_ROUNDS + 3;
for (let i = 0; i < rounds; i++) {
fetchMock.mockResolvedValueOnce(ndjsonResponse([distinctCall(i)]));
}
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("done")));
const result = await runAgent({ ...baseOptions([{ role: "user", content: "hi" }]), maxToolRounds: null });
expect(result.toolCalls).toHaveLength(rounds);
expect(result.content).toBe("done");
});
it("reports each tool start with how many times that call has come round", async () => {
// This is what the transcript shows while a turn is in flight, so a repeat
// is visible as it happens and not only in the record afterwards.
const started: { name: string; label: string; count: number }[] = [];
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { tz: "UTC" })]));
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { tz: "UTC" })]));
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("done")));
await runAgent({
...baseOptions([{ role: "user", content: "hi" }]),
events: { onToolStart: (name, label, count) => started.push({ name, label, count }) },
});
expect(started).toEqual([
{ name: "get_datetime", label: "Checking the time", count: 1 },
{ name: "get_datetime", label: "Checking the time", count: 2 },
]);
});
describe("module wiring", () => {
it("keeps the request, tool, and agent layers free of Vicinae imports", () => {
// These modules must stay renderer-free: they are pure logic, they are what
@@ -263,6 +408,7 @@ describe("module wiring", () => {
"capture.ts",
"attachments.ts",
"startup.ts",
"retention.ts",
]) {
const source = readFileSync(path.resolve(__dirname, `../lib/${file}`), "utf8");
expect(source, `${file} should not import @vicinae/api`).not.toMatch(/from\s+"@vicinae\/api"/);
+177
View File
@@ -0,0 +1,177 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { access, mkdir, rm, utimes, writeFile } from "node:fs/promises";
import { __resetLocalStorage, __resetToasts, __toasts } from "../__mocks__/@vicinae/api";
import { clearAllChats, clearOrphanedSnips, runAutoDelete } from "../lib/cleanup";
import { createSession, createTurn, listSessions, saveSession } from "../lib/sessions";
/**
* The one thing the sweep does that the pure decision in `retention.ts` cannot:
* tell the user about it. What matters here is that it stays quiet otherwise,
* because a notification on every launch is one people stop reading.
*/
const DAY = 24 * 60 * 60 * 1000;
const DIRECTORY = "/tmp/lam-agent-support/snips";
beforeEach(async () => {
__resetLocalStorage();
__resetToasts();
await rm(DIRECTORY, { recursive: true, force: true });
});
afterEach(async () => {
await rm(DIRECTORY, { recursive: true, force: true });
});
/** A turn carrying one attached capture. */
function snipTurn(path: string) {
return { ...createTurn("what is this", "m", Date.now()), attachments: [{ path, mime: "image/png" }] };
}
/** Saves a conversation that refers to a fresh capture of the given name. */
async function referenceSnip(name: string, ageMs: number): Promise<string> {
await mkdir(DIRECTORY, { recursive: true });
const path = `${DIRECTORY}/${name}`;
await writeFile(path, "png");
const when = new Date(Date.now() - ageMs);
await utimes(path, when, when);
await saveSession({
...createSession("m", Date.now()),
turns: [snipTurn(path)],
});
return path;
}
describe("runAutoDelete", () => {
it("deletes nothing and says nothing when auto-deletion is off", async () => {
await saveSession({ ...createSession("m", 1), updatedAt: 1 });
await runAutoDelete({ maxAgeMs: undefined });
expect(await listSessions()).toHaveLength(1);
expect(__toasts).toEqual([]);
});
it("stays quiet when nothing was old enough", async () => {
await saveSession({ ...createSession("m", Date.now()) });
await runAutoDelete({ maxAgeMs: 7 * DAY });
expect(__toasts).toEqual([]);
expect(await listSessions()).toHaveLength(1);
});
it("reports what it deleted", async () => {
const now = Date.now();
await saveSession({ ...createSession("m", now - 60 * DAY), updatedAt: now - 60 * DAY });
await saveSession({ ...createSession("m", now), updatedAt: now });
await runAutoDelete({ maxAgeMs: 7 * DAY });
expect(await listSessions()).toHaveLength(1);
expect(__toasts).toHaveLength(1);
expect(__toasts[0].title).toBe("Cleared out old conversations");
expect(__toasts[0].message).toContain("1 conversation");
});
it("stays quiet when the only old conversation is the one being rendered", async () => {
const now = Date.now();
const open = { ...createSession("m", now), updatedAt: now - 60 * DAY };
await saveSession(open);
await runAutoDelete({ maxAgeMs: 7 * DAY, keepId: open.id });
expect(__toasts).toEqual([]);
expect(await listSessions()).toHaveLength(1);
});
it("survives a corrupt index rather than failing the command that called it", async () => {
const { LocalStorage } = await import("../__mocks__/@vicinae/api");
await LocalStorage.setItem("lam.sessions.index", "{not json");
await expect(runAutoDelete({ maxAgeMs: 7 * DAY })).resolves.toBeUndefined();
expect(__toasts).toEqual([]);
});
});
describe("the maintenance actions", () => {
const DAY = 24 * 60 * 60 * 1000;
async function makeSnip(name: string, ageMs: number): Promise<string> {
await mkdir(`${DIRECTORY}`, { recursive: true });
const path = `${DIRECTORY}/${name}`;
await writeFile(path, "png");
const when = new Date(Date.now() - ageMs);
await utimes(path, when, when);
return path;
}
it("deletes every conversation and its screenshots, and reports both counts", async () => {
const path = await makeSnip("one.png", 0);
await saveSession({
...createSession("m", Date.now()),
turns: [snipTurn(path)],
});
await saveSession({ ...createSession("m", Date.now()), turns: [createTurn("hi", "m", Date.now())] });
await clearAllChats();
expect(await listSessions()).toHaveLength(0);
await expect(access(path)).rejects.toThrow();
expect(__toasts).toEqual([
{
title: "Cleared out all conversations",
message: "2 conversations and 1 screenshot",
},
]);
});
it("answers an explicit clear even when there was nothing to delete", async () => {
// The user pressed a button, so silence would read as a failure. The
// background sweep stays quiet in the same situation, on purpose.
await clearAllChats();
expect(__toasts).toEqual([
{ title: "Cleared out all conversations", message: "there was nothing to delete" },
]);
});
it("collects unattached photos and reports the count", async () => {
await referenceSnip("kept.png", 30 * DAY);
await makeSnip("orphan.png", 30 * DAY);
await clearOrphanedSnips();
expect(__toasts).toEqual([{ title: "Cleared out unattached photos", message: "1 screenshot" }]);
});
it("says there was nothing to delete when every photo is spoken for", async () => {
await referenceSnip("kept.png", 30 * DAY);
await clearOrphanedSnips();
expect(__toasts).toEqual([
{ title: "Cleared out unattached photos", message: "there was nothing to delete" },
]);
});
it("refuses rather than guessing when a conversation cannot be read", async () => {
await makeSnip("orphan.png", 30 * DAY);
await saveSession({ ...createSession("m", Date.now()) });
const { LocalStorage } = await import("../__mocks__/@vicinae/api");
const [summary] = await listSessions();
const legacy = { ...summary };
delete legacy.attachmentPaths;
await LocalStorage.setItem("lam.sessions.index", JSON.stringify([legacy]));
await LocalStorage.removeItem(`lam.session.${summary.id}`);
await clearOrphanedSnips();
expect(__toasts).toHaveLength(1);
expect(__toasts[0].title).toContain("Could not check");
});
});
+73
View File
@@ -231,6 +231,79 @@ describe("buildThreadMarkdown", () => {
expect(reasoningOnly).toContain("Reasoning");
});
it("names a refused repeat, and does not call it a failure", () => {
const markdown = buildThreadMarkdown([
message({
role: "assistant",
text: "a",
toolCalls: [
{ name: "web_search", arguments: {}, result: "ok", isError: false },
{
name: "web_search",
arguments: {},
result: "Refused",
isError: true,
repeated: true,
},
],
}),
]);
expect(markdown).toContain("`web_search` (repeated, stopped)");
expect(markdown).not.toContain("(failed)");
});
it("shows a failed call even when tool calls are hidden", () => {
// Hiding the line would leave the user with an answer they cannot account
// for, which is worse than a quiet italic line they did not ask for.
const markdown = buildThreadMarkdown(
[
message({
role: "assistant",
text: "a",
toolCalls: [{ name: "web_fetch", arguments: {}, result: "boom", isError: true }],
}),
],
{ showToolCalls: false, showReasoning: false },
);
expect(markdown).toContain("`web_fetch` (failed)");
});
it("shows a refused repeat even when tool calls are hidden", () => {
// This is the one the user most needs to see: a model going in circles is
// exactly the thing a hidden tool list would conceal.
const markdown = buildThreadMarkdown(
[
message({
role: "assistant",
text: "a",
toolCalls: [
{ name: "web_search", arguments: {}, result: "Refused", isError: true, repeated: true },
],
}),
],
{ showToolCalls: false, showReasoning: false },
);
expect(markdown).toContain("`web_search` (repeated, stopped)");
});
it("still hides the ordinary calls when tool calls are off", () => {
const markdown = buildThreadMarkdown(
[
message({
role: "assistant",
text: "a",
toolCalls: [{ name: "get_datetime", arguments: {}, result: "ok", isError: false }],
}),
],
{ showToolCalls: false, showReasoning: false },
);
expect(markdown).not.toContain("_Tools:");
});
it("only shows a reasoning section when there is reasoning", () => {
expect(buildThreadMarkdown([message({ role: "assistant", text: "a" })])).not.toContain("Reasoning");
expect(buildThreadMarkdown([message({ role: "assistant", text: "a", thinking: "hmm" })])).toContain(
+100 -2
View File
@@ -1,6 +1,14 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_HOST, DEFAULT_MODEL } from "../lib/constants";
import { describeResumeTimeout, resolvePreferences, resolveResumeTimeout } from "../lib/preferences";
import { DEFAULT_HOST, DEFAULT_MAX_TOOL_ROUNDS, DEFAULT_MODEL } from "../lib/constants";
import {
describeAutoDelete,
describeMaxToolRounds,
describeResumeTimeout,
resolveAutoDelete,
resolveMaxToolRounds,
resolvePreferences,
resolveResumeTimeout,
} from "../lib/preferences";
const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
@@ -53,6 +61,10 @@ describe("resolvePreferences", () => {
expect(resolved.showReasoning).toBe(true);
expect(resolved.think).toBeUndefined();
expect(resolved.resumeTimeoutMs).toBe(HOUR);
// Auto-deletion is the one setting here that destroys data, so its default
// is off and every unresolvable value lands on the same answer.
expect(resolved.autoDeleteMs).toBeUndefined();
expect(resolved.maxToolRounds).toBe(DEFAULT_MAX_TOOL_ROUNDS);
// An empty string rather than undefined, so callers never branch on it.
expect(resolved.place).toBe("");
});
@@ -104,4 +116,90 @@ describe("resolvePreferences", () => {
expect(resolvePreferences({ resumeTimeout: "15m" }).resumeTimeoutMs).toBe(15 * MINUTE);
expect(resolvePreferences({ resumeTimeout: "never" }).resumeTimeoutMs).toBeUndefined();
});
it("carries the auto-delete window through, including disabled", () => {
expect(resolvePreferences({ autoDeleteAfter: "30d" }).autoDeleteMs).toBe(30 * DAY);
expect(resolvePreferences({ autoDeleteAfter: "disabled" }).autoDeleteMs).toBeUndefined();
expect(resolvePreferences({ autoDeleteAfter: "garbage" }).autoDeleteMs).toBeUndefined();
});
it("carries the tool-call cap through, including unlimited", () => {
expect(resolvePreferences({ maxToolRounds: "16" }).maxToolRounds).toBe(16);
expect(resolvePreferences({ maxToolRounds: "unlimited" }).maxToolRounds).toBeUndefined();
expect(resolvePreferences({ maxToolRounds: "garbage" }).maxToolRounds).toBe(DEFAULT_MAX_TOOL_ROUNDS);
});
});
describe("resolveAutoDelete", () => {
it("maps each configured window", () => {
expect(resolveAutoDelete("1d")).toBe(DAY);
expect(resolveAutoDelete("7d")).toBe(7 * DAY);
expect(resolveAutoDelete("30d")).toBe(30 * DAY);
expect(resolveAutoDelete("90d")).toBe(90 * DAY);
});
it("returns undefined for 'disabled', which is the default", () => {
expect(resolveAutoDelete("disabled")).toBeUndefined();
});
it("returns undefined for missing or unrecognised values rather than guessing", () => {
// Both readings of an unknown value are safe here only because the fallback
// is "delete nothing". See `resolveMaxToolRounds` for the other convention.
expect(resolveAutoDelete(undefined)).toBeUndefined();
expect(resolveAutoDelete("")).toBeUndefined();
expect(resolveAutoDelete("banana")).toBeUndefined();
expect(resolveAutoDelete("1y")).toBeUndefined();
});
it("tolerates surrounding whitespace and casing", () => {
expect(resolveAutoDelete(" 7D ")).toBe(7 * DAY);
});
});
describe("describeAutoDelete", () => {
it("names the off case rather than showing a number", () => {
expect(describeAutoDelete(undefined)).toContain("Disabled");
});
it("describes days", () => {
expect(describeAutoDelete(DAY)).toBe("After 1 day");
expect(describeAutoDelete(30 * DAY)).toBe("After 30 days");
});
});
describe("resolveMaxToolRounds", () => {
it("maps each configured cap", () => {
expect(resolveMaxToolRounds("4")).toBe(4);
expect(resolveMaxToolRounds("8")).toBe(8);
expect(resolveMaxToolRounds("16")).toBe(16);
});
it("returns undefined for unlimited, which is what removes the cap", () => {
expect(resolveMaxToolRounds("unlimited")).toBeUndefined();
});
it("falls back to the default cap for anything unrecognised", () => {
// Deliberately the opposite of `resolveAutoDelete`: those two settings are
// safe when they do nothing, and this one guards a loop, so the safe reading
// of a value this codebase does not understand is the cap and not its absence.
expect(resolveMaxToolRounds(undefined)).toBe(DEFAULT_MAX_TOOL_ROUNDS);
expect(resolveMaxToolRounds("")).toBe(DEFAULT_MAX_TOOL_ROUNDS);
expect(resolveMaxToolRounds("banana")).toBe(DEFAULT_MAX_TOOL_ROUNDS);
expect(resolveMaxToolRounds("0")).toBe(DEFAULT_MAX_TOOL_ROUNDS);
});
it("tolerates surrounding whitespace and casing", () => {
expect(resolveMaxToolRounds(" UNLIMITED ")).toBeUndefined();
});
});
describe("describeMaxToolRounds", () => {
it("names the uncapped case", () => {
expect(describeMaxToolRounds(undefined)).toContain("Unlimited");
});
it("describes a cap in rounds", () => {
expect(describeMaxToolRounds(8)).toBe("8 rounds per answer");
expect(describeMaxToolRounds(1)).toBe("1 round per answer");
});
});
+267
View File
@@ -0,0 +1,267 @@
import { describe, expect, it, vi } from "vitest";
import { SNIP_ORPHAN_GRACE_MS } from "../lib/constants";
import {
describeRemoval,
expiredSessions,
orphanedSnips,
sweepExpired,
type SnipFile,
type SweepPorts,
} from "../lib/retention";
import type { SessionSummary } from "../lib/types";
/**
* The deletion rules.
*
* This is the only code in the extension that destroys anything, so these tests
* are mostly about what it refuses to delete: the off switch, the open
* conversation, a snip that is still pending, and the whole orphan pass when the
* referenced set is not trustworthy.
*/
const DAY = 24 * 60 * 60 * 1000;
const now = 1_000 * DAY;
function summary(id: string, updatedAt: number): SessionSummary {
return {
id,
title: id,
model: "test-model",
createdAt: updatedAt,
updatedAt,
turnCount: 1,
preview: "",
attachmentPaths: [],
};
}
function snip(path: string, mtimeMs: number): SnipFile {
return { path, mtimeMs };
}
describe("expiredSessions", () => {
it("is off when no window is configured", () => {
expect(expiredSessions([summary("a", now - 400 * DAY)], undefined, now)).toEqual([]);
});
it("picks the conversations that have not been used for longer than the window", () => {
const recent = summary("recent", now - DAY + 1);
const stale = summary("stale", now - 8 * DAY);
expect(expiredSessions([recent, stale], 7 * DAY, now)).toEqual([stale]);
});
it("ages a conversation from when it was last used, not when it was started", () => {
// A chat started last month and used this morning is not old.
const longLived = { ...summary("long-lived", now - 1_000), createdAt: now - 60 * DAY };
expect(expiredSessions([longLived], 7 * DAY, now)).toEqual([]);
});
it("counts a conversation updated exactly at the window as expired", () => {
expect(expiredSessions([summary("edge", now - 7 * DAY)], 7 * DAY, now)).toHaveLength(1);
});
it("never expires the conversation that is open", () => {
const open = summary("open", now - 400 * DAY);
expect(expiredSessions([open, summary("other", now - 400 * DAY)], 7 * DAY, now, "open")).toEqual([
expect.objectContaining({ id: "other" }),
]);
});
it("treats a clock that has run backwards as fresh rather than as ancient", () => {
expect(expiredSessions([summary("future", now + DAY)], 7 * DAY, now)).toEqual([]);
});
});
describe("orphanedSnips", () => {
it("collects an unreferenced file once it has waited out the grace period", () => {
const old = snip("/snips/old.png", now - SNIP_ORPHAN_GRACE_MS - 1);
expect(orphanedSnips([old], [], now)).toEqual([old]);
});
it("leaves an unreferenced file alone while it could still be a pending snip", () => {
// The file is on disk before the chat view opens and stays unreferenced
// until the user sends it, so this wait is what protects it.
const fresh = snip("/snips/fresh.png", now - 1_000);
expect(orphanedSnips([fresh], [], now)).toEqual([]);
});
it("keeps a file a surviving conversation still refers to, however old it is", () => {
const referenced = snip("/snips/referenced.png", now - 400 * DAY);
expect(orphanedSnips([referenced], ["/snips/referenced.png"], now)).toEqual([]);
});
it("honours a caller's own grace period", () => {
const file = snip("/snips/one.png", now - 2 * 60 * 1000);
expect(orphanedSnips([file], [], now, 60 * 1000)).toEqual([file]);
});
});
describe("sweepExpired", () => {
function ports(overrides: Partial<SweepPorts> = {}): SweepPorts {
return {
listSessions: vi.fn(async () => []),
deleteSessions: vi.fn(async () => []),
referencedAttachments: vi.fn(async () => []),
listSnipFiles: vi.fn(async () => []),
removeSnipFiles: vi.fn(async () => 0),
...overrides,
};
}
it("reads and writes nothing at all when it is disabled", async () => {
const fake = ports();
const outcome = await sweepExpired({ maxAgeMs: undefined, now }, fake);
expect(outcome).toEqual({ sessions: 0, files: 0 });
expect(fake.listSessions).not.toHaveBeenCalled();
expect(fake.listSnipFiles).not.toHaveBeenCalled();
expect(fake.removeSnipFiles).not.toHaveBeenCalled();
});
it("deletes expired conversations and the screenshots they held", async () => {
const fake = ports({
listSessions: vi.fn(async () => [summary("stale", now - 8 * DAY), summary("fresh", now)]),
deleteSessions: vi.fn(async () => ["/snips/taken-minutes-ago.png"]),
});
const outcome = await sweepExpired({ maxAgeMs: 7 * DAY, now }, fake);
expect(fake.deleteSessions).toHaveBeenCalledWith(["stale"]);
// Explicitly, not by the grace-period rule: a screenshot taken minutes before
// its conversation is deleted should go with it.
expect(fake.removeSnipFiles).toHaveBeenCalledWith(["/snips/taken-minutes-ago.png"]);
expect(outcome).toEqual({ sessions: 1, files: 1 });
});
it("collects orphan files and spares the ones a conversation still refers to", async () => {
const orphan = "/snips/orphan.png";
const referenced = "/snips/referenced.png";
const fake = ports({
listSessions: vi.fn(async () => [summary("kept", now)]),
referencedAttachments: vi.fn(async () => [referenced]),
listSnipFiles: vi.fn(async () => [
snip(orphan, now - 2 * DAY),
snip(referenced, now - 2 * DAY),
]),
});
const outcome = await sweepExpired({ maxAgeMs: 7 * DAY, now }, fake);
expect(fake.removeSnipFiles).toHaveBeenCalledWith([orphan]);
expect(outcome).toEqual({ sessions: 0, files: 1 });
});
it("skips the orphan pass when the referenced set cannot be established", async () => {
// An incomplete set of references is indistinguishable from a pile of
// orphans, and the wrong answer deletes real screenshots.
const fake = ports({
referencedAttachments: vi.fn(async () => undefined),
listSnipFiles: vi.fn(async () => [snip("/snips/maybe.png", now - 400 * DAY)]),
});
const outcome = await sweepExpired({ maxAgeMs: 7 * DAY, now }, fake);
expect(fake.removeSnipFiles).not.toHaveBeenCalled();
expect(outcome).toEqual({ sessions: 0, files: 0 });
});
it("never deletes the conversation that is open", async () => {
const fake = ports({
listSessions: vi.fn(async () => [summary("open", now - 400 * DAY)]),
});
const outcome = await sweepExpired({ maxAgeMs: 7 * DAY, now, keepId: "open" }, fake);
expect(fake.deleteSessions).not.toHaveBeenCalled();
expect(outcome.sessions).toBe(0);
});
it("keeps a screenshot a surviving conversation also refers to", async () => {
// Two conversations can hold the same capture, so the deleted one's files are
// not simply its own.
const shared = "/snips/shared.png";
const fake = ports({
listSessions: vi.fn(async () => [summary("stale", now - 8 * DAY), summary("keep", now)]),
deleteSessions: vi.fn(async () => [shared]),
referencedAttachments: vi.fn(async () => [shared]),
});
const outcome = await sweepExpired({ maxAgeMs: 7 * DAY, now }, fake);
expect(fake.removeSnipFiles).not.toHaveBeenCalled();
expect(outcome).toEqual({ sessions: 1, files: 0 });
});
it("does not remove a deleted conversation's files when the references are unknown", async () => {
// Leaking a file is recoverable. Deleting one a survivor still shows is not.
const fake = ports({
listSessions: vi.fn(async () => [summary("stale", now - 8 * DAY)]),
deleteSessions: vi.fn(async () => ["/snips/unverifiable.png"]),
referencedAttachments: vi.fn(async () => undefined),
});
const outcome = await sweepExpired({ maxAgeMs: 7 * DAY, now }, fake);
expect(fake.removeSnipFiles).not.toHaveBeenCalled();
expect(outcome).toEqual({ sessions: 1, files: 0 });
});
it("does not remove the same file twice when both passes find it", async () => {
const fake = ports({
listSessions: vi.fn(async () => [summary("stale", now - 8 * DAY)]),
deleteSessions: vi.fn(async () => ["/snips/both.png"]),
listSnipFiles: vi.fn(async () => [snip("/snips/both.png", now - 8 * DAY)]),
});
await sweepExpired({ maxAgeMs: 7 * DAY, now }, fake);
expect(fake.removeSnipFiles).toHaveBeenCalledWith(["/snips/both.png"]);
});
it("still tidies files when there is nothing to expire", async () => {
// An unsent capture belongs to no conversation, so no amount of session
// deletion would ever reach it.
const fake = ports({
listSnipFiles: vi.fn(async () => [snip("/snips/unsent.png", now - 400 * DAY)]),
});
const outcome = await sweepExpired({ maxAgeMs: 7 * DAY, now }, fake);
expect(outcome).toEqual({ sessions: 0, files: 1 });
});
it("leaves storage and the file system alone when nothing qualifies", async () => {
const fake = ports({ listSnipFiles: vi.fn(async () => [snip("/snips/fresh.png", now)]) });
await sweepExpired({ maxAgeMs: 7 * DAY, now }, fake);
expect(fake.removeSnipFiles).not.toHaveBeenCalled();
});
});
describe("describeRemoval", () => {
it("says nothing when nothing was deleted", () => {
expect(describeRemoval({ sessions: 0, files: 0 })).toBeUndefined();
});
it("counts conversations, keeping the noun singular at one", () => {
expect(describeRemoval({ sessions: 1, files: 0 })).toBe("1 conversation");
expect(describeRemoval({ sessions: 3, files: 0 })).toBe("3 conversations");
});
it("mentions screenshots when it removed any", () => {
expect(describeRemoval({ sessions: 0, files: 1 })).toBe("1 screenshot");
expect(describeRemoval({ sessions: 2, files: 4 })).toBe("2 conversations and 4 screenshots");
});
});
+355 -1
View File
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it } from "vitest";
import { access, mkdir, rm, utimes, writeFile } from "node:fs/promises";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { __resetLocalStorage } from "../__mocks__/@vicinae/api";
import { MAX_STORED_CHARS_PER_TURN, MAX_TURNS_PER_SESSION } from "../lib/constants";
import {
@@ -7,10 +8,14 @@ import {
createSession,
createTurn,
clearSessions,
cullOrphanedSnips,
deleteSession,
deleteSessions,
listSessions,
loadSession,
referencedAttachments,
saveSession,
sweepExpiredSessions,
withTitleFromFirstTurn,
} from "../lib/sessions";
import type { ChatTurn } from "../lib/types";
@@ -160,3 +165,352 @@ describe("createTurn", () => {
expect(created.toolCalls).toEqual([]);
});
});
describe("attachment paths", () => {
const PATH = "/snips/one.png";
function sniffedTurn(index: number, path: string): ChatTurn {
return { ...turn(index), attachments: [{ path, mime: "image/png" }] };
}
it("records the paths a conversation refers to on its index entry", async () => {
// The index has to know which screenshots are spoken for without reading a
// transcript, because the orphan sweep runs after every single answer.
const session = { ...createSession("m", 1_000), turns: [sniffedTurn(1, PATH)] };
await saveSession(session);
const [summary] = await listSessions();
expect(summary.attachmentPaths).toEqual([PATH]);
});
it("deduplicates a path used by more than one turn", async () => {
const session = {
...createSession("m", 1_000),
turns: [sniffedTurn(1, PATH), sniffedTurn(2, PATH), sniffedTurn(3, "/snips/two.png")],
};
await saveSession(session);
const [summary] = await listSessions();
expect(summary.attachmentPaths).toEqual([PATH, "/snips/two.png"]);
});
it("reports every path the conversations that remain refer to", async () => {
await saveSession({ ...createSession("m", 1_000), turns: [sniffedTurn(1, PATH)] });
expect(await referencedAttachments()).toEqual([PATH]);
});
it("fills in an index written before attachment paths were recorded", async () => {
const { LocalStorage } = await import("../__mocks__/@vicinae/api");
await saveSession({ ...createSession("m", 1_000), turns: [sniffedTurn(1, PATH)] });
// Exactly what an upgrade looks like: the field is simply absent.
const [summary] = await listSessions();
const legacy = { ...summary };
delete legacy.attachmentPaths;
await LocalStorage.setItem("lam.sessions.index", JSON.stringify([legacy]));
expect(await referencedAttachments()).toEqual([PATH]);
// And repaired, so the next sweep does not read the transcript again.
const [repaired] = await listSessions();
expect(repaired.attachmentPaths).toEqual([PATH]);
});
it("refuses to guess when a transcript will not load", async () => {
// A session whose body cannot be read would look like it referred to
// nothing, and everything it referred to would be deleted.
const { LocalStorage } = await import("../__mocks__/@vicinae/api");
await saveSession({ ...createSession("m", 1_000), turns: [sniffedTurn(1, PATH)] });
const [summary] = await listSessions();
const legacy = { ...summary };
delete legacy.attachmentPaths;
await LocalStorage.setItem("lam.sessions.index", JSON.stringify([legacy]));
await LocalStorage.removeItem(`lam.session.${summary.id}`);
expect(await referencedAttachments()).toBeUndefined();
});
it("deletes several conversations in one pass and hands back their screenshots", async () => {
const first = { ...createSession("m", 1_000), turns: [sniffedTurn(1, PATH)] };
const second = { ...createSession("m", 2_000), turns: [sniffedTurn(1, "/snips/two.png")] };
const kept = { ...createSession("m", 3_000), turns: [sniffedTurn(1, "/snips/three.png")] };
await saveSession(first);
await saveSession(second);
await saveSession(kept);
const removed = await deleteSessions([first.id, second.id]);
expect(removed.sort()).toEqual([PATH, "/snips/two.png"]);
expect((await listSessions()).map((summary) => summary.id)).toEqual([kept.id]);
expect(await loadSession(first.id)).toBeUndefined();
expect(await loadSession(second.id)).toBeUndefined();
});
it("does nothing when asked to delete nothing", async () => {
await saveSession(createSession("m", 1_000));
expect(await deleteSessions([])).toEqual([]);
expect(await listSessions()).toHaveLength(1);
});
});
describe("sweepExpiredSessions", () => {
const DIRECTORY = "/tmp/lam-agent-support/snips";
const DAY = 24 * 60 * 60 * 1000;
async function makeSnip(name: string, ageMs: number): Promise<string> {
const path = `${DIRECTORY}/${name}`;
await writeFile(path, "png");
const when = new Date(Date.now() - ageMs);
await utimes(path, when, when);
return path;
}
beforeEach(async () => {
await rm(DIRECTORY, { recursive: true, force: true });
await mkdir(DIRECTORY, { recursive: true });
});
afterEach(async () => {
await rm(DIRECTORY, { recursive: true, force: true });
});
it("deletes expired conversations, their screenshots, and unreferenced leftovers", async () => {
const now = Date.now();
const takenRecently = await makeSnip("recent-deleted.png", 60 * 1000);
const referenced = await makeSnip("still-referenced.png", 30 * DAY);
const abandoned = await makeSnip("never-sent.png", 3 * DAY);
const stale = {
...createSession("m", now - 60 * DAY),
updatedAt: now - 60 * DAY,
turns: [{ ...turn(1), attachments: [{ path: takenRecently, mime: "image/png" }] }],
};
const kept = {
...createSession("m", now),
updatedAt: now,
turns: [{ ...turn(2), attachments: [{ path: referenced, mime: "image/png" }] }],
};
await saveSession(stale);
await saveSession(kept);
const outcome = await sweepExpiredSessions({ maxAgeMs: 7 * DAY, now });
expect(outcome).toEqual({ sessions: 1, files: 2 });
expect((await listSessions()).map((summary) => summary.id)).toEqual([kept.id]);
// The screenshot of the conversation that went, gone with it, even though it
// was taken a minute ago.
await expect(access(takenRecently)).rejects.toThrow();
// A snip nothing ever referred to, collected after its wait.
await expect(access(abandoned)).rejects.toThrow();
// Still spoken for, so untouched however old it is.
await expect(access(referenced)).resolves.toBeUndefined();
});
it("leaves the open conversation and its screenshots alone", async () => {
const now = Date.now();
const referenced = await makeSnip("open-chat.png", 30 * DAY);
const open = {
...createSession("m", now - 60 * DAY),
updatedAt: now - 60 * DAY,
turns: [{ ...turn(1), attachments: [{ path: referenced, mime: "image/png" }] }],
};
await saveSession(open);
const outcome = await sweepExpiredSessions({ maxAgeMs: 7 * DAY, now, keepId: open.id });
expect(outcome.sessions).toBe(0);
expect((await listSessions()).map((summary) => summary.id)).toEqual([open.id]);
await expect(access(referenced)).resolves.toBeUndefined();
});
it("keeps a screenshot an expired conversation shared with a surviving one", async () => {
const now = Date.now();
const shared = await makeSnip("shared.png", 30 * DAY);
const attachment = [{ path: shared, mime: "image/png" }];
const stale = {
...createSession("m", now - 60 * DAY),
updatedAt: now - 60 * DAY,
turns: [{ ...turn(1), attachments: attachment }],
};
const kept = {
...createSession("m", now),
updatedAt: now,
turns: [{ ...turn(2), attachments: attachment }],
};
await saveSession(stale);
await saveSession(kept);
const outcome = await sweepExpiredSessions({ maxAgeMs: 7 * DAY, now });
expect(outcome).toEqual({ sessions: 1, files: 0 });
await expect(access(shared)).resolves.toBeUndefined();
});
it("touches nothing when auto-deletion is off", async () => {
const now = Date.now();
const abandoned = await makeSnip("never-sent.png", 400 * DAY);
const stale = { ...createSession("m", now - 400 * DAY), updatedAt: now - 400 * DAY };
await saveSession(stale);
expect(await sweepExpiredSessions({ maxAgeMs: undefined, now })).toEqual({ sessions: 0, files: 0 });
expect(await listSessions()).toHaveLength(1);
await expect(access(abandoned)).resolves.toBeUndefined();
});
});
describe("manual deletion", () => {
const DIRECTORY = "/tmp/lam-agent-support/snips";
async function makeSnip(name: string): Promise<string> {
const path = `${DIRECTORY}/${name}`;
await writeFile(path, "png");
return path;
}
beforeEach(async () => {
await rm(DIRECTORY, { recursive: true, force: true });
await mkdir(DIRECTORY, { recursive: true });
});
afterEach(async () => {
await rm(DIRECTORY, { recursive: true, force: true });
});
it("takes a deleted conversation's screenshots with it", async () => {
// Otherwise a manual delete leaks images that nothing will ever collect,
// since the orphan pass only runs while auto-deletion is switched on.
const path = await makeSnip("one.png");
const session = {
...createSession("m", 1_000),
turns: [{ ...turn(1), attachments: [{ path, mime: "image/png" }] }],
};
await saveSession(session);
await deleteSession(session.id);
await expect(access(path)).rejects.toThrow();
});
it("takes every conversation's screenshots when clearing all of them", async () => {
const first = await makeSnip("one.png");
const second = await makeSnip("two.png");
await saveSession({
...createSession("m", 1_000),
turns: [{ ...turn(1), attachments: [{ path: first, mime: "image/png" }] }],
});
await saveSession({
...createSession("m", 2_000),
turns: [{ ...turn(1), attachments: [{ path: second, mime: "image/png" }] }],
});
await clearSessions();
expect(await listSessions()).toHaveLength(0);
await expect(access(first)).rejects.toThrow();
await expect(access(second)).rejects.toThrow();
});
it("does not touch a screenshot another conversation still uses", async () => {
const shared = await makeSnip("shared.png");
const attachment = [{ path: shared, mime: "image/png" }];
const doomed = { ...createSession("m", 1_000), turns: [{ ...turn(1), attachments: attachment }] };
const kept = { ...createSession("m", 2_000), turns: [{ ...turn(2), attachments: attachment }] };
await saveSession(doomed);
await saveSession(kept);
await deleteSession(doomed.id);
// Deleting the file would leave the surviving conversation showing a broken
// thumbnail, which the orphan pass would never catch because it is referenced.
await expect(access(shared)).resolves.toBeUndefined();
});
});
describe("cullOrphanedSnips", () => {
const DIRECTORY = "/tmp/lam-agent-support/snips";
const DAY = 24 * 60 * 60 * 1000;
async function makeSnip(name: string, ageMs: number): Promise<string> {
const path = `${DIRECTORY}/${name}`;
await writeFile(path, "png");
const when = new Date(Date.now() - ageMs);
await utimes(path, when, when);
return path;
}
async function reference(path: string): Promise<void> {
await saveSession({
...createSession("m", Date.now()),
turns: [{ ...turn(1), attachments: [{ path, mime: "image/png" }] }],
});
}
beforeEach(async () => {
await rm(DIRECTORY, { recursive: true, force: true });
await mkdir(DIRECTORY, { recursive: true });
});
afterEach(async () => {
await rm(DIRECTORY, { recursive: true, force: true });
});
it("collects a capture that no conversation refers to", async () => {
const orphan = await makeSnip("orphan.png", 2 * DAY);
await expect(cullOrphanedSnips()).resolves.toBe(1);
await expect(access(orphan)).rejects.toThrow();
});
it("keeps a capture a conversation still shows", async () => {
const referenced = await makeSnip("referenced.png", 30 * DAY);
await reference(referenced);
await expect(cullOrphanedSnips()).resolves.toBe(0);
await expect(access(referenced)).resolves.toBeUndefined();
});
it("keeps a fresh capture, which could still be one the user has not sent", async () => {
// This is the same grace period the background sweep uses. The file is on
// disk before the chat view opens and stays unreferenced until the question
// is sent, so deleting it early would break a snip in flight.
const pending = await makeSnip("pending.png", 60 * 1000);
await expect(cullOrphanedSnips()).resolves.toBe(0);
await expect(access(pending)).resolves.toBeUndefined();
});
it("answers nothing rather than zero when a transcript will not load", async () => {
// Zero would be a claim that everything referenced is accounted for. With an
// unreadable conversation that claim cannot be made, so the caller is told
// the answer is unknown and deletes nothing.
const { LocalStorage } = await import("../__mocks__/@vicinae/api");
const orphan = await makeSnip("orphan.png", 30 * DAY);
await saveSession({ ...createSession("m", Date.now()), turns: [turn(1)] });
const [summary] = await listSessions();
const legacy = { ...summary };
delete legacy.attachmentPaths;
await LocalStorage.setItem("lam.sessions.index", JSON.stringify([legacy]));
await LocalStorage.removeItem(`lam.session.${summary.id}`);
await expect(cullOrphanedSnips()).resolves.toBeUndefined();
await expect(access(orphan)).resolves.toBeUndefined();
});
it("reports zero when there is nothing to collect", async () => {
await expect(cullOrphanedSnips()).resolves.toBe(0);
});
});
+96 -2
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { readSnipContext, snipDirectory } from "../lib/snip";
import { access, mkdir, rm, utimes, writeFile } from "node:fs/promises";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { listSnipFiles, readSnipContext, removeSnipFiles, snipDirectory } from "../lib/snip";
/**
* The snip handoff crosses a command boundary as a serialized launch context, so
@@ -55,3 +56,96 @@ describe("snipDirectory", () => {
expect(snipDirectory()).toBe("/tmp/lam-agent-support/snips");
});
});
describe("listSnipFiles", () => {
const DIRECTORY = "/tmp/lam-agent-support/snips";
beforeEach(async () => {
await rm(DIRECTORY, { recursive: true, force: true });
});
afterEach(async () => {
await rm(DIRECTORY, { recursive: true, force: true });
});
it("returns nothing before anything has been snipped", async () => {
await expect(listSnipFiles()).resolves.toEqual([]);
});
it("reports each capture with the time it was written", async () => {
await mkdir(DIRECTORY, { recursive: true });
const path = `${DIRECTORY}/one.png`;
await writeFile(path, "png");
const when = new Date(Date.now() - 60_000);
await utimes(path, when, when);
const [file] = await listSnipFiles();
expect(file.path).toBe(path);
expect(Date.now() - file.mtimeMs).toBeGreaterThan(30_000);
});
it("ignores a subdirectory, which is not a capture", async () => {
await mkdir(`${DIRECTORY}/nested`, { recursive: true });
await expect(listSnipFiles()).resolves.toEqual([]);
});
});
describe("removeSnipFiles", () => {
const DIRECTORY = "/tmp/lam-agent-support/snips";
const OUTSIDE = "/tmp/lam-agent-not-a-snip.png";
beforeEach(async () => {
await rm(DIRECTORY, { recursive: true, force: true });
await mkdir(DIRECTORY, { recursive: true });
await writeFile(OUTSIDE, "do not delete me");
});
afterEach(async () => {
await rm(DIRECTORY, { recursive: true, force: true });
await rm(OUTSIDE, { force: true });
});
it("deletes a capture inside the snip directory and counts it", async () => {
const path = `${DIRECTORY}/one.png`;
await writeFile(path, "png");
await expect(removeSnipFiles([path])).resolves.toBe(1);
await expect(access(path)).rejects.toThrow();
});
it("counts a file that was already gone, because the question is whether it is still there", async () => {
await expect(removeSnipFiles([`${DIRECTORY}/never-existed.png`])).resolves.toBe(1);
});
it("refuses a path outside the snip directory and does not count it", async () => {
// These paths come out of stored JSON, which is data rather than code, so
// this must not be a general-purpose delete-any-file helper.
await expect(removeSnipFiles([OUTSIDE])).resolves.toBe(0);
await expect(access(OUTSIDE)).resolves.toBeUndefined();
});
it("refuses a path that climbs out of the directory", async () => {
await expect(removeSnipFiles([`${DIRECTORY}/../../lam-agent-not-a-snip.png`])).resolves.toBe(0);
await expect(access(OUTSIDE)).resolves.toBeUndefined();
});
it("refuses the directory itself", async () => {
await expect(removeSnipFiles([DIRECTORY])).resolves.toBe(0);
await expect(access(DIRECTORY)).resolves.toBeUndefined();
});
it("keeps going when one path in the batch is refused, and counts only the one it acted on", async () => {
const inside = `${DIRECTORY}/one.png`;
await writeFile(inside, "png");
await expect(removeSnipFiles([OUTSIDE, inside])).resolves.toBe(1);
await expect(access(inside)).rejects.toThrow();
await expect(access(OUTSIDE)).resolves.toBeUndefined();
});
});