diff --git a/src/panel/panel.ts b/src/panel/panel.ts index b34cc6d..b69eddf 100644 --- a/src/panel/panel.ts +++ b/src/panel/panel.ts @@ -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 @@ -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. */ @@ -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; diff --git a/src/recommend.ts b/src/recommend.ts index d018d91..39d97d9 100644 --- a/src/recommend.ts +++ b/src/recommend.ts @@ -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"; } diff --git a/src/report.ts b/src/report.ts index cc53e23..17761a8 100644 --- a/src/report.ts +++ b/src/report.ts @@ -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 { @@ -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 { @@ -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(); + 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 }; @@ -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; } const refNum = (ref: string): number => { const m = ref.match(/\d+/); return m ? parseInt(m[0], 10) : 0; }; + const dominantTool = (toolTokens: Map): 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); @@ -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([[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`); diff --git a/tests/report-tool-accounting.test.ts b/tests/report-tool-accounting.test.ts new file mode 100644 index 0000000..eb63db4 --- /dev/null +++ b/tests/report-tool-accounting.test.ts @@ -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}`); +});