SPB Git

spb/khaelor Public

KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.

TypeScript 82.9% HTML 14.9% CSS 1.1% JavaScript 0.7%
6.8 KB · 163 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: tests/context/engine.test.ts4 * Description: ContextEngine tests — byte-stability, volatile isolation, budget observation, compress integration.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { describe, expect, it } from "vitest";11import type { SystemTier } from "../../src/anthropic/index.js";12import {13  ContextBudget,14  KhaelorContextEngine,15  parseCheckpoint,16} from "../../src/context/index.js";17import { isPairingSafeCut } from "../../src/session/index.js";18import { FAKE_CHECKPOINT_TEXT, FakeModelClient, sampleSession } from "./fixtures.js";1920const SYSTEM_TIERS: SystemTier[] = [21  { name: "identity", text: "You are KHAELOR." },22  { name: "tools", text: "Available tools: read, bash." },23  { name: "project-instructions", text: "# Project instructions\n\nUse tabs." },24];2526function makeEngine(client = new FakeModelClient(FAKE_CHECKPOINT_TEXT)): KhaelorContextEngine {27  return new KhaelorContextEngine({28    model: "main-model",29    auxModel: "aux-model",30    maxOutputTokens: 4_000,31    systemTiers: SYSTEM_TIERS,32    tools: [{ name: "read", description: "Read a file", inputSchema: { type: "object" } }],33    modelClient: client,34    budget: new ContextBudget({35      model: "main-model",36      reservedOutputTokens: 4_000,37      contextWindow: { defaultWindow: 100_000 },38      compactionBufferTokens: 6_000,39    }),40    compaction: { protectTailTokens: 1 },41  });42}4344describe("selectContext byte-stability (ADR-7)", () => {45  it("same session state → byte-identical system tiers and message history across calls", async () => {46    const engine = makeEngine();47    const state = { events: sampleSession().events };48    const first = await engine.selectContext(state);49    const second = await engine.selectContext(state);50    expect(JSON.stringify(second.request.system)).toBe(JSON.stringify(first.request.system));51    expect(JSON.stringify(second.request.messages)).toBe(JSON.stringify(first.request.messages));52    expect(first.request.model).toBe("main-model");53    expect(first.request.maxOutputTokens).toBe(4_000);54  });5556  it("injects volatile context ONLY into the API copy of the current message", async () => {57    const engine = makeEngine();58    const events = sampleSession().events;59    const stable = await engine.selectContext({ events });60    const withVolatile = await engine.selectContext({61      events,62      volatile: [{ name: "git", text: "branch main, 2 dirty files" }],63    });6465    // History prefix is byte-identical; only the final (current) message differs.66    const count = stable.request.messages.length;67    expect(withVolatile.request.messages).toHaveLength(count);68    expect(JSON.stringify(withVolatile.request.messages.slice(0, -1))).toBe(69      JSON.stringify(stable.request.messages.slice(0, -1)),70    );71    const last = withVolatile.request.messages[count - 1];72    const stableLast = stable.request.messages[count - 1];73    expect(last?.content.length).toBe((stableLast?.content.length ?? 0) + 1);74    expect(JSON.stringify(last)).toContain("branch main, 2 dirty files");7576    // Volatile never mutates recorded history: rebuilding without it is byte-identical.77    const after = await engine.selectContext({ events });78    expect(JSON.stringify(after.request.messages)).toBe(JSON.stringify(stable.request.messages));79    expect(JSON.stringify(after.request.system)).toBe(JSON.stringify(stable.request.system));80    // The stable build's own last message was not touched by the volatile build.81    expect(JSON.stringify(stable.request.messages[count - 1])).not.toContain("branch main");82  });8384  it("reports per-section token estimates for /context", async () => {85    const engine = makeEngine();86    const built = await engine.selectContext({87      events: sampleSession().events,88      volatile: [{ name: "git", text: "clean" }],89    });90    const names = built.stats.sections.map((s) => s.name);91    expect(names).toEqual([92      "system:identity",93      "system:tools",94      "system:project-instructions",95      "tools",96      "history",97      "volatile",98    ]);99    expect(built.stats.estimatedInputTokens).toBe(100      built.stats.sections.reduce((sum, s) => sum + s.estimatedTokens, 0),101    );102    expect(built.stats.estimatedInputTokens).toBeGreaterThan(0);103  });104});105106describe("onTurnComplete and budget triggers", () => {107  it("records real usage and flips the compaction trigger when the threshold is crossed", () => {108    const engine = makeEngine();109    engine.onTurnComplete({ inputTokens: 10_000, outputTokens: 500, cacheReadTokens: 0, cacheWriteTokens: 0 });110    expect(engine.budget.shouldCompact()).toBe(false);111    engine.onTurnComplete({ inputTokens: 60_000, outputTokens: 1_000, cacheReadTokens: 30_000, cacheWriteTokens: 0 });112    expect(engine.budget.lastTotalTokens).toBe(91_000);113    expect(engine.budget.shouldCompact()).toBe(true); // usable window = 90_000114  });115});116117describe("pruneToolResults", () => {118  it("returns a decision shaped like the ContextPruned payload", () => {119    const engine = makeEngine();120    const big = "y".repeat(10_000);121    const session = sampleSession({ bigToolOutput: big });122    const decision = engine.pruneToolResults({ events: session.events });123    // Default protection (~40K tokens) covers this small session entirely.124    expect(decision.placeholder).toBeTypeOf("string");125    expect(Array.isArray(decision.toolUseIds)).toBe(true);126    expect(decision.tokensReclaimedEstimate).toBeGreaterThanOrEqual(0);127  });128});129130describe("compress", () => {131  it("produces a pairing-safe checkpoint via the aux model with real token accounting", async () => {132    const client = new FakeModelClient(FAKE_CHECKPOINT_TEXT);133    const engine = makeEngine(client);134    engine.onTurnComplete({ inputTokens: 88_000, outputTokens: 3_000, cacheReadTokens: 0, cacheWriteTokens: 0 });135136    const events = sampleSession().events;137    const result = await engine.compress({ events });138139    expect(isPairingSafeCut(events, result.cut)).toBe(true);140    expect(result.summaryModel).toBe("aux-model");141    expect(result.tokensBefore).toBe(91_000); // from real usage, not estimated142    expect(result.trigger).toBe("proactive-token-budget");143    expect(parseCheckpoint(result.checkpointYaml).ok).toBe(true);144    expect(client.requests[0]?.model).toBe("aux-model");145  });146147  it("reports a reactive-overflow trigger when a context-overflow failure is recorded", async () => {148    const engine = makeEngine();149    const session = sampleSession();150    session.add({151      type: "model.request-failed",152      payload: { requestId: "req_9", kind: "context-overflow", message: "too long", retriesExhausted: false },153    });154    const result = await engine.compress({ events: session.events });155    expect(result.trigger).toBe("reactive-overflow");156  });157158  it("throws when the conversation is too small to cut safely", async () => {159    const engine = makeEngine();160    await expect(engine.compress({ events: [] })).rejects.toThrow(/no pairing-safe/);161  });162});163