fix(host): price assistant replies and tool results by their content

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.
This commit is contained in:
bowenliang123 committed 2026-08-14 23:27:04 +08:00
1 parent 72667cd5d3
commit f8d66d08b2
4 files changed
+69 -11

No files matched your search

+8 -3
View File
@@ -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)')
+16
View File
@@ -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)
+11 -1
View File
@@ -145,6 +145,7 @@ const DICT_ZH: Record<string, string> = {
'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<string, string> = {
'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' },
+34 -7
View File
@@ -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':