Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions src/compress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { applyMessageFilters, listMessageFilters } from "./filter/index.js";
import { createRenderRefsNode } from "./render-refs.js";
import type { RenderStrategy } from "./render-refs.js";
import { isMessageProtected } from "./protected.js";
import { isToolMessage } from "./message-kind.js";
import { adjustBoundariesForToolPairs } from "./tool-pairs.js";
import { adjustBoundariesForReasoningPairs } from "./reasoning-pairs.js";
import {
Expand Down Expand Up @@ -1302,10 +1303,7 @@ function computeContextBreakdown(
const tokens = count(msg.text ?? "");
if (msg.text?.startsWith("[Compressed conversation section]")) {
summaries += tokens;
} else if (
msg.contentType === "tool-call" ||
msg.contentType === "tool-result"
) {
} else if (isToolMessage(msg)) {
tool += tokens;
} else if (msg.role === "system") {
system += tokens;
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export type {
} from "./decompress.js";
export { buildStatusReport, buildRecap } from "./report.js";
export type { StatusReportOptions } from "./report.js";
export { isToolMessage } from "./message-kind.js";
export { hideConsumedCompressCalls } from "./hide-consumed.js";
export type { HideConsumedResult } from "./hide-consumed.js";
export {
Expand Down
13 changes: 13 additions & 0 deletions src/message-kind.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { CoreMessage } from "./types.js";

/**
* The single "tool message" definition for all accounting surfaces
* (status report, compressible ranges, context breakdown). A message is a
* tool message when it is a tool call OR its result — never by `toolName`
* presence: wire converters only set toolName on the call side, so a
* toolName-based check silently classifies every tool-result as text
* (issue #390).
*/
export function isToolMessage(message: CoreMessage): boolean {
return message.contentType === "tool-call" || message.contentType === "tool-result";
}
20 changes: 11 additions & 9 deletions src/panel/panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,13 @@ export interface StatusPanelInput {
nudge: NudgeDecision | undefined;
/** Configured model context window, in tokens. */
modelContextLimit: number;
/** chars/4 estimate of the FULL (unpruned) core-message projection — the
* same estimation scale as the kernel breakdown. When provided, the
* panel derives `Session-only` on that scale (unpruned − sent). Without
* it the line is omitted: subtracting the host's provider-scale number
* from an estimate-scale number invents a third, meaningless scale
* (issue #18 "看板统计的和拆分的有差异"). */
/** Estimate of the FULL (unpruned) core-message projection, computed with
* the SAME estimator the core uses for the nudge contextBreakdown
* (defaultCountTokens unless the host injects one into createCore) —
* never chars/4 when the core runs CJK-aware. When provided, the panel
* derives `Session-only` on that scale (unpruned − sent). Without it the
* line is omitted: subtracting numbers from different scales invents a
* meaningless third scale (issue #18 "看板统计的和拆分的有差异"). */
unprunedTokens?: number;
/** Per-request prompt-cache usage (from assistant messages' provider-
* reported `usage`). Requests without cache reporting are excluded by
Expand Down Expand Up @@ -67,9 +68,10 @@ export function buildStatusPanel(input: StatusPanelInput): string {
const classified = bd ? bd.system + bd.tool + bd.summaries + bd.code + bd.text : 0;
const systemPromptTokens = input.systemPromptTokens;
const sentTotal = classified + systemPromptTokens;
// Same-scale derivation only: both sides are chars/4 estimates. The host
// footer's tokenCount (provider-anchored, session-tree) is displayed as
// its own line and never fed into an arithmetic difference with these.
// Same-scale derivation only: both sides use the core's estimator
// (defaultCountTokens unless the host injects one into createCore). The
// host footer's tokenCount (provider-anchored, session-tree) is displayed
// as its own line and never fed into an arithmetic difference with these.
const sessionOnly = input.unprunedTokens !== undefined ? Math.max(0, input.unprunedTokens - sentTotal) : 0;
const displayTotal = tokenCount;
const displayPct = limit > 0 ? Math.round((displayTotal / limit) * 100) : 0;
Expand Down
6 changes: 1 addition & 5 deletions src/recommend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
ProtectedRange,
} from "./types.js";
import type { CompressionState } from "./types.js";
import { isToolMessage } from "./message-kind.js";
import {
collectProtectedToolCallIds,
isMessageProtectedWithPairing,
Expand All @@ -39,11 +40,6 @@ function estimateTextTokens(text: string): number {
return Math.ceil(text.length / 4);
}

function isToolMessage(message: CoreMessage): boolean {
return message.contentType === "tool-call" || message.contentType === "tool-result";
}


function isSyntheticOrPruned(
message: CoreMessage,
state: CompressionState,
Expand Down
24 changes: 19 additions & 5 deletions src/report.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isToolMessage } from "./message-kind.js";
import { refForRaw } from "./refs.js";
import type { CompressionBlock, CompressionState, CoreMessage } from "./types.js";

Expand All @@ -8,7 +9,7 @@ function formatTokens(n: number): string {

function pct(n: number, total: number): number {
if (n <= 0 || total <= 0) return 0;
return Math.max(1, Math.round((n / total) * 100));
return Math.round((n / total) * 100);
}

function numericPart(blockId: string): number {
Expand Down Expand Up @@ -60,6 +61,7 @@ interface VisibleMessageInfo {
ref: string;
tokens: number;
tool: string;
isTool: boolean;
index: number;
}

Expand All @@ -77,14 +79,26 @@ function collectVisible(
for (const block of state.blocks) {
if (block.active) summaryTokens += summaryTokensOf(block, countTokens);
}
// Wire converters only set toolName on the call side; resolve result
// names through the shared toolCallId so both halves land in the same
// tool bucket (issue #390).
const nameByCallId = new Map<string, string>();
for (const message of messages) {
if (message.contentType === "tool-call" && message.toolCallId && message.toolName) {
nameByCallId.set(message.toolCallId, message.toolName);
}
}
const visible: VisibleMessageInfo[] = [];
messages.forEach((message, index) => {
if (coveredIds.has(message.id)) return;
const ref = refForRaw(state.messageRefs, message.id);
if (!ref) return;
const tokens = countTokens(message.text ?? "");
const tool = message.toolName ?? "text";
if (tokens > 0) visible.push({ ref, tokens, tool, index });
const isTool = isToolMessage(message);
const tool = isTool
? (message.toolName ?? (message.toolCallId ? nameByCallId.get(message.toolCallId) : undefined) ?? "tool")
: "text";
if (tokens > 0) visible.push({ ref, tokens, tool, isTool, index });
});
return { visible, summaryTokens };
}
Expand Down Expand Up @@ -145,10 +159,10 @@ function renderOverview(
const topTool = [...toolTypeMap.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];

const totalTool = visible
.filter((m) => m.tool !== "text")
.filter((m) => m.isTool)
.reduce((sum, m) => sum + m.tokens, 0);
const totalText = visible
.filter((m) => m.tool === "text")
.filter((m) => !m.isTool)
.reduce((sum, m) => sum + m.tokens, 0);
const total = summaryTokens + totalTool + totalText;

Expand Down
24 changes: 24 additions & 0 deletions tests/panel.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { buildStatusPanel, topicFallback, formatCompactTokens, cacheHitStats, formatHitRate } from "../src/panel/index.js";
import { defaultCountTokens } from "../src/tokenize.js";
import { VIABLE_RANGE_MIN_TOKENS } from "../src/viable.js";

test("panel separates session accounting from sent view", () => {
Expand Down Expand Up @@ -85,6 +86,29 @@ test("session-only derives on the estimation scale, never cross-scale", () => {
assert.match(text, /Session-only \(compressed originals, est\.\): 110k — pruned from every request/);
});

test("panel session-only stays on the core's estimator scale for CJK (issue #390)", () => {
// unpruned must be estimated with the SAME estimator the nudge breakdown
// uses (defaultCountTokens, CJK 1:1) — not chars/4. 1000 CJK chars of
// compressed originals = 1000 tokens on that scale (250 on chars/4).
const sentTotal = 24_000;
const unpruned = sentTotal + defaultCountTokens("中".repeat(1_000));
const nudge = {
shouldInject: false,
reason: "idle",
contextBreakdown: { system: 0, tool: 20_000, text: 4_000, code: 0, summaries: 0, total: 24_000, growth: 0 },
};
const state = { blocks: [], messageRefs: { byRaw: {}, byRef: {} }, nudge: {}, stats: { tokensCompressed: 0 }, nextBlockId: 1, nextRunId: 1 };
const text = buildStatusPanel({
tokenCount: 430_000,
systemPromptTokens: 0,
state: state as never,
nudge: nudge as never,
modelContextLimit: 1_000_000,
unprunedTokens: unpruned,
});
assert.match(text, /Session-only \(compressed originals, est\.\): 1\.0k — pruned from every request/);
});

test("topicFallback takes the first sentence segment, ≤30 chars", () => {
assert.equal(topicFallback("Database migration steps failed twice."), "Database migration steps faile…");
assert.equal(topicFallback('He said "hello". More text.'), 'He said "hello"');
Expand Down
122 changes: 122 additions & 0 deletions tests/report-accounting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { buildStatusReport } from "../src/report.js";
import { buildCompressibleRanges } from "../src/recommend.js";
import { createInitialState } from "../src/state.js";
import { assignRefs } from "../src/refs.js";
import { defaultCountTokens, estimateTokensFast } from "../src/tokenize.js";
import type { Config, CompressionState, CoreMessage } from "../src/types.js";

function config(overrides: Partial<Config> = {}): Config {
return {
tiers: { enabled: true, tier2Trigger: 5, tier3Trigger: 10 },
nudge: {
maxContextLimitPct: 0.55,
minContextLimitPct: 0.45,
frequency: 5,
iterationThreshold: 15,
force: "soft",
growthRatio: 0.05,
},
promotionThreshold: 5,
truncate: { threshold: 1 },
merge: { maxSummaryLength: 3000, minOldGenBlocks: 3 },
compress: { minCompressRange: 0, maxSummaryLength: 0, minSummaryLength: 0 },
protectedTools: [],
preserveRecentMessages: 0,
preserveRecentTokens: 0,
modelContextLimit: 100000,
...overrides,
};
}

function withRefs(messages: CoreMessage[]): CompressionState {
const state = createInitialState();
state.messageRefs = assignRefs(messages, {
existing: state.messageRefs,
nextIndex: 1,
}).map;
return state;
}

function breakdownLine(report: string): string {
const line = report.split("\n").find((l) => l.includes("tool ("));
assert.ok(line, `no breakdown line in:\n${report}`);
return line;
}

test("breakdown counts tool-results in the tool bucket (issue #390)", () => {
const messages: CoreMessage[] = [
{ id: "m1", role: "user", contentType: "text", text: "run the build" },
{ id: "m2", role: "assistant", contentType: "tool-call", toolName: "bash", toolCallId: "c1", text: "npm test" },
// wire converters never set toolName on the result side
{ id: "m3", role: "tool", contentType: "tool-result", toolCallId: "c1", text: "x".repeat(51_361) },
];
const state = withRefs(messages);
const report = buildStatusReport(state, messages, defaultCountTokens);
// tool = ceil(51361/4) + ceil(8/4) = 12843, text = ceil(13/4) = 4
assert.match(breakdownLine(report), /12\.8K tool \(100%\)/);
assert.match(breakdownLine(report), /4 text \(0%\)/);
assert.match(report, /Top tools: bash \(100%\)/);
});

test("breakdown tool split corroborates compressible-range toolPct (issue #390)", () => {
const messages: CoreMessage[] = [
{ id: "m1", role: "assistant", contentType: "tool-call", toolName: "bash", toolCallId: "c1", text: "ls" },
{ id: "m2", role: "tool", contentType: "tool-result", toolCallId: "c1", text: "y".repeat(40_000) },
];
const state = withRefs(messages);
const ranges = buildCompressibleRanges(messages, state, config(), new Set(), defaultCountTokens);
assert.equal(ranges.compressible.length, 1);
const range = ranges.compressible[0]!;
// same message set, both surfaces must agree: all-tool
assert.equal(range.toolPct, 100);
assert.equal(range.textPct, 0);
assert.match(breakdownLine(buildStatusReport(state, messages, defaultCountTokens)), /100%\)/);
});

test("pct has no 1% floor — tiny buckets print 0% and sums stay <= 100 (issue #390)", () => {
const messages: CoreMessage[] = [
{ id: "m1", role: "user", contentType: "text", text: "z".repeat(4000) }, // 1000 tokens
{ id: "m2", role: "assistant", contentType: "tool-call", toolName: "bash", toolCallId: "c1", text: "ls" }, // 1 token
];
const state = withRefs(messages);
const line = breakdownLine(buildStatusReport(state, messages, defaultCountTokens));
assert.match(line, /1 tool \(0%\)/);
assert.match(line, /1\.0K text \(100%\)/);
const pcts = [...line.matchAll(/(\d+)%/g)].map((m) => Number(m[1]!));
assert.ok(pcts.reduce((s, n) => s + n, 0) <= 100, `bucket percents must not exceed 100: ${line}`);
});

test("breakdown uses the injected estimator — CJK is not 4x-underestimated (issue #390)", () => {
const messages: CoreMessage[] = [
{ id: "m1", role: "user", contentType: "text", text: "中".repeat(4000) },
];
const state = withRefs(messages);
assert.match(breakdownLine(buildStatusReport(state, messages, defaultCountTokens)), /4\.0K text \(100%\)/);
assert.match(breakdownLine(buildStatusReport(state, messages, estimateTokensFast)), /1\.0K text \(100%\)/);
});

test("tool-result without a resolvable call stays in the tool bucket", () => {
const messages: CoreMessage[] = [
{ id: "m1", role: "tool", contentType: "tool-result", toolCallId: "orphan", text: "w".repeat(4000) },
];
const state = withRefs(messages);
const report = buildStatusReport(state, messages, defaultCountTokens);
assert.match(breakdownLine(report), /1\.0K tool \(100%\)/);
assert.match(report, /Top tools: tool \(100%\)/);
});

test("message drilldown filters results by resolved tool name", () => {
const messages: CoreMessage[] = [
{ id: "m1", role: "assistant", contentType: "tool-call", toolName: "bash", toolCallId: "c1", text: "ls" },
{ id: "m2", role: "tool", contentType: "tool-result", toolCallId: "c1", text: "y".repeat(4000) },
];
const state = withRefs(messages);
const report = buildStatusReport(state, messages, defaultCountTokens, {
scope: "uncompressed",
view: "messages",
tool: "bash",
});
assert.match(report, /bash: .* \| 2 msgs/);
});
Loading