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:
@@ -1,4 +1,4 @@
|
||||
import { deleteChatSession, getChat, isTurnRunning, sessionCoverage, stores, type ChatDeleteMode } from "@/src/server";
|
||||
import { compactionInFlight, deleteChatSession, getChat, isTurnRunning, sessionCoverage, stores, type ChatDeleteMode } from "@/src/server";
|
||||
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
@@ -18,6 +18,7 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
|
||||
...chat,
|
||||
messages: chat.messages,
|
||||
turnInFlight: isTurnRunning(id),
|
||||
compacting: compactionInFlight(id),
|
||||
coverage: sessionCoverage(id),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { chainsForSelection, formatChainLine } from "@/src/core";
|
||||
import { newGraphContextMessage, type GraphContextPick } from "@/src/core";
|
||||
import { createChat, isTurnRunning, listChats, readSettingsRecord, stores, titleFromPrompt } from "@/src/server";
|
||||
import { compactionInFlight, createChat, isTurnRunning, listChats, readSettingsRecord, stores, titleFromPrompt } from "@/src/server";
|
||||
import type { ChatListItem } from "@/src/core";
|
||||
|
||||
/** The session picker's list: record-derived summaries (phase, pending
|
||||
@@ -14,6 +14,7 @@ export async function GET() {
|
||||
const chats: ChatListItem[] = listChats().map((c) => ({
|
||||
...c,
|
||||
busy: isTurnRunning(c.id),
|
||||
compacting: compactionInFlight(c.id),
|
||||
}));
|
||||
return Response.json({ chats });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Chat } from "@/components/chat";
|
||||
import { slotsConfigured } from "@/src/core";
|
||||
import { getChat, isTurnRunning, readSettingsRecord } from "@/src/server";
|
||||
import { compactionInFlight, getChat, isTurnRunning, readSettingsRecord } from "@/src/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -34,6 +34,7 @@ export default async function ChatPage({
|
||||
// A teacher turn that outlived its client: the remounted view must
|
||||
// attach (busy shimmer + poll), never resurrect the stale record.
|
||||
initiallyInFlight={isTurnRunning(chat.id)}
|
||||
initiallyCompacting={compactionInFlight(chat.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+18
-1
@@ -183,6 +183,7 @@ export function Chat({
|
||||
initialMessages,
|
||||
initialPrompt,
|
||||
initiallyInFlight,
|
||||
initiallyCompacting = false,
|
||||
initialConfigured = false,
|
||||
kind = "teach",
|
||||
}: {
|
||||
@@ -197,6 +198,11 @@ export function Chat({
|
||||
/** A teacher turn is running server-side but attached to no client
|
||||
* (navigated away mid-turn, returned while in flight). */
|
||||
initiallyInFlight?: boolean;
|
||||
/** A compaction holds the chat's model gate at mount (navigated away
|
||||
* mid-compaction — the registry is live server state, the
|
||||
* compaction-changed event already fired). The transcript spinner
|
||||
* and send gates key on it. */
|
||||
initiallyCompacting?: boolean;
|
||||
/** slotsConfigured(server record) — the persistent setup CTA keys on
|
||||
* it (BYOK, owner 2026-09-22). The server gate stays the truth; this
|
||||
* only decides whether the pill rides the composer. */
|
||||
@@ -623,7 +629,7 @@ export function Chat({
|
||||
// flips it; the send paths, canSend, and the docks gate on it (the
|
||||
// server's registry mutex is the structural gate; this is the
|
||||
// affordance). The summary dialog state rides with it.
|
||||
const [compacting, setCompacting] = useState(false);
|
||||
const [compacting, setCompacting] = useState(initiallyCompacting);
|
||||
const [compactionSummary, setCompactionSummary] = useState<CompactionDividerData | null>(null);
|
||||
const canSend =
|
||||
(text.trim().length > 0 || dictation.active) && !pending && !activeProblem && !compacting;
|
||||
@@ -1425,6 +1431,17 @@ export function Chat({
|
||||
provider-connect gap. turnInFlight: the same state
|
||||
entered by external discovery (wake) / the poll
|
||||
fallback. Never collapsible itself. */}
|
||||
{/* Compaction (owner round 15): the same register as the
|
||||
thinking placeholder — a visible session-wide state while
|
||||
the summarizer holds the model gate. Mutually exclusive with
|
||||
the THINKING placeholder by construction (the gate sits
|
||||
BEFORE beginTurn; the settle kick runs after endTurn). */}
|
||||
{compacting && (
|
||||
<span className="flex items-center gap-2 font-ui text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<BrailleSpinner label="Compacting context" />
|
||||
<Shimmer duration={1}>COMPACTING CONTEXT…</Shimmer>
|
||||
</span>
|
||||
)}
|
||||
{(status === "submitted" || turnInFlight) && (
|
||||
<span className="flex items-center gap-2 font-ui text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<BrailleSpinner label="Thinking" />
|
||||
|
||||
@@ -243,6 +243,7 @@ export function timeAgo(iso: string, now = new Date()): string {
|
||||
* still rides the API payload, so this is a one-line flip back if it's
|
||||
* ever wanted. */
|
||||
function sessionMeta(chat: ChatListItem): string {
|
||||
if (chat.compacting) return `Compacting context · ${timeAgo(chat.updatedAt)}`;
|
||||
if (chat.pendingQuestion) return `Waiting for your answer · ${timeAgo(chat.updatedAt)}`;
|
||||
if ((chat.kind ?? "teach") === "practice" && chat.pendingProblem) {
|
||||
return `Problem waiting · ${timeAgo(chat.updatedAt)}`;
|
||||
@@ -284,7 +285,7 @@ export function SessionRow({
|
||||
kbdSelected && !active && "bg-accent/60 ring-1 ring-border",
|
||||
)}
|
||||
>
|
||||
<SessionStatus busy={chat.busy} />
|
||||
<SessionStatus busy={chat.busy || chat.compacting} />
|
||||
<span className="min-w-0 flex-1">
|
||||
{/* Titles are the learner's/teacher's WORDS — Source Sans 3, the
|
||||
same voice the words were typed in (every input is lh-prose);
|
||||
@@ -486,6 +487,7 @@ function SessionsSidebar() {
|
||||
useServerEvent("chats-changed", scheduleEventRefresh);
|
||||
useServerEvent("turn-started", scheduleEventRefresh);
|
||||
useServerEvent("turn-settled", scheduleEventRefresh);
|
||||
useServerEvent("compaction-changed", scheduleEventRefresh);
|
||||
useServerResync(() => void refreshRef.current());
|
||||
useEffect(
|
||||
() => () => {
|
||||
|
||||
+115
-10
@@ -16,8 +16,11 @@
|
||||
*
|
||||
* Trigger threshold (owner ruling): effective = min(settings threshold,
|
||||
* model contextWindow − 16k reserve). Defaults: threshold 150k, kept
|
||||
* tail ~20k recent tokens (pi's defaults, owner-approved), token
|
||||
* estimates chars/4 (pi's method).
|
||||
* tail ~20k recent tokens (pi's defaults, owner-approved). Token
|
||||
* estimation follows pi's law (2026-10-05 incident fix): anchor on the
|
||||
* provider-reported usage of the last assistant message, chars/4 only
|
||||
* for messages appended after that anchor; the heuristic alone runs
|
||||
* only when no usage exists yet.
|
||||
*
|
||||
* Summary content (owner's three requirements + continuity): the
|
||||
* learner's understanding and its EVOLUTION (quiz outcomes, confidence,
|
||||
@@ -56,17 +59,119 @@ export const DEFAULT_COMPACTION_THRESHOLD_TOKENS = 150_000;
|
||||
* in the SUMMARY INPUT — the kept tail carries them whole). */
|
||||
const SUMMARY_TOOL_RESULT_TRUNCATE = 2_000;
|
||||
|
||||
// ─── token estimation (pi's method: chars/4, uniform over shapes) ───────────
|
||||
// ─── token estimation (pi's law: usage-anchored, chars/4 only for the tail) ─
|
||||
|
||||
/** Estimate a message's context tokens. JSON-serializes the message minus
|
||||
* its record id — uniform across user/assistant/toolResult/markers, no
|
||||
* shape forking. An ESTIMATE (the trigger check, not billing). */
|
||||
export function estimateMessageTokens(message: LhMessage | AgentMessage): number {
|
||||
const { id: _id, ...rest } = message as LhMessage;
|
||||
return Math.ceil(JSON.stringify(rest).length / 4);
|
||||
/** Context tokens from a provider-reported usage block — the most honest
|
||||
* measure of "what the last request actually carried." Mirrors pi's
|
||||
* calculateContextTokens. */
|
||||
export function calculateContextTokens(usage: {
|
||||
totalTokens?: number;
|
||||
input?: number;
|
||||
output?: number;
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
}): number {
|
||||
return (
|
||||
usage.totalTokens ||
|
||||
(usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
/** Estimate a whole message list. */
|
||||
/** Estimate a message's context tokens from its WIRE-relevant text only
|
||||
* (pi's estimator): text + thinking + toolCall name/args for assistants,
|
||||
* content text for user/toolResult/custom. NEVER JSON.stringify of the
|
||||
* whole message — record scaffolding (ids, usage, responseId, app-side
|
||||
* details, escaped quotes/backslashes) inflates ~3x (measured on the
|
||||
* 2026-10-05 relational-databases incident: 84k estimated vs ~30k real). */
|
||||
export function estimateMessageTokens(message: LhMessage | AgentMessage): number {
|
||||
const content: unknown = (message as { content?: unknown }).content;
|
||||
let chars = 0;
|
||||
if ((message as { role?: string }).role === "assistant") {
|
||||
for (const block of (Array.isArray(content) ? content : []) as Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
thinking?: string;
|
||||
name?: string;
|
||||
arguments?: unknown;
|
||||
}>) {
|
||||
if (block.type === "text") chars += block.text?.length ?? 0;
|
||||
else if (block.type === "thinking") chars += block.thinking?.length ?? 0;
|
||||
else if (block.type === "toolCall") chars += (block.name?.length ?? 0) + JSON.stringify(block.arguments ?? {}).length;
|
||||
}
|
||||
} else {
|
||||
chars += textCharsOfContent(content);
|
||||
}
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
|
||||
function textCharsOfContent(content: unknown): number {
|
||||
if (typeof content === "string") return content.length;
|
||||
if (!Array.isArray(content)) return 0;
|
||||
let chars = 0;
|
||||
for (const block of content as Array<string | { type?: string; text?: string }>) {
|
||||
if (typeof block === "string") chars += block.length;
|
||||
else if (block?.type === "text" || "text" in block) chars += block.text?.length ?? 0;
|
||||
}
|
||||
return chars;
|
||||
}
|
||||
|
||||
/** The newest assistant message carrying VALID provider-reported usage
|
||||
* (pi's rule: skip aborted messages and all-zero usage — they carry no
|
||||
* truth about the wire). The scan STOPS at the newest compaction
|
||||
* marker: usage captured before a compaction described the pre-cut
|
||||
* context — the next request carries the summary + kept tail instead,
|
||||
* so pre-marker usage is not a valid anchor (pi's "don't trust usage
|
||||
* captured before a later compaction" law). Index into messages, or
|
||||
* null. */
|
||||
export function lastAssistantUsageIndex(
|
||||
messages: readonly (LhMessage | AgentMessage)[],
|
||||
): number | null {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]!;
|
||||
if ((m as { role?: string }).role === "custom" && (m as LhMarkerMessage).customType === "compaction") return null;
|
||||
if ((m as { role?: string }).role !== "assistant") continue;
|
||||
const a = m as AssistantMessage & { stopReason?: string };
|
||||
if (a.stopReason === "aborted") continue;
|
||||
if (calculateContextTokens((a.usage ?? {}) as Parameters<typeof calculateContextTokens>[0]) === 0) continue;
|
||||
return i;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface ContextTokenEstimate {
|
||||
/** The estimate the trigger should compare against the threshold. */
|
||||
tokens: number;
|
||||
/** Provider-reported tokens from the usage anchor (0 when unanchored). */
|
||||
usageTokens: number;
|
||||
/** chars/4-estimated tokens after the anchor (or everything, unanchored). */
|
||||
trailingTokens: number;
|
||||
/** Index of the usage anchor, or null (diagnostics). */
|
||||
lastUsageIndex: number | null;
|
||||
}
|
||||
|
||||
/** THE trigger estimator (pi's estimateContextTokens): anchor on the last
|
||||
* assistant usage the provider reported — prompt, tools, tokenizer
|
||||
* density and all — and chars/4-estimate ONLY what was appended after
|
||||
* it. The heuristic never re-estimates history the provider already
|
||||
* counted (the relational-databases bug: full-record JSON.stringify/4
|
||||
* tripled the true context and fired a 30k session at an 80k threshold). */
|
||||
export function estimateContextTokens(
|
||||
messages: readonly (LhMessage | AgentMessage)[],
|
||||
): ContextTokenEstimate {
|
||||
const anchor = lastAssistantUsageIndex(messages);
|
||||
if (anchor === null) {
|
||||
let trailing = 0;
|
||||
for (const m of messages) trailing += estimateMessageTokens(m);
|
||||
return { tokens: trailing, usageTokens: 0, trailingTokens: trailing, lastUsageIndex: null };
|
||||
}
|
||||
const anchored = messages[anchor]! as AssistantMessage;
|
||||
const usageTokens = calculateContextTokens(anchored.usage as Parameters<typeof calculateContextTokens>[0]);
|
||||
let trailing = 0;
|
||||
for (let i = anchor + 1; i < messages.length; i++) trailing += estimateMessageTokens(messages[i]!);
|
||||
return { tokens: usageTokens + trailing, usageTokens, trailingTokens: trailing, lastUsageIndex: anchor };
|
||||
}
|
||||
|
||||
/** Estimate a whole message list (the plain heuristic — cut-walk use). */
|
||||
export function estimateTokens(messages: readonly (LhMessage | AgentMessage)[]): number {
|
||||
let total = 0;
|
||||
for (const m of messages) total += estimateMessageTokens(m);
|
||||
|
||||
@@ -263,6 +263,11 @@ export interface ChatSummary {
|
||||
* split at exactly the record/liveness seam. */
|
||||
export interface ChatListItem extends ChatSummary {
|
||||
busy: boolean;
|
||||
/** Live compaction liveness (turn-liveness's twin): true while a
|
||||
* background context compaction holds the chat's model gate. The
|
||||
* route attaches it from the compaction registry — never
|
||||
* record-derived (the marker persists, the RUN is ephemeral). */
|
||||
compacting: boolean;
|
||||
}
|
||||
|
||||
// ─── Artifact renderer registry (client extension point) ────────────────────
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
buildCompactionPrompt,
|
||||
compactionCutIndex,
|
||||
COMPACTION_RESERVE_TOKENS,
|
||||
estimateContextTokens,
|
||||
estimateTokens,
|
||||
newCompactionMarker,
|
||||
planTreeToText,
|
||||
@@ -142,13 +143,19 @@ export function effectiveThresholdTokens(settingTokens: number | undefined, cont
|
||||
|
||||
// ─── the estimate over the ACTIVE projection ────────────────────────────────
|
||||
|
||||
/** The projected context estimate: what the next LLM call would carry —
|
||||
* toLlmMessages output (cut honored) + the system prompt. The record
|
||||
* path carries no leading system message (settleWith filters it), so
|
||||
* the chrome rides as the additive term instead: prompt text here,
|
||||
* tool declarations absorbed by the 16k reserve (the declarations are
|
||||
* a few k and stable per session). chars/4. */
|
||||
/** The projected context estimate: what the next LLM call would carry.
|
||||
* THE TRIGGER LAW (pi's, verified 2026-10-05): anchor on the last
|
||||
* assistant message's provider-reported usage — it already counted
|
||||
* the system prompt, tool declarations, and the provider's own
|
||||
* tokenizer — and chars/4-estimate ONLY messages appended after it.
|
||||
* estimateContextTokens refuses anchors that predate a compaction
|
||||
* marker (that usage described context the next request will NOT
|
||||
* carry), so the fallback path runs the plain heuristic over the
|
||||
* CUT-HONORED projection (never the dropped span) plus the additive
|
||||
* prompt term the anchor would have covered. */
|
||||
export function projectedContextTokens(messages: readonly LhMessage[], systemPromptTokens: number): number {
|
||||
const est = estimateContextTokens(messages);
|
||||
if (est.lastUsageIndex !== null) return est.tokens;
|
||||
return estimateTokens(toLlmMessages(messages as readonly AgentMessage[]) as unknown as readonly LhMessage[]) + systemPromptTokens;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ export * from "./settings-body.ts";
|
||||
export * from "./turn-liveness.ts";
|
||||
export * from "./turn-runs.ts";
|
||||
export * from "./dismissal-park.ts";
|
||||
export * from "./compaction.ts";
|
||||
export { runPiTurn, piTurnResponse, type PiTurnHandle, type PiTurnSpec } from "./pi/ui-stream.ts";
|
||||
export * from "./observability.ts";
|
||||
export * from "./model-catalog.ts";
|
||||
|
||||
@@ -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