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:
+167
-21
@@ -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"/);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user