diff --git a/src/compress.ts b/src/compress.ts index de76ee7..a6611c0 100644 --- a/src/compress.ts +++ b/src/compress.ts @@ -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 { @@ -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; diff --git a/src/index.ts b/src/index.ts index 01889a2..afba6a7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 { diff --git a/src/message-kind.ts b/src/message-kind.ts new file mode 100644 index 0000000..76525a2 --- /dev/null +++ b/src/message-kind.ts @@ -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"; +} diff --git a/src/panel/panel.ts b/src/panel/panel.ts index b34cc6d..a587902 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 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 @@ -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; diff --git a/src/recommend.ts b/src/recommend.ts index d018d91..673bfab 100644 --- a/src/recommend.ts +++ b/src/recommend.ts @@ -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, @@ -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, diff --git a/src/report.ts b/src/report.ts index cc53e23..e74ca71 100644 --- a/src/report.ts +++ b/src/report.ts @@ -1,3 +1,4 @@ +import { isToolMessage } from "./message-kind.js"; import { refForRaw } from "./refs.js"; import type { CompressionBlock, CompressionState, CoreMessage } from "./types.js"; @@ -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 { @@ -60,6 +61,7 @@ interface VisibleMessageInfo { ref: string; tokens: number; tool: string; + isTool: boolean; index: number; } @@ -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(); + 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 }; } @@ -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; diff --git a/tests/panel.test.ts b/tests/panel.test.ts index 063c41a..204ae0d 100644 --- a/tests/panel.test.ts +++ b/tests/panel.test.ts @@ -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", () => { @@ -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"'); diff --git a/tests/report-accounting.test.ts b/tests/report-accounting.test.ts new file mode 100644 index 0000000..5c8a7f6 --- /dev/null +++ b/tests/report-accounting.test.ts @@ -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 { + 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/); +});