diff options
| author | Adam Malczewski <[email protected]> | 2026-06-11 12:23:06 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-11 12:23:06 +0900 |
| commit | c2b4c05d91fa88b8d02c055a0e15c22abd8e21f3 (patch) | |
| tree | 3f7c2feddbe697a79abd952bb80ed0e01dac0a7a /packages/cache-warming/src/warmer.test.ts | |
| parent | f6b45507210e04e9884256b0132900640de4334b (diff) | |
| download | dispatch-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.ts | 207 |
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); + }); +}); |
