import { type Api, type AssistantMessage, type AssistantMessageEventStream, type Model, type ModelsSimpleStreamOptions, normalizeContext, type Usage, } from "@earendil-works/pi-ai"; import { getBuiltinModel } from "@earendil-works/pi-ai/providers/all"; import { afterEach, describe, expect, it, vi } from "vitest"; import { AuthStorage } from "../src/core/auth-storage.ts"; import { CacheWarmer, type CacheWarmingAction, type CacheWarmingDecision, type CacheWarmingDecisionEvent, type CacheWarmRequest, formatCacheWarmingStatus, formatCacheWarmingUsage, getCacheWarmingDelayMs, getPromptCacheTtlMs, isReplayable, } from "../src/core/cache-warmer.ts"; import { createEventBus } from "../src/core/event-bus.ts"; import { createExtensionRuntime, loadExtensionFromFactory } from "../src/core/extensions/loader.ts"; import { ExtensionRunner } from "../src/core/extensions/runner.ts"; import type { ExtensionFactory } from "../src/core/extensions/types.ts"; import { type SessionEntry, SessionManager, type UsageEntry } from "../src/core/session-manager.ts"; import type { CacheWarmingMode } from "../src/core/settings-manager.ts"; import { createInMemoryModelRegistry } from "./model-runtime-test-utils.ts"; const adaptiveModel: Model = { ...getBuiltinModel("anthropic", "claude-opus-4-6"), promptCache: { short: 300, long: 3600 }, }; const budgetModel: Model = { ...getBuiltinModel("anthropic", "claude-sonnet-4-5"), promptCache: { short: 300, long: 3600 }, }; const openaiModel: Model = { ...getBuiltinModel("openai", "gpt-5"), promptCache: { short: 300, long: 86_400 }, }; const unknownModel: Model = { ...adaptiveModel, promptCache: undefined }; const warmUsage: Usage = { input: 0, output: 1, cacheRead: 100, cacheWrite: 0, totalTokens: 101, cost: { input: 0, output: 0, cacheRead: 0.01, cacheWrite: 0, total: 0.01 }, }; function response(model: Model, stopReason: AssistantMessage["stopReason"] = "length"): AssistantMessage { return { role: "assistant", content: [], api: model.api, provider: model.provider, model: model.id, usage: warmUsage, stopReason, timestamp: 0, }; } function branchWithPrompt(promptTokens: number): SessionEntry[] { return [ { type: "message", id: "a", parentId: null, timestamp: new Date(0).toISOString(), message: { ...response(adaptiveModel), usage: { ...warmUsage, output: 10, cacheRead: promptTokens, totalTokens: promptTokens + 10 }, }, }, ]; } function fakeRuntime( options: { result?: (model: Model) => Promise; decide?: (event: CacheWarmingDecisionEvent) => CacheWarmingAction | Promise; mode?: CacheWarmingMode; branch?: SessionEntry[]; } = {}, ) { const calls: Array<{ model: Model; options: ModelsSimpleStreamOptions | undefined }> = []; const events: CacheWarmingDecisionEvent[] = []; const warmedEntries: UsageEntry[] = []; const usageManager = SessionManager.inMemory(); const appendUsage = vi.fn(usageManager.appendUsage.bind(usageManager)); const state = { mode: options.mode ?? "idle", branch: options.branch ?? branchWithPrompt(100_000) }; const warmer = new CacheWarmer( { streamSimple: (model, _context, streamOptions) => { calls.push({ model, options: streamOptions }); return { result: () => (options.result ?? (async (m: Model) => response(m)))(model), } as unknown as AssistantMessageEventStream; }, }, { appendUsage, getBranch: () => state.branch }, () => state.mode, async (event) => { events.push(event); return options.decide?.(event) ?? event.action; }, ); warmer.onWarmed = (entry) => warmedEntries.push(entry); return { warmer, calls, events, warmedEntries, appendUsage, state }; } function request(model: Model = adaptiveModel, options: ModelsSimpleStreamOptions = {}): CacheWarmRequest { return { model, context: normalizeContext({ messages: [] }), options }; } const current = () => true; afterEach(() => vi.useRealTimers()); describe("cache warming", () => { it("derives eligibility and timing from retention and provider behavior", () => { expect([ getPromptCacheTtlMs(adaptiveModel, undefined), getPromptCacheTtlMs(adaptiveModel, { cacheRetention: "long" }), getPromptCacheTtlMs(adaptiveModel, { cacheRetention: "none" }), getPromptCacheTtlMs(adaptiveModel, { env: { PI_CACHE_RETENTION: "long" } }), getPromptCacheTtlMs(openaiModel, { cacheRetention: "long" }), getPromptCacheTtlMs(unknownModel, undefined), ]).toEqual([300_000, 3_600_000, undefined, 3_600_000, 86_400_000, undefined]); expect([getCacheWarmingDelayMs(300_000), getCacheWarmingDelayMs(60_000), getCacheWarmingDelayMs(10_000)]).toEqual( [270_000, 50_000, undefined], ); expect([ isReplayable(budgetModel, { reasoning: "medium" }), isReplayable(budgetModel, undefined), isReplayable(adaptiveModel, { reasoning: "medium" }), isReplayable(openaiModel, { reasoning: "medium" }), ]).toEqual([false, true, true, true]); }); it("replays profitable requests and preserves options across repeated refreshes", async () => { vi.useFakeTimers(); const { warmer, calls, events, appendUsage, warmedEntries } = fakeRuntime(); const signal = new AbortController().signal; const transformHeaders = async () => ({}); warmer.start(request(adaptiveModel, { reasoning: "high", signal, sessionId: "s", transformHeaders }), current); await vi.advanceTimersByTimeAsync(270_000); expect(calls[0]).toMatchObject({ model: adaptiveModel, options: { reasoning: "high", sessionId: "s", transformHeaders, maxTokens: 1, maxRetries: 0 }, }); expect(calls[0].options?.signal).not.toBe(signal); expect(events[0]).toMatchObject({ type: "cache_warming_decision", continuationProbability: 1, action: "warm", }); expect(events[0].missCost).toBeCloseTo(0.575); expect(events[0].warmCost).toBeCloseTo(0.050025); expect(appendUsage).toHaveBeenCalledWith( "cache_warm", adaptiveModel.provider, adaptiveModel.id, warmUsage, undefined, ); expect(warmedEntries).toEqual([appendUsage.mock.results[0]?.value]); await vi.advanceTimersByTimeAsync(270_000); expect(calls).toHaveLength(2); warmer.cancel(); }); it("does not issue refreshes after their safe deadline", async () => { vi.useFakeTimers(); vi.setSystemTime(0); const { warmer, calls } = fakeRuntime(); warmer.start(request(), current); // A five-minute cache is scheduled for 4m30s and retains 15 seconds of // the 30-second expiry margin. Simulate a timer delayed by sleep. vi.setSystemTime(285_001); vi.clearAllTimers(); const internal = warmer as unknown as { run: object | undefined; refresh: (run: object) => Promise }; if (!internal.run) throw new Error("expected an active cache-warming run"); await internal.refresh(internal.run); expect(calls).toHaveLength(0); expect(warmer.status).toMatchObject({ state: "inactive", reason: "cache refresh deadline missed" }); }); it("rechecks the deadline after an extension decision", async () => { vi.useFakeTimers(); vi.setSystemTime(0); const { warmer, calls } = fakeRuntime({ decide: async () => { await Promise.resolve(); vi.setSystemTime(285_001); return "warm" as const; }, }); warmer.start(request(), current); vi.clearAllTimers(); const internal = warmer as unknown as { run: object | undefined; refresh: (run: object) => Promise }; if (!internal.run) throw new Error("expected an active cache-warming run"); await internal.refresh(internal.run); expect(calls).toHaveLength(0); expect(warmer.status).toMatchObject({ state: "inactive", reason: "cache refresh deadline missed" }); }); it("applies economic decisions and extension overrides", async () => { vi.useFakeTimers(); const unprofitable = fakeRuntime({ branch: branchWithPrompt(5_000) }); unprofitable.warmer.start(request(), current); await vi.advanceTimersByTimeAsync(270_000); expect(unprofitable.calls).toHaveLength(0); expect(unprofitable.warmer.status).toMatchObject({ state: "inactive", decision: { action: "stop", economicsAvailable: true }, extensionOverride: false, }); const forced = fakeRuntime({ branch: branchWithPrompt(5_000), decide: () => "warm" }); forced.warmer.start(request(), current); await vi.advanceTimersByTimeAsync(270_000); expect(forced.calls).toHaveLength(1); expect(forced.warmedEntries[0]?.note).toBe("extension override"); forced.warmer.cancel(); const vetoed = fakeRuntime({ decide: () => "stop" }); vetoed.warmer.start(request(), current); await vi.advanceTimersByTimeAsync(270_000); expect(vetoed.calls).toHaveLength(0); expect(vetoed.warmer.status).toMatchObject({ state: "inactive", extensionOverride: true }); const unavailable = fakeRuntime({ branch: branchWithPrompt(0) }); unavailable.warmer.start(request(), current); expect(unavailable.warmer.status).toMatchObject({ state: "inactive", reason: "cache economics unavailable", }); await vi.advanceTimersByTimeAsync(270_000); expect(unavailable.calls).toHaveLength(0); }); it("stops for unsupported requests, context changes, and mode changes", async () => { vi.useFakeTimers(); const unsupported = fakeRuntime(); unsupported.state.mode = "off"; unsupported.warmer.start(request(), current); expect(unsupported.warmer.status.reason).toBe("cache warming disabled"); unsupported.state.mode = "idle"; unsupported.warmer.start(request(unknownModel), current); expect(unsupported.warmer.status.reason).toBe("cache lifetime unavailable"); unsupported.warmer.start(request(budgetModel, { reasoning: "high" }), current); expect(unsupported.warmer.status.reason).toBe("request cannot be replayed safely"); let stillCurrent = true; unsupported.warmer.start(request(), () => stillCurrent); stillCurrent = false; expect(unsupported.warmer.status.reason).toBe("conversation context changed"); await vi.advanceTimersByTimeAsync(270_000); expect(unsupported.calls).toHaveLength(0); unsupported.warmer.start(request(), current); unsupported.state.mode = "off"; await vi.advanceTimersByTimeAsync(270_000); expect(unsupported.calls).toHaveLength(0); const streaming = fakeRuntime({ mode: "streaming", branch: branchWithPrompt(400_000) }); streaming.warmer.start(request(), current); streaming.warmer.onAgentSettled(); expect(streaming.warmer.status.reason).toBe("agent run settled"); }); it("aborts replaced requests and does not record failed refreshes", async () => { vi.useFakeTimers(); let release!: () => void; const pending = fakeRuntime({ result: (model) => new Promise((resolve) => { release = () => resolve(response(model)); }), }); pending.warmer.start(request(), current); await vi.advanceTimersByTimeAsync(270_000); pending.warmer.start(request(), current); expect(pending.calls[0].options?.signal?.aborted).toBe(true); release(); pending.warmer.cancel(); await vi.advanceTimersByTimeAsync(600_000); expect(pending.calls).toHaveLength(1); const failed = fakeRuntime({ result: async (model) => response(model, "error") }); failed.warmer.start(request(), current); await vi.advanceTimersByTimeAsync(270_000); expect(failed.appendUsage).not.toHaveBeenCalled(); failed.warmer.cancel(); }); it("formats status and usage entries", () => { const decision: CacheWarmingDecision = { phase: "idle", warmCost: 0.013, missCost: 0.621, continuationProbability: 0.6, expectedSavings: 0.36, economicsAvailable: true, action: "warm", }; expect(formatCacheWarmingStatus({ state: "scheduled", nextWarmAt: 222_000, decision }, 0)).toBe( "Decision in 3m 42s (60% continuation probability, expected savings $0.360 >= $0.050 -> warm)", ); const usage = { ...warmUsage, cost: { input: 0.00004, output: 0.00005, cacheRead: 0.02940725, cacheWrite: 0, total: 0.02949725 }, }; const entry = SessionManager.inMemory().appendUsage( "cache_warm", adaptiveModel.provider, adaptiveModel.id, usage, "extension override", ); expect(formatCacheWarmingUsage(entry)).toBe("Cache warmed (extension override): $0.029497"); }); }); describe("ExtensionRunner.emitCacheWarmingDecision", () => { it("uses the last extension override", async () => { const runtime = createExtensionRuntime(); const eventBus = createEventBus(); const factories: ExtensionFactory[] = [ (pi) => { pi.on("cache_warming_decision", () => ({ action: "warm" })); }, (pi) => { pi.on("cache_warming_decision", () => ({ action: "stop" })); }, ]; const extensions = []; for (const factory of factories) { extensions.push(await loadExtensionFromFactory(factory, process.cwd(), eventBus, runtime)); } const modelRegistry = await createInMemoryModelRegistry(AuthStorage.inMemory()); const runner = new ExtensionRunner(extensions, runtime, process.cwd(), SessionManager.inMemory(), modelRegistry); const event: CacheWarmingDecisionEvent = { type: "cache_warming_decision", warmCost: 0.05, missCost: 0.5, continuationProbability: 0.15, action: "warm", }; expect(await runner.emitCacheWarmingDecision(event)).toBe("stop"); }); });