Files
learnharness/tests/compaction-service.test.ts
amosblomqvist dd159cc8fb compaction: the service — settle kick, turn-start gate, registry mutex (round 14 slice 2)
- src/server/compaction.ts: ensureCompacted (the turn-start gate — awaits
  in-flight compaction, compacts synchronously when still over threshold),
  kickCompaction (the settle kick — summary starts inside the learner's
  reading/answering pause; question/problem poses end runs so the pause is
  always a between-runs window), registry mutex on globalThis, anti-churn
  guard (empty-span skip), summary via the zero-tool one-shot sub-agent
  seam on the session's OWN engine (owner ruling), usage to the turns log
  keyed by the marker, record-changed + compaction-changed events
- chat-handler: the gate rides with the BYOK/key gates (before appends,
  beginTurn; record re-read after); the kick at the onFinish tail
  (re-resolves engine fresh; silent no-op when engineless)
- events: compaction-changed {chatId, running} — running=true is the
  client send-gate signal
- settings: compaction.thresholdTokens (factory 150k, floor 32k,
  clamp-never-reject) resolved concrete in EffectiveSettings
- effective threshold = min(setting, contextWindow − 16k) over the live
  catalog index
- overflow recovery needs no special path: the next turn's gate compacts
- 8 service pins (mutex, events order, chained spans, failure re-arm)
2026-10-05 22:17:33 +03:00

