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:
1 parent
69c175e76a
commit
39554df985
10 files changed
+70
-20
No files matched your search
@@ -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>
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)')
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user