summaryrefslogtreecommitdiffhomepage
path: root/packages/cache-warming/src/warmer.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cache-warming/src/warmer.test.ts')
-rw-r--r--packages/cache-warming/src/warmer.test.ts1072
1 files changed, 536 insertions, 536 deletions
diff --git a/packages/cache-warming/src/warmer.test.ts b/packages/cache-warming/src/warmer.test.ts
index 98fa634..26445a2 100644
--- a/packages/cache-warming/src/warmer.test.ts
+++ b/packages/cache-warming/src/warmer.test.ts
@@ -5,559 +5,559 @@ import { MIN_INTERVAL_MS } from "./pure.js";
import { createCacheWarmer, type TimerDeps } from "./warmer.js";
function memStorage(): StorageNamespace {
- const map = new Map<string, string>();
- return {
- get: async (k) => map.get(k) ?? null,
- set: async (k, v) => {
- map.set(k, v);
- },
- delete: async (k) => {
- map.delete(k);
- },
- has: async (k) => map.has(k),
- keys: async (prefix) =>
- [...map.keys()].filter((k) => (prefix === undefined ? true : k.startsWith(prefix))),
- };
+ const map = new Map<string, string>();
+ return {
+ get: async (k) => map.get(k) ?? null,
+ set: async (k, v) => {
+ map.set(k, v);
+ },
+ delete: async (k) => {
+ map.delete(k);
+ },
+ has: async (k) => map.has(k),
+ keys: async (prefix) =>
+ [...map.keys()].filter((k) => (prefix === undefined ? true : k.startsWith(prefix))),
+ };
}
function makeSpan(): Span {
- const span: Span = {
- id: "span",
- log: makeLogger(),
- setAttributes: () => {},
- addLink: () => {},
- child: () => makeSpan(),
- end: () => {},
- };
- return span;
+ const span: Span = {
+ id: "span",
+ log: makeLogger(),
+ setAttributes: () => {},
+ addLink: () => {},
+ child: () => makeSpan(),
+ end: () => {},
+ };
+ return span;
}
function makeLogger(): Logger {
- return {
- debug: () => {},
- info: () => {},
- warn: () => {},
- error: () => {},
- child: () => makeLogger(),
- span: () => makeSpan(),
- };
+ return {
+ debug: () => {},
+ info: () => {},
+ warn: () => {},
+ error: () => {},
+ child: () => makeLogger(),
+ span: () => makeSpan(),
+ };
}
function fakeTimers(): TimerDeps & { flush: () => void } {
- let nextId = 1;
- const pending = new Map<number, () => void>();
- return {
- setTimer(fn, _ms) {
- const id = nextId++;
- pending.set(id, fn);
- return id;
- },
- clearTimer(id) {
- pending.delete(id);
- },
- flush() {
- const fns = [...pending.values()];
- pending.clear();
- for (const fn of fns) fn();
- },
- };
+ let nextId = 1;
+ const pending = new Map<number, () => void>();
+ return {
+ setTimer(fn, _ms) {
+ const id = nextId++;
+ pending.set(id, fn);
+ return id;
+ },
+ clearTimer(id) {
+ pending.delete(id);
+ },
+ flush() {
+ const fns = [...pending.values()];
+ pending.clear();
+ for (const fn of fns) fn();
+ },
+ };
}
const WARM_RESULT: WarmResult = {
- inputTokens: 1000,
- outputTokens: 10,
- cacheReadTokens: 800,
- cacheWriteTokens: 0,
+ inputTokens: 1000,
+ outputTokens: 10,
+ cacheReadTokens: 800,
+ cacheWriteTokens: 0,
};
function makeDeps(
- overrides: Partial<{
- warm: (conversationId: string) => Promise<WarmResult>;
- onSurfaceChange: () => void;
- now: () => number;
- }> = {},
+ overrides: Partial<{
+ warm: (conversationId: string) => Promise<WarmResult>;
+ onSurfaceChange: () => void;
+ now: () => number;
+ }> = {},
) {
- const timers = fakeTimers();
- let nowMs = 1000;
- return {
- timers,
- warm: overrides.warm ?? (async () => WARM_RESULT),
- storage: memStorage(),
- logger: makeLogger(),
- now: overrides.now ?? (() => nowMs),
- onSurfaceChange: overrides.onSurfaceChange ?? (() => {}),
- setNow(ms: number) {
- nowMs = ms;
- },
- };
+ const timers = fakeTimers();
+ let nowMs = 1000;
+ return {
+ timers,
+ warm: overrides.warm ?? (async () => WARM_RESULT),
+ storage: memStorage(),
+ logger: makeLogger(),
+ now: overrides.now ?? (() => nowMs),
+ onSurfaceChange: overrides.onSurfaceChange ?? (() => {}),
+ setNow(ms: number) {
+ nowMs = ms;
+ },
+ };
}
describe("CacheWarmer", () => {
- it("arms a timer on turnSettled and warms when it fires (enabled)", async () => {
- const deps = makeDeps();
- const warmCalls: string[] = [];
- deps.warm = async (convId) => {
- warmCalls.push(convId);
- return WARM_RESULT;
- };
- const warmer = createCacheWarmer(deps);
-
- await warmer.setEnabled("conv-1", true);
- warmer.onTurnSettled("conv-1", {});
- deps.timers.flush();
-
- await new Promise((r) => setTimeout(r, 10));
- expect(warmCalls).toContain("conv-1");
- });
-
- it("warming is OFF by default — a new conversation never arms on turnSettled (CR-4a)", async () => {
- const deps = makeDeps();
- const warmCalls: string[] = [];
- deps.warm = async (convId) => {
- warmCalls.push(convId);
- return WARM_RESULT;
- };
- const warmer = createCacheWarmer(deps);
-
- expect(warmer.getState("conv-1").enabled).toBe(false);
- warmer.onTurnSettled("conv-1", {});
- expect(warmer.getState("conv-1").nextWarmAt).toBeNull();
- deps.timers.flush();
-
- await new Promise((r) => setTimeout(r, 10));
- expect(warmCalls).toHaveLength(0);
- });
-
- it("cancels the timer on turnStarted (no warm while generating)", () => {
- const deps = makeDeps();
- const warmCalls: string[] = [];
- deps.warm = async (convId) => {
- warmCalls.push(convId);
- return WARM_RESULT;
- };
- const warmer = createCacheWarmer(deps);
-
- warmer.onTurnSettled("conv-1", {});
- warmer.onTurnStarted("conv-1");
- deps.timers.flush();
-
- expect(warmCalls).toHaveLength(0);
- });
-
- it("disabled conversation does not warm", async () => {
- const deps = makeDeps();
- const warmCalls: string[] = [];
- deps.warm = async (convId) => {
- warmCalls.push(convId);
- return WARM_RESULT;
- };
- const warmer = createCacheWarmer(deps);
-
- await warmer.setEnabled("conv-1", false);
- warmer.onTurnSettled("conv-1", {});
- deps.timers.flush();
-
- await new Promise((r) => setTimeout(r, 10));
- expect(warmCalls).toHaveLength(0);
- });
-
- it("setIntervalMs converts seconds→ms, floors at MIN_INTERVAL_MS, and re-arms", async () => {
- const deps = makeDeps();
- const warmCalls: string[] = [];
- deps.warm = async (convId) => {
- warmCalls.push(convId);
- return WARM_RESULT;
- };
- const warmer = createCacheWarmer(deps);
-
- // Enable and settle to arm the timer
- await warmer.setEnabled("conv-1", true);
- warmer.onTurnSettled("conv-1", {});
-
- // Set interval to 30 seconds (30000ms)
- const settings = await warmer.setIntervalMs("conv-1", 30_000);
- expect(settings.intervalMs).toBe(30_000);
-
- const state = warmer.getState("conv-1");
- expect(state.intervalMs).toBe(30_000);
-
- // Timer should still be armed — flush fires it
- deps.timers.flush();
- await new Promise((r) => setTimeout(r, 10));
- expect(warmCalls).toContain("conv-1");
- });
-
- it("setIntervalMs clamps values below MIN_INTERVAL_MS", async () => {
- const deps = makeDeps();
- const warmer = createCacheWarmer(deps);
-
- warmer.onTurnSettled("conv-1", {});
-
- // Set interval to 500ms — should clamp to MIN_INTERVAL_MS (1000)
- const settings = await warmer.setIntervalMs("conv-1", 500);
- expect(settings.intervalMs).toBe(1000);
- });
-
- it("setIntervalMs ignores NaN / non-positive (clamps to MIN_INTERVAL_MS)", async () => {
- const deps = makeDeps();
- const warmer = createCacheWarmer(deps);
-
- warmer.onTurnSettled("conv-1", {});
-
- const settings1 = await warmer.setIntervalMs("conv-1", Number.NaN);
- expect(settings1.intervalMs).toBe(MIN_INTERVAL_MS);
-
- const settings2 = await warmer.setIntervalMs("conv-1", -5000);
- expect(settings2.intervalMs).toBe(MIN_INTERVAL_MS);
-
- const settings3 = await warmer.setIntervalMs("conv-1", 0);
- expect(settings3.intervalMs).toBe(MIN_INTERVAL_MS);
- });
-
- it("setEnabled flips enabled for a conversation", async () => {
- const deps = makeDeps();
- const warmer = createCacheWarmer(deps);
-
- // Default is DISABLED (opt-in per conversation)
- expect(warmer.getState("conv-1").enabled).toBe(false);
-
- // Toggle on
- await warmer.setEnabled("conv-1", true);
- expect(warmer.getState("conv-1").enabled).toBe(true);
-
- // Toggle off
- await warmer.setEnabled("conv-1", false);
- expect(warmer.getState("conv-1").enabled).toBe(false);
- });
-
- it("re-enabling restores the PERSISTED interval into runtime state", async () => {
- const deps = makeDeps();
- const warmer = createCacheWarmer(deps);
-
- await warmer.setIntervalMs("conv-1", 30_000);
- await warmer.setEnabled("conv-1", false);
-
- // Fresh warmer over the same storage (simulates restart)
- const warmer2 = createCacheWarmer(deps);
- expect(warmer2.getState("conv-1").intervalMs).toBe(240_000); // runtime default
- await warmer2.setEnabled("conv-1", true);
- expect(warmer2.getState("conv-1").intervalMs).toBe(30_000); // persisted restored
- });
-
- it("onSurfaceChange is called when settings change", async () => {
- let changeCount = 0;
- const deps = makeDeps({
- onSurfaceChange: () => {
- changeCount++;
- },
- });
- const warmer = createCacheWarmer(deps);
-
- await warmer.setEnabled("conv-1", false);
- expect(changeCount).toBe(1);
-
- await warmer.setIntervalMs("conv-1", 30_000);
- expect(changeCount).toBe(2);
- });
-
- it("warmCompleted updates lastPct/lastExpectedPct/lastWarmAt and re-arms (nextWarmAt set), pushes onSurfaceChange", async () => {
- let changeCount = 0;
- let nowMs = 5000;
- const deps = makeDeps({
- onSurfaceChange: () => {
- changeCount++;
- },
- now: () => nowMs,
- });
- const warmer = createCacheWarmer(deps);
-
- await warmer.setEnabled("conv-1", true);
- warmer.onTurnSettled("conv-1", {});
- const stateBefore = warmer.getState("conv-1");
- expect(stateBefore.lastPct).toBeNull();
- expect(stateBefore.lastExpectedPct).toBeNull();
- expect(stateBefore.lastWarmAt).toBeNull();
-
- const countBefore = changeCount;
- nowMs = 6000;
- warmer.onWarmCompleted({
- conversationId: "conv-1",
- usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 700, cacheWriteTokens: 300 },
- });
-
- const state = warmer.getState("conv-1");
- expect(state.lastPct).toBe(70);
- expect(state.lastExpectedPct).toBe(70);
- expect(state.lastWarmAt).toBe(6000);
- expect(state.nextWarmAt).not.toBeNull();
- expect(changeCount).toBe(countBefore + 1);
- });
-
- it("the post-warm surface notify observes the NEW future nextWarmAt, not the consumed one (CR-4b)", async () => {
- let nowMs = 5000;
- const observed: (number | null)[] = [];
- const deps = makeDeps({ now: () => nowMs });
- const warmer = createCacheWarmer(deps);
- deps.onSurfaceChange = () => {
- observed.push(warmer.getState("conv-1").nextWarmAt);
- };
-
- await warmer.setEnabled("conv-1", true);
- warmer.onTurnSettled("conv-1", {});
- observed.length = 0;
-
- nowMs = 9000;
- warmer.onWarmCompleted({ conversationId: "conv-1", usage: WARM_RESULT });
-
- // Exactly one notify, and AT NOTIFY TIME the state already carried the
- // re-armed, future fire time (lastWarmAt + interval) — never the past one.
- expect(observed).toEqual([9000 + 240_000]);
- });
-
- it("the post-warm surface notify carries nextWarmAt: null when warming was disabled mid-flight", async () => {
- let nowMs = 5000;
- const observed: (number | null)[] = [];
- const deps = makeDeps({ now: () => nowMs });
- const warmer = createCacheWarmer(deps);
- deps.onSurfaceChange = () => {
- observed.push(warmer.getState("conv-1").nextWarmAt);
- };
-
- await warmer.setEnabled("conv-1", true);
- warmer.onTurnSettled("conv-1", {});
- await warmer.setEnabled("conv-1", false);
- observed.length = 0;
-
- nowMs = 9000;
- warmer.onWarmCompleted({ conversationId: "conv-1", usage: WARM_RESULT });
-
- // Not re-armed (disabled) — the notify must NOT carry a stale past value.
- expect(observed).toEqual([null]);
- });
-
- it("onTurnSettled pushes a surface notify carrying the fresh schedule (CR-4b post-seal path)", async () => {
- let nowMs = 5000;
- const observed: (number | null)[] = [];
- const deps = makeDeps({ now: () => nowMs });
- const warmer = createCacheWarmer(deps);
- deps.onSurfaceChange = () => {
- observed.push(warmer.getState("conv-1").nextWarmAt);
- };
-
- await warmer.setEnabled("conv-1", true);
- warmer.onTurnStarted("conv-1");
- observed.length = 0;
-
- nowMs = 12_000;
- warmer.onTurnSettled("conv-1", {});
-
- // At notify time the new schedule is already armed.
- expect(observed).toEqual([12_000 + 240_000]);
- });
-
- it("a warm that completes while the conversation is active is dropped (no update, no re-arm)", async () => {
- let changeCount = 0;
- const deps = makeDeps({
- onSurfaceChange: () => {
- changeCount++;
- },
- now: () => 5000,
- });
- const warmer = createCacheWarmer(deps);
-
- await warmer.setEnabled("conv-1", true);
- warmer.onTurnSettled("conv-1", {});
- warmer.onTurnStarted("conv-1");
-
- const countBefore = changeCount;
- warmer.onWarmCompleted({
- conversationId: "conv-1",
- usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 800, cacheWriteTokens: 0 },
- });
-
- const state = warmer.getState("conv-1");
- expect(state.lastPct).toBeNull();
- expect(state.lastWarmAt).toBeNull();
- expect(state.nextWarmAt).toBeNull();
- expect(changeCount).toBe(countBefore);
- });
-
- it("nextWarmAt is set when armed and null when disabled or active", async () => {
- let nowMs = 1000;
- const deps = makeDeps({ now: () => nowMs });
- const warmer = createCacheWarmer(deps);
-
- // Before any event — not armed
- expect(warmer.getState("conv-1").nextWarmAt).toBeNull();
-
- // After enable + turnSettled — armed with nextWarmAt
- await warmer.setEnabled("conv-1", true);
- nowMs = 2000;
- warmer.onTurnSettled("conv-1", {});
- const stateArmed = warmer.getState("conv-1");
- expect(stateArmed.nextWarmAt).toBe(2000 + 240_000);
-
- // After turnStarted — cancelled (null)
- warmer.onTurnStarted("conv-1");
- expect(warmer.getState("conv-1").nextWarmAt).toBeNull();
-
- // After disabling — null
- await warmer.setEnabled("conv-2", true);
- warmer.onTurnSettled("conv-2", {});
- await warmer.setEnabled("conv-2", false);
- expect(warmer.getState("conv-2").nextWarmAt).toBeNull();
- });
-
- it("a manual warm (warmCompleted for a conversation) resets the timer + refreshes the surface", async () => {
- let changeCount = 0;
- let nowMs = 5000;
- const deps = makeDeps({
- onSurfaceChange: () => {
- changeCount++;
- },
- now: () => nowMs,
- });
- const warmer = createCacheWarmer(deps);
-
- // Enable + settle to arm the timer
- await warmer.setEnabled("conv-1", true);
- warmer.onTurnSettled("conv-1", {});
- const armed = warmer.getState("conv-1");
- expect(armed.nextWarmAt).toBe(5000 + 240_000);
-
- // Simulate a manual warm completing at t=8000
- const countBefore = changeCount;
- nowMs = 8000;
- warmer.onWarmCompleted({
- conversationId: "conv-1",
- usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 900, cacheWriteTokens: 100 },
- });
-
- const after = warmer.getState("conv-1");
- expect(after.lastPct).toBe(90);
- expect(after.lastExpectedPct).toBe(90);
- expect(after.lastWarmAt).toBe(8000);
- // Timer should be re-armed with new nextWarmAt
- expect(after.nextWarmAt).toBe(8000 + 240_000);
- expect(changeCount).toBe(countBefore + 1);
- });
-
- it("stores lastPct from the warmCompleted event", () => {
- const deps = makeDeps({ now: () => 5000 });
- const warmer = createCacheWarmer(deps);
-
- warmer.onTurnSettled("conv-1", {});
- warmer.onWarmCompleted({
- conversationId: "conv-1",
- usage: WARM_RESULT,
- });
-
- const state = warmer.getState("conv-1");
- expect(state.lastPct).toBe(80);
- });
-
- it("a completed warm stores both lastPct (rate) and lastExpectedPct (retention)", () => {
- const deps = makeDeps({ now: () => 5000 });
- const warmer = createCacheWarmer(deps);
-
- warmer.onTurnSettled("conv-1", {});
- warmer.onWarmCompleted({
- conversationId: "conv-1",
- usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 700, cacheWriteTokens: 300 },
- });
-
- const state = warmer.getState("conv-1");
- expect(state.lastPct).toBe(70);
- expect(state.lastExpectedPct).toBe(70);
- });
-
- it("re-arms timer after warmCompleted", async () => {
- let nowMs = 1000;
- const deps = makeDeps({ now: () => nowMs });
- const warmer = createCacheWarmer(deps);
-
- await warmer.setEnabled("conv-1", true);
- warmer.onTurnSettled("conv-1", {});
- const firstNextWarmAt = warmer.getState("conv-1").nextWarmAt;
-
- nowMs = 5000;
- warmer.onWarmCompleted({
- conversationId: "conv-1",
- usage: WARM_RESULT,
- });
-
- const after = warmer.getState("conv-1");
- expect(after.nextWarmAt).not.toBeNull();
- expect(after.nextWarmAt).not.toBe(firstNextWarmAt);
- expect(after.nextWarmAt).toBe(5000 + 240_000);
- });
-
- it("onConversationClosed cancels the schedule, disables warming, persists OFF, and notifies (CR-4c)", async () => {
- let changeCount = 0;
- const deps = makeDeps({
- onSurfaceChange: () => {
- changeCount++;
- },
- now: () => 5000,
- });
- const warmCalls: string[] = [];
- deps.warm = async (convId) => {
- warmCalls.push(convId);
- return WARM_RESULT;
- };
- const warmer = createCacheWarmer(deps);
-
- await warmer.setEnabled("conv-1", true);
- warmer.onTurnSettled("conv-1", {});
- expect(warmer.getState("conv-1").nextWarmAt).not.toBeNull();
-
- const countBefore = changeCount;
- await warmer.onConversationClosed("conv-1");
-
- const state = warmer.getState("conv-1");
- expect(state.enabled).toBe(false);
- expect(state.nextWarmAt).toBeNull();
- expect(changeCount).toBe(countBefore + 1);
-
- // The pending timer is cancelled — flushing fires nothing.
- deps.timers.flush();
- await new Promise((r) => setTimeout(r, 10));
- expect(warmCalls).toHaveLength(0);
-
- // Persisted OFF: a fresh warmer over the same storage stays disabled on enable-read.
- const raw = await deps.storage.get("settings:conv-1");
- expect(raw).not.toBeNull();
- expect(JSON.parse(raw as string).enabled).toBe(false);
- });
-
- it("a turnSettled racing a close does not re-arm (enabled flipped synchronously)", async () => {
- const deps = makeDeps({ now: () => 5000 });
- const warmer = createCacheWarmer(deps);
-
- await warmer.setEnabled("conv-1", true);
- warmer.onTurnStarted("conv-1");
-
- // Close while "generating" — do NOT await: the sync part must suffice.
- const closed = warmer.onConversationClosed("conv-1");
- // The turn settles immediately after the close was issued.
- warmer.onTurnSettled("conv-1", {});
-
- expect(warmer.getState("conv-1").enabled).toBe(false);
- expect(warmer.getState("conv-1").nextWarmAt).toBeNull();
- await closed;
- });
-
- it("the per-conversation spec includes a cache-retention stat", () => {
- const deps = makeDeps({ now: () => 5000 });
- const warmer = createCacheWarmer(deps);
-
- warmer.onTurnSettled("conv-1", {});
- warmer.onWarmCompleted({
- conversationId: "conv-1",
- usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 900, cacheWriteTokens: 100 },
- });
-
- const state = warmer.getState("conv-1");
- expect(state.lastExpectedPct).toBe(90);
- });
+ it("arms a timer on turnSettled and warms when it fires (enabled)", async () => {
+ const deps = makeDeps();
+ const warmCalls: string[] = [];
+ deps.warm = async (convId) => {
+ warmCalls.push(convId);
+ return WARM_RESULT;
+ };
+ const warmer = createCacheWarmer(deps);
+
+ await warmer.setEnabled("conv-1", true);
+ warmer.onTurnSettled("conv-1", {});
+ deps.timers.flush();
+
+ await new Promise((r) => setTimeout(r, 10));
+ expect(warmCalls).toContain("conv-1");
+ });
+
+ it("warming is OFF by default — a new conversation never arms on turnSettled (CR-4a)", async () => {
+ const deps = makeDeps();
+ const warmCalls: string[] = [];
+ deps.warm = async (convId) => {
+ warmCalls.push(convId);
+ return WARM_RESULT;
+ };
+ const warmer = createCacheWarmer(deps);
+
+ expect(warmer.getState("conv-1").enabled).toBe(false);
+ warmer.onTurnSettled("conv-1", {});
+ expect(warmer.getState("conv-1").nextWarmAt).toBeNull();
+ deps.timers.flush();
+
+ await new Promise((r) => setTimeout(r, 10));
+ expect(warmCalls).toHaveLength(0);
+ });
+
+ it("cancels the timer on turnStarted (no warm while generating)", () => {
+ const deps = makeDeps();
+ const warmCalls: string[] = [];
+ deps.warm = async (convId) => {
+ warmCalls.push(convId);
+ return WARM_RESULT;
+ };
+ const warmer = createCacheWarmer(deps);
+
+ warmer.onTurnSettled("conv-1", {});
+ warmer.onTurnStarted("conv-1");
+ deps.timers.flush();
+
+ expect(warmCalls).toHaveLength(0);
+ });
+
+ it("disabled conversation does not warm", async () => {
+ const deps = makeDeps();
+ const warmCalls: string[] = [];
+ deps.warm = async (convId) => {
+ warmCalls.push(convId);
+ return WARM_RESULT;
+ };
+ const warmer = createCacheWarmer(deps);
+
+ await warmer.setEnabled("conv-1", false);
+ warmer.onTurnSettled("conv-1", {});
+ deps.timers.flush();
+
+ await new Promise((r) => setTimeout(r, 10));
+ expect(warmCalls).toHaveLength(0);
+ });
+
+ it("setIntervalMs converts seconds→ms, floors at MIN_INTERVAL_MS, and re-arms", async () => {
+ const deps = makeDeps();
+ const warmCalls: string[] = [];
+ deps.warm = async (convId) => {
+ warmCalls.push(convId);
+ return WARM_RESULT;
+ };
+ const warmer = createCacheWarmer(deps);
+
+ // Enable and settle to arm the timer
+ await warmer.setEnabled("conv-1", true);
+ warmer.onTurnSettled("conv-1", {});
+
+ // Set interval to 30 seconds (30000ms)
+ const settings = await warmer.setIntervalMs("conv-1", 30_000);
+ expect(settings.intervalMs).toBe(30_000);
+
+ const state = warmer.getState("conv-1");
+ expect(state.intervalMs).toBe(30_000);
+
+ // Timer should still be armed — flush fires it
+ deps.timers.flush();
+ await new Promise((r) => setTimeout(r, 10));
+ expect(warmCalls).toContain("conv-1");
+ });
+
+ it("setIntervalMs clamps values below MIN_INTERVAL_MS", async () => {
+ const deps = makeDeps();
+ const warmer = createCacheWarmer(deps);
+
+ warmer.onTurnSettled("conv-1", {});
+
+ // Set interval to 500ms — should clamp to MIN_INTERVAL_MS (1000)
+ const settings = await warmer.setIntervalMs("conv-1", 500);
+ expect(settings.intervalMs).toBe(1000);
+ });
+
+ it("setIntervalMs ignores NaN / non-positive (clamps to MIN_INTERVAL_MS)", async () => {
+ const deps = makeDeps();
+ const warmer = createCacheWarmer(deps);
+
+ warmer.onTurnSettled("conv-1", {});
+
+ const settings1 = await warmer.setIntervalMs("conv-1", Number.NaN);
+ expect(settings1.intervalMs).toBe(MIN_INTERVAL_MS);
+
+ const settings2 = await warmer.setIntervalMs("conv-1", -5000);
+ expect(settings2.intervalMs).toBe(MIN_INTERVAL_MS);
+
+ const settings3 = await warmer.setIntervalMs("conv-1", 0);
+ expect(settings3.intervalMs).toBe(MIN_INTERVAL_MS);
+ });
+
+ it("setEnabled flips enabled for a conversation", async () => {
+ const deps = makeDeps();
+ const warmer = createCacheWarmer(deps);
+
+ // Default is DISABLED (opt-in per conversation)
+ expect(warmer.getState("conv-1").enabled).toBe(false);
+
+ // Toggle on
+ await warmer.setEnabled("conv-1", true);
+ expect(warmer.getState("conv-1").enabled).toBe(true);
+
+ // Toggle off
+ await warmer.setEnabled("conv-1", false);
+ expect(warmer.getState("conv-1").enabled).toBe(false);
+ });
+
+ it("re-enabling restores the PERSISTED interval into runtime state", async () => {
+ const deps = makeDeps();
+ const warmer = createCacheWarmer(deps);
+
+ await warmer.setIntervalMs("conv-1", 30_000);
+ await warmer.setEnabled("conv-1", false);
+
+ // Fresh warmer over the same storage (simulates restart)
+ const warmer2 = createCacheWarmer(deps);
+ expect(warmer2.getState("conv-1").intervalMs).toBe(240_000); // runtime default
+ await warmer2.setEnabled("conv-1", true);
+ expect(warmer2.getState("conv-1").intervalMs).toBe(30_000); // persisted restored
+ });
+
+ it("onSurfaceChange is called when settings change", async () => {
+ let changeCount = 0;
+ const deps = makeDeps({
+ onSurfaceChange: () => {
+ changeCount++;
+ },
+ });
+ const warmer = createCacheWarmer(deps);
+
+ await warmer.setEnabled("conv-1", false);
+ expect(changeCount).toBe(1);
+
+ await warmer.setIntervalMs("conv-1", 30_000);
+ expect(changeCount).toBe(2);
+ });
+
+ it("warmCompleted updates lastPct/lastExpectedPct/lastWarmAt and re-arms (nextWarmAt set), pushes onSurfaceChange", async () => {
+ let changeCount = 0;
+ let nowMs = 5000;
+ const deps = makeDeps({
+ onSurfaceChange: () => {
+ changeCount++;
+ },
+ now: () => nowMs,
+ });
+ const warmer = createCacheWarmer(deps);
+
+ await warmer.setEnabled("conv-1", true);
+ warmer.onTurnSettled("conv-1", {});
+ const stateBefore = warmer.getState("conv-1");
+ expect(stateBefore.lastPct).toBeNull();
+ expect(stateBefore.lastExpectedPct).toBeNull();
+ expect(stateBefore.lastWarmAt).toBeNull();
+
+ const countBefore = changeCount;
+ nowMs = 6000;
+ warmer.onWarmCompleted({
+ conversationId: "conv-1",
+ usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 700, cacheWriteTokens: 300 },
+ });
+
+ const state = warmer.getState("conv-1");
+ expect(state.lastPct).toBe(70);
+ expect(state.lastExpectedPct).toBe(70);
+ expect(state.lastWarmAt).toBe(6000);
+ expect(state.nextWarmAt).not.toBeNull();
+ expect(changeCount).toBe(countBefore + 1);
+ });
+
+ it("the post-warm surface notify observes the NEW future nextWarmAt, not the consumed one (CR-4b)", async () => {
+ let nowMs = 5000;
+ const observed: (number | null)[] = [];
+ const deps = makeDeps({ now: () => nowMs });
+ const warmer = createCacheWarmer(deps);
+ deps.onSurfaceChange = () => {
+ observed.push(warmer.getState("conv-1").nextWarmAt);
+ };
+
+ await warmer.setEnabled("conv-1", true);
+ warmer.onTurnSettled("conv-1", {});
+ observed.length = 0;
+
+ nowMs = 9000;
+ warmer.onWarmCompleted({ conversationId: "conv-1", usage: WARM_RESULT });
+
+ // Exactly one notify, and AT NOTIFY TIME the state already carried the
+ // re-armed, future fire time (lastWarmAt + interval) — never the past one.
+ expect(observed).toEqual([9000 + 240_000]);
+ });
+
+ it("the post-warm surface notify carries nextWarmAt: null when warming was disabled mid-flight", async () => {
+ let nowMs = 5000;
+ const observed: (number | null)[] = [];
+ const deps = makeDeps({ now: () => nowMs });
+ const warmer = createCacheWarmer(deps);
+ deps.onSurfaceChange = () => {
+ observed.push(warmer.getState("conv-1").nextWarmAt);
+ };
+
+ await warmer.setEnabled("conv-1", true);
+ warmer.onTurnSettled("conv-1", {});
+ await warmer.setEnabled("conv-1", false);
+ observed.length = 0;
+
+ nowMs = 9000;
+ warmer.onWarmCompleted({ conversationId: "conv-1", usage: WARM_RESULT });
+
+ // Not re-armed (disabled) — the notify must NOT carry a stale past value.
+ expect(observed).toEqual([null]);
+ });
+
+ it("onTurnSettled pushes a surface notify carrying the fresh schedule (CR-4b post-seal path)", async () => {
+ let nowMs = 5000;
+ const observed: (number | null)[] = [];
+ const deps = makeDeps({ now: () => nowMs });
+ const warmer = createCacheWarmer(deps);
+ deps.onSurfaceChange = () => {
+ observed.push(warmer.getState("conv-1").nextWarmAt);
+ };
+
+ await warmer.setEnabled("conv-1", true);
+ warmer.onTurnStarted("conv-1");
+ observed.length = 0;
+
+ nowMs = 12_000;
+ warmer.onTurnSettled("conv-1", {});
+
+ // At notify time the new schedule is already armed.
+ expect(observed).toEqual([12_000 + 240_000]);
+ });
+
+ it("a warm that completes while the conversation is active is dropped (no update, no re-arm)", async () => {
+ let changeCount = 0;
+ const deps = makeDeps({
+ onSurfaceChange: () => {
+ changeCount++;
+ },
+ now: () => 5000,
+ });
+ const warmer = createCacheWarmer(deps);
+
+ await warmer.setEnabled("conv-1", true);
+ warmer.onTurnSettled("conv-1", {});
+ warmer.onTurnStarted("conv-1");
+
+ const countBefore = changeCount;
+ warmer.onWarmCompleted({
+ conversationId: "conv-1",
+ usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 800, cacheWriteTokens: 0 },
+ });
+
+ const state = warmer.getState("conv-1");
+ expect(state.lastPct).toBeNull();
+ expect(state.lastWarmAt).toBeNull();
+ expect(state.nextWarmAt).toBeNull();
+ expect(changeCount).toBe(countBefore);
+ });
+
+ it("nextWarmAt is set when armed and null when disabled or active", async () => {
+ let nowMs = 1000;
+ const deps = makeDeps({ now: () => nowMs });
+ const warmer = createCacheWarmer(deps);
+
+ // Before any event — not armed
+ expect(warmer.getState("conv-1").nextWarmAt).toBeNull();
+
+ // After enable + turnSettled — armed with nextWarmAt
+ await warmer.setEnabled("conv-1", true);
+ nowMs = 2000;
+ warmer.onTurnSettled("conv-1", {});
+ const stateArmed = warmer.getState("conv-1");
+ expect(stateArmed.nextWarmAt).toBe(2000 + 240_000);
+
+ // After turnStarted — cancelled (null)
+ warmer.onTurnStarted("conv-1");
+ expect(warmer.getState("conv-1").nextWarmAt).toBeNull();
+
+ // After disabling — null
+ await warmer.setEnabled("conv-2", true);
+ warmer.onTurnSettled("conv-2", {});
+ await warmer.setEnabled("conv-2", false);
+ expect(warmer.getState("conv-2").nextWarmAt).toBeNull();
+ });
+
+ it("a manual warm (warmCompleted for a conversation) resets the timer + refreshes the surface", async () => {
+ let changeCount = 0;
+ let nowMs = 5000;
+ const deps = makeDeps({
+ onSurfaceChange: () => {
+ changeCount++;
+ },
+ now: () => nowMs,
+ });
+ const warmer = createCacheWarmer(deps);
+
+ // Enable + settle to arm the timer
+ await warmer.setEnabled("conv-1", true);
+ warmer.onTurnSettled("conv-1", {});
+ const armed = warmer.getState("conv-1");
+ expect(armed.nextWarmAt).toBe(5000 + 240_000);
+
+ // Simulate a manual warm completing at t=8000
+ const countBefore = changeCount;
+ nowMs = 8000;
+ warmer.onWarmCompleted({
+ conversationId: "conv-1",
+ usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 900, cacheWriteTokens: 100 },
+ });
+
+ const after = warmer.getState("conv-1");
+ expect(after.lastPct).toBe(90);
+ expect(after.lastExpectedPct).toBe(90);
+ expect(after.lastWarmAt).toBe(8000);
+ // Timer should be re-armed with new nextWarmAt
+ expect(after.nextWarmAt).toBe(8000 + 240_000);
+ expect(changeCount).toBe(countBefore + 1);
+ });
+
+ it("stores lastPct from the warmCompleted event", () => {
+ const deps = makeDeps({ now: () => 5000 });
+ const warmer = createCacheWarmer(deps);
+
+ warmer.onTurnSettled("conv-1", {});
+ warmer.onWarmCompleted({
+ conversationId: "conv-1",
+ usage: WARM_RESULT,
+ });
+
+ const state = warmer.getState("conv-1");
+ expect(state.lastPct).toBe(80);
+ });
+
+ it("a completed warm stores both lastPct (rate) and lastExpectedPct (retention)", () => {
+ const deps = makeDeps({ now: () => 5000 });
+ const warmer = createCacheWarmer(deps);
+
+ warmer.onTurnSettled("conv-1", {});
+ warmer.onWarmCompleted({
+ conversationId: "conv-1",
+ usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 700, cacheWriteTokens: 300 },
+ });
+
+ const state = warmer.getState("conv-1");
+ expect(state.lastPct).toBe(70);
+ expect(state.lastExpectedPct).toBe(70);
+ });
+
+ it("re-arms timer after warmCompleted", async () => {
+ let nowMs = 1000;
+ const deps = makeDeps({ now: () => nowMs });
+ const warmer = createCacheWarmer(deps);
+
+ await warmer.setEnabled("conv-1", true);
+ warmer.onTurnSettled("conv-1", {});
+ const firstNextWarmAt = warmer.getState("conv-1").nextWarmAt;
+
+ nowMs = 5000;
+ warmer.onWarmCompleted({
+ conversationId: "conv-1",
+ usage: WARM_RESULT,
+ });
+
+ const after = warmer.getState("conv-1");
+ expect(after.nextWarmAt).not.toBeNull();
+ expect(after.nextWarmAt).not.toBe(firstNextWarmAt);
+ expect(after.nextWarmAt).toBe(5000 + 240_000);
+ });
+
+ it("onConversationClosed cancels the schedule, disables warming, persists OFF, and notifies (CR-4c)", async () => {
+ let changeCount = 0;
+ const deps = makeDeps({
+ onSurfaceChange: () => {
+ changeCount++;
+ },
+ now: () => 5000,
+ });
+ const warmCalls: string[] = [];
+ deps.warm = async (convId) => {
+ warmCalls.push(convId);
+ return WARM_RESULT;
+ };
+ const warmer = createCacheWarmer(deps);
+
+ await warmer.setEnabled("conv-1", true);
+ warmer.onTurnSettled("conv-1", {});
+ expect(warmer.getState("conv-1").nextWarmAt).not.toBeNull();
+
+ const countBefore = changeCount;
+ await warmer.onConversationClosed("conv-1");
+
+ const state = warmer.getState("conv-1");
+ expect(state.enabled).toBe(false);
+ expect(state.nextWarmAt).toBeNull();
+ expect(changeCount).toBe(countBefore + 1);
+
+ // The pending timer is cancelled — flushing fires nothing.
+ deps.timers.flush();
+ await new Promise((r) => setTimeout(r, 10));
+ expect(warmCalls).toHaveLength(0);
+
+ // Persisted OFF: a fresh warmer over the same storage stays disabled on enable-read.
+ const raw = await deps.storage.get("settings:conv-1");
+ expect(raw).not.toBeNull();
+ expect(JSON.parse(raw as string).enabled).toBe(false);
+ });
+
+ it("a turnSettled racing a close does not re-arm (enabled flipped synchronously)", async () => {
+ const deps = makeDeps({ now: () => 5000 });
+ const warmer = createCacheWarmer(deps);
+
+ await warmer.setEnabled("conv-1", true);
+ warmer.onTurnStarted("conv-1");
+
+ // Close while "generating" — do NOT await: the sync part must suffice.
+ const closed = warmer.onConversationClosed("conv-1");
+ // The turn settles immediately after the close was issued.
+ warmer.onTurnSettled("conv-1", {});
+
+ expect(warmer.getState("conv-1").enabled).toBe(false);
+ expect(warmer.getState("conv-1").nextWarmAt).toBeNull();
+ await closed;
+ });
+
+ it("the per-conversation spec includes a cache-retention stat", () => {
+ const deps = makeDeps({ now: () => 5000 });
+ const warmer = createCacheWarmer(deps);
+
+ warmer.onTurnSettled("conv-1", {});
+ warmer.onWarmCompleted({
+ conversationId: "conv-1",
+ usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 900, cacheWriteTokens: 100 },
+ });
+
+ const state = warmer.getState("conv-1");
+ expect(state.lastExpectedPct).toBe(90);
+ });
});