From 39554df985b6c8b23889dcd59a700235ef46cf2c Mon Sep 17 00:00:00 2001 From: Bowen Liang Date: Mon, 24 Aug 2026 00:01:47 +0800 Subject: [PATCH] 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. --- src/client/components/contextView.tsx | 3 ++- src/client/components/statsBoard.tsx | 10 +++++++--- src/client/i18n.ts | 2 ++ src/client/services.ts | 1 + src/host/fold.ts | 5 +++++ src/host/timeline.ts | 3 +++ src/shared/types.ts | 7 +++++++ tests/chart.spec.ts | 26 ++++++++++++++------------ tests/multimodal.spec.ts | 24 ++++++++++++++++++++++++ tests/resilience.spec.ts | 9 +++++---- 10 files changed, 70 insertions(+), 20 deletions(-) diff --git a/src/client/components/contextView.tsx b/src/client/components/contextView.tsx index 5253554..9b744ec 100644 --- a/src/client/components/contextView.tsx +++ b/src/client/components/contextView.tsx @@ -271,7 +271,8 @@ export function makeContextView(ctx: ClientCtx, kit: ViewKit): (props: ContextVi {/* ---- session context stats + plugin info, side by side (7 : 3) ---- */}
- +
diff --git a/src/client/components/statsBoard.tsx b/src/client/components/statsBoard.tsx index 6a078cf..9717d22 100644 --- a/src/client/components/statsBoard.tsx +++ b/src/client/components/statsBoard.tsx @@ -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)} diff --git a/src/client/i18n.ts b/src/client/i18n.ts index 0711f32..caa683a 100644 --- a/src/client/i18n.ts +++ b/src/client/i18n.ts @@ -21,6 +21,7 @@ export const DICT_ZH: Record = { 'stats.injects': '注入', 'stats.compactions': '压缩', 'stats.prunes': '剪枝', + 'stats.toolCalls': '工具调用', 'stats.images': '图片', 'stats.cacheHit': '缓存命中', 'stats.cost': '预估费用', @@ -142,6 +143,7 @@ export const DICT_EN: Record = { 'stats.injects': 'Injections', 'stats.compactions': 'Compactions', 'stats.prunes': 'Prunes', + 'stats.toolCalls': 'Tool Calls', 'stats.images': 'Images', 'stats.cacheHit': 'Cache Hit', 'stats.cost': 'Cost', diff --git a/src/client/services.ts b/src/client/services.ts index 83d220a..5be9680 100644 --- a/src/client/services.ts +++ b/src/client/services.ts @@ -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 } : {}), diff --git a/src/host/fold.ts b/src/host/fold.ts index 0520166..bc10a9c 100644 --- a/src/host/fold.ts +++ b/src/host/fold.ts @@ -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: [], diff --git a/src/host/timeline.ts b/src/host/timeline.ts index 5bdd5ba..fe21174 100644 --- a/src/host/timeline.ts +++ b/src/host/timeline.ts @@ -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 diff --git a/src/shared/types.ts b/src/shared/types.ts index ed2d7c6..eafd178 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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[] /** diff --git a/tests/chart.spec.ts b/tests/chart.spec.ts index 94ef490..de4d640 100644 --- a/tests/chart.spec.ts +++ b/tests/chart.spec.ts @@ -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 diff --git a/tests/multimodal.spec.ts b/tests/multimodal.spec.ts index 8b66982..d4f69ef 100644 --- a/tests/multimodal.spec.ts +++ b/tests/multimodal.spec.ts @@ -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)') }) diff --git a/tests/resilience.spec.ts b/tests/resilience.spec.ts index b3ba62c..6724f05 100644 --- a/tests/resilience.spec.ts +++ b/tests/resilience.spec.ts @@ -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')