Skip to content
Merged
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
28 changes: 16 additions & 12 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 countTokens the core uses (kernel default = CJK-aware
* defaultCountTokens) — 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 "看板统计的和拆分的有差异"). */
unprunedTokens?: number;
/** Per-request prompt-cache usage (from assistant messages' provider-
* reported `usage`). Requests without cache reporting are excluded by
Expand All @@ -51,11 +52,13 @@ function bar(value: number, total: number, width: number = 20): string {
* - Session accounting (host footer scale): the append-only session tree
* INCLUDING compressed originals. It never shrinks — adapter pruning is
* a per-request transform view the host cannot see.
* - Sent view (chars/4 est.): what actually reaches the LLM after
* compression (kernel's classification over the pruned projection +
* measured system prompt). This is the number compression controls.
* - Session-only (chars/4 est.): unpruned projection − sent view; the
* compressed originals pruned from every request.
* - Sent view (estimated, kernel countTokens scale): what actually
* reaches the LLM after compression (kernel's classification over the
* pruned projection + measured system prompt). This is the number
* compression controls.
* - Session-only (estimated, kernel countTokens scale): unpruned
* projection − sent view; the compressed originals pruned from every
* request.
* Subtracting the host number from an estimate produced numbers that
* reconciled with neither scale ("Framework 390K", "session-only 29k vs
* 112k compressed") — that is what issue #18 reported. */
Expand All @@ -67,7 +70,8 @@ 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
// Same-scale derivation only: both sides use the core's countTokens
// estimate (kernel default = CJK-aware defaultCountTokens). 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;
Expand Down
2 changes: 1 addition & 1 deletion src/recommend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ function estimateTextTokens(text: string): number {
return Math.ceil(text.length / 4);
}

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

Expand Down
36 changes: 31 additions & 5 deletions src/report.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { refForRaw } from "./refs.js";
import { isToolMessage } from "./recommend.js";
import type { CompressionBlock, CompressionState, CoreMessage } from "./types.js";

