compaction: the usage-anchored trigger + visible session state (round 15)

The relational-databases incident, diagnosed and fixed:

TRIGGER — pi's estimator law, adopted exactly: anchor on the last
assistant message's provider-reported usage (totalTokens || the field
sum) and chars/4-estimate ONLY what was appended after it. The anchor
scan skips aborted/zero-usage assistants and STOPS at the newest
compaction marker (pre-cut usage described context the next request
will not carry — pi's distrust rule). The old estimator
(JSON.stringify of every message /4) tripled the true context
(measured: 84,412 'tokens' estimated vs 38,799 provider-reported on
the incident record) — scaffolding, escaped LaTeX, thinking blocks
and app-side details all counted. estimateMessageTokens now counts
wire text only (text + thinking + toolCall name/args; content text
for user/toolResult/custom). The unanchored fallback runs the plain
heuristic over the CUT-HONORED projection plus the additive prompt
term; the anchored path adds nothing (usage already billed prompt
and tools).

OBSERVABILITY — compaction is a visible session-wide state (owner
spec): the transcript-bottom placeholder register gains COMPACTING
CONTEXT… (BrailleSpinner + Shimmer, the THINKING placeholder's twin,
mutually exclusive by construction — the gate precedes beginTurn),
and the sidebar row shows 'Compacting context · <ago>' with the
spinner (sessionMeta outranks busy; compacting-changed joins
turn-started/turn-settled as refresh triggers). Liveness rides the
API like turn-busy: ChatListItem.compacting + compactionInFlight on
both GET /api/chats and GET /api/chats/[id] (initiallyCompacting
covers the navigate-back-mid-compaction mount — the event already
fired), exported through the server barrel.

Also: the relational-databases record rolled back to pre-compaction
(marker, post-marker turns, tree reset; backup in /tmp) for a clean
retest. The summarizer-usage 'gap' was a misread — steps[] was
populated all along.
This commit is contained in:
amosblomqvist
2026-10-06 00:38:15 +03:00
parent 6dcad55175
commit a2db80b72d
11 changed files with 247 additions and 21 deletions
+85
View File
@@ -2,6 +2,9 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
activeCompactionOf,
calculateContextTokens,
estimateContextTokens,
estimateMessageTokens,
buildCompactionPrompt,
compactionCutIndex,
estimateTokens,
@@ -59,6 +62,88 @@ const resultFor = (callId: string, text: string): LhMessage & ToolResultMessage
const big = (id: string, kb = 8): LhMessage => newUserMessage(`${id}: ${"x".repeat(kb * 1024)}`);
// ─── the anchored estimator (pi's law — 2026-10-05 incident fix) ─────────────
describe("estimateContextTokens (usage-anchored, pi's law)", () => {
const usageAssistant = (id: string, total: number, trailing = false): LhMessage =>
({
id,
role: "assistant",
content: [{ type: "text", text: "ok" }],
usage: { input: total - 10, output: 10, cacheRead: 0, cacheWrite: 0, reasoning: 0, totalTokens: total, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
stopReason: trailing ? "aborted" : "toolUse",
timestamp: 1,
}) as never;
it("anchors on the last assistant's provider-reported usage and chars/4-estimates ONLY the tail", () => {
const msgs = [
newUserMessage(`${"x".repeat(400_000)}`), // 100k chars of DROPPED-span text
usageAssistant("a1", 30_000),
newUserMessage("y".repeat(4_000)), // 1k estimated tokens AFTER the anchor
] as LhMessage[];
// ^ newUserMessage signature: (text, ts?) — second arg timestamp
const est = estimateContextTokens(msgs);
assert.equal(est.lastUsageIndex !== null, true);
assert.equal(est.usageTokens, 30_000);
assert.equal(est.trailingTokens, 1_000);
assert.equal(est.tokens, 31_000); // the 100k chars before the anchor are NOT re-estimated
});
it("skips aborted and all-zero-usage assistants when hunting the anchor", () => {
const msgs = [
usageAssistant("a1", 30_000),
usageAssistant("a2", 99_999, true), // aborted after the real one
{ ...usageAssistant("a3", 0), stopReason: "toolUse" }, // zero usage
] as LhMessage[];
const est = estimateContextTokens(msgs);
assert.equal(est.lastUsageIndex, 0);
assert.equal(est.tokens, 30_000 + estimateMessageTokens(msgs[1]!) + estimateMessageTokens(msgs[2]!));
});
it("refuses anchors older than the newest compaction marker (pre-cut usage is not truth)", () => {
const marker = newCompactionMarker("summary text", {
firstKeptMessageId: "m1",
tokensBefore: 500_000,
at: "2026-10-05T00:00:00.000Z",
});
const msgs = [usageAssistant("a1", 500_000), marker as LhMessage, newUserMessage("kept tail")] as LhMessage[];
const est = estimateContextTokens(msgs);
assert.equal(est.lastUsageIndex, null, "the marker invalidates every anchor behind it");
});
it("unanchored (no usage at all): the plain heuristic over everything", () => {
const msgs = [big("m1", 4), big("m2", 4)] as LhMessage[]; // 2 × 4,116ish chars
const est = estimateContextTokens(msgs);
assert.equal(est.lastUsageIndex, null);
assert.equal(est.tokens, estimateTokens(msgs));
});
});
describe("estimateMessageTokens (wire text only — the 3x-inflation fix)", () => {
it("assistant: text + thinking + toolCall name/args — scaffolding never counted", () => {
const m = {
id: "a1",
role: "assistant",
content: [
{ type: "text", text: "x".repeat(400) },
{ type: "thinking", thinking: "y".repeat(800), thinkingSignature: "sig" },
{ type: "toolCall", id: "c1", name: "quiz", arguments: { q: "z".repeat(200) } },
],
usage: { input: 9_999_999, output: 1, cacheRead: 0, cacheWrite: 0, reasoning: 0, totalTokens: 10_000_000, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
responseId: "r".repeat(300),
timestamp: 1,
} as never as LhMessage;
const expected = Math.ceil((400 + 800 + ("quiz".length + JSON.stringify({ q: "z".repeat(200) }).length)) / 4);
assert.equal(estimateMessageTokens(m), expected); // usage/responseId/signature inflate nothing
});
it("calculateContextTokens: totalTokens wins, else the field sum", () => {
assert.equal(calculateContextTokens({ totalTokens: 42, input: 1, output: 2 }), 42);
assert.equal(calculateContextTokens({ input: 10, output: 5, cacheRead: 3, cacheWrite: 2 }), 20);
assert.equal(calculateContextTokens({}), 0);
});
});
// ─── the cut walk ────────────────────────────────────────────────────────────
describe("compactionCutIndex", () => {
+1
View File
@@ -29,6 +29,7 @@ function item(overrides: Partial<ChatListItem>): ChatListItem {
pendingQuestion: false,
pendingProblem: false,
busy: false,
compacting: false,
...overrides,
};
}