Skip to content

Commit 7c10029

Browse files
committed
feat(desktop): add session-wide token and time breakdowns to the trace panel
The Inspector overview answered "what did this session cost" with two figures — estimated cost and cache-hit rate — while token totals and elapsed time lived only in per-turn rows. The panel now opens with two donut charts: metered tokens split the way a bill reads (cache read / uncached input / output incl. reasoning) and recorded time split between LLM calls and tool executions. Each ring is linked to its legend in both directions — hovering either side holds that segment and dims the rest — with a hairline minimum sweep so tiny nonzero shares stay visible and a thin-line floor under the hovered segment. The token split reads the existing Session usage summary: uncached input is the `input − cacheRead` residual (providers that report only the cached share leave the ledger miss at zero), floored by the ledger miss when no prompt total was reported. The time split needs new aggregates: `UsageSummaryV2` gains optional `totalDurationMs` (summed attempt latency from both the legacy store and the canonical ledger) and `toolUsage` (session-scoped totals from a new `TelemetryRepo.toolSummary` over the tool-invocation ledger). Both ride the Runtime Host usage protocol as optional keys, so hosts that predate them keep decoding and the time ring is omitted rather than drawn as zeros. Generated-by: ZCode
1 parent c76fbda commit 7c10029

19 files changed