207 lines
9.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* THE COMPACTION SERVICE (owner round 14, 2026-10-05): the trigger
* plumbing — settle kick + turn-start gate, the registry mutex, the
* summary call, usage, events. Pinned here with an injected summarizer
* (the sub-agent one-shot needs a live engine; the SERVICE laws don't).
*
* The laws:
* - the registry is a mutex: concurrent ensureCompacted → ONE summary;
* - over-threshold → marker appended + usage logged + events emitted;
* - under-threshold (or the projection post-marker) → no-op;
* - chained compaction: the span carries the prior summary forward;
* - the effective threshold: min(setting, window − reserve).
*/
import assert from "node:assert/strict";
import fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { after, describe, it } from "node:test";
// ── the seam, BEFORE any config-loading import (the birth-stamp discipline) ─
const TMP_DATA = fs.mkdtempSync(path.join(os.tmpdir(), "lh-compaction-"));
process.env.LH_DATA_DIR = TMP_DATA;
const { chatStore } = await import("../src/store/chats.ts");
const {
ensureCompacted,
kickCompaction,
awaitCompaction,
effectiveThresholdTokens,
projectedContextTokens,
} = await import("../src/server/compaction.ts");
import type { CompactionDeps } from "../src/server/compaction.ts";
const { subscribe } = await import("../src/core/events.ts");
const { activeCompactionOf, newUserMessage, COMPACTED_HISTORY_HEADER } = await import("../src/core/index.ts");
import type { LhMessage } from "../src/core/index.ts";
after(() => {
fs.rmSync(TMP_DATA, { recursive: true, force: true });
});
// ── fixtures ────────────────────────────────────────────────────────────────
const big = (text: string, kb = 100): LhMessage => newUserMessage(`${text}: ${"x".repeat(kb * 1024)}`);
/** A chat whose record projects far over any test threshold. */
function overThresholdChat(label: string): string {
const chat = chatStore.createChat(label);
const messages = [big("m1"), big("m2"), big("m3"), newUserMessage("recent")];
chatStore.saveMessages(chat.id, messages);
return chat.id;
}
let summarizeCalls = 0;
const depsOf = (chatId: string, thresholdTokens: number): CompactionDeps => ({
chatStore,
// The engine is never touched when summarize is injected (only its
// identity rides the marker's provenance) — a minimal stand-in.
engine: { model: { provider: "faux", id: "test", contextWindow: 1_000_000 } } as never,
modelId: "faux:test",
kind: "teach",
thresholdTokens,
systemPromptTokens: 100,
summarize: async (prompt) => {
summarizeCalls++;
assert.ok(prompt.includes("TRANSCRIPT") || prompt.length > 0);
return {
summary: `SUMMARY #${summarizeCalls}: the learner knows X.`,
usage: { input: 1_000, output: 500, cacheRead: 0, cacheWrite: 0, totalTokens: 1_500, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
};
},
});
// ── the laws ────────────────────────────────────────────────────────────────
describe("the compaction service", () => {
it("over-threshold record → marker appended, usage logged, both events emitted", async () => {
const chatId = overThresholdChat("compact: basic");
const events: string[] = [];
const off = subscribe((e) => {
const { chatId: c, running } = e as { chatId?: string; running?: boolean };
if (e.type === "compaction-changed" && c === chatId) events.push(`${running}`);
if (e.type === "record-changed" && c === chatId) events.push("record");
});
await ensureCompacted(depsOf(chatId, 40_000), chatId);
off();
const record = chatStore.getChat(chatId)!;
const marker = activeCompactionOf(record.messages);
assert.ok(marker, "the marker landed");
assert.ok(marker!.content.includes("SUMMARY #"));
assert.ok(marker!.details.tokensBefore > 40_000, "provenance carries the trigger reading");
assert.equal(marker!.details.model, "faux:test");
// the projection now fits: the dropped span is gone from the wire view
assert.ok(projectedContextTokens(record.messages, 100) < 40_000, "the cut brought the projection under threshold+summary");
// usage landed on the money channel, keyed by the marker
assert.ok((record.turns ?? []).some((t) => t.messageId === marker!.id && t.model === "faux:test"), "usage keyed by the marker");
// events: running true → record-changed → running false
assert.deepEqual(events, ["true", "record", "false"]);
});
it("under-threshold record → a no-op (no marker, no summary call)", async () => {
const chatId = overThresholdChat("compact: under");
const before = summarizeCalls;
await ensureCompacted(depsOf(chatId, 10_000_000), chatId);
assert.equal(summarizeCalls, before);
assert.equal(activeCompactionOf(chatStore.getChat(chatId)!.messages), null);
});
it("the registry is a mutex: concurrent ensureCompacted → ONE summary", async () => {
const chatId = overThresholdChat("compact: mutex");
const before = summarizeCalls;
const deps = depsOf(chatId, 40_000);
await Promise.all([ensureCompacted(deps, chatId), ensureCompacted(deps, chatId), ensureCompacted(deps, chatId)]);
assert.equal(summarizeCalls, before + 1, "exactly one summary however many gates race");
});
it("awaitCompaction: a later call rides the in-flight one (the structural gate)", async () => {
const chatId = overThresholdChat("compact: gate");
const events: string[] = [];
const off = subscribe((e) => {
const { chatId: c, running } = e as { chatId?: string; running?: boolean };
if (e.type === "compaction-changed" && c === chatId) events.push(`${running}`);
});
const p = ensureCompacted(depsOf(chatId, 40_000), chatId);
await awaitCompaction(chatId); // rides the same promise
await p;
off();
assert.deepEqual(events, ["true", "false"], "no second compaction started");
});
it("chained compaction: the span carries the PRIOR summary forward", async () => {
const chatId = overThresholdChat("compact: chained");
let seenPrompt = "";
const deps: CompactionDeps = {
...depsOf(chatId, 40_000),
summarize: async (prompt) => {
seenPrompt = prompt;
return {
summary: "SECOND SUMMARY.",
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
};
},
};
// first compaction
await ensureCompacted({ ...depsOf(chatId, 40_000) }, chatId);
// grow the record past the threshold again (post-markers + new tail)
chatStore.saveMessages(chatId, [...chatStore.getChat(chatId)!.messages, big("m9"), big("m10"), big("m11")]);
// second compaction — the span must open with the FIRST summary
await ensureCompacted(deps, chatId);
assert.ok(seenPrompt.includes("prior compaction summary"), "the prior summary rides the span");
assert.ok(seenPrompt.includes(COMPACTED_HISTORY_HEADER), "as the marker's canonical text");
const record = chatStore.getChat(chatId)!;
const markers = record.messages.filter((m) => (m as { customType?: string }).customType === "compaction");
assert.equal(markers.length, 2, "both markers persist (history is never rewritten)");
assert.ok(activeCompactionOf(record.messages)!.content.includes("SECOND SUMMARY"), "the newest rules");
});
it("effectiveThresholdTokens: the setting rules big windows; the window rule binds small ones", () => {
assert.equal(effectiveThresholdTokens(150_000, 200_000), 150_000);
assert.equal(effectiveThresholdTokens(150_000, 100_000), 100_000 - 16_384);
assert.equal(effectiveThresholdTokens(undefined, 200_000), 150_000);
assert.equal(effectiveThresholdTokens(undefined, undefined), 150_000);
});
it("kickCompaction: fire-and-forget, registry-set, over-threshold only", async () => {
const chatId = overThresholdChat("compact: kick");
const before = summarizeCalls;
let built = 0;
kickCompaction(() => {
built++;
return depsOf(chatId, 40_000);
}, chatId);
assert.equal(built, 1, "deps built once");
await awaitCompaction(chatId);
assert.equal(summarizeCalls, before + 1);
// beneath threshold now → a second kick builds its deps, the
// pre-check refuses, and NO summary runs (building deps is the
// kick's discovery step — the run is what costs)
const afterFirst = summarizeCalls;
kickCompaction(() => {
built++;
return depsOf(chatId, 40_000);
}, chatId);
assert.equal(summarizeCalls, afterFirst, "post-compaction record is under threshold — no run");
// a null deps build (engineless) is a silent no-op
kickCompaction(() => null, chatId);
});
it("a summarizer failure leaves the record untouched and the gate re-arms", async () => {
const chatId = overThresholdChat("compact: fail");
const before = summarizeCalls;
const failing: CompactionDeps = {
...depsOf(chatId, 40_000),
summarize: async () => {
throw new Error("boom");
},
};
await ensureCompacted(failing, chatId);
assert.equal(activeCompactionOf(chatStore.getChat(chatId)!.messages), null, "no marker on failure");
// the next gate retries cleanly
await ensureCompacted(depsOf(chatId, 40_000), chatId);
assert.ok(activeCompactionOf(chatStore.getChat(chatId)!.messages));
assert.equal(summarizeCalls, before + 1);
});
});