function formatTokens(n: number): string {
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 @@ -78,12 +79,24 @@ function collectVisible(
if (block.active) summaryTokens += summaryTokensOf(block, countTokens);
}
const visible: VisibleMessageInfo[] = [];
// Tool RESULTS carry no toolName of their own — resolve them through
// their call so the tool bucket attributes result payload (#386: with
// `toolName ?? "text"` every tool-result landed in the text bucket and
// the tool bucket showed ~0.6% of the real volume).
const toolCallNames = new Map<string, string>();
for (const message of messages) {
if (message.contentType === "tool-call" && message.toolCallId && message.toolName) {
toolCallNames.set(message.toolCallId, message.toolName);
}
}
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";
const tool = isToolMessage(message)
? message.toolName ?? (message.toolCallId ? toolCallNames.get(message.toolCallId) : undefined) ?? "tool"
: "text";
if (tokens > 0) visible.push({ ref, tokens, tool, index });
});
return { visible, summaryTokens };
Expand Down Expand Up @@ -213,11 +226,22 @@ function renderUncompressedRanges(visible: VisibleMessageInfo[]): string {
// Merge consecutive messages into ranges (by numeric ref), aggregating
// token counts and dominant tool so the view reads as blocks, not a
// per-message firehose — mirroring the Compressible Ranges output.
interface Merged { startRef: string; endRef: string; startNum: number; count: number; tokens: number; tool: string; }
interface Merged { startRef: string; endRef: string; startNum: number; count: number; tokens: number; toolTokens: Map<string, number>; }
const refNum = (ref: string): number => {
const m = ref.match(/\d+/);
return m ? parseInt(m[0], 10) : 0;
};
const dominantTool = (toolTokens: Map<string, number>): string => {
let best = "text";
let bestN = -1;
for (const [tool, n] of toolTokens) {
if (n > bestN) {
best = tool;
bestN = n;
}
}
return best;
};
const merged: Merged[] = [];
for (const m of visible) {
const num = refNum(m.ref);
Expand All @@ -226,13 +250,15 @@ function renderUncompressedRanges(visible: VisibleMessageInfo[]): string {
last.endRef = m.ref;
last.count += 1;
last.tokens += m.tokens;
last.toolTokens.set(m.tool, (last.toolTokens.get(m.tool) ?? 0) + m.tokens);
} else {
merged.push({ startRef: m.ref, endRef: m.ref, startNum: num, count: 1, tokens: m.tokens, tool: m.tool });
const toolTokens = new Map<string, number>([[m.tool, m.tokens]]);
merged.push({ startRef: m.ref, endRef: m.ref, startNum: num, count: 1, tokens: m.tokens, toolTokens });
}
}
for (const r of merged.slice(0, 30)) {
const range = r.count === 1 ? r.startRef : `${r.startRef}–${r.endRef}`;
lines.push(` ${range} (${r.count} msgs, ${formatTokens(r.tokens)}${r.count > 1 ? ` (${Math.round(r.tokens / r.count)}/msg)` : ""}) ${r.tool}`);
lines.push(` ${range} (${r.count} msgs, ${formatTokens(r.tokens)}${r.count > 1 ? ` (${Math.round(r.tokens / r.count)}/msg)` : ""}) ${dominantTool(r.toolTokens)}`);
}
if (merged.length > 30) {
lines.push(` ... and ${merged.length - 30} more ranges`);
Expand Down
145 changes: 145 additions & 0 deletions tests/report-tool-accounting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { buildStatusReport } from "../src/report.js";
import { createInitialState } from "../src/state.js";
import { defaultCountTokens } from "../src/tokenize.js";
import { indexToRef } from "../src/refs.js";
import type { CompressionState, CoreMessage } from "../src/types.js";

// ASCII payload: defaultCountTokens treats non-CJK remainder as chars/4, so
// token counts below are exact arithmetic, letting tests assert real values.
const pad = (n: number): string => "a".repeat(n);

interface Pair {
callId: string;
toolName: string;
resultChars: number;
}

function toolHeavySession(pairs: Pair[], textChars = 200): CoreMessage[] {
const messages: CoreMessage[] = [
{ id: "m1", role: "user", contentType: "text", text: pad(textChars) },
];
for (const p of pairs) {
messages.push({
id: `call-${p.callId}`,
role: "assistant",
contentType: "tool-call",
toolName: p.toolName,
toolCallId: p.callId,
text: "{}",
});
messages.push({
id: `res-${p.callId}`,
role: "user",
contentType: "tool-result",
toolCallId: p.callId,
text: pad(p.resultChars),
});
}
return messages;
}

function stateFor(messages: CoreMessage[]): CompressionState {
const state = createInitialState();
messages.forEach((m, i) => {
const ref = indexToRef(i + 1);
state.messageRefs.byRaw[m.id] = ref;
state.messageRefs.byRef[ref] = m.id;
});
return state;
}

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

function bucketPct(line: string, label: string): number {
const m = new RegExp(`\\((\\d+)%\\) \\| ${label}`).exec(line) ?? new RegExp(`${label} \\((\\d+)%\\)`).exec(line);
assert.ok(m, `no ${label} pct in: ${line}`);
return Number(m[1]);
}

test("tool-result volume is attributed to the tool bucket (#386)", () => {
// 5 tools x 10_000 chars ASCII = 5 x 2_500 = 12_500 tokens of tool
// RESULTS. Wire adapters set toolName only on the call, never the result.
const pairs: Pair[] = [
{ callId: "c1", toolName: "bash", resultChars: 10_000 },
{ callId: "c2", toolName: "read", resultChars: 10_000 },
{ callId: "c3", toolName: "edit", resultChars: 10_000 },
{ callId: "c4", toolName: "grep", resultChars: 10_000 },
{ callId: "c5", toolName: "bash", resultChars: 10_000 },
];
const messages = toolHeavySession(pairs);
const report = buildStatusReport(stateFor(messages), messages, defaultCountTokens);
const line = breakdownLine(report);
// 12_500 tool vs 50 text + 5 calls x 1 = true ratio must be ~99%.
assert.ok(line.includes("12.5K tool"), `tool bucket wrong: ${line}`);
assert.ok(bucketPct(line, "tool") >= 99, `tool pct wrong: ${line}`);
});

test("tool-results resolve back to their calling tool's name (#386)", () => {
const pairs: Pair[] = [
{ callId: "c1", toolName: "bash", resultChars: 8_000 },
{ callId: "c2", toolName: "read", resultChars: 2_000 },
];
const messages = toolHeavySession(pairs);
const state = stateFor(messages);
const report = buildStatusReport(state, messages, defaultCountTokens);
const top = report.split("\n").find((l) => l.startsWith(" Top tools:"));
assert.ok(top, `no Top tools line in:\n${report}`);
assert.ok(top.includes("bash"), `bash missing: ${top}`);
assert.ok(top.includes("read"), `read missing: ${top}`);
const textPct = /text \((\d+)%\)/.exec(top);
assert.ok(!textPct || Number(textPct[1]) <= 5, `text dominates Top tools: ${top}`);
// Message drilldown by resolved name lists the result refs.
const byName = buildStatusReport(state, messages, defaultCountTokens, {
scope: "uncompressed",
view: "messages",
tool: "bash",
});
assert.ok(byName.includes("m00003"), `bash drilldown should include result ref:\n${byName}`);
});

test("orphan tool-results fall into the generic tool bucket, not text (#386)", () => {
const messages: CoreMessage[] = [
{ id: "m1", role: "user", contentType: "text", text: pad(200) },
{
id: "m2",
role: "user",
contentType: "tool-result",
toolCallId: "missing-call",
text: pad(8_000),
},
];
const state = stateFor(messages);
const report = buildStatusReport(state, messages, defaultCountTokens);
const line = breakdownLine(report);
assert.ok(line.includes("2.0K tool"), `orphan result should stay in tool bucket: ${line}`);
assert.ok(bucketPct(line, "tool") >= 97, `tool pct wrong: ${line}`);
});

test("pct() has no artificial floor: tiny buckets show 0% and sum <= 100 (#386)", () => {
const pairs: Pair[] = [{ callId: "c1", toolName: "bash", resultChars: 40_000 }];
const messages = toolHeavySession(pairs, 4);
const report = buildStatusReport(stateFor(messages), messages, defaultCountTokens);
const line = breakdownLine(report);
const toolPct = bucketPct(line, "tool");
const textPct = bucketPct(line, "text");
const sumPct = bucketPct(line, "summaries");
assert.equal(textPct, 0, `1 token of 10_002 must round to 0%, got: ${line}`);
assert.ok(toolPct + textPct + sumPct <= 100, `bucket sum exceeds 100: ${line}`);
});

test("ranges view attributes resolved tool names consistently with the breakdown (#386)", () => {
const pairs: Pair[] = [{ callId: "c1", toolName: "bash", resultChars: 8_000 }];
const messages = toolHeavySession(pairs);
const state = stateFor(messages);
const ranges = buildStatusReport(state, messages, defaultCountTokens, { scope: "uncompressed" });
const rangeLines = ranges.split("\n").filter((l) => /^ m\d/.test(l));
assert.ok(rangeLines.length > 0, `no range lines:\n${ranges}`);
const resultRange = rangeLines.find((l) => l.trimEnd().endsWith("bash"));
assert.ok(resultRange, `result range should be labeled bash:\n${ranges}`);
});
Loading