/** * KHAELOR * File: tests/context/engine.test.ts * Description: ContextEngine tests — byte-stability, volatile isolation, budget observation, compress integration. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { describe, expect, it } from "vitest"; import type { SystemTier } from "../../src/anthropic/index.js"; import { ContextBudget, KhaelorContextEngine, parseCheckpoint, } from "../../src/context/index.js"; import { isPairingSafeCut } from "../../src/session/index.js"; import { FAKE_CHECKPOINT_TEXT, FakeModelClient, sampleSession } from "./fixtures.js"; const SYSTEM_TIERS: SystemTier[] = [ { name: "identity", text: "You are KHAELOR." }, { name: "tools", text: "Available tools: read, bash." }, { name: "project-instructions", text: "# Project instructions\n\nUse tabs." }, ]; function makeEngine(client = new FakeModelClient(FAKE_CHECKPOINT_TEXT)): KhaelorContextEngine { return new KhaelorContextEngine({ model: "main-model", auxModel: "aux-model", maxOutputTokens: 4_000, systemTiers: SYSTEM_TIERS, tools: [{ name: "read", description: "Read a file", inputSchema: { type: "object" } }], modelClient: client, budget: new ContextBudget({ model: "main-model", reservedOutputTokens: 4_000, contextWindow: { defaultWindow: 100_000 }, compactionBufferTokens: 6_000, }), compaction: { protectTailTokens: 1 }, }); } describe("selectContext byte-stability (ADR-7)", () => { it("same session state → byte-identical system tiers and message history across calls", async () => { const engine = makeEngine(); const state = { events: sampleSession().events }; const first = await engine.selectContext(state); const second = await engine.selectContext(state); expect(JSON.stringify(second.request.system)).toBe(JSON.stringify(first.request.system)); expect(JSON.stringify(second.request.messages)).toBe(JSON.stringify(first.request.messages)); expect(first.request.model).toBe("main-model"); expect(first.request.maxOutputTokens).toBe(4_000); }); it("injects volatile context ONLY into the API copy of the current message", async () => { const engine = makeEngine(); const events = sampleSession().events; const stable = await engine.selectContext({ events }); const withVolatile = await engine.selectContext({ events, volatile: [{ name: "git", text: "branch main, 2 dirty files" }], }); // History prefix is byte-identical; only the final (current) message differs. const count = stable.request.messages.length; expect(withVolatile.request.messages).toHaveLength(count); expect(JSON.stringify(withVolatile.request.messages.slice(0, -1))).toBe( JSON.stringify(stable.request.messages.slice(0, -1)), ); const last = withVolatile.request.messages[count - 1]; const stableLast = stable.request.messages[count - 1]; expect(last?.content.length).toBe((stableLast?.content.length ?? 0) + 1); expect(JSON.stringify(last)).toContain("branch main, 2 dirty files"); // Volatile never mutates recorded history: rebuilding without it is byte-identical. const after = await engine.selectContext({ events }); expect(JSON.stringify(after.request.messages)).toBe(JSON.stringify(stable.request.messages)); expect(JSON.stringify(after.request.system)).toBe(JSON.stringify(stable.request.system)); // The stable build's own last message was not touched by the volatile build. expect(JSON.stringify(stable.request.messages[count - 1])).not.toContain("branch main"); }); it("reports per-section token estimates for /context", async () => { const engine = makeEngine(); const built = await engine.selectContext({ events: sampleSession().events, volatile: [{ name: "git", text: "clean" }], }); const names = built.stats.sections.map((s) => s.name); expect(names).toEqual([ "system:identity", "system:tools", "system:project-instructions", "tools", "history", "volatile", ]); expect(built.stats.estimatedInputTokens).toBe( built.stats.sections.reduce((sum, s) => sum + s.estimatedTokens, 0), ); expect(built.stats.estimatedInputTokens).toBeGreaterThan(0); }); }); describe("onTurnComplete and budget triggers", () => { it("records real usage and flips the compaction trigger when the threshold is crossed", () => { const engine = makeEngine(); engine.onTurnComplete({ inputTokens: 10_000, outputTokens: 500, cacheReadTokens: 0, cacheWriteTokens: 0 }); expect(engine.budget.shouldCompact()).toBe(false); engine.onTurnComplete({ inputTokens: 60_000, outputTokens: 1_000, cacheReadTokens: 30_000, cacheWriteTokens: 0 }); expect(engine.budget.lastTotalTokens).toBe(91_000); expect(engine.budget.shouldCompact()).toBe(true); // usable window = 90_000 }); }); describe("pruneToolResults", () => { it("returns a decision shaped like the ContextPruned payload", () => { const engine = makeEngine(); const big = "y".repeat(10_000); const session = sampleSession({ bigToolOutput: big }); const decision = engine.pruneToolResults({ events: session.events }); // Default protection (~40K tokens) covers this small session entirely. expect(decision.placeholder).toBeTypeOf("string"); expect(Array.isArray(decision.toolUseIds)).toBe(true); expect(decision.tokensReclaimedEstimate).toBeGreaterThanOrEqual(0); }); }); describe("compress", () => { it("produces a pairing-safe checkpoint via the aux model with real token accounting", async () => { const client = new FakeModelClient(FAKE_CHECKPOINT_TEXT); const engine = makeEngine(client); engine.onTurnComplete({ inputTokens: 88_000, outputTokens: 3_000, cacheReadTokens: 0, cacheWriteTokens: 0 }); const events = sampleSession().events; const result = await engine.compress({ events }); expect(isPairingSafeCut(events, result.cut)).toBe(true); expect(result.summaryModel).toBe("aux-model"); expect(result.tokensBefore).toBe(91_000); // from real usage, not estimated expect(result.trigger).toBe("proactive-token-budget"); expect(parseCheckpoint(result.checkpointYaml).ok).toBe(true); expect(client.requests[0]?.model).toBe("aux-model"); }); it("reports a reactive-overflow trigger when a context-overflow failure is recorded", async () => { const engine = makeEngine(); const session = sampleSession(); session.add({ type: "model.request-failed", payload: { requestId: "req_9", kind: "context-overflow", message: "too long", retriesExhausted: false }, }); const result = await engine.compress({ events: session.events }); expect(result.trigger).toBe("reactive-overflow"); }); it("throws when the conversation is too small to cut safely", async () => { const engine = makeEngine(); await expect(engine.compress({ events: [] })).rejects.toThrow(/no pairing-safe/); }); });