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
+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");
});
});