summaryrefslogtreecommitdiffhomepage
path: root/packages/cache-warming/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cache-warming/src')
-rw-r--r--packages/cache-warming/src/extension.ts272
-rw-r--r--packages/cache-warming/src/index.ts40
-rw-r--r--packages/cache-warming/src/pure.test.ts486
-rw-r--r--packages/cache-warming/src/pure.ts236
-rw-r--r--packages/cache-warming/src/warmer.test.ts1072
-rw-r--r--packages/cache-warming/src/warmer.ts550
6 files changed, 1328 insertions, 1328 deletions
diff --git a/packages/cache-warming/src/extension.ts b/packages/cache-warming/src/extension.ts
index 920177f..99b9e1e 100644
--- a/packages/cache-warming/src/extension.ts
+++ b/packages/cache-warming/src/extension.ts
@@ -1,154 +1,154 @@
import type { Extension, HostAPI, Manifest } from "@dispatch/kernel";
import {
- cacheWarmHandle,
- conversationClosed,
- turnSettled,
- turnStarted,
- warmCompleted,
+ cacheWarmHandle,
+ conversationClosed,
+ turnSettled,
+ turnStarted,
+ warmCompleted,
} from "@dispatch/session-orchestrator";
import type { SurfaceContext, SurfaceProvider } from "@dispatch/surface-registry";
import { surfaceRegistryHandle } from "@dispatch/surface-registry";
import type { SurfaceSpec } from "@dispatch/ui-contract";
import {
- buildConversationSpec,
- buildDefaultSpec,
- parseIntervalPayload,
- secondsToMs,
+ buildConversationSpec,
+ buildDefaultSpec,
+ parseIntervalPayload,
+ secondsToMs,
} from "./pure.js";
import { createCacheWarmer } from "./warmer.js";
export const manifest: Manifest = {
- id: "cache-warming",
- name: "Cache Warming",
- version: "0.0.0",
- apiVersion: "^0.1.0",
- trust: "bundled",
- activation: "eager",
- dependsOn: ["session-orchestrator", "surface-registry"],
- capabilities: {},
- contributes: {
- services: ["cache-warming/surface"],
- },
+ id: "cache-warming",
+ name: "Cache Warming",
+ version: "0.0.0",
+ apiVersion: "^0.1.0",
+ trust: "bundled",
+ activation: "eager",
+ dependsOn: ["session-orchestrator", "surface-registry"],
+ capabilities: {},
+ contributes: {
+ services: ["cache-warming/surface"],
+ },
};
export function activate(host: HostAPI): void {
- const warmService = host.getService(cacheWarmHandle);
- const registry = host.getService(surfaceRegistryHandle);
- const storage = host.storage("cache-warming");
-
- const subscribers = new Set<() => void>();
-
- const timeoutMap = new Map<number, ReturnType<typeof setTimeout>>();
- let nextTimerId = 1;
-
- const warmer = createCacheWarmer({
- warm: warmService.warm,
- storage,
- logger: host.logger,
- timers: {
- setTimer(fn, ms) {
- const id = nextTimerId++;
- timeoutMap.set(id, setTimeout(fn, ms));
- return id;
- },
- clearTimer(id) {
- const timeout = timeoutMap.get(id);
- if (timeout !== undefined) {
- clearTimeout(timeout);
- timeoutMap.delete(id);
- }
- },
- },
- now: () => Date.now(),
- onSurfaceChange: () => {
- for (const notify of subscribers) {
- notify();
- }
- },
- });
-
- host.on(turnStarted, (payload) => {
- warmer.onTurnStarted(payload.conversationId);
- });
-
- host.on(turnSettled, (payload) => {
- warmer.onTurnSettled(payload.conversationId, {
- ...(payload.cwd !== undefined ? { cwd: payload.cwd } : {}),
- ...(payload.modelName !== undefined ? { modelName: payload.modelName } : {}),
- });
- });
-
- host.on(warmCompleted, (payload) => {
- warmer.onWarmCompleted(payload);
- });
-
- host.on(conversationClosed, (payload) => {
- // Sync part (cancel + disable) runs before the first await inside;
- // only the settings persist is deferred.
- void warmer.onConversationClosed(payload.conversationId);
- });
-
- function getSpec(context?: SurfaceContext): SurfaceSpec {
- const convId = context?.conversationId;
- if (convId === undefined) {
- return buildDefaultSpec();
- }
- const state = warmer.getState(convId);
- return buildConversationSpec(
- state.enabled,
- state.intervalMs,
- state.lastPct,
- state.lastExpectedPct,
- state.nextWarmAt,
- state.lastWarmAt,
- );
- }
-
- async function invoke(
- actionId: string,
- payload?: unknown,
- context?: SurfaceContext,
- ): Promise<void> {
- const convId = context?.conversationId;
- if (convId === undefined) return;
-
- if (actionId === "cache-warming/toggle") {
- const current = warmer.getState(convId);
- await warmer.setEnabled(convId, !current.enabled);
- }
-
- if (actionId === "cache-warming/set-interval") {
- const seconds = parseIntervalPayload(payload);
- if (seconds === null) return;
- const ms = secondsToMs(seconds);
- if (ms === null) return;
- await warmer.setIntervalMs(convId, ms);
- }
- }
-
- const provider: SurfaceProvider = {
- catalogEntry: {
- id: "cache-warming",
- region: "side",
- title: "Cache Warming",
- scope: "conversation",
- },
- getSpec,
- invoke,
- subscribe(onChange) {
- subscribers.add(onChange);
- return () => {
- subscribers.delete(onChange);
- };
- },
- };
-
- registry.register(provider);
-
- host.logger.info("cache-warming: registered");
+ const warmService = host.getService(cacheWarmHandle);
+ const registry = host.getService(surfaceRegistryHandle);
+ const storage = host.storage("cache-warming");
+
+ const subscribers = new Set<() => void>();
+
+ const timeoutMap = new Map<number, ReturnType<typeof setTimeout>>();
+ let nextTimerId = 1;
+
+ const warmer = createCacheWarmer({
+ warm: warmService.warm,
+ storage,
+ logger: host.logger,
+ timers: {
+ setTimer(fn, ms) {
+ const id = nextTimerId++;
+ timeoutMap.set(id, setTimeout(fn, ms));
+ return id;
+ },
+ clearTimer(id) {
+ const timeout = timeoutMap.get(id);
+ if (timeout !== undefined) {
+ clearTimeout(timeout);
+ timeoutMap.delete(id);
+ }
+ },
+ },
+ now: () => Date.now(),
+ onSurfaceChange: () => {
+ for (const notify of subscribers) {
+ notify();
+ }
+ },
+ });
+
+ host.on(turnStarted, (payload) => {
+ warmer.onTurnStarted(payload.conversationId);
+ });
+
+ host.on(turnSettled, (payload) => {
+ warmer.onTurnSettled(payload.conversationId, {
+ ...(payload.cwd !== undefined ? { cwd: payload.cwd } : {}),
+ ...(payload.modelName !== undefined ? { modelName: payload.modelName } : {}),
+ });
+ });
+
+ host.on(warmCompleted, (payload) => {
+ warmer.onWarmCompleted(payload);
+ });
+
+ host.on(conversationClosed, (payload) => {
+ // Sync part (cancel + disable) runs before the first await inside;
+ // only the settings persist is deferred.
+ void warmer.onConversationClosed(payload.conversationId);
+ });
+
+ function getSpec(context?: SurfaceContext): SurfaceSpec {
+ const convId = context?.conversationId;
+ if (convId === undefined) {
+ return buildDefaultSpec();
+ }
+ const state = warmer.getState(convId);
+ return buildConversationSpec(
+ state.enabled,
+ state.intervalMs,
+ state.lastPct,
+ state.lastExpectedPct,
+ state.nextWarmAt,
+ state.lastWarmAt,
+ );
+ }
+
+ async function invoke(
+ actionId: string,
+ payload?: unknown,
+ context?: SurfaceContext,
+ ): Promise<void> {
+ const convId = context?.conversationId;
+ if (convId === undefined) return;
+
+ if (actionId === "cache-warming/toggle") {
+ const current = warmer.getState(convId);
+ await warmer.setEnabled(convId, !current.enabled);
+ }
+
+ if (actionId === "cache-warming/set-interval") {
+ const seconds = parseIntervalPayload(payload);
+ if (seconds === null) return;
+ const ms = secondsToMs(seconds);
+ if (ms === null) return;
+ await warmer.setIntervalMs(convId, ms);
+ }
+ }
+
+ const provider: SurfaceProvider = {
+ catalogEntry: {
+ id: "cache-warming",
+ region: "side",
+ title: "Cache Warming",
+ scope: "conversation",
+ },
+ getSpec,
+ invoke,
+ subscribe(onChange) {
+ subscribers.add(onChange);
+ return () => {
+ subscribers.delete(onChange);
+ };
+ },
+ };
+
+ registry.register(provider);
+
+ host.logger.info("cache-warming: registered");
}
export const extension: Extension = {
- manifest,
- activate,
+ manifest,
+ activate,
};
diff --git a/packages/cache-warming/src/index.ts b/packages/cache-warming/src/index.ts
index 88cab3b..a6c3e99 100644
--- a/packages/cache-warming/src/index.ts
+++ b/packages/cache-warming/src/index.ts
@@ -1,25 +1,25 @@
export { extension, manifest } from "./extension.js";
export {
- buildConversationSpec,
- buildDefaultSpec,
- type ConversationSettings,
- type ConversationState,
- computeCachePct,
- computeExpectedCacheRate,
- DEFAULT_INTERVAL_MS,
- isTokenCurrent,
- MIN_INTERVAL_MS,
- msToSeconds,
- parseIntervalPayload,
- parseSettings,
- secondsToMs,
- serializeSettings,
- settingsKey,
- shouldWarm,
+ buildConversationSpec,
+ buildDefaultSpec,
+ type ConversationSettings,
+ type ConversationState,
+ computeCachePct,
+ computeExpectedCacheRate,
+ DEFAULT_INTERVAL_MS,
+ isTokenCurrent,
+ MIN_INTERVAL_MS,
+ msToSeconds,
+ parseIntervalPayload,
+ parseSettings,
+ secondsToMs,
+ serializeSettings,
+ settingsKey,
+ shouldWarm,
} from "./pure.js";
export {
- type CacheWarmer,
- type CacheWarmerDeps,
- createCacheWarmer,
- type TimerDeps,
+ type CacheWarmer,
+ type CacheWarmerDeps,
+ createCacheWarmer,
+ type TimerDeps,
} from "./warmer.js";
diff --git a/packages/cache-warming/src/pure.test.ts b/packages/cache-warming/src/pure.test.ts
index 357fcb9..02adaf7 100644
--- a/packages/cache-warming/src/pure.test.ts
+++ b/packages/cache-warming/src/pure.test.ts
@@ -1,297 +1,297 @@
import { describe, expect, it } from "vitest";
import type { ConversationState } from "./pure.js";
import {
- buildConversationSpec,
- buildDefaultSpec,
- computeCachePct,
- computeExpectedCacheRate,
- isTokenCurrent,
- MIN_INTERVAL_MS,
- msToSeconds,
- parseIntervalPayload,
- parseSettings,
- secondsToMs,
- serializeSettings,
- shouldWarm,
+ buildConversationSpec,
+ buildDefaultSpec,
+ computeCachePct,
+ computeExpectedCacheRate,
+ isTokenCurrent,
+ MIN_INTERVAL_MS,
+ msToSeconds,
+ parseIntervalPayload,
+ parseSettings,
+ secondsToMs,
+ serializeSettings,
+ shouldWarm,
} from "./pure.js";
describe("computeCachePct", () => {
- it("cacheRead/input rounded and clamped to 0..100", () => {
- expect(computeCachePct(1000, 800)).toBe(80);
- expect(computeCachePct(1000, 1200)).toBe(100);
- expect(computeCachePct(1000, -100)).toBe(0);
- expect(computeCachePct(1000, 0)).toBe(0);
- expect(computeCachePct(1000, 333)).toBe(33);
- });
+ it("cacheRead/input rounded and clamped to 0..100", () => {
+ expect(computeCachePct(1000, 800)).toBe(80);
+ expect(computeCachePct(1000, 1200)).toBe(100);
+ expect(computeCachePct(1000, -100)).toBe(0);
+ expect(computeCachePct(1000, 0)).toBe(0);
+ expect(computeCachePct(1000, 333)).toBe(33);
+ });
- it("zero input tokens → 0", () => {
- expect(computeCachePct(0, 500)).toBe(0);
- expect(computeCachePct(-1, 500)).toBe(0);
- });
+ it("zero input tokens → 0", () => {
+ expect(computeCachePct(0, 500)).toBe(0);
+ expect(computeCachePct(-1, 500)).toBe(0);
+ });
});
describe("computeExpectedCacheRate", () => {
- it("cacheRead/(cacheRead+cacheWrite) rounded", () => {
- expect(computeExpectedCacheRate(800, 200)).toBe(80);
- expect(computeExpectedCacheRate(500, 500)).toBe(50);
- expect(computeExpectedCacheRate(1000, 0)).toBe(100);
- expect(computeExpectedCacheRate(0, 1000)).toBe(0);
- expect(computeExpectedCacheRate(333, 667)).toBe(33);
- });
+ it("cacheRead/(cacheRead+cacheWrite) rounded", () => {
+ expect(computeExpectedCacheRate(800, 200)).toBe(80);
+ expect(computeExpectedCacheRate(500, 500)).toBe(50);
+ expect(computeExpectedCacheRate(1000, 0)).toBe(100);
+ expect(computeExpectedCacheRate(0, 1000)).toBe(0);
+ expect(computeExpectedCacheRate(333, 667)).toBe(33);
+ });
- it("0 when cacheRead+cacheWrite is 0", () => {
- expect(computeExpectedCacheRate(0, 0)).toBe(0);
- });
+ it("0 when cacheRead+cacheWrite is 0", () => {
+ expect(computeExpectedCacheRate(0, 0)).toBe(0);
+ });
});
describe("shouldWarm", () => {
- it("returns true when enabled, idle, and token matches", () => {
- const state: ConversationState = {
- enabled: true,
- intervalMs: 240_000,
- active: false,
- lastPct: null,
- lastExpectedPct: null,
- lastWarmAt: null,
- nextWarmAt: null,
- token: 5,
- };
- expect(shouldWarm(state, 5)).toBe(true);
- });
+ it("returns true when enabled, idle, and token matches", () => {
+ const state: ConversationState = {
+ enabled: true,
+ intervalMs: 240_000,
+ active: false,
+ lastPct: null,
+ lastExpectedPct: null,
+ lastWarmAt: null,
+ nextWarmAt: null,
+ token: 5,
+ };
+ expect(shouldWarm(state, 5)).toBe(true);
+ });
- it("returns false when disabled", () => {
- const state: ConversationState = {
- enabled: false,
- intervalMs: 240_000,
- active: false,
- lastPct: null,
- lastExpectedPct: null,
- lastWarmAt: null,
- nextWarmAt: null,
- token: 5,
- };
- expect(shouldWarm(state, 5)).toBe(false);
- });
+ it("returns false when disabled", () => {
+ const state: ConversationState = {
+ enabled: false,
+ intervalMs: 240_000,
+ active: false,
+ lastPct: null,
+ lastExpectedPct: null,
+ lastWarmAt: null,
+ nextWarmAt: null,
+ token: 5,
+ };
+ expect(shouldWarm(state, 5)).toBe(false);
+ });
- it("returns false when active", () => {
- const state: ConversationState = {
- enabled: true,
- intervalMs: 240_000,
- active: true,
- lastPct: null,
- lastExpectedPct: null,
- lastWarmAt: null,
- nextWarmAt: null,
- token: 5,
- };
- expect(shouldWarm(state, 5)).toBe(false);
- });
+ it("returns false when active", () => {
+ const state: ConversationState = {
+ enabled: true,
+ intervalMs: 240_000,
+ active: true,
+ lastPct: null,
+ lastExpectedPct: null,
+ lastWarmAt: null,
+ nextWarmAt: null,
+ token: 5,
+ };
+ expect(shouldWarm(state, 5)).toBe(false);
+ });
- it("returns false when token is superseded", () => {
- const state: ConversationState = {
- enabled: true,
- intervalMs: 240_000,
- active: false,
- lastPct: null,
- lastExpectedPct: null,
- lastWarmAt: null,
- nextWarmAt: null,
- token: 5,
- };
- expect(shouldWarm(state, 6)).toBe(false);
- });
+ it("returns false when token is superseded", () => {
+ const state: ConversationState = {
+ enabled: true,
+ intervalMs: 240_000,
+ active: false,
+ lastPct: null,
+ lastExpectedPct: null,
+ lastWarmAt: null,
+ nextWarmAt: null,
+ token: 5,
+ };
+ expect(shouldWarm(state, 6)).toBe(false);
+ });
});
describe("isTokenCurrent", () => {
- it("returns true when tokens match", () => {
- expect(isTokenCurrent(5, 5)).toBe(true);
- });
+ it("returns true when tokens match", () => {
+ expect(isTokenCurrent(5, 5)).toBe(true);
+ });
- it("returns false when tokens differ", () => {
- expect(isTokenCurrent(5, 6)).toBe(false);
- });
+ it("returns false when tokens differ", () => {
+ expect(isTokenCurrent(5, 6)).toBe(false);
+ });
});
describe("parseSettings/serializeSettings round-trip", () => {
- it("round-trips enabled + intervalMs", () => {
- const original = { enabled: false, intervalMs: 120_000 };
- const serialized = serializeSettings(original);
- const parsed = parseSettings(serialized);
- expect(parsed).toEqual(original);
- });
+ it("round-trips enabled + intervalMs", () => {
+ const original = { enabled: false, intervalMs: 120_000 };
+ const serialized = serializeSettings(original);
+ const parsed = parseSettings(serialized);
+ expect(parsed).toEqual(original);
+ });
- it("returns defaults for null input (warming OFF by default — CR-4a)", () => {
- const parsed = parseSettings(null);
- expect(parsed).toEqual({ enabled: false, intervalMs: 240_000 });
- });
+ it("returns defaults for null input (warming OFF by default — CR-4a)", () => {
+ const parsed = parseSettings(null);
+ expect(parsed).toEqual({ enabled: false, intervalMs: 240_000 });
+ });
- it("returns defaults for malformed JSON (warming OFF by default — CR-4a)", () => {
- const parsed = parseSettings("not-json{{{");
- expect(parsed).toEqual({ enabled: false, intervalMs: 240_000 });
- });
+ it("returns defaults for malformed JSON (warming OFF by default — CR-4a)", () => {
+ const parsed = parseSettings("not-json{{{");
+ expect(parsed).toEqual({ enabled: false, intervalMs: 240_000 });
+ });
- it("clamps non-positive interval to MIN_INTERVAL_MS", () => {
- const parsed = parseSettings(JSON.stringify({ enabled: true, intervalMs: -500 }));
- expect(parsed.intervalMs).toBe(1000);
- });
+ it("clamps non-positive interval to MIN_INTERVAL_MS", () => {
+ const parsed = parseSettings(JSON.stringify({ enabled: true, intervalMs: -500 }));
+ expect(parsed.intervalMs).toBe(1000);
+ });
- it("uses default for NaN interval", () => {
- const parsed = parseSettings(JSON.stringify({ enabled: true, intervalMs: Number.NaN }));
- expect(parsed.intervalMs).toBe(240_000);
- });
+ it("uses default for NaN interval", () => {
+ const parsed = parseSettings(JSON.stringify({ enabled: true, intervalMs: Number.NaN }));
+ expect(parsed.intervalMs).toBe(240_000);
+ });
});
describe("msToSeconds", () => {
- it("converts ms to seconds, rounded", () => {
- expect(msToSeconds(240_000)).toBe(240);
- expect(msToSeconds(1500)).toBe(2);
- expect(msToSeconds(1000)).toBe(1);
- expect(msToSeconds(0)).toBe(0);
- });
+ it("converts ms to seconds, rounded", () => {
+ expect(msToSeconds(240_000)).toBe(240);
+ expect(msToSeconds(1500)).toBe(2);
+ expect(msToSeconds(1000)).toBe(1);
+ expect(msToSeconds(0)).toBe(0);
+ });
});
describe("secondsToMs", () => {
- it("converts seconds to ms, floors at MIN_INTERVAL_MS", () => {
- expect(secondsToMs(240)).toBe(240_000);
- expect(secondsToMs(1)).toBe(1000);
- expect(secondsToMs(0.5)).toBe(MIN_INTERVAL_MS);
- });
+ it("converts seconds to ms, floors at MIN_INTERVAL_MS", () => {
+ expect(secondsToMs(240)).toBe(240_000);
+ expect(secondsToMs(1)).toBe(1000);
+ expect(secondsToMs(0.5)).toBe(MIN_INTERVAL_MS);
+ });
- it("returns null for NaN / non-positive", () => {
- expect(secondsToMs(Number.NaN)).toBeNull();
- expect(secondsToMs(0)).toBeNull();
- expect(secondsToMs(-5)).toBeNull();
- expect(secondsToMs(Number.POSITIVE_INFINITY)).toBeNull();
- });
+ it("returns null for NaN / non-positive", () => {
+ expect(secondsToMs(Number.NaN)).toBeNull();
+ expect(secondsToMs(0)).toBeNull();
+ expect(secondsToMs(-5)).toBeNull();
+ expect(secondsToMs(Number.POSITIVE_INFINITY)).toBeNull();
+ });
});
describe("parseIntervalPayload", () => {
- it("accepts a bare positive number", () => {
- expect(parseIntervalPayload(30)).toBe(30);
- expect(parseIntervalPayload(1)).toBe(1);
- });
+ it("accepts a bare positive number", () => {
+ expect(parseIntervalPayload(30)).toBe(30);
+ expect(parseIntervalPayload(1)).toBe(1);
+ });
- it("accepts { value: number }", () => {
- expect(parseIntervalPayload({ value: 30 })).toBe(30);
- expect(parseIntervalPayload({ value: 1 })).toBe(1);
- });
+ it("accepts { value: number }", () => {
+ expect(parseIntervalPayload({ value: 30 })).toBe(30);
+ expect(parseIntervalPayload({ value: 1 })).toBe(1);
+ });
- it("returns null for NaN / non-positive / wrong shape", () => {
- expect(parseIntervalPayload(Number.NaN)).toBeNull();
- expect(parseIntervalPayload(0)).toBeNull();
- expect(parseIntervalPayload(-5)).toBeNull();
- expect(parseIntervalPayload("30")).toBeNull();
- expect(parseIntervalPayload({ value: "30" })).toBeNull();
- expect(parseIntervalPayload({})).toBeNull();
- expect(parseIntervalPayload(null)).toBeNull();
- expect(parseIntervalPayload(undefined)).toBeNull();
- });
+ it("returns null for NaN / non-positive / wrong shape", () => {
+ expect(parseIntervalPayload(Number.NaN)).toBeNull();
+ expect(parseIntervalPayload(0)).toBeNull();
+ expect(parseIntervalPayload(-5)).toBeNull();
+ expect(parseIntervalPayload("30")).toBeNull();
+ expect(parseIntervalPayload({ value: "30" })).toBeNull();
+ expect(parseIntervalPayload({})).toBeNull();
+ expect(parseIntervalPayload(null)).toBeNull();
+ expect(parseIntervalPayload(undefined)).toBeNull();
+ });
});
describe("buildConversationSpec", () => {
- it("builds a per-conversation spec with toggle + number(interval) + last-% + retention + timer fields", () => {
- const spec = buildConversationSpec(true, 240_000, 80, 95, 1000, 500);
- expect(spec.id).toBe("cache-warming");
- expect(spec.region).toBe("side");
- expect(spec.title).toBe("Cache Warming");
- expect(spec.fields).toHaveLength(5);
+ it("builds a per-conversation spec with toggle + number(interval) + last-% + retention + timer fields", () => {
+ const spec = buildConversationSpec(true, 240_000, 80, 95, 1000, 500);
+ expect(spec.id).toBe("cache-warming");
+ expect(spec.region).toBe("side");
+ expect(spec.title).toBe("Cache Warming");
+ expect(spec.fields).toHaveLength(5);
- const toggle = spec.fields[0];
- expect(toggle).toEqual({
- kind: "toggle",
- label: "Enabled",
- value: true,
- action: { actionId: "cache-warming/toggle" },
- });
+ const toggle = spec.fields[0];
+ expect(toggle).toEqual({
+ kind: "toggle",
+ label: "Enabled",
+ value: true,
+ action: { actionId: "cache-warming/toggle" },
+ });
- const number = spec.fields[1];
- expect(number).toEqual({
- kind: "number",
- label: "Refresh Interval",
- value: 240,
- min: 1,
- step: 1,
- unit: "s",
- action: { actionId: "cache-warming/set-interval" },
- });
+ const number = spec.fields[1];
+ expect(number).toEqual({
+ kind: "number",
+ label: "Refresh Interval",
+ value: 240,
+ min: 1,
+ step: 1,
+ unit: "s",
+ action: { actionId: "cache-warming/set-interval" },
+ });
- const stat = spec.fields[2];
- expect(stat).toEqual({
- kind: "stat",
- label: "Last Cache %",
- value: "80%",
- });
+ const stat = spec.fields[2];
+ expect(stat).toEqual({
+ kind: "stat",
+ label: "Last Cache %",
+ value: "80%",
+ });
- const retention = spec.fields[3];
- expect(retention).toEqual({
- kind: "stat",
- label: "Cache retention",
- value: "95%",
- });
+ const retention = spec.fields[3];
+ expect(retention).toEqual({
+ kind: "stat",
+ label: "Cache retention",
+ value: "95%",
+ });
- const timer = spec.fields[4];
- expect(timer).toEqual({
- kind: "custom",
- rendererId: "cache-warming-timer",
- payload: { nextWarmAt: 1000, lastWarmAt: 500 },
- });
- });
+ const timer = spec.fields[4];
+ expect(timer).toEqual({
+ kind: "custom",
+ rendererId: "cache-warming-timer",
+ payload: { nextWarmAt: 1000, lastWarmAt: 500 },
+ });
+ });
- it("shows — when lastPct and lastExpectedPct are null", () => {
- const spec = buildConversationSpec(true, 240_000, null, null, null, null);
- const stat = spec.fields[2];
- expect(stat).toEqual({
- kind: "stat",
- label: "Last Cache %",
- value: "—",
- });
- const retention = spec.fields[3];
- expect(retention).toEqual({
- kind: "stat",
- label: "Cache retention",
- value: "—",
- });
- const timer = spec.fields[4];
- expect(timer).toEqual({
- kind: "custom",
- rendererId: "cache-warming-timer",
- payload: { nextWarmAt: null, lastWarmAt: null },
- });
- });
+ it("shows — when lastPct and lastExpectedPct are null", () => {
+ const spec = buildConversationSpec(true, 240_000, null, null, null, null);
+ const stat = spec.fields[2];
+ expect(stat).toEqual({
+ kind: "stat",
+ label: "Last Cache %",
+ value: "—",
+ });
+ const retention = spec.fields[3];
+ expect(retention).toEqual({
+ kind: "stat",
+ label: "Cache retention",
+ value: "—",
+ });
+ const timer = spec.fields[4];
+ expect(timer).toEqual({
+ kind: "custom",
+ rendererId: "cache-warming-timer",
+ payload: { nextWarmAt: null, lastWarmAt: null },
+ });
+ });
- it("reflects disabled state", () => {
- const spec = buildConversationSpec(false, 120_000, 50, 75, null, null);
- const toggle = spec.fields[0];
- expect(toggle).toEqual({
- kind: "toggle",
- label: "Enabled",
- value: false,
- action: { actionId: "cache-warming/toggle" },
- });
- const number = spec.fields[1];
- expect(number).toEqual({
- kind: "number",
- label: "Refresh Interval",
- value: 120,
- min: 1,
- step: 1,
- unit: "s",
- action: { actionId: "cache-warming/set-interval" },
- });
- });
+ it("reflects disabled state", () => {
+ const spec = buildConversationSpec(false, 120_000, 50, 75, null, null);
+ const toggle = spec.fields[0];
+ expect(toggle).toEqual({
+ kind: "toggle",
+ label: "Enabled",
+ value: false,
+ action: { actionId: "cache-warming/toggle" },
+ });
+ const number = spec.fields[1];
+ expect(number).toEqual({
+ kind: "number",
+ label: "Refresh Interval",
+ value: 120,
+ min: 1,
+ step: 1,
+ unit: "s",
+ action: { actionId: "cache-warming/set-interval" },
+ });
+ });
});
describe("buildDefaultSpec", () => {
- it("returns a default spec with no conversationId", () => {
- const spec = buildDefaultSpec();
- expect(spec.id).toBe("cache-warming");
- expect(spec.region).toBe("side");
- expect(spec.title).toBe("Cache Warming");
- expect(spec.fields).toHaveLength(1);
- expect(spec.fields[0]).toEqual({
- kind: "stat",
- label: "Status",
- value: "No conversation focused",
- });
- });
+ it("returns a default spec with no conversationId", () => {
+ const spec = buildDefaultSpec();
+ expect(spec.id).toBe("cache-warming");
+ expect(spec.region).toBe("side");
+ expect(spec.title).toBe("Cache Warming");
+ expect(spec.fields).toHaveLength(1);
+ expect(spec.fields[0]).toEqual({
+ kind: "stat",
+ label: "Status",
+ value: "No conversation focused",
+ });
+ });
});
diff --git a/packages/cache-warming/src/pure.ts b/packages/cache-warming/src/pure.ts
index 2a2fd1b..16b3cb1 100644
--- a/packages/cache-warming/src/pure.ts
+++ b/packages/cache-warming/src/pure.ts
@@ -4,35 +4,35 @@
*/
import type {
- CustomField,
- NumberField,
- StatField,
- SurfaceSpec,
- ToggleField,
+ CustomField,
+ NumberField,
+ StatField,
+ SurfaceSpec,
+ ToggleField,
} from "@dispatch/ui-contract";
// --- Types ---
/** Persisted per-conversation settings (storage-facing). */
export interface ConversationSettings {
- readonly enabled: boolean;
- readonly intervalMs: number;
+ readonly enabled: boolean;
+ readonly intervalMs: number;
}
/** Full per-conversation runtime state (in-memory, not persisted). */
export interface ConversationState extends ConversationSettings {
- readonly active: boolean;
- readonly lastPct: number | null;
- readonly lastExpectedPct: number | null;
- readonly lastWarmAt: number | null;
- readonly nextWarmAt: number | null;
- readonly token: number;
+ readonly active: boolean;
+ readonly lastPct: number | null;
+ readonly lastExpectedPct: number | null;
+ readonly lastWarmAt: number | null;
+ readonly nextWarmAt: number | null;
+ readonly token: number;
}
/** Context stored per-conversation from the latest lifecycle event. */
export interface ConversationContext {
- readonly cwd?: string;
- readonly modelName?: string;
+ readonly cwd?: string;
+ readonly modelName?: string;
}
export const DEFAULT_INTERVAL_MS = 240_000;
@@ -45,10 +45,10 @@ export const MIN_INTERVAL_MS = 1000;
* Returns an integer in [0, 100]. inputTokens ≤ 0 → 0.
*/
export function computeCachePct(inputTokens: number, cacheReadTokens: number): number {
- if (inputTokens <= 0) return 0;
- const ratio = cacheReadTokens / inputTokens;
- const clamped = Math.max(0, Math.min(1, ratio));
- return Math.round(clamped * 100);
+ if (inputTokens <= 0) return 0;
+ const ratio = cacheReadTokens / inputTokens;
+ const clamped = Math.max(0, Math.min(1, ratio));
+ return Math.round(clamped * 100);
}
/**
@@ -58,12 +58,12 @@ export function computeCachePct(inputTokens: number, cacheReadTokens: number): n
* Returns an integer in [0, 100]. cacheRead + cacheWrite ≤ 0 → 0.
*/
export function computeExpectedCacheRate(
- cacheReadTokens: number,
- cacheWriteTokens: number,
+ cacheReadTokens: number,
+ cacheWriteTokens: number,
): number {
- const total = cacheReadTokens + cacheWriteTokens;
- if (total <= 0) return 0;
- return Math.round((cacheReadTokens / total) * 100);
+ const total = cacheReadTokens + cacheWriteTokens;
+ if (total <= 0) return 0;
+ return Math.round((cacheReadTokens / total) * 100);
}
/**
@@ -71,14 +71,14 @@ export function computeExpectedCacheRate(
* Requires: enabled, idle (not active), and the token is current (not superseded).
*/
export function shouldWarm(state: ConversationState, currentToken: number): boolean {
- return state.enabled && !state.active && state.token === currentToken;
+ return state.enabled && !state.active && state.token === currentToken;
}
/**
* Check whether a token is still current (not superseded by a newer cancel/fire).
*/
export function isTokenCurrent(current: number, expected: number): boolean {
- return current === expected;
+ return current === expected;
}
const SETTINGS_KEY = "settings";
@@ -91,43 +91,43 @@ const SETTINGS_KEY = "settings";
* until the user explicitly opts in via the toggle.
*/
export function parseSettings(raw: string | null): ConversationSettings {
- if (raw === null) return { enabled: false, intervalMs: DEFAULT_INTERVAL_MS };
- try {
- const parsed: unknown = JSON.parse(raw);
- if (typeof parsed !== "object" || parsed === null) {
- return { enabled: false, intervalMs: DEFAULT_INTERVAL_MS };
- }
- const obj = parsed as Record<string, unknown>;
- const enabled = typeof obj.enabled === "boolean" ? obj.enabled : false;
- const rawInterval = obj.intervalMs;
- let intervalMs = DEFAULT_INTERVAL_MS;
- if (typeof rawInterval === "number" && Number.isFinite(rawInterval)) {
- intervalMs =
- rawInterval <= 0 ? MIN_INTERVAL_MS : Math.max(MIN_INTERVAL_MS, Math.round(rawInterval));
- }
- return { enabled, intervalMs };
- } catch {
- return { enabled: false, intervalMs: DEFAULT_INTERVAL_MS };
- }
+ if (raw === null) return { enabled: false, intervalMs: DEFAULT_INTERVAL_MS };
+ try {
+ const parsed: unknown = JSON.parse(raw);
+ if (typeof parsed !== "object" || parsed === null) {
+ return { enabled: false, intervalMs: DEFAULT_INTERVAL_MS };
+ }
+ const obj = parsed as Record<string, unknown>;
+ const enabled = typeof obj.enabled === "boolean" ? obj.enabled : false;
+ const rawInterval = obj.intervalMs;
+ let intervalMs = DEFAULT_INTERVAL_MS;
+ if (typeof rawInterval === "number" && Number.isFinite(rawInterval)) {
+ intervalMs =
+ rawInterval <= 0 ? MIN_INTERVAL_MS : Math.max(MIN_INTERVAL_MS, Math.round(rawInterval));
+ }
+ return { enabled, intervalMs };
+ } catch {
+ return { enabled: false, intervalMs: DEFAULT_INTERVAL_MS };
+ }
}
/**
* Serialize settings for storage.
*/
export function serializeSettings(settings: ConversationSettings): string {
- return JSON.stringify(settings);
+ return JSON.stringify(settings);
}
/** The storage key for a conversation's settings. */
export function settingsKey(conversationId: string): string {
- return `${SETTINGS_KEY}:${conversationId}`;
+ return `${SETTINGS_KEY}:${conversationId}`;
}
// --- Surface spec builders (pure) ---
/** Convert intervalMs to display seconds (rounded). */
export function msToSeconds(intervalMs: number): number {
- return Math.round(intervalMs / 1000);
+ return Math.round(intervalMs / 1000);
}
/**
@@ -135,8 +135,8 @@ export function msToSeconds(intervalMs: number): number {
* Returns null for NaN / non-positive (caller should ignore).
*/
export function secondsToMs(seconds: number): number | null {
- if (!Number.isFinite(seconds) || seconds <= 0) return null;
- return Math.max(MIN_INTERVAL_MS, Math.round(seconds * 1000));
+ if (!Number.isFinite(seconds) || seconds <= 0) return null;
+ return Math.max(MIN_INTERVAL_MS, Math.round(seconds * 1000));
}
/**
@@ -144,51 +144,51 @@ export function secondsToMs(seconds: number): number | null {
* Pure — no I/O.
*/
export function buildConversationSpec(
- enabled: boolean,
- intervalMs: number,
- lastPct: number | null,
- lastExpectedPct: number | null,
- nextWarmAt: number | null,
- lastWarmAt: number | null,
+ enabled: boolean,
+ intervalMs: number,
+ lastPct: number | null,
+ lastExpectedPct: number | null,
+ nextWarmAt: number | null,
+ lastWarmAt: number | null,
): SurfaceSpec {
- const pctDisplay = lastPct === null ? "—" : `${lastPct}%`;
- const retentionDisplay = lastExpectedPct === null ? "—" : `${lastExpectedPct}%`;
- const toggle: ToggleField = {
- kind: "toggle",
- label: "Enabled",
- value: enabled,
- action: { actionId: "cache-warming/toggle" },
- };
- const interval: NumberField = {
- kind: "number",
- label: "Refresh Interval",
- value: msToSeconds(intervalMs),
- min: 1,
- step: 1,
- unit: "s",
- action: { actionId: "cache-warming/set-interval" },
- };
- const stat: StatField = {
- kind: "stat",
- label: "Last Cache %",
- value: pctDisplay,
- };
- const retentionStat: StatField = {
- kind: "stat",
- label: "Cache retention",
- value: retentionDisplay,
- };
- const timer: CustomField = {
- kind: "custom",
- rendererId: "cache-warming-timer",
- payload: { nextWarmAt, lastWarmAt },
- };
- return {
- id: "cache-warming",
- region: "side",
- title: "Cache Warming",
- fields: [toggle, interval, stat, retentionStat, timer],
- };
+ const pctDisplay = lastPct === null ? "—" : `${lastPct}%`;
+ const retentionDisplay = lastExpectedPct === null ? "—" : `${lastExpectedPct}%`;
+ const toggle: ToggleField = {
+ kind: "toggle",
+ label: "Enabled",
+ value: enabled,
+ action: { actionId: "cache-warming/toggle" },
+ };
+ const interval: NumberField = {
+ kind: "number",
+ label: "Refresh Interval",
+ value: msToSeconds(intervalMs),
+ min: 1,
+ step: 1,
+ unit: "s",
+ action: { actionId: "cache-warming/set-interval" },
+ };
+ const stat: StatField = {
+ kind: "stat",
+ label: "Last Cache %",
+ value: pctDisplay,
+ };
+ const retentionStat: StatField = {
+ kind: "stat",
+ label: "Cache retention",
+ value: retentionDisplay,
+ };
+ const timer: CustomField = {
+ kind: "custom",
+ rendererId: "cache-warming-timer",
+ payload: { nextWarmAt, lastWarmAt },
+ };
+ return {
+ id: "cache-warming",
+ region: "side",
+ title: "Cache Warming",
+ fields: [toggle, interval, stat, retentionStat, timer],
+ };
}
/**
@@ -196,18 +196,18 @@ export function buildConversationSpec(
* Pure — no I/O.
*/
export function buildDefaultSpec(): SurfaceSpec {
- return {
- id: "cache-warming",
- region: "side",
- title: "Cache Warming",
- fields: [
- {
- kind: "stat",
- label: "Status",
- value: "No conversation focused",
- },
- ],
- };
+ return {
+ id: "cache-warming",
+ region: "side",
+ title: "Cache Warming",
+ fields: [
+ {
+ kind: "stat",
+ label: "Status",
+ value: "No conversation focused",
+ },
+ ],
+ };
}
/**
@@ -216,17 +216,17 @@ export function buildDefaultSpec(): SurfaceSpec {
* null if the payload is invalid (NaN / non-positive / wrong shape).
*/
export function parseIntervalPayload(payload: unknown): number | null {
- if (typeof payload === "number" && Number.isFinite(payload) && payload > 0) {
- return payload;
- }
- if (
- typeof payload === "object" &&
- payload !== null &&
- "value" in payload &&
- typeof (payload as Record<string, unknown>).value === "number"
- ) {
- const v = (payload as Record<string, unknown>).value as number;
- if (Number.isFinite(v) && v > 0) return v;
- }
- return null;
+ if (typeof payload === "number" && Number.isFinite(payload) && payload > 0) {
+ return payload;
+ }
+ if (
+ typeof payload === "object" &&
+ payload !== null &&
+ "value" in payload &&
+ typeof (payload as Record<string, unknown>).value === "number"
+ ) {
+ const v = (payload as Record<string, unknown>).value as number;
+ if (Number.isFinite(v) && v > 0) return v;
+ }
+ return null;
}
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);
+ });
});
diff --git a/packages/cache-warming/src/warmer.ts b/packages/cache-warming/src/warmer.ts
index 6ad5c33..1d504e9 100644
--- a/packages/cache-warming/src/warmer.ts
+++ b/packages/cache-warming/src/warmer.ts
@@ -1,308 +1,308 @@
import type { Logger, StorageNamespace } from "@dispatch/kernel";
import type { WarmCompletedPayload, WarmService } from "@dispatch/session-orchestrator";
import {
- type ConversationContext,
- type ConversationSettings,
- type ConversationState,
- computeCachePct,
- computeExpectedCacheRate,
- DEFAULT_INTERVAL_MS,
- MIN_INTERVAL_MS,
- parseSettings,
- serializeSettings,
- settingsKey,
- shouldWarm,
+ type ConversationContext,
+ type ConversationSettings,
+ type ConversationState,
+ computeCachePct,
+ computeExpectedCacheRate,
+ DEFAULT_INTERVAL_MS,
+ MIN_INTERVAL_MS,
+ parseSettings,
+ serializeSettings,
+ settingsKey,
+ shouldWarm,
} from "./pure.js";
// --- Timer abstraction (injectable for tests) ---
export interface TimerDeps {
- readonly setTimer: (fn: () => void, ms: number) => number;
- readonly clearTimer: (id: number) => void;
+ readonly setTimer: (fn: () => void, ms: number) => number;
+ readonly clearTimer: (id: number) => void;
}
// --- Warmer interface ---
export interface CacheWarmer {
- /** Handle a turnStarted event — mark conversation active, cancel pending warm. */
- readonly onTurnStarted: (conversationId: string) => void;
+ /** Handle a turnStarted event — mark conversation active, cancel pending warm. */
+ readonly onTurnStarted: (conversationId: string) => void;
- /** Handle a turnSettled event — mark idle, store context, arm timer if enabled. */
- readonly onTurnSettled: (conversationId: string, ctx: ConversationContext) => void;
+ /** Handle a turnSettled event — mark idle, store context, arm timer if enabled. */
+ readonly onTurnSettled: (conversationId: string, ctx: ConversationContext) => void;
- /** Handle a warmCompleted event — process warm result, re-arm timer, update surface. */
- readonly onWarmCompleted: (payload: WarmCompletedPayload) => void;
+ /** Handle a warmCompleted event — process warm result, re-arm timer, update surface. */
+ readonly onWarmCompleted: (payload: WarmCompletedPayload) => void;
- /**
- * Handle an explicit "conversation closed" (tab close ≠ disconnect): stop the
- * schedule and persist warming OFF for the conversation. The enabled flip is
- * applied to in-memory state synchronously (so a turnSettled racing this close
- * can never re-arm); only the settings persist is awaited.
- */
- readonly onConversationClosed: (conversationId: string) => Promise<void>;
+ /**
+ * Handle an explicit "conversation closed" (tab close ≠ disconnect): stop the
+ * schedule and persist warming OFF for the conversation. The enabled flip is
+ * applied to in-memory state synchronously (so a turnSettled racing this close
+ * can never re-arm); only the settings persist is awaited.
+ */
+ readonly onConversationClosed: (conversationId: string) => Promise<void>;
- /** Get the current state for a conversation (for surface rendering). */
- readonly getState: (conversationId: string) => ConversationState;
+ /** Get the current state for a conversation (for surface rendering). */
+ readonly getState: (conversationId: string) => ConversationState;
- /** Get the stored context for a conversation. */
- readonly getContext: (conversationId: string) => ConversationContext;
+ /** Get the stored context for a conversation. */
+ readonly getContext: (conversationId: string) => ConversationContext;
- /** Toggle enabled for a conversation. Returns updated settings. */
- readonly setEnabled: (conversationId: string, enabled: boolean) => Promise<ConversationSettings>;
+ /** Toggle enabled for a conversation. Returns updated settings. */
+ readonly setEnabled: (conversationId: string, enabled: boolean) => Promise<ConversationSettings>;
- /** Set the refresh interval for a conversation. Returns updated settings. */
- readonly setIntervalMs: (
- conversationId: string,
- intervalMs: number,
- ) => Promise<ConversationSettings>;
+ /** Set the refresh interval for a conversation. Returns updated settings. */
+ readonly setIntervalMs: (
+ conversationId: string,
+ intervalMs: number,
+ ) => Promise<ConversationSettings>;
- /** Dispose all timers (for cleanup). */
- readonly dispose: () => void;
+ /** Dispose all timers (for cleanup). */
+ readonly dispose: () => void;
}
export interface CacheWarmerDeps {
- readonly warm: WarmService["warm"];
- readonly storage: StorageNamespace;
- readonly logger: Logger;
- readonly timers: TimerDeps;
- /** Injected clock — returns epoch-ms. */
- readonly now: () => number;
- /** Called when surface subscribers should re-fetch the spec. */
- readonly onSurfaceChange: () => void;
+ readonly warm: WarmService["warm"];
+ readonly storage: StorageNamespace;
+ readonly logger: Logger;
+ readonly timers: TimerDeps;
+ /** Injected clock — returns epoch-ms. */
+ readonly now: () => number;
+ /** Called when surface subscribers should re-fetch the spec. */
+ readonly onSurfaceChange: () => void;
}
// Warming is OPT-IN per conversation (CR-4a): default OFF, no warm scheduled
// until the user enables it.
const DEFAULT_STATE: ConversationState = {
- enabled: false,
- intervalMs: DEFAULT_INTERVAL_MS,
- active: false,
- lastPct: null,
- lastExpectedPct: null,
- lastWarmAt: null,
- nextWarmAt: null,
- token: 0,
+ enabled: false,
+ intervalMs: DEFAULT_INTERVAL_MS,
+ active: false,
+ lastPct: null,
+ lastExpectedPct: null,
+ lastWarmAt: null,
+ nextWarmAt: null,
+ token: 0,
};
export function createCacheWarmer(deps: CacheWarmerDeps): CacheWarmer {
- // Per-conversation runtime state (not persisted — reconstructed from storage + events)
- const states = new Map<string, ConversationState>();
- // Per-conversation context from latest lifecycle event
- const contexts = new Map<string, ConversationContext>();
- // Per-conversation pending timer id
- const timers = new Map<string, number>();
- // Monotonic token per conversation for in-flight invalidation
- let nextToken = 1;
-
- function getState(conversationId: string): ConversationState {
- return states.get(conversationId) ?? DEFAULT_STATE;
- }
-
- function getContext(conversationId: string): ConversationContext {
- return contexts.get(conversationId) ?? {};
- }
-
- function setState(conversationId: string, state: ConversationState): void {
- states.set(conversationId, state);
- }
-
- function cancelTimer(conversationId: string): void {
- const existing = timers.get(conversationId);
- if (existing !== undefined) {
- deps.timers.clearTimer(existing);
- timers.delete(conversationId);
- }
- mergeState(conversationId, { nextWarmAt: null });
- }
-
- function armTimer(conversationId: string): void {
- cancelTimer(conversationId);
- const state = getState(conversationId);
- if (!state.enabled || state.active) return;
-
- const token = nextToken++;
- const nowMs = deps.now();
- const nextWarmAt = nowMs + state.intervalMs;
- setState(conversationId, { ...state, token, nextWarmAt });
-
- const timerId = deps.timers.setTimer(() => {
- timers.delete(conversationId);
- void fireWarm(conversationId, token);
- }, state.intervalMs);
-
- timers.set(conversationId, timerId);
- }
-
- async function fireWarm(conversationId: string, token: number): Promise<void> {
- const state = getState(conversationId);
- if (!shouldWarm(state, token)) {
- deps.logger.debug("cache-warming: skip warm (superseded or disabled)", {
- conversationId,
- });
- return;
- }
-
- const ctx = getContext(conversationId);
- deps.logger.debug("cache-warming: firing warm", { conversationId });
-
- await deps.warm(conversationId, {
- ...(ctx.cwd !== undefined ? { cwd: ctx.cwd } : {}),
- ...(ctx.modelName !== undefined ? { modelName: ctx.modelName } : {}),
- });
-
- // Result processing is handled by the warmCompleted event handler.
- // Timer re-arm is also handled there on success.
- }
-
- async function loadSettings(conversationId: string): Promise<ConversationSettings> {
- const raw = await deps.storage.get(settingsKey(conversationId));
- return parseSettings(raw);
- }
-
- async function persistSettings(
- conversationId: string,
- settings: ConversationSettings,
- ): Promise<void> {
- await deps.storage.set(settingsKey(conversationId), serializeSettings(settings));
- }
-
- function mergeState(
- conversationId: string,
- partial: Partial<ConversationState>,
- ): ConversationState {
- const current = getState(conversationId);
- const updated = { ...current, ...partial };
- setState(conversationId, updated);
- return updated;
- }
-
- return {
- onTurnStarted(conversationId) {
- deps.logger.debug("cache-warming: turn started", { conversationId });
- mergeState(conversationId, { active: true });
- cancelTimer(conversationId);
- // Push the cleared schedule (nextWarmAt: null) so subscribers see
- // "no warm scheduled" while the turn is generating.
- deps.onSurfaceChange();
- },
-
- onTurnSettled(conversationId, ctx) {
- deps.logger.debug("cache-warming: turn settled", { conversationId });
- contexts.set(conversationId, ctx);
- mergeState(conversationId, { active: false });
-
- const state = getState(conversationId);
- if (state.enabled) {
- armTimer(conversationId);
- }
- // Push the post-seal reschedule so subscribers get the NEW (future)
- // nextWarmAt instead of a stale pre-turn one (CR-4b).
- deps.onSurfaceChange();
- },
-
- onWarmCompleted(payload) {
- const { conversationId, usage } = payload;
- const state = getState(conversationId);
-
- // Drop if the conversation became active while the warm was in flight
- if (state.active) {
- deps.logger.debug("cache-warming: dropping warm result (conversation active)", {
- conversationId,
- });
- return;
- }
-
- const pct = computeCachePct(usage.inputTokens, usage.cacheReadTokens);
- const expectedPct = computeExpectedCacheRate(usage.cacheReadTokens, usage.cacheWriteTokens);
- const nowMs = deps.now();
- setState(conversationId, {
- ...state,
- lastPct: pct,
- lastExpectedPct: expectedPct,
- lastWarmAt: nowMs,
- // The just-fired schedule is consumed; cleared here so a non-re-armed
- // path never reports a stale (past) nextWarmAt.
- nextWarmAt: null,
- });
-
- // Re-arm the automatic timer if enabled and not active — BEFORE the
- // surface notify, so the pushed update carries the NEW future nextWarmAt
- // instead of the fire time of the warm that just completed (CR-4b).
- const updated = getState(conversationId);
- if (updated.enabled && !updated.active) {
- armTimer(conversationId);
- }
-
- deps.onSurfaceChange();
- deps.logger.debug("cache-warming: warm complete", {
- conversationId,
- pct,
- expectedPct,
- });
- },
-
- getState,
- getContext,
-
- async onConversationClosed(conversationId) {
- deps.logger.debug("cache-warming: conversation closed", { conversationId });
- // Synchronous part FIRST: stop the schedule + flip enabled in memory so
- // any racing turnSettled sees enabled=false and never re-arms.
- cancelTimer(conversationId);
- const updated = mergeState(conversationId, { enabled: false });
- deps.onSurfaceChange();
- // Persist OFF so a reopened conversation stays opt-in.
- await persistSettings(conversationId, {
- enabled: false,
- intervalMs: updated.intervalMs,
- });
- },
-
- async setEnabled(conversationId, enabled) {
- const settings = await loadSettings(conversationId);
- const updated = { ...settings, enabled };
- await persistSettings(conversationId, updated);
- // Merge the FULL settings (not just `enabled`) so re-enabling restores
- // the persisted interval into runtime state.
- mergeState(conversationId, updated);
-
- if (enabled) {
- const state = getState(conversationId);
- if (!state.active) {
- armTimer(conversationId);
- }
- } else {
- cancelTimer(conversationId);
- }
-
- deps.onSurfaceChange();
- return updated;
- },
-
- async setIntervalMs(conversationId, intervalMs) {
- const clamped =
- !Number.isFinite(intervalMs) || intervalMs <= 0
- ? MIN_INTERVAL_MS
- : Math.max(MIN_INTERVAL_MS, Math.round(intervalMs));
- const settings = await loadSettings(conversationId);
- const updated = { ...settings, intervalMs: clamped };
- await persistSettings(conversationId, updated);
- mergeState(conversationId, { intervalMs: clamped });
-
- // Re-arm with new interval if currently armed
- const state = getState(conversationId);
- if (state.enabled && !state.active && timers.has(conversationId)) {
- armTimer(conversationId);
- }
-
- deps.onSurfaceChange();
- return updated;
- },
-
- dispose() {
- for (const [conversationId] of timers) {
- cancelTimer(conversationId);
- }
- },
- };
+ // Per-conversation runtime state (not persisted — reconstructed from storage + events)
+ const states = new Map<string, ConversationState>();
+ // Per-conversation context from latest lifecycle event
+ const contexts = new Map<string, ConversationContext>();
+ // Per-conversation pending timer id
+ const timers = new Map<string, number>();
+ // Monotonic token per conversation for in-flight invalidation
+ let nextToken = 1;
+
+ function getState(conversationId: string): ConversationState {
+ return states.get(conversationId) ?? DEFAULT_STATE;
+ }
+
+ function getContext(conversationId: string): ConversationContext {
+ return contexts.get(conversationId) ?? {};
+ }
+
+ function setState(conversationId: string, state: ConversationState): void {
+ states.set(conversationId, state);
+ }
+
+ function cancelTimer(conversationId: string): void {
+ const existing = timers.get(conversationId);
+ if (existing !== undefined) {
+ deps.timers.clearTimer(existing);
+ timers.delete(conversationId);
+ }
+ mergeState(conversationId, { nextWarmAt: null });
+ }
+
+ function armTimer(conversationId: string): void {
+ cancelTimer(conversationId);
+ const state = getState(conversationId);
+ if (!state.enabled || state.active) return;
+
+ const token = nextToken++;
+ const nowMs = deps.now();
+ const nextWarmAt = nowMs + state.intervalMs;
+ setState(conversationId, { ...state, token, nextWarmAt });
+
+ const timerId = deps.timers.setTimer(() => {
+ timers.delete(conversationId);
+ void fireWarm(conversationId, token);
+ }, state.intervalMs);
+
+ timers.set(conversationId, timerId);
+ }
+
+ async function fireWarm(conversationId: string, token: number): Promise<void> {
+ const state = getState(conversationId);
+ if (!shouldWarm(state, token)) {
+ deps.logger.debug("cache-warming: skip warm (superseded or disabled)", {
+ conversationId,
+ });
+ return;
+ }
+
+ const ctx = getContext(conversationId);
+ deps.logger.debug("cache-warming: firing warm", { conversationId });
+
+ await deps.warm(conversationId, {
+ ...(ctx.cwd !== undefined ? { cwd: ctx.cwd } : {}),
+ ...(ctx.modelName !== undefined ? { modelName: ctx.modelName } : {}),
+ });
+
+ // Result processing is handled by the warmCompleted event handler.
+ // Timer re-arm is also handled there on success.
+ }
+
+ async function loadSettings(conversationId: string): Promise<ConversationSettings> {
+ const raw = await deps.storage.get(settingsKey(conversationId));
+ return parseSettings(raw);
+ }
+
+ async function persistSettings(
+ conversationId: string,
+ settings: ConversationSettings,
+ ): Promise<void> {
+ await deps.storage.set(settingsKey(conversationId), serializeSettings(settings));
+ }
+
+ function mergeState(
+ conversationId: string,
+ partial: Partial<ConversationState>,
+ ): ConversationState {
+ const current = getState(conversationId);
+ const updated = { ...current, ...partial };
+ setState(conversationId, updated);
+ return updated;
+ }
+
+ return {
+ onTurnStarted(conversationId) {
+ deps.logger.debug("cache-warming: turn started", { conversationId });
+ mergeState(conversationId, { active: true });
+ cancelTimer(conversationId);
+ // Push the cleared schedule (nextWarmAt: null) so subscribers see
+ // "no warm scheduled" while the turn is generating.
+ deps.onSurfaceChange();
+ },
+
+ onTurnSettled(conversationId, ctx) {
+ deps.logger.debug("cache-warming: turn settled", { conversationId });
+ contexts.set(conversationId, ctx);
+ mergeState(conversationId, { active: false });
+
+ const state = getState(conversationId);
+ if (state.enabled) {
+ armTimer(conversationId);
+ }
+ // Push the post-seal reschedule so subscribers get the NEW (future)
+ // nextWarmAt instead of a stale pre-turn one (CR-4b).
+ deps.onSurfaceChange();
+ },
+
+ onWarmCompleted(payload) {
+ const { conversationId, usage } = payload;
+ const state = getState(conversationId);
+
+ // Drop if the conversation became active while the warm was in flight
+ if (state.active) {
+ deps.logger.debug("cache-warming: dropping warm result (conversation active)", {
+ conversationId,
+ });
+ return;
+ }
+
+ const pct = computeCachePct(usage.inputTokens, usage.cacheReadTokens);
+ const expectedPct = computeExpectedCacheRate(usage.cacheReadTokens, usage.cacheWriteTokens);
+ const nowMs = deps.now();
+ setState(conversationId, {
+ ...state,
+ lastPct: pct,
+ lastExpectedPct: expectedPct,
+ lastWarmAt: nowMs,
+ // The just-fired schedule is consumed; cleared here so a non-re-armed
+ // path never reports a stale (past) nextWarmAt.
+ nextWarmAt: null,
+ });
+
+ // Re-arm the automatic timer if enabled and not active — BEFORE the
+ // surface notify, so the pushed update carries the NEW future nextWarmAt
+ // instead of the fire time of the warm that just completed (CR-4b).
+ const updated = getState(conversationId);
+ if (updated.enabled && !updated.active) {
+ armTimer(conversationId);
+ }
+
+ deps.onSurfaceChange();
+ deps.logger.debug("cache-warming: warm complete", {
+ conversationId,
+ pct,
+ expectedPct,
+ });
+ },
+
+ getState,
+ getContext,
+
+ async onConversationClosed(conversationId) {
+ deps.logger.debug("cache-warming: conversation closed", { conversationId });
+ // Synchronous part FIRST: stop the schedule + flip enabled in memory so
+ // any racing turnSettled sees enabled=false and never re-arms.
+ cancelTimer(conversationId);
+ const updated = mergeState(conversationId, { enabled: false });
+ deps.onSurfaceChange();
+ // Persist OFF so a reopened conversation stays opt-in.
+ await persistSettings(conversationId, {
+ enabled: false,
+ intervalMs: updated.intervalMs,
+ });
+ },
+
+ async setEnabled(conversationId, enabled) {
+ const settings = await loadSettings(conversationId);
+ const updated = { ...settings, enabled };
+ await persistSettings(conversationId, updated);
+ // Merge the FULL settings (not just `enabled`) so re-enabling restores
+ // the persisted interval into runtime state.
+ mergeState(conversationId, updated);
+
+ if (enabled) {
+ const state = getState(conversationId);
+ if (!state.active) {
+ armTimer(conversationId);
+ }
+ } else {
+ cancelTimer(conversationId);
+ }
+
+ deps.onSurfaceChange();
+ return updated;
+ },
+
+ async setIntervalMs(conversationId, intervalMs) {
+ const clamped =
+ !Number.isFinite(intervalMs) || intervalMs <= 0
+ ? MIN_INTERVAL_MS
+ : Math.max(MIN_INTERVAL_MS, Math.round(intervalMs));
+ const settings = await loadSettings(conversationId);
+ const updated = { ...settings, intervalMs: clamped };
+ await persistSettings(conversationId, updated);
+ mergeState(conversationId, { intervalMs: clamped });
+
+ // Re-arm with new interval if currently armed
+ const state = getState(conversationId);
+ if (state.enabled && !state.active && timers.has(conversationId)) {
+ armTimer(conversationId);
+ }
+
+ deps.onSurfaceChange();
+ return updated;
+ },
+
+ dispose() {
+ for (const [conversationId] of timers) {
+ cancelTimer(conversationId);
+ }
+ },
+ };
}