Lines changed: 1125 additions & 16 deletions
Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import assert from 'node:assert/strict';
21+
import { test } from 'node:test';
22+
import {
23+
deriveInspectorOverviewModel,
24+
RING_ACTIVE_MIN_SWEEP,
25+
RING_MIN_SWEEP,
26+
usageRingArcs,
27+
} from '../../renderer/features/workbar/testing.js';
28+
import type { UsageSummaryV2 } from '@maka/core/usage-stats/types';
29+
30+
function usageSummary(overrides: Partial<UsageSummaryV2> = {}): UsageSummaryV2 {
31+
return {
32+
range: { from: 0, to: 1 },
33+
totalRequests: 3,
34+
totalCostUsd: 0.02,
35+
totalTokens: {
36+
input: 4_000_000,
37+
output: 60_300,
38+
cacheMiss: 100_000,
39+
cacheRead: 3_900_000,
40+
cacheWrite: 0,
41+
reasoning: 12_000,
42+
total: 4_060_300,
43+
},
44+
cacheHitRequests: 2,
45+
cacheCreateRequests: 0,
46+
errorRequests: 0,
47+
...overrides,
48+
};
49+
}
50+
51+
test('splits the session metered tokens the way a bill reads', () => {
52+
const { tokenUsage } = deriveInspectorOverviewModel(
53+
undefined,
54+
usageSummary({ totalDurationMs: 1_885_000 }),
55+
);
56+
57+
assert.ok(tokenUsage);
58+
assert.deepEqual(
59+
tokenUsage.segments.map((segment) => [segment.kind, segment.tokens]),
60+
[
61+
['cacheRead', 3_900_000],
62+
['cacheMiss', 100_000],
63+
['output', 60_300],
64+
],
65+
);
66+
// The readout is the sum of the drawn rows, so the legend and its total
67+
// cannot disagree even when the ledger's own parts drifted.
68+
assert.equal(
69+
tokenUsage.total,
70+
tokenUsage.segments.reduce((carry, segment) => carry + segment.tokens, 0),
71+
);
72+
});
73+
74+
test('derives uncached input as the prompt residual when a provider reports only its cache', () => {
75+
// A cache-reading-only provider leaves the ledger's cacheMiss at zero; the
76+
// row must still carry what the session paid as uncached input.
77+
const { tokenUsage } = deriveInspectorOverviewModel(
78+
undefined,
79+
usageSummary({
80+
totalTokens: {
81+
input: 260_500,
82+
output: 60_300,
83+
cacheMiss: 0,
84+
cacheRead: 200_000,
85+
cacheWrite: 0,
86+
reasoning: 0,
87+
total: 320_800,
88+
},
89+
}),
90+
);
91+
92+
assert.deepEqual(
93+
tokenUsage?.segments.map((segment) => [segment.kind, segment.tokens]),
94+
[
95+
['cacheRead', 200_000],
96+
['cacheMiss', 60_500],
97+
['output', 60_300],
98+
],
99+
);
100+
});
101+
102+
test('keeps the ledger cacheMiss as a floor for records that reported no prompt total', () => {
103+
const { tokenUsage } = deriveInspectorOverviewModel(
104+
undefined,
105+
usageSummary({
106+
totalTokens: {
107+
input: 0,
108+
output: 100,
109+
cacheMiss: 500,
110+
cacheRead: 0,
111+
cacheWrite: 0,
112+
reasoning: 0,
113+
total: 100,
114+
},
115+
}),
116+
);
117+
118+
assert.deepEqual(
119+
tokenUsage?.segments.map((segment) => [segment.kind, segment.tokens]),
120+
[
121+
['cacheMiss', 500],
122+
['output', 100],
123+
],
124+
);
125+
});
126+
127+
test('a session with nothing metered has no token split to show', () => {
128+
const { tokenUsage } = deriveInspectorOverviewModel(
129+
undefined,
130+
usageSummary({
131+
totalTokens: {
132+
input: 0,
133+
output: 0,
134+
cacheMiss: 0,
135+
cacheRead: 0,
136+
cacheWrite: 0,
137+
reasoning: 0,
138+
total: 0,
139+
},
140+
}),
141+
);
142+
assert.equal(tokenUsage, undefined);
143+
assert.equal(deriveInspectorOverviewModel(undefined, undefined).tokenUsage, undefined);
144+
});
145+
146+
test('shows the token split even when usage coverage is partial, since the rows are what ran', () => {
147+
// The cache-hit RATE goes unavailable under partial usage — a rate over a
148+
// part is a lie. The split stays: its rows state what was recorded, which
149+
// only undercounts, never fabricates.
150+
const { tokenUsage, cacheHitRate } = deriveInspectorOverviewModel(undefined, {
151+
...usageSummary(),
152+
provenance: {
153+
coverage: {
154+
attempts: 3,
155+
pricedAttempts: 3,
156+
unpricedAttempts: 0,
157+
usageReportedAttempts: 2,
158+
usagePartialAttempts: 1,
159+
usageMissingAttempts: 0,
160+
},
161+
legacyRecords: 0,
162+
unreadableRecords: 0,
163+
pendingRepairs: 0,
164+
},
165+
});
166+
assert.ok(tokenUsage);
167+
assert.equal(cacheHitRate, undefined);
168+
});
169+
170+
test('splits recorded time between model calls and tool executions', () => {
171+
const { durationUsage } = deriveInspectorOverviewModel(
172+
undefined,
173+
usageSummary({ totalDurationMs: 1_873_000, toolUsage: { requests: 78, durationMs: 78_000 } }),
174+
);
175+
176+
assert.deepEqual(
177+
durationUsage?.segments.map((segment) => [segment.kind, segment.count, segment.durationMs]),
178+
[
179+
['model', 3, 1_873_000],
180+
['tool', 78, 78_000],
181+
],
182+
);
183+
assert.equal(durationUsage?.totalDurationMs, 1_873_000 + 78_000);
184+
});
185+
186+
test('a host from before duration reporting shows no time split rather than zeros', () => {
187+
const { durationUsage } = deriveInspectorOverviewModel(undefined, usageSummary());
188+
assert.equal(durationUsage, undefined);
189+
});
190+
191+
test('a tool row without a recorded duration still reports its count', () => {
192+
const { durationUsage } = deriveInspectorOverviewModel(
193+
undefined,
194+
usageSummary({ totalRequests: 2, toolUsage: { requests: 4, durationMs: 0 } }),
195+
);
196+
197+
assert.deepEqual(
198+
durationUsage?.segments.map((segment) => [segment.kind, segment.count, segment.durationMs]),
199+
[['tool', 4, 0]],
200+
);
201+
assert.equal(durationUsage?.totalDurationMs, 0);
202+
});
203+
204+
test('model time without tool usage reads as a single-segment split', () => {
205+
const { durationUsage } = deriveInspectorOverviewModel(
206+
undefined,
207+
usageSummary({ totalRequests: 5, totalDurationMs: 2_500 }),
208+
);
209+
210+
assert.deepEqual(
211+
durationUsage?.segments.map((segment) => [segment.kind, segment.count, segment.durationMs]),
212+
[['model', 5, 2_500]],
213+
);
214+
});
215+
216+
test('ring arcs keep reading order and clamp the last segment to the full turn', () => {
217+
const arcs = usageRingArcs(
218+
[
219+
{ kind: 'cacheRead' as const, amount: 1 },
220+
{ kind: 'cacheMiss' as const, amount: 1 },
221+
{ kind: 'output' as const, amount: 1 },
222+
],
223+
3,
224+
);
225+
assert.deepEqual(
226+
arcs.map((arc) => arc.kind),
227+
['cacheRead', 'cacheMiss', 'output'],
228+
);
229+
assert.equal(arcs[0]?.start, 0);
230+
// The last arc is clamped to the full turn rather than accumulated, so a
231+
// rounded share can never leave an unexplained sliver at the seam.
232+
assert.equal(arcs.at(-1)?.end, 1);
233+
for (const arc of arcs) {
234+
assert.ok(arc.end > arc.start);
235+
assert.match(arc.d, /^M [\d. ]+ A/);
236+
assert.match(arc.d, / Z$/);
237+
}
238+
});
239+
240+
test('a segment owning the whole ring walks two half-turns instead of one arc', () => {
241+
const arcs = usageRingArcs([{ kind: 'model' as const, amount: 2_500 }], 2_500);
242+
assert.equal(arcs.length, 1);
243+
assert.equal(arcs[0]?.start, 0);
244+
assert.equal(arcs[0]?.end, 1);
245+
// One arc cannot sweep 360°; two half arcs render the full donut.
246+
assert.equal(arcs[0]?.d.match(/ A /g)?.length, 4);
247+
});
248+
249+
test('a ring with nothing measured draws no arcs and leaves the muted track', () => {
250+
assert.equal(usageRingArcs([{ kind: 'tool' as const, amount: 0 }], 0).length, 0);
251+
assert.equal(usageRingArcs([], 100).length, 0);
252+
});
253+
254+
test('a nonzero sliver keeps a visible sweep even when its share rounds to zero', () => {
255+
const arcs = usageRingArcs(
256+
[
257+
{ kind: 'model' as const, amount: 77_000 },
258+
{ kind: 'tool' as const, amount: 35 },
259+
],
260+
77_035,
261+
);
262+
const tool = arcs[1];
263+
// The end-of-turn clamp re-derives the last sweep from the running cursor,
264+
// so compare with the usual floating-point courtesy.
265+
assert.ok(tool.end - tool.start >= RING_MIN_SWEEP - 1e-9);
266+
assert.equal(tool.end, 1);
267+
});
268+
269+
test('hovering a tiny segment expands it on the ring so the highlight lands somewhere', () => {
270+
const arcs = usageRingArcs(
271+
[
272+
{ kind: 'model' as const, amount: 77_000 },
273+
{ kind: 'tool' as const, amount: 35 },
274+
],
275+
77_035,
276+
'tool',
277+
);
278+
const tool = arcs[1];
279+
// The end-of-turn clamp re-derives the last sweep from the running cursor,
280+
// so compare with the usual floating-point courtesy.
281+
assert.ok(tool.end - tool.start >= RING_ACTIVE_MIN_SWEEP - 1e-9);
282+
// The cost of the focus floor comes out of the dominant share, and the
283+
// seam still closes on the full turn.
284+
assert.ok(arcs[0].end <= 1 - RING_ACTIVE_MIN_SWEEP + 1e-9);
285+
assert.equal(tool.end, 1);
286+
});
287+
288+
test('hovering the dominant segment leaves the layout untouched', () => {
289+
const arcs = usageRingArcs(
290+
[
291+
{ kind: 'model' as const, amount: 77_000 },
292+
{ kind: 'tool' as const, amount: 35 },
293+
],
294+
77_035,
295+
'model',
296+
);
297+
assert.ok(arcs[0].end >= 1 - RING_MIN_SWEEP);
298+
assert.equal(arcs[1].end, 1);
299+
});

apps/desktop/src/renderer/features/workbar/testing.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,13 @@ export * from './model/workbar-tool-definitions.js';
3232
export * from './tools/artifacts/artifact-list-keyboard.js';
3333
export * from './tools/artifacts/artifact-visibility.js';
3434
export * from './tools/inspector/session-inspector-panel-model.js';
35-
export { compactNumberFormatter, InspectorCompositionSection } from './tools/inspector/session-inspector-panel.js';
35+
export {
36+
compactNumberFormatter,
37+
InspectorCompositionSection,
38+
RING_ACTIVE_MIN_SWEEP,
39+
RING_MIN_SWEEP,
40+
usageRingArcs,
41+
} from './tools/inspector/session-inspector-panel.js';
3642
export * from './tools/inspector/session-inspector-overview-model.js';
3743
export * from './tools/side-chat/quote-companion-panel-state.js';
3844
export * from './tools/side-chat/quote-companion-core.js';

0 commit comments

Comments
 (0)