summaryrefslogtreecommitdiffhomepage
path: root/packages/cache-warming/src/warmer.test.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-11 12:23:06 +0900
committerAdam Malczewski <[email protected]>2026-06-11 12:23:06 +0900
commitc2b4c05d91fa88b8d02c055a0e15c22abd8e21f3 (patch)
tree3f7c2feddbe697a79abd952bb80ed0e01dac0a7a /packages/cache-warming/src/warmer.test.ts
parentf6b45507210e04e9884256b0132900640de4334b (diff)
downloaddispatch-c2b4c05d91fa88b8d02c055a0e15c22abd8e21f3.tar.gz
dispatch-c2b4c05d91fa88b8d02c055a0e15c22abd8e21f3.zip
feat(cache-warming): per-conversation prompt-cache warming + warm() service
Backend-driven warming targeting whatever provider a conversation uses (incl. the external Claude provider-anthropic). Core engine + on/off + last-cache-% done; interval-as-view-control pending a ui-contract NumberField (surface-system gap). Mechanism: - kernel: expose HostAPI.emit (typed bus event emit; counterpart of on) - session-orchestrator: turnStarted/turnSettled event hooks (conversationId/cwd/model); warm() service (cacheWarmHandle) reusing the real-turn assembly (byte-identical prefix, provider-agnostic), refuses mid-turn, never persists/emits, returns Usage - cache-warming (new ext): per-conversation timers (arm on settle, cancel on start, in-flight invalidation), calls warm(), pct=round(clamp(cacheRead/input,0,1)*100), persists {enabled,intervalMs} (default on/240s), registers a controls surface - host-bin: register cache-warming; transport-http: HostAPI stub +emit (fan-out) Honors old-code invariants. 760 vitest + 109 bun = 869 tests; tsc -b EXIT 0; biome clean.
Diffstat (limited to 'packages/cache-warming/src/warmer.test.ts')
-rw-r--r--packages/cache-warming/src/warmer.test.ts207
1 files changed, 207 insertions, 0 deletions
diff --git a/packages/cache-warming/src/warmer.test.ts b/packages/cache-warming/src/warmer.test.ts
new file mode 100644
index 0000000..9b9ba93
--- /dev/null
+++ b/packages/cache-warming/src/warmer.test.ts
@@ -0,0 +1,207 @@
+import type { Logger, Span } from "@dispatch/kernel";
+import type { WarmResult } from "@dispatch/session-orchestrator";
+import { describe, expect, it } from "vitest";
+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))),
+ };
+}
+
+function makeSpan(): 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(),
+ };
+}
+
+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();
+ },
+ };
+}
+
+const WARM_RESULT: WarmResult = {
+ inputTokens: 1000,
+ outputTokens: 10,
+ cacheReadTokens: 800,
+ cacheWriteTokens: 0,
+};
+
+import type { StorageNamespace } from "@dispatch/kernel";
+
+describe("CacheWarmer", () => {
+ it("arms a timer on turnSettled and warms when it fires (enabled)", async () => {
+ const timers = fakeTimers();
+ const warmCalls: string[] = [];
+ const warmer = createCacheWarmer({
+ warm: async (convId) => {
+ warmCalls.push(convId);
+ return WARM_RESULT;
+ },
+ storage: memStorage(),
+ logger: makeLogger(),
+ timers,
+ onSurfaceChange: () => {},
+ });
+
+ warmer.onTurnSettled("conv-1", {});
+ timers.flush();
+
+ await new Promise((r) => setTimeout(r, 10));
+ expect(warmCalls).toContain("conv-1");
+ });
+
+ it("cancels the timer on turnStarted (no warm while generating)", () => {
+ const timers = fakeTimers();
+ const warmCalls: string[] = [];
+ const warmer = createCacheWarmer({
+ warm: async (convId) => {
+ warmCalls.push(convId);
+ return WARM_RESULT;
+ },
+ storage: memStorage(),
+ logger: makeLogger(),
+ timers,
+ onSurfaceChange: () => {},
+ });
+
+ warmer.onTurnSettled("conv-1", {});
+ warmer.onTurnStarted("conv-1");
+ timers.flush();
+
+ expect(warmCalls).toHaveLength(0);
+ });
+
+ it("in-flight warm result is dropped when superseded (token mismatch)", async () => {
+ const timers = fakeTimers();
+ let resolveWarm: (v: WarmResult) => void = () => {};
+ const warmPromise = new Promise<WarmResult>((r) => {
+ resolveWarm = r;
+ });
+ const warmer = createCacheWarmer({
+ warm: () => warmPromise,
+ storage: memStorage(),
+ logger: makeLogger(),
+ timers,
+ onSurfaceChange: () => {},
+ });
+
+ warmer.onTurnSettled("conv-1", {});
+ timers.flush();
+
+ warmer.onTurnStarted("conv-1");
+ warmer.onTurnSettled("conv-1", {});
+
+ resolveWarm?.(WARM_RESULT);
+ await new Promise((r) => setTimeout(r, 10));
+
+ const state = warmer.getState("conv-1");
+ expect(state.lastPct).toBeNull();
+ });
+
+ it("disabled conversation does not warm", async () => {
+ const timers = fakeTimers();
+ const warmCalls: string[] = [];
+ const warmer = createCacheWarmer({
+ warm: async (convId) => {
+ warmCalls.push(convId);
+ return WARM_RESULT;
+ },
+ storage: memStorage(),
+ logger: makeLogger(),
+ timers,
+ onSurfaceChange: () => {},
+ });
+
+ await warmer.setEnabled("conv-1", false);
+ warmer.onTurnSettled("conv-1", {});
+ timers.flush();
+
+ await new Promise((r) => setTimeout(r, 10));
+ expect(warmCalls).toHaveLength(0);
+ });
+
+ it("stores lastPct from the warm result", async () => {
+ const timers = fakeTimers();
+ const warmer = createCacheWarmer({
+ warm: async () => WARM_RESULT,
+ storage: memStorage(),
+ logger: makeLogger(),
+ timers,
+ onSurfaceChange: () => {},
+ });
+
+ warmer.onTurnSettled("conv-1", {});
+ timers.flush();
+
+ await new Promise((r) => setTimeout(r, 10));
+ const state = warmer.getState("conv-1");
+ expect(state.lastPct).toBe(80);
+ });
+
+ it("re-arms timer after warm completes", async () => {
+ const timers = fakeTimers();
+ let warmCount = 0;
+ const warmer = createCacheWarmer({
+ warm: async () => {
+ warmCount++;
+ return WARM_RESULT;
+ },
+ storage: memStorage(),
+ logger: makeLogger(),
+ timers,
+ onSurfaceChange: () => {},
+ });
+
+ warmer.onTurnSettled("conv-1", {});
+ timers.flush();
+ await new Promise((r) => setTimeout(r, 10));
+
+ timers.flush();
+ await new Promise((r) => setTimeout(r, 10));
+
+ expect(warmCount).toBe(2);
+ });
+});