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:
@@ -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", () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ function item(overrides: Partial<ChatListItem>): ChatListItem {
|
||||
pendingQuestion: false,
|
||||
pendingProblem: false,
|
||||
busy: false,
|
||||
compacting: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user