feat: add a Tool Calls cell to the Context Stats board

The cell sits left of Images and counts the tool calls whose result is
live in the current context: one tool/result folds to exactly one tool
surface node, so the host derives the figure at view time from the live
surface (no persisted counter, no state-version bump). Calls still in
flight and results compacted or pruned out of the surface are excluded.
This commit is contained in:
Bowen Liang committed 2026-08-24 00:01:47 +08:00
1 parent 69c175e76a
commit 39554df985
10 files changed
+70 -20

No files matched your search

+2 -1
View File
@@ -271,7 +271,8 @@ export function makeContextView(ctx: ClientCtx, kit: ViewKit): (props: ContextVi
{/* ---- session context stats + plugin info, side by side (7 : 3) ---- */}
<div className="lc-cols lc-head">
<StatsBoard requests={requests} events={events} usage={usage} images={data.images} cost={data.cost} locale={activeLocale} />
<StatsBoard requests={requests} events={events} usage={usage} toolCalls={data.toolCalls} images={data.images}
cost={data.cost} locale={activeLocale} />
<PluginInfo />
</div>
+7 -3
View File
@@ -1,9 +1,9 @@
/**
* StatsBoard — the session context statistics card above the composition:
* conversation size (turns/steps), context churn (compaction count,
* prune count, injection count), the whole-session image-file count, the
* cache-hit share, and the estimated
* cumulative session cost. The counts cover the retained history window,
* prune count, injection count), the tool calls with a result live in the
* current context, the whole-session image-file count, the cache-hit share,
* and the estimated cumulative session cost. The counts cover the retained history window,
* matching the History chart; the cache-hit figure reuses the official
* token-meter `tokenUsage` projection verbatim — the exact same data and
* formula as the chat stats line below the input box — and the cost cell
@@ -50,6 +50,8 @@ export function makeStatsBoard(kit: ViewKit): (props: {
requests: RequestRecord[]
events: ContextEventRecord[]
usage: TokenUsage | null
/** Tool calls with a result live in the current context (absent on older hosts). */
toolCalls?: number
/** Cumulative session image-block count (whole log; absent on older hosts). */
images?: number
/** Session-cost raw material from the timeline (undefined: nothing priced). */
@@ -62,6 +64,7 @@ export function makeStatsBoard(kit: ViewKit): (props: {
requests: RequestRecord[]
events: ContextEventRecord[]
usage: TokenUsage | null
toolCalls?: number
images?: number
cost?: SessionCostUsage
locale: string
@@ -123,6 +126,7 @@ export function makeStatsBoard(kit: ViewKit): (props: {
{cell(t('stats.injects'), fmt(injects))}
{cell(t('stats.compactions'), fmt(compactions))}
{cell(t('stats.prunes'), fmt(prunes))}
{cell(t('stats.toolCalls'), fmt(props.toolCalls ?? 0))}
{cell(t('stats.images'), fmt(props.images ?? 0))}
{cell(t('stats.cacheHit'), hit === null ? '—' : hit + '%')}
{cell(t('stats.cost'), cost === null ? '—' : formatCost(cost, currency), costTip)}
+2
View File
@@ -21,6 +21,7 @@ export const DICT_ZH: Record<string, string> = {
'stats.injects': '注入',
'stats.compactions': '压缩',
'stats.prunes': '剪枝',
'stats.toolCalls': '工具调用',
'stats.images': '图片',
'stats.cacheHit': '缓存命中',
'stats.cost': '预估费用',
@@ -142,6 +143,7 @@ export const DICT_EN: Record<string, string> = {
'stats.injects': 'Injections',
'stats.compactions': 'Compactions',
'stats.prunes': 'Prunes',
'stats.toolCalls': 'Tool Calls',
'stats.images': 'Images',
'stats.cacheHit': 'Cache Hit',
'stats.cost': 'Cost',
+1
View File
@@ -205,6 +205,7 @@ export function timelineOf(value: unknown): ContextTimeline | null {
nodes: objectsOf(data.nodes),
droppedNodes: numOf(data.droppedNodes),
...(typeof data.images === 'number' ? { images: data.images } : {}),
...(typeof data.toolCalls === 'number' ? { toolCalls: data.toolCalls } : {}),
archive: objectsOf(data.archive),
...(cost !== undefined ? { cost } : {}),
...(typeof data.surfaceFloor === 'number' ? { surfaceFloor: data.surfaceFloor } : {}),
+5
View File
@@ -671,6 +671,11 @@ export function buildTimelineView(state: TimelineState, bounds: FoldBounds): Sna
},
toolList: state.toolList,
images: state.images,
// Tool calls WITH A RESULT live in the current context: one `tool/result`
// folds to exactly one `tool` surface node, so live tool nodes are the
// count. Calls still in flight (no result yet) and results compacted or
// pruned out of the surface are both excluded.
toolCalls: state.surface.reduce((n, node) => node.cat === 'tool' ? n + 1 : n, 0),
requests: state.requests.map(r => ({ ...r })),
events: state.events.map(e => ({ ...e })),
nodes: [],
+3
View File
@@ -106,6 +106,7 @@ const contextTimelineSchema = z.object({
current: currentSchema,
toolList: z.array(z.object({ name: z.string(), tokens: z.number().int().nonnegative() }).strict()),
images: z.number().int().nonnegative().optional(),
toolCalls: z.number().int().nonnegative().optional(),
requests: z.array(requestRecordSchema),
events: z.array(contextEventSchema),
cost: z.object({ flash: costFamilySchema.optional(), pro: costFamilySchema.optional() }).strict().optional(),
@@ -201,6 +202,8 @@ export function createContextTimelineDefinition(config: Config): ProjectionDefin
// nodes and are refolded.
// 7: the whole-session image-block count (`images`) joined the persisted
// state; cached rows predate the shape and are refolded.
// (The stats board's tool-call cell is a VIEW-TIME count of live `tool`
// surface nodes — no persisted field, so no version bump.)
stateVersion: 7,
}
return definition
+7
View File
@@ -79,6 +79,13 @@ export interface Snapshot {
* hosts; clients treat absence as zero.
*/
images?: number
/**
* Tool calls whose result is live in the CURRENT context (one `tool/result`
* folds to one `tool` surface node). Calls still in flight and results
* compacted/pruned out of the surface are not counted. Absent from older
* hosts; clients treat absence as zero.
*/
toolCalls?: number
requests: RequestRecord[]
events: ContextEventRecord[]
/**
+14 -12
View File
@@ -35,15 +35,16 @@ test('context view: trend chart, stats board, plugin info, hover linking, granul
// fixture: 4 requests (turns 1,1,2,3), no events yet -> all event counters 0.
const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'))
const statVals = byClass(tree, 'lc-stat-value').map(n => n.args[2])
assert.equal(statVals.length, 8, 'eight stats cells (turns / steps / injections / compactions / prunes / images / cache hit / cost)')
assert.equal(statVals.length, 9, 'nine stats cells (turns / steps / injections / compactions / prunes / tool calls / images / cache hit / cost)')
assert.equal(statVals[0], '3', 'turns counted by distinct turn')
assert.equal(statVals[1], '4', 'steps = request count')
assert.equal(statVals[2], '0', 'no injections yet')
assert.equal(statVals[3], '0', 'no compactions yet')
assert.equal(statVals[4], '0', 'no prunes yet')
assert.equal(statVals[5], '0', 'no image blocks in the fixture log')
assert.equal(statVals[6], '—', 'no tokenUsage projection yet -> cache-hit cell shows a dash')
assert.equal(statVals[7], '—', 'no cost totals yet -> cost cell shows a dash')
assert.equal(statVals[5], '0', 'no tool calls in the fixture log')
assert.equal(statVals[6], '0', 'no image blocks in the fixture log')
assert.equal(statVals[7], '—', 'no tokenUsage projection yet -> cache-hit cell shows a dash')
assert.equal(statVals[8], '—', 'no cost totals yet -> cost cell shows a dash')
// ---- plugin info card: two full-width rows; every row is itself a link ----
const piLabels = byClass(tree, 'lc-pi-label').map(n => n.args[2])
@@ -414,8 +415,9 @@ test('context view: trend chart, stats board, plugin info, hover linking, granul
assert.equal(statVals2[2], '1', 'one injection counted')
assert.equal(statVals2[3], '2', 'two compactions counted')
assert.equal(statVals2[4], '2', 'two prunes counted')
assert.equal(statVals2[5], '0', 'image cell still zero')
assert.equal(statVals2[6], '—', 'cache-hit cell still a dash before a tokenUsage projection lands')
assert.equal(statVals2[5], '0', 'tool-call cell still zero')
assert.equal(statVals2[6], '0', 'image cell still zero')
assert.equal(statVals2[7], '—', 'cache-hit cell still a dash before a tokenUsage projection lands')
// the cache-hit cell reuses the OFFICIAL token-meter `tokenUsage` projection —
// the exact same data the chat stats line below the input box reads, same
@@ -423,16 +425,16 @@ test('context view: trend chart, stats board, plugin info, hover linking, granul
bed.usageValue = { uncachedInputTokens: 10, outputTokens: 40, cacheReadTokens: 80, cacheWriteTokens: 10 }
tr = renderView()
const statVals3 = byClass(tr, 'lc-stat-value').map(n => n.args[2])
assert.equal(statVals3.length, 8, 'eight stats cells with the cache-hit and cost cells')
assert.equal(statVals3[6], '80.00%', 'cache hit = cacheRead / (uncached + cacheRead + cacheWrite), two decimals')
assert.equal(statVals3[7], '—', 'cost cell stays a dash without cost totals')
assert.equal(statVals3.length, 9, 'nine stats cells with the cache-hit and cost cells')
assert.equal(statVals3[7], '80.00%', 'cache hit = cacheRead / (uncached + cacheRead + cacheWrite), two decimals')
assert.equal(statVals3[8], '—', 'cost cell stays a dash without cost totals')
assert.equal(statVals3[0], '3', 'turn count unchanged by the usage projection')
assert.equal(statVals3[3], '2', 'compaction count unchanged by the usage projection')
// truncation proof: 8334 / 25000 = 33.336% -> cut to 33.33%, never rounded up
bed.usageValue = { uncachedInputTokens: 10000, outputTokens: 40, cacheReadTokens: 8334, cacheWriteTokens: 6666 }
tr = renderView()
const statVals4 = byClass(tr, 'lc-stat-value').map(n => n.args[2])
assert.equal(statVals4[6], '33.33%', 'two decimals are TRUNCATED, not rounded (33.336 -> 33.33)')
assert.equal(statVals4[7], '33.33%', 'two decimals are TRUNCATED, not rounded (33.336 -> 33.33)')
bed.usageValue = undefined
// the cost cell prices the host-folded cumulative totals with the hardcoded
@@ -442,8 +444,8 @@ test('context view: trend chart, stats board, plugin info, hover linking, granul
bed.dataValue = { ...prevData, cost: { flash: { off: { uncached: 1000000, cacheRead: 1000000, cacheWrite: 0, output: 0 } } } }
tr = renderView()
const statVals5 = byClass(tr, 'lc-stat-value').map(n => n.args[2])
assert.equal(statVals5[7], '$0.23', 'cost = (cacheRead x hit + uncached x miss) at the off-peak flash rate')
const costCell = byClass(tr, 'lc-stat')[7]
assert.equal(statVals5[8], '$0.23', 'cost = (cacheRead x hit + uncached x miss) at the off-peak flash rate')
const costCell = byClass(tr, 'lc-stat')[8]
assert.equal(byClass(tr, 'lc-stat-q').length, 1, 'cost cell label shows the "?" affordance')
// The explanation is a styled DOM bubble (the native `title` attribute is
// invisible in the harness GUI): intro sentence + the price list built from
+24
View File
@@ -72,6 +72,9 @@ test('host fold: image blocks price by the official DeepSeek image token formula
assert.equal(v.current.user, n1.tokens + n2.tokens + n3.tokens)
// Whole-session image count: three user uploads + the nested tool-result image.
assert.equal(v.images, 4, 'image blocks counted across user messages and tool results')
// Tool calls: only results LIVE in the current context count — the one
// tool/result folded above is on the surface.
assert.equal(v.toolCalls, 1, 'a tool call with a live result counts')
// Compaction shadows the two image messages: the fold subtracts its own
// corrected prices, keeping the surface sum internally consistent.
@@ -87,6 +90,27 @@ test('host fold: image blocks price by the official DeepSeek image token formula
const summary = v2.nodes.find(n => n.seq === 4)
assert.equal(v2.current.user, kept.tokens + summary.tokens, 'shadowed image leaves no residue')
assert.equal(v2.images, 1, 'the image count is cumulative — compaction does not decrement it')
assert.equal(v2.toolCalls, 0, 'no tool results on the surface -> zero tool calls')
// A tool result compacted OUT of the surface stops counting; a call still
// in flight (no result folded) never counted.
const v3 = drive([
{ seq: 1, type: 'tool/call', time: 1000, data: { callId: 'c1', name: 'read', arguments: '{}' } },
{ seq: 2, type: 'tool/result', time: 1100, data: { callId: 'c1', message: { source: { kind: 'tool', callId: 'c1' }, content: [
{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'file body' }] },
] } } },
{ seq: 3, type: 'tool/call', time: 1200, data: { callId: 'c2', name: 'bash', arguments: '{}' } },
])
assert.equal(v3.toolCalls, 1, 'the in-flight call (c2) has no result in context -> not counted')
const v4 = drive([
{ seq: 1, type: 'tool/call', time: 1000, data: { callId: 'c1', name: 'read', arguments: '{}' } },
{ seq: 2, type: 'tool/result', time: 1100, data: { callId: 'c1', message: { source: { kind: 'tool', callId: 'c1' }, content: [
{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'file body' }] },
] } } },
{ seq: 3, type: 'compaction/summary', time: 3000, data: { shadowedTokenCount: 999, shadowedSeqs: [2] } },
{ seq: 4, type: 'user/message', time: 3100, surfaceOp: { op: 'replace', start: 2, end: 2 }, data: { content: [{ type: 'text', text: 'summary' }] } },
])
assert.equal(v4.toolCalls, 0, 'a result compacted out of the context stops counting')
console.log('✔ host fold image pricing passed (official formula, JSON fallback, nesting, compaction shadow)')
})
+5 -4
View File
@@ -42,13 +42,14 @@ test('backend-parse-failure resilience: sanitized render of corrupt payloads, de
}
tr = renderView() // must not throw
assert.equal(byClass(tr, 'lc-bar').length, 1, 'the usable request entry still renders a trend bar')
assert.equal(byClass(tr, 'lc-stat-value').length, 8, 'stats board still renders')
assert.equal(byClass(tr, 'lc-stat-value').length, 9, 'stats board still renders')
const statValsM = byClass(tr, 'lc-stat-value').map(n => n.args[2])
assert.equal(statValsM[0], '1', 'turns count survives (non-array requests degraded, entries filtered)')
assert.equal(statValsM[1], '1', 'steps count survives')
assert.equal(statValsM[5], '0', 'image cell falls back to zero when absent')
assert.equal(statValsM[6], '—', 'cache hit degrades to a dash')
assert.equal(statValsM[7], '—', 'cost degrades to a dash (non-record cost dropped)')
assert.equal(statValsM[5], '0', 'tool-call cell falls back to zero when absent')
assert.equal(statValsM[6], '0', 'image cell falls back to zero when absent')
assert.equal(statValsM[7], '—', 'cache hit degrades to a dash')
assert.equal(statValsM[8], '—', 'cost degrades to a dash (non-record cost dropped)')
assert.equal(byClass(tr, 'lc-event').length, 1, 'the usable event entry still shows')
assert.equal(byClass(tr, 'lc-node').length, 1, 'the usable node entry still shows')
assert.equal(textOf(byClass(tr, 'lc-node-time')[0]), '—', 'an invalid timestamp shows a dash instead of throwing')