From f8d66d08b2875fe7e55f78ea6f97073f05bbb123 Mon Sep 17 00:00:00 2001 From: bowenliang123 Date: Fri, 14 Aug 2026 23:27:04 +0800 Subject: [PATCH] fix(host): price assistant replies and tool results by their content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TS port passed the event ENVELOPE (data) instead of the model-visible message (data.message) for assistant/message and tool/result — every such node was priced at a flat 4 tokens (role framing only), so tool-heavy and assistant-rich sessions were massively undercounted in the Current composition. This is why the overview (20.4k) disagreed with the real usage. Mirrors dsh's deriveEventMessage exactly now; empty-content assistant messages price 0; tools total uses dsh's whole-array price; the overview additionally shows the last provider-reported actual prompt alongside the estimate. --- scripts/test-client.mjs | 11 ++++++++--- scripts/test-host.mjs | 16 ++++++++++++++++ src/client.cts | 12 +++++++++++- src/host.ts | 41 ++++++++++++++++++++++++++++++++++------- 4 files changed, 69 insertions(+), 11 deletions(-) diff --git a/scripts/test-client.mjs b/scripts/test-client.mjs index a669860..07c5d03 100644 --- a/scripts/test-client.mjs +++ b/scripts/test-client.mjs @@ -152,7 +152,7 @@ const requireStateful = (spec) => { const m2 = { exports: {} } const pluginExports2 = factory(requireStateful, m2, globalThis.window, fakeDoc) -const DICT_FOR_TEST = { 'tab': 'Context', 'loading': '…', 'error': 'x', 'detail.step': 'T{t} · step {s}', 'gran.step': 'Step', 'gran.turn': 'Turn', 'detail.turn': 'T{t} · {n} steps', 'detail.lastStep': 'last step' } +const DICT_FOR_TEST = { 'tab': 'Context', 'loading': '…', 'error': 'x', 'detail.step': 'T{t} · step {s}', 'gran.step': 'Step', 'gran.turn': 'Turn', 'detail.turn': 'T{t} · {n} steps', 'detail.lastStep': 'last step', 'overview.actual': '· last actual prompt {n}' } let viewComponent = null const fakeCtx2 = { get: () => undefined, @@ -234,7 +234,7 @@ const snapshot = { { seq: 1, turn: 1, step: 0, time: 1000, system: 10, tools: 20, user: 30, inject: 5, assistant: 15, tool: 20, total: 100, prompt: 95 }, { seq: 2, turn: 1, step: 1, time: 2000, system: 10, tools: 20, user: 25, inject: 5, assistant: 10, tool: 20, total: 90 }, { seq: 3, turn: 2, step: 0, time: 3000, system: 10, tools: 20, user: 40, inject: 5, assistant: 12, tool: 20, total: 107 }, - { seq: 4, turn: 3, step: 0, time: 4000, system: 10, tools: 20, user: 20, inject: 5, assistant: 8, tool: 20, total: 83 }, + { seq: 4, turn: 3, step: 0, time: 4000, system: 10, tools: 20, user: 20, inject: 5, assistant: 8, tool: 20, total: 83, prompt: 83000 }, ], events: [], nodes: [ { seq: 1, cat: 'user', tokens: 10, text: 'first message', time: 1000 }, @@ -469,4 +469,9 @@ const fmtTimeLocal = (t) => { assert.equal(nodeTimes[0].args[2], fmtTimeLocal(65000), 'formatted time (65000ms)') assert.equal(nodeTimes[1].args[2], fmtTimeLocal(1000), 'formatted time (1000ms)') -console.log('✔ chart render test passed (fixed-width bars, scroll container, turn ranges, hover linking, overview tooltip, turn strip, granularity toggle, edge fades, full history, right-anchored default, message times)') +// ---- overview shows the provider-reported actual prompt of the last request ---- +const overviewNum = byClass(tr, 'lc-overview-num')[0] +assert.ok(overviewNum, 'overview number row present') +assert.match(textOf(overviewNum), /· last actual prompt 83\.0k/, 'overview shows the last actual prompt alongside the estimate') + +console.log('✔ chart render test passed (fixed-width bars, scroll container, turn ranges, hover linking, overview tooltip, turn strip, granularity toggle, edge fades, full history, right-anchored default, message times, overview actual)') diff --git a/scripts/test-host.mjs b/scripts/test-host.mjs index dde890d..821108e 100644 --- a/scripts/test-host.mjs +++ b/scripts/test-host.mjs @@ -77,6 +77,14 @@ assert.ok(v.events.some(e => e.kind === 'inject' && e.form === 'notice'), 'injec assert.ok(v.events.some(e => e.kind === 'compaction'), 'compaction event recorded') assert.ok(v.nodes.length >= 4, 'surface nodes folded') assert.equal(v.nodes[0].time, 2000, 'nodes carry their event timestamp') +// Message projection must price CONTENT: assistant replies and tool results +// carry their message body (data.message), not a flat envelope price. +const asstNode = v.nodes.find(n => n.seq === 7) +assert.equal(asstNode.tokens, 9, "assistant 'Hi!' prices its text content (1 + 4 + 4)") +const toolNode = v.nodes.find(n => n.seq === 6) +assert.equal(toolNode.tokens, 13, 'tool result prices its nested content (5 + 4 + 4)') +assert.equal(toolNode.tool, 'bash', 'tool result node names its tool') +assert.equal(asstNode.text, 'Hi!', 'assistant node carries its text preview') // -- second snapshot: incremental (same count) must be served from cache -- const before = await handler('snapshot', { sessionId: 's1' }) @@ -89,6 +97,14 @@ assert.equal(after.ok, true) assert.equal(after.value.requests.length, 2, 'new request folded') assert.equal(after.value.requests[1].turn, 2) +// -- an empty-content assistant message (usage-only step) prices 0 tokens -- +live.events.push({ seq: 10, type: 'assistant/message', time: 8000, data: { turn: 2, step: 2, message: { content: [] } } }) +const emptyMsg = await handler('snapshot', { sessionId: 's1' }) +assert.equal(emptyMsg.ok, true) +const emptyNode = emptyMsg.value.nodes.find(n => n.seq === 10) +assert.ok(emptyNode, 'usage-only message is still a surface node') +assert.equal(emptyNode.tokens, 0, 'empty assistant message prices 0, like dsh deriveEventMessage') + // -- error paths -- const bad = await handler('snapshot', {}) assert.equal(bad.ok, false) diff --git a/src/client.cts b/src/client.cts index c572f5e..457bf32 100644 --- a/src/client.cts +++ b/src/client.cts @@ -145,6 +145,7 @@ const DICT_ZH: Record = { 'overview.title': '当前构成', 'overview.ofWindow': 'tokens(约 {p}%)', 'overview.estimate': 'tokens(估算)', + 'overview.actual': '· 上次实际 prompt {n}', 'tools.top': '工具定义 Top:', 'tools.more': '等 {n} 个', 'trend.title': '历史趋势', @@ -190,6 +191,7 @@ const DICT_EN: Record = { 'overview.title': 'Current composition', 'overview.ofWindow': 'tokens (~{p}%)', 'overview.estimate': 'tokens (estimated)', + 'overview.actual': '· last actual prompt {n}', 'tools.top': 'Top tool schemas:', 'tools.more': 'of {n}', 'trend.title': 'History', @@ -721,7 +723,15 @@ function makeView(ctx: ClientCtx, t: Translate): (props: ContextViewProps) => Re h('b', null, fmt(current.total)), h('span', null, data.contextWindow ? ' / ' + fmt(data.contextWindow) + ' ' + tr('overview.ofWindow', { p: windowPct ?? 0 }) - : ' ' + t('overview.estimate'))), + : ' ' + t('overview.estimate')), + // The provider-reported prompt of the last request is the best + // ground truth for what the model actually received; the fixed + // density heuristic can undercount CJK-heavy content, so show the + // real number alongside the estimate. + displayRequests.length > 0 && displayRequests[displayRequests.length - 1].prompt !== undefined + ? h('span', { className: 'lc-actual' }, + tr('overview.actual', { n: fmt(displayRequests[displayRequests.length - 1].prompt ?? 0) })) + : null), h(StackedBar, { parts: partsOf(current), height: 16, max: data.contextWindow, hoverKey: hoverCat, onHoverKey: setHoverCat }), h(Legend, { parts: partsOf(current), hoverKey: hoverCat, onHoverKey: setHoverCat }), (data.toolList && data.toolList.length > 0) ? h('div', { className: 'lc-tools' }, diff --git a/src/host.ts b/src/host.ts index 04819d4..9168b70 100644 --- a/src/host.ts +++ b/src/host.ts @@ -198,7 +198,16 @@ function estimateBlocks(blocks: ContentBlock[] | undefined): number { return tokens } -function estimateMessage(message: { content?: ContentBlock[] } | undefined | null): number { +/** + * Price one surface message exactly like dsh's token-meter estimate: + * an empty-content assistant/message projects to NO message (it only hosts + * usage), so it prices 0; every other message pays content + role framing. + */ +function estimateMessage(message: { content?: ContentBlock[] } | undefined | null, emptyIsZero = false): number { + if (emptyIsZero && (message === null || message === undefined + || !Array.isArray(message.content) || message.content.length === 0)) { + return 0 + } return estimateBlocks(message?.content) + ROLE_OVERHEAD } @@ -207,6 +216,7 @@ function estimateSystem(text: unknown): number { return Math.ceil(text.length / CHARS_PER_TOKEN) + ROLE_OVERHEAD } +/** Per-tool price for the top-tools display (the total uses dsh's whole-array price). */ function estimateToolSchema(tool: unknown): number { return Math.ceil(JSON.stringify(tool).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD } @@ -321,7 +331,14 @@ function applySurface( message: MessageLike | undefined, ): SurfaceNode { const cat = categoryOf(type, message) - const node: SurfaceNode = { seq: ev.seq, time: ev.time, cat, tokens: estimateMessage(message) } + const node: SurfaceNode = { + seq: ev.seq, + time: ev.time, + cat, + // Empty assistant messages project to no model message (usage-only), so + // they price 0 — mirroring dsh's deriveEventMessage/estimate. + tokens: estimateMessage(message, type === 'assistant/message'), + } const source = message?.source const form = source?.form if (typeof form === 'string') node.form = form @@ -389,8 +406,11 @@ function foldInto(st: FoldState, events: readonly SessionEvent[]): void { name: typeof (t as { name?: unknown }).name === 'string' ? (t as { name: string }).name : '?', tokens: estimateToolSchema(t), })) - st.toolsTokens = st.toolList.reduce((a, t) => a + t.tokens, 0) - if (tools.length > 0) st.toolsTokens += BLOCK_OVERHEAD + // The tools TOTAL uses dsh's whole-array price (one JSON string of + // every schema); per-tool prices above are display-only rankings. + st.toolsTokens = tools.length > 0 + ? Math.ceil(JSON.stringify(tools).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD + : 0 st.systemTokens = estimateSystem(header.system) if (header.config && typeof header.config.model === 'string') st.model = header.config.model if (header.config && typeof header.config.provider === 'string') st.provider = header.config.provider @@ -426,9 +446,13 @@ function foldInto(st: FoldState, events: readonly SessionEvent[]): void { } break } - case 'tool/result': - applySurface(st, ev, ev.type, data, data as unknown as MessageLike) + case 'tool/result': { + // The model-visible message is data.message (the envelope also + // carries callId/error); pricing the envelope would miss all content. + const toolMsg = (data?.message ?? null) as MessageLike | undefined + applySurface(st, ev, ev.type, data, toolMsg) break + } case 'assistant/message': { // Snapshot the request exactly as dispatched: current surface + header, // before this response joins the surface. @@ -451,7 +475,10 @@ function foldInto(st: FoldState, events: readonly SessionEvent[]): void { if (typeof usage.outputTokens === 'number') record.output = usage.outputTokens } st.requests.push(record) - applySurface(st, ev, ev.type, data, data as unknown as MessageLike) + // The model-visible message is data.message (mirrors dsh's + // deriveEventMessage); the envelope also carries turn/step/usage. + const asstMsg = (data?.message ?? null) as MessageLike | undefined + applySurface(st, ev, ev.type, data, asstMsg) break } case 'compaction/summary':