oc-metricboard 0.1.17 → 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assistant-progress.js +2 -2
- package/dist/collector.js +31 -4
- package/dist/event-bus.js +32 -0
- package/dist/event-handlers.js +39 -12
- package/dist/metrics.js +16 -0
- package/dist/tui-v2.js +17 -2
- package/dist/turn-state.js +63 -0
- package/package.json +1 -1
- package/src/assistant-progress.ts +2 -2
- package/src/collector.ts +29 -4
- package/src/event-bus.ts +30 -0
- package/src/event-handlers.ts +39 -12
- package/src/metrics.ts +16 -0
- package/src/tui-v2.tsx +17 -2
- package/src/turn-state.ts +70 -0
- package/src/types.ts +7 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { estimateTokens } from "./metrics";
|
|
2
2
|
import { parseAssistantTextDelta } from "./event-shapes";
|
|
3
3
|
import { currentRequest } from "./request-state";
|
|
4
|
-
import { eventAggregateID, eventID, eventProperties, stringEventProperty } from "./event-bus";
|
|
4
|
+
import { eventAggregateID, eventID, eventPerfTime, eventProperties, stringEventProperty } from "./event-bus";
|
|
5
5
|
import { recordLiveTokens } from "./live-speed";
|
|
6
6
|
import { ensureTurn, recordTurnFirstToken } from "./turn-state";
|
|
7
7
|
function partEstimateKey(sessionID, messageID, partID) {
|
|
@@ -51,7 +51,7 @@ export function applyAssistantDelta(ctx, event) {
|
|
|
51
51
|
sessionAliases.add(aggregateID);
|
|
52
52
|
state.sessionAliases.set(text.sessionID, sessionAliases);
|
|
53
53
|
}
|
|
54
|
-
const now = performance.now();
|
|
54
|
+
const now = eventPerfTime(event, performance.now());
|
|
55
55
|
actions.startSessionTiming(text.sessionID, now);
|
|
56
56
|
const key = partEstimateKey(text.sessionID, text.messageID, text.partID);
|
|
57
57
|
const deltaTokens = estimateTokens(text.delta, ctx.config.estimationRatio);
|
package/dist/collector.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { getDisplayInputTokens, getDisplayOutputTokens, getTtft } from "./metrics";
|
|
1
|
+
import { credibleTps, getDisplayInputTokens, getDisplayOutputTokens, getTtft, MAX_CREDIBLE_TPS } from "./metrics";
|
|
2
2
|
import { registerEventHandlers } from "./event-handlers";
|
|
3
3
|
import { hydrateSession, isHydrationApi, callWithFallback } from "./session-hydration";
|
|
4
4
|
import { createSessionTree } from "./session-tree";
|
|
5
5
|
import { getScopeElapsedMs, getSessionElapsedMs, startSessionTiming, stopSessionTiming } from "./session-timing";
|
|
6
6
|
import { clearLiveSpeed, getLiveTps } from "./live-speed";
|
|
7
|
-
import { getTurnTtft, liveRequestOutput, turnInputTokens } from "./turn-state";
|
|
7
|
+
import { getTurnTtft, liveRequestOutput, toolOverlapMs, turnInputTokens } from "./turn-state";
|
|
8
8
|
function isRecord(value) {
|
|
9
9
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
10
10
|
}
|
|
@@ -358,8 +358,21 @@ export function createCollector(api, config, log) {
|
|
|
358
358
|
: latest.isStreaming || latestLastActivity === null ? now : latestLastActivity;
|
|
359
359
|
const saneStart = genStart !== null && Number.isFinite(genStart) && genStart >= 0 && genStart <= now;
|
|
360
360
|
const saneEnd = genEnd !== null && Number.isFinite(genEnd) && genEnd >= 0 && genEnd <= now + 60_000;
|
|
361
|
+
let primary = null;
|
|
361
362
|
if (frTokens > 0 && saneStart && saneEnd && genEnd > genStart) {
|
|
362
|
-
|
|
363
|
+
primary = Math.round((frTokens / ((genEnd - genStart) / 1000)) * 10) / 10;
|
|
364
|
+
}
|
|
365
|
+
// Same turn-anchored fallback as the main aggregate, using the latest
|
|
366
|
+
// request's own turn.
|
|
367
|
+
const latestTurn = state.turns.get(latest.sessionID);
|
|
368
|
+
if ((primary === null || primary > MAX_CREDIBLE_TPS) && latestTurn && frTokens > 0) {
|
|
369
|
+
const turnEnd = completeTime ?? latestLastActivity ?? now;
|
|
370
|
+
const turnWindow = turnEnd - latestTurn.turnStartTime
|
|
371
|
+
- toolOverlapMs(latestTurn, latestTurn.turnStartTime, turnEnd);
|
|
372
|
+
displayTps = credibleTps(frTokens, turnWindow) ?? primary;
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
displayTps = primary;
|
|
363
376
|
}
|
|
364
377
|
}
|
|
365
378
|
result.push({
|
|
@@ -506,8 +519,22 @@ export function createCollector(api, config, log) {
|
|
|
506
519
|
: foregroundActive || foregroundLastActivity === null ? now : foregroundLastActivity;
|
|
507
520
|
const saneStart = genStart !== null && Number.isFinite(genStart) && genStart >= 0 && genStart <= now;
|
|
508
521
|
const saneEnd = genEnd !== null && Number.isFinite(genEnd) && genEnd >= 0 && genEnd <= now + 60_000;
|
|
522
|
+
let primary = null;
|
|
509
523
|
if (frTokens > 0 && saneStart && saneEnd && genEnd > genStart) {
|
|
510
|
-
|
|
524
|
+
primary = Math.round((frTokens / ((genEnd - genStart) / 1000)) * 10) / 10;
|
|
525
|
+
}
|
|
526
|
+
// Fall back to the turn-anchored window when the per-step window
|
|
527
|
+
// implies a physically incredible rate: per-delta times may be
|
|
528
|
+
// delivery-stamped (batched flushes), while turn admission, step
|
|
529
|
+
// boundaries, and tool spans use discrete truthful anchors.
|
|
530
|
+
if ((primary === null || primary > MAX_CREDIBLE_TPS) && foregroundTurn && frTokens > 0) {
|
|
531
|
+
const turnEnd = completeTime ?? foregroundLastActivity ?? now;
|
|
532
|
+
const turnWindow = turnEnd - foregroundTurn.turnStartTime
|
|
533
|
+
- toolOverlapMs(foregroundTurn, foregroundTurn.turnStartTime, turnEnd);
|
|
534
|
+
displayTps = credibleTps(frTokens, turnWindow) ?? primary;
|
|
535
|
+
}
|
|
536
|
+
else {
|
|
537
|
+
displayTps = primary;
|
|
511
538
|
}
|
|
512
539
|
}
|
|
513
540
|
// NEW: Build per-model breakdown for tree scope
|
package/dist/event-bus.js
CHANGED
|
@@ -51,6 +51,38 @@ export function statusType(event) {
|
|
|
51
51
|
return "";
|
|
52
52
|
return typeof status.type === "string" ? status.type : "";
|
|
53
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Host-stamped wall-clock time of an event (V2 envelope `created`, epoch ms),
|
|
56
|
+
* or null when absent/implausible. V1 events carry no timestamp at all.
|
|
57
|
+
*/
|
|
58
|
+
export function eventCreatedWallTime(event) {
|
|
59
|
+
if (!isRecord(event))
|
|
60
|
+
return null;
|
|
61
|
+
const created = event.created;
|
|
62
|
+
if (typeof created !== "number" || !Number.isFinite(created) || created <= 0)
|
|
63
|
+
return null;
|
|
64
|
+
// Guard against a foreign epoch or clock skew: anything more than a day out
|
|
65
|
+
// is not usable as event time.
|
|
66
|
+
if (Math.abs(created - Date.now()) > 86_400_000)
|
|
67
|
+
return null;
|
|
68
|
+
return created;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Performance-base timestamp for an event. Prefers the host stamp converted
|
|
72
|
+
* into this process's performance.now() base; falls back to `now`.
|
|
73
|
+
*
|
|
74
|
+
* WHY: the V2 host delivers events in batches — a whole turn's deltas can
|
|
75
|
+
* arrive in one flush long after generation. Stamping flush time
|
|
76
|
+
* (performance.now() at handling) collapses the generation window to
|
|
77
|
+
* milliseconds and inflates frozen TPS absurdly (e.g. 259 tokens / 6ms =
|
|
78
|
+
* 42627 t/s). V1 events have no `created`, so this is a no-op for V1.
|
|
79
|
+
*/
|
|
80
|
+
export function eventPerfTime(event, now) {
|
|
81
|
+
const wall = eventCreatedWallTime(event);
|
|
82
|
+
if (wall === null)
|
|
83
|
+
return now;
|
|
84
|
+
return now - Math.max(0, Date.now() - wall);
|
|
85
|
+
}
|
|
54
86
|
export function isMetricsEventApi(value) {
|
|
55
87
|
if (!isRecord(value))
|
|
56
88
|
return false;
|
package/dist/event-handlers.js
CHANGED
|
@@ -3,9 +3,9 @@ import { createFreshMetrics, estimateTokens } from "./metrics";
|
|
|
3
3
|
import { parseAssistantMessage, parseAssistantProgressPart, parseAssistantStepEnded, parseAssistantStepStarted, parseSessionInfo, parseTextPart, parseUserMessage, } from "./event-shapes";
|
|
4
4
|
import { applyAssistantTokens, applySessionModel, hasPositiveAssistantTokens, mergeAssistantModel } from "./request-updates";
|
|
5
5
|
import { completeRequest, currentRequest } from "./request-state";
|
|
6
|
-
import { eventAggregateID, eventID, eventProperties, eventProperty, statusType, stringEventProperty } from "./event-bus";
|
|
6
|
+
import { eventAggregateID, eventID, eventPerfTime, eventProperties, eventProperty, statusType, stringEventProperty } from "./event-bus";
|
|
7
7
|
import { clearLiveSpeed, resetLiveSpeed } from "./live-speed";
|
|
8
|
-
import { ensureTurn, retireRequestIntoTurn, startTurn } from "./turn-state";
|
|
8
|
+
import { ensureTurn, recordToolCalled, recordToolSettled, retireRequestIntoTurn, startTurn } from "./turn-state";
|
|
9
9
|
export function registerEventHandlers(ctx) {
|
|
10
10
|
const { api, config, log, state, actions } = ctx;
|
|
11
11
|
const disposers = [];
|
|
@@ -86,7 +86,7 @@ export function registerEventHandlers(ctx) {
|
|
|
86
86
|
const type = statusType(event);
|
|
87
87
|
log(`session.status: ${sessionID} -> ${type}`);
|
|
88
88
|
if (type === "busy") {
|
|
89
|
-
const now = performance.now();
|
|
89
|
+
const now = eventPerfTime(event, performance.now());
|
|
90
90
|
actions.startSessionTiming(sessionID, now);
|
|
91
91
|
actions.clearHoldTimer(sessionID);
|
|
92
92
|
const existingTurn = state.turns.get(sessionID);
|
|
@@ -108,7 +108,7 @@ export function registerEventHandlers(ctx) {
|
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
if (type === "idle") {
|
|
111
|
-
completeRequest({ state, actions, sessionID, now: performance.now(), holdDurationMs: config.holdDurationMs, log });
|
|
111
|
+
completeRequest({ state, actions, sessionID, now: eventPerfTime(event, performance.now()), holdDurationMs: config.holdDurationMs, log });
|
|
112
112
|
}
|
|
113
113
|
});
|
|
114
114
|
on("session.idle", (event) => {
|
|
@@ -116,7 +116,7 @@ export function registerEventHandlers(ctx) {
|
|
|
116
116
|
if (sessionID.length === 0)
|
|
117
117
|
return;
|
|
118
118
|
recordEventAlias(event, sessionID);
|
|
119
|
-
completeRequest({ state, actions, sessionID, now: performance.now(), holdDurationMs: config.holdDurationMs, log });
|
|
119
|
+
completeRequest({ state, actions, sessionID, now: eventPerfTime(event, performance.now()), holdDurationMs: config.holdDurationMs, log });
|
|
120
120
|
});
|
|
121
121
|
// ── message.part.delta ──
|
|
122
122
|
on("message.part.delta", (event) => {
|
|
@@ -130,8 +130,8 @@ export function registerEventHandlers(ctx) {
|
|
|
130
130
|
recordEventAlias(event, sessionID);
|
|
131
131
|
if (field !== "text")
|
|
132
132
|
return;
|
|
133
|
-
actions.startSessionTiming(sessionID, performance.now());
|
|
134
|
-
const now = performance.now();
|
|
133
|
+
actions.startSessionTiming(sessionID, eventPerfTime(event, performance.now()));
|
|
134
|
+
const now = eventPerfTime(event, performance.now());
|
|
135
135
|
ensureTurn(state.turns, sessionID, now);
|
|
136
136
|
applyAssistantPartDelta(ctx, sessionID, messageID, partID, delta, now);
|
|
137
137
|
});
|
|
@@ -140,7 +140,7 @@ export function registerEventHandlers(ctx) {
|
|
|
140
140
|
if (!step)
|
|
141
141
|
return;
|
|
142
142
|
recordEventAlias(event, step.sessionID);
|
|
143
|
-
const now = performance.now();
|
|
143
|
+
const now = eventPerfTime(event, performance.now());
|
|
144
144
|
actions.startSessionTiming(step.sessionID, now);
|
|
145
145
|
const existingTurn = state.turns.get(step.sessionID);
|
|
146
146
|
if (!existingTurn || existingTurn.isComplete)
|
|
@@ -157,6 +157,33 @@ export function registerEventHandlers(ctx) {
|
|
|
157
157
|
});
|
|
158
158
|
on("session.next.text.delta", (event) => applyAssistantDelta(ctx, event));
|
|
159
159
|
on("session.next.reasoning.delta", (event) => applyAssistantDelta(ctx, event));
|
|
160
|
+
// ── tool execution spans (V2 tool.* events; V1 has none) ──
|
|
161
|
+
// Feeds per-turn tool intervals so frozen TPS can subtract tool time from
|
|
162
|
+
// the turn-anchored window. Unknown shapes are ignored defensively.
|
|
163
|
+
on("session.tool.called", (event) => {
|
|
164
|
+
const sessionID = stringEventProperty(event, "sessionID");
|
|
165
|
+
if (sessionID.length === 0)
|
|
166
|
+
return;
|
|
167
|
+
recordEventAlias(event, sessionID);
|
|
168
|
+
recordToolCalled(state.turns, sessionID, stringEventProperty(event, "id"), eventPerfTime(event, performance.now()));
|
|
169
|
+
actions.notify();
|
|
170
|
+
});
|
|
171
|
+
on("session.tool.success", (event) => {
|
|
172
|
+
const sessionID = stringEventProperty(event, "sessionID");
|
|
173
|
+
if (sessionID.length === 0)
|
|
174
|
+
return;
|
|
175
|
+
recordEventAlias(event, sessionID);
|
|
176
|
+
recordToolSettled(state.turns, sessionID, stringEventProperty(event, "id"), eventPerfTime(event, performance.now()));
|
|
177
|
+
actions.notify();
|
|
178
|
+
});
|
|
179
|
+
on("session.tool.failed", (event) => {
|
|
180
|
+
const sessionID = stringEventProperty(event, "sessionID");
|
|
181
|
+
if (sessionID.length === 0)
|
|
182
|
+
return;
|
|
183
|
+
recordEventAlias(event, sessionID);
|
|
184
|
+
recordToolSettled(state.turns, sessionID, stringEventProperty(event, "id"), eventPerfTime(event, performance.now()));
|
|
185
|
+
actions.notify();
|
|
186
|
+
});
|
|
160
187
|
on("session.next.step.ended", (event) => {
|
|
161
188
|
const sessionID = stringEventProperty(event, "sessionID");
|
|
162
189
|
const fallbackMessageID = sessionID ? state.assistantMessageIds.get(sessionID) ?? eventID(event) : eventID(event);
|
|
@@ -164,7 +191,7 @@ export function registerEventHandlers(ctx) {
|
|
|
164
191
|
if (!step)
|
|
165
192
|
return;
|
|
166
193
|
recordEventAlias(event, step.sessionID);
|
|
167
|
-
const now = performance.now();
|
|
194
|
+
const now = eventPerfTime(event, performance.now());
|
|
168
195
|
actions.startSessionTiming(step.sessionID, now);
|
|
169
196
|
const turn = ensureTurn(state.turns, step.sessionID, now);
|
|
170
197
|
const existing = state.requests.get(step.sessionID);
|
|
@@ -205,7 +232,7 @@ export function registerEventHandlers(ctx) {
|
|
|
205
232
|
recordEventAlias(event, sessionID);
|
|
206
233
|
const rawPart = eventProperty(event, "part");
|
|
207
234
|
const part = parseTextPart(rawPart);
|
|
208
|
-
const now = performance.now();
|
|
235
|
+
const now = eventPerfTime(event, performance.now());
|
|
209
236
|
const msgIds = state.userMessageIds.get(sessionID);
|
|
210
237
|
if (part && msgIds?.has(part.messageID)) {
|
|
211
238
|
const current = state.requests.get(sessionID);
|
|
@@ -235,7 +262,7 @@ export function registerEventHandlers(ctx) {
|
|
|
235
262
|
const nextModel = mergeAssistantModel(state.sessionModels.get(sessionID), assistant);
|
|
236
263
|
if (nextModel)
|
|
237
264
|
state.sessionModels.set(sessionID, nextModel);
|
|
238
|
-
const now = performance.now();
|
|
265
|
+
const now = eventPerfTime(event, performance.now());
|
|
239
266
|
const tokens = assistant.tokens;
|
|
240
267
|
const existing = state.requests.get(sessionID);
|
|
241
268
|
const turn = ensureTurn(state.turns, sessionID, now);
|
|
@@ -278,7 +305,7 @@ export function registerEventHandlers(ctx) {
|
|
|
278
305
|
if (userMsgID && state.userMessageIds.get(sessionID)?.has(userMsgID)) {
|
|
279
306
|
return;
|
|
280
307
|
}
|
|
281
|
-
const now = performance.now();
|
|
308
|
+
const now = eventPerfTime(event, performance.now());
|
|
282
309
|
const previous = state.requests.get(sessionID);
|
|
283
310
|
const previousTurn = state.turns.get(sessionID);
|
|
284
311
|
if (previous && previousTurn)
|
package/dist/metrics.js
CHANGED
|
@@ -113,6 +113,22 @@ export function getTtft(m) {
|
|
|
113
113
|
return null;
|
|
114
114
|
return gateTtft(m.firstTokenTime - m.requestStartTime);
|
|
115
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* Upper bound for a believable token rate. Mirrors the live-TPS sanity gate:
|
|
118
|
+
* per-delta times may be delivery-stamped (batched flushes), collapsing the
|
|
119
|
+
* generation window to milliseconds and inflating the quotient absurdly
|
|
120
|
+
* (e.g. 61 tokens / 42ms = 1450 t/s — physically impossible decoding).
|
|
121
|
+
* Windows implying a faster rate fall back to turn-anchored timing.
|
|
122
|
+
*/
|
|
123
|
+
export const MAX_CREDIBLE_TPS = 300;
|
|
124
|
+
export function credibleTps(tokens, windowMs) {
|
|
125
|
+
if (!(tokens > 0) || !(windowMs > 0))
|
|
126
|
+
return null;
|
|
127
|
+
const tps = Math.round((tokens / (windowMs / 1000)) * 10) / 10;
|
|
128
|
+
if (!Number.isFinite(tps) || tps < 0 || tps > MAX_CREDIBLE_TPS)
|
|
129
|
+
return null;
|
|
130
|
+
return tps;
|
|
131
|
+
}
|
|
116
132
|
/**
|
|
117
133
|
* Calculate average token speed (tokens/second).
|
|
118
134
|
* Based on cumulative streaming time (now - firstTokenTime).
|
package/dist/tui-v2.js
CHANGED
|
@@ -64,6 +64,9 @@ const V2_EVENT_MAP = {
|
|
|
64
64
|
"session.next.reasoning.delta": "session.reasoning.delta",
|
|
65
65
|
"session.next.step.started": "session.step.started",
|
|
66
66
|
"session.next.step.ended": "session.step.ended",
|
|
67
|
+
"session.tool.called": "session.tool.called",
|
|
68
|
+
"session.tool.success": "session.tool.success",
|
|
69
|
+
"session.tool.failed": "session.tool.failed",
|
|
67
70
|
};
|
|
68
71
|
function isRecord(value) {
|
|
69
72
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -81,7 +84,14 @@ export function translateExecutionStartedToBusy(event) {
|
|
|
81
84
|
const sessionID = str(data.sessionID);
|
|
82
85
|
if (!sessionID)
|
|
83
86
|
return null;
|
|
84
|
-
|
|
87
|
+
// Preserve the host stamp so downstream timing uses generation time,
|
|
88
|
+
// not the (possibly batched) delivery time.
|
|
89
|
+
const created = typeof event.created === "number" ? event.created : undefined;
|
|
90
|
+
return {
|
|
91
|
+
type: "session.status",
|
|
92
|
+
...(created !== undefined ? { created } : {}),
|
|
93
|
+
properties: { sessionID, status: { type: "busy" } },
|
|
94
|
+
};
|
|
85
95
|
}
|
|
86
96
|
/**
|
|
87
97
|
* Turn-settled execution maps to the V1 `session.idle` shape that drives
|
|
@@ -93,7 +103,12 @@ export function translateExecutionSettledToIdle(event) {
|
|
|
93
103
|
const sessionID = str(data.sessionID);
|
|
94
104
|
if (!sessionID)
|
|
95
105
|
return null;
|
|
96
|
-
|
|
106
|
+
const created = typeof event.created === "number" ? event.created : undefined;
|
|
107
|
+
return {
|
|
108
|
+
type: "session.idle",
|
|
109
|
+
...(created !== undefined ? { created } : {}),
|
|
110
|
+
properties: { sessionID },
|
|
111
|
+
};
|
|
97
112
|
}
|
|
98
113
|
function eventSessionID(event) {
|
|
99
114
|
if (!isRecord(event))
|
package/dist/turn-state.js
CHANGED
|
@@ -12,6 +12,7 @@ export function createTurnMetrics(sessionID, now) {
|
|
|
12
12
|
hasStickyContextTokens: false,
|
|
13
13
|
stickyCacheReadTokens: 0,
|
|
14
14
|
hasStickyCacheReadTokens: false,
|
|
15
|
+
toolIntervals: [],
|
|
15
16
|
isComplete: false,
|
|
16
17
|
};
|
|
17
18
|
}
|
|
@@ -78,6 +79,68 @@ export function recordTurnFirstToken(turn, now) {
|
|
|
78
79
|
if (turn.firstTokenTime === null)
|
|
79
80
|
turn.firstTokenTime = now;
|
|
80
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Record a tool execution span on the session's turn. Tools in one turn may
|
|
84
|
+
* overlap (parallel calls), so every span is kept and the union is computed
|
|
85
|
+
* at query time — a plain sum would double-count overlaps.
|
|
86
|
+
*/
|
|
87
|
+
export function recordToolCalled(turns, sessionID, toolID, now) {
|
|
88
|
+
const turn = ensureTurn(turns, sessionID, now);
|
|
89
|
+
turn.toolIntervals.push({ id: toolID, start: now, end: null });
|
|
90
|
+
}
|
|
91
|
+
export function recordToolSettled(turns, sessionID, toolID, now) {
|
|
92
|
+
const turn = turns.get(sessionID);
|
|
93
|
+
if (!turn)
|
|
94
|
+
return;
|
|
95
|
+
if (toolID) {
|
|
96
|
+
const open = turn.toolIntervals.find((interval) => interval.id === toolID && interval.end === null);
|
|
97
|
+
if (open) {
|
|
98
|
+
open.end = Math.max(now, open.start);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Unknown id (or unsettled shape): close the most recent open span so a
|
|
103
|
+
// lost settle event cannot leak an infinite interval.
|
|
104
|
+
for (let i = turn.toolIntervals.length - 1; i >= 0; i--) {
|
|
105
|
+
if (turn.toolIntervals[i].end === null) {
|
|
106
|
+
turn.toolIntervals[i].end = Math.max(now, turn.toolIntervals[i].start);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Milliseconds of tool execution overlapping [from, to], unioned so parallel
|
|
113
|
+
* tool calls count once. Open spans are clamped at `to`.
|
|
114
|
+
*/
|
|
115
|
+
export function toolOverlapMs(turn, from, to) {
|
|
116
|
+
if (!turn || to <= from)
|
|
117
|
+
return 0;
|
|
118
|
+
const spans = [];
|
|
119
|
+
for (const interval of turn.toolIntervals) {
|
|
120
|
+
const start = Math.max(interval.start, from);
|
|
121
|
+
const end = Math.min(interval.end ?? to, to);
|
|
122
|
+
if (end > start)
|
|
123
|
+
spans.push({ start, end });
|
|
124
|
+
}
|
|
125
|
+
spans.sort((a, b) => a.start - b.start);
|
|
126
|
+
let total = 0;
|
|
127
|
+
let cursor = null;
|
|
128
|
+
let cursorEnd = 0;
|
|
129
|
+
for (const span of spans) {
|
|
130
|
+
if (cursor === null || span.start > cursorEnd) {
|
|
131
|
+
if (cursor !== null)
|
|
132
|
+
total += cursorEnd - cursor;
|
|
133
|
+
cursor = span.start;
|
|
134
|
+
cursorEnd = span.end;
|
|
135
|
+
}
|
|
136
|
+
else if (span.end > cursorEnd) {
|
|
137
|
+
cursorEnd = span.end;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (cursor !== null)
|
|
141
|
+
total += cursorEnd - cursor;
|
|
142
|
+
return total;
|
|
143
|
+
}
|
|
81
144
|
/**
|
|
82
145
|
* Turn-level TTFT (user message → first token), gated. Falls back to the
|
|
83
146
|
* supplied per-request TTFT when the turn has no first-token timing. This is
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { estimateTokens } from "./metrics"
|
|
2
2
|
import { parseAssistantTextDelta } from "./event-shapes"
|
|
3
3
|
import { currentRequest } from "./request-state"
|
|
4
|
-
import { eventAggregateID, eventID, eventProperties, stringEventProperty } from "./event-bus"
|
|
4
|
+
import { eventAggregateID, eventID, eventPerfTime, eventProperties, stringEventProperty } from "./event-bus"
|
|
5
5
|
import type { EventHandlerContext } from "./collector-state"
|
|
6
6
|
import { recordLiveTokens } from "./live-speed"
|
|
7
7
|
import { ensureTurn, recordTurnFirstToken } from "./turn-state"
|
|
@@ -78,7 +78,7 @@ export function applyAssistantDelta(ctx: EventHandlerContext, event: unknown): v
|
|
|
78
78
|
sessionAliases.add(aggregateID)
|
|
79
79
|
state.sessionAliases.set(text.sessionID, sessionAliases)
|
|
80
80
|
}
|
|
81
|
-
const now = performance.now()
|
|
81
|
+
const now = eventPerfTime(event, performance.now())
|
|
82
82
|
actions.startSessionTiming(text.sessionID, now)
|
|
83
83
|
const key = partEstimateKey(text.sessionID, text.messageID, text.partID)
|
|
84
84
|
const deltaTokens = estimateTokens(text.delta, ctx.config.estimationRatio)
|
package/src/collector.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
|
|
2
2
|
import type { BarConfig, CacheReadCompleteness, MetricsAggregate, MetricsScope, ModelMetrics, RequestMetrics } from "./types"
|
|
3
|
-
import { getDisplayInputTokens, getDisplayOutputTokens, getTtft } from "./metrics"
|
|
3
|
+
import { credibleTps, getDisplayInputTokens, getDisplayOutputTokens, getTtft, MAX_CREDIBLE_TPS } from "./metrics"
|
|
4
4
|
import { registerEventHandlers } from "./event-handlers"
|
|
5
5
|
import type { CollectorState } from "./collector-state"
|
|
6
6
|
import type { MetricsEventApi } from "./event-bus"
|
|
@@ -8,7 +8,7 @@ import { hydrateSession, isHydrationApi, callWithFallback, type HydrationApi } f
|
|
|
8
8
|
import { createSessionTree } from "./session-tree"
|
|
9
9
|
import { getScopeElapsedMs, getSessionElapsedMs, startSessionTiming, stopSessionTiming } from "./session-timing"
|
|
10
10
|
import { clearLiveSpeed, getLiveTps } from "./live-speed"
|
|
11
|
-
import { getTurnTtft, liveRequestOutput, turnInputTokens } from "./turn-state"
|
|
11
|
+
import { getTurnTtft, liveRequestOutput, toolOverlapMs, turnInputTokens } from "./turn-state"
|
|
12
12
|
|
|
13
13
|
export type MetricsListener = () => void
|
|
14
14
|
type MetricsHydrationApi = MetricsEventApi & HydrationApi
|
|
@@ -426,8 +426,20 @@ export function createCollector(
|
|
|
426
426
|
: latest.isStreaming || latestLastActivity === null ? now : latestLastActivity
|
|
427
427
|
const saneStart = genStart !== null && Number.isFinite(genStart) && genStart >= 0 && genStart <= now
|
|
428
428
|
const saneEnd = genEnd !== null && Number.isFinite(genEnd) && genEnd >= 0 && genEnd <= now + 60_000
|
|
429
|
+
let primary: number | null = null
|
|
429
430
|
if (frTokens > 0 && saneStart && saneEnd && genEnd > genStart) {
|
|
430
|
-
|
|
431
|
+
primary = Math.round((frTokens / ((genEnd - genStart) / 1000)) * 10) / 10
|
|
432
|
+
}
|
|
433
|
+
// Same turn-anchored fallback as the main aggregate, using the latest
|
|
434
|
+
// request's own turn.
|
|
435
|
+
const latestTurn = state.turns.get(latest.sessionID)
|
|
436
|
+
if ((primary === null || primary > MAX_CREDIBLE_TPS) && latestTurn && frTokens > 0) {
|
|
437
|
+
const turnEnd = completeTime ?? latestLastActivity ?? now
|
|
438
|
+
const turnWindow = turnEnd - latestTurn.turnStartTime
|
|
439
|
+
- toolOverlapMs(latestTurn, latestTurn.turnStartTime, turnEnd)
|
|
440
|
+
displayTps = credibleTps(frTokens, turnWindow) ?? primary
|
|
441
|
+
} else {
|
|
442
|
+
displayTps = primary
|
|
431
443
|
}
|
|
432
444
|
}
|
|
433
445
|
|
|
@@ -580,8 +592,21 @@ export function createCollector(
|
|
|
580
592
|
: foregroundActive || foregroundLastActivity === null ? now : foregroundLastActivity
|
|
581
593
|
const saneStart = genStart !== null && Number.isFinite(genStart) && genStart >= 0 && genStart <= now
|
|
582
594
|
const saneEnd = genEnd !== null && Number.isFinite(genEnd) && genEnd >= 0 && genEnd <= now + 60_000
|
|
595
|
+
let primary: number | null = null
|
|
583
596
|
if (frTokens > 0 && saneStart && saneEnd && genEnd > genStart) {
|
|
584
|
-
|
|
597
|
+
primary = Math.round((frTokens / ((genEnd - genStart) / 1000)) * 10) / 10
|
|
598
|
+
}
|
|
599
|
+
// Fall back to the turn-anchored window when the per-step window
|
|
600
|
+
// implies a physically incredible rate: per-delta times may be
|
|
601
|
+
// delivery-stamped (batched flushes), while turn admission, step
|
|
602
|
+
// boundaries, and tool spans use discrete truthful anchors.
|
|
603
|
+
if ((primary === null || primary > MAX_CREDIBLE_TPS) && foregroundTurn && frTokens > 0) {
|
|
604
|
+
const turnEnd = completeTime ?? foregroundLastActivity ?? now
|
|
605
|
+
const turnWindow = turnEnd - foregroundTurn.turnStartTime
|
|
606
|
+
- toolOverlapMs(foregroundTurn, foregroundTurn.turnStartTime, turnEnd)
|
|
607
|
+
displayTps = credibleTps(frTokens, turnWindow) ?? primary
|
|
608
|
+
} else {
|
|
609
|
+
displayTps = primary
|
|
585
610
|
}
|
|
586
611
|
}
|
|
587
612
|
|
package/src/event-bus.ts
CHANGED
|
@@ -54,6 +54,36 @@ export function statusType(event: unknown): string {
|
|
|
54
54
|
return typeof status.type === "string" ? status.type : ""
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Host-stamped wall-clock time of an event (V2 envelope `created`, epoch ms),
|
|
59
|
+
* or null when absent/implausible. V1 events carry no timestamp at all.
|
|
60
|
+
*/
|
|
61
|
+
export function eventCreatedWallTime(event: unknown): number | null {
|
|
62
|
+
if (!isRecord(event)) return null
|
|
63
|
+
const created = event.created
|
|
64
|
+
if (typeof created !== "number" || !Number.isFinite(created) || created <= 0) return null
|
|
65
|
+
// Guard against a foreign epoch or clock skew: anything more than a day out
|
|
66
|
+
// is not usable as event time.
|
|
67
|
+
if (Math.abs(created - Date.now()) > 86_400_000) return null
|
|
68
|
+
return created
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Performance-base timestamp for an event. Prefers the host stamp converted
|
|
73
|
+
* into this process's performance.now() base; falls back to `now`.
|
|
74
|
+
*
|
|
75
|
+
* WHY: the V2 host delivers events in batches — a whole turn's deltas can
|
|
76
|
+
* arrive in one flush long after generation. Stamping flush time
|
|
77
|
+
* (performance.now() at handling) collapses the generation window to
|
|
78
|
+
* milliseconds and inflates frozen TPS absurdly (e.g. 259 tokens / 6ms =
|
|
79
|
+
* 42627 t/s). V1 events have no `created`, so this is a no-op for V1.
|
|
80
|
+
*/
|
|
81
|
+
export function eventPerfTime(event: unknown, now: number): number {
|
|
82
|
+
const wall = eventCreatedWallTime(event)
|
|
83
|
+
if (wall === null) return now
|
|
84
|
+
return now - Math.max(0, Date.now() - wall)
|
|
85
|
+
}
|
|
86
|
+
|
|
57
87
|
export function isMetricsEventApi(value: unknown): value is MetricsEventApi {
|
|
58
88
|
if (!isRecord(value)) return false
|
|
59
89
|
const event = value.event
|
package/src/event-handlers.ts
CHANGED
|
@@ -11,10 +11,10 @@ import {
|
|
|
11
11
|
} from "./event-shapes"
|
|
12
12
|
import { applyAssistantTokens, applySessionModel, hasPositiveAssistantTokens, mergeAssistantModel } from "./request-updates"
|
|
13
13
|
import { completeRequest, currentRequest } from "./request-state"
|
|
14
|
-
import { eventAggregateID, eventID, eventProperties, eventProperty, statusType, stringEventProperty } from "./event-bus"
|
|
14
|
+
import { eventAggregateID, eventID, eventPerfTime, eventProperties, eventProperty, statusType, stringEventProperty } from "./event-bus"
|
|
15
15
|
import type { EventHandlerContext } from "./collector-state"
|
|
16
16
|
import { clearLiveSpeed, resetLiveSpeed } from "./live-speed"
|
|
17
|
-
import { ensureTurn, retireRequestIntoTurn, startTurn } from "./turn-state"
|
|
17
|
+
import { ensureTurn, recordToolCalled, recordToolSettled, retireRequestIntoTurn, startTurn } from "./turn-state"
|
|
18
18
|
|
|
19
19
|
export function registerEventHandlers(ctx: EventHandlerContext): Array<() => void> {
|
|
20
20
|
const { api, config, log, state, actions } = ctx
|
|
@@ -97,7 +97,7 @@ export function registerEventHandlers(ctx: EventHandlerContext): Array<() => voi
|
|
|
97
97
|
log(`session.status: ${sessionID} -> ${type}`)
|
|
98
98
|
|
|
99
99
|
if (type === "busy") {
|
|
100
|
-
const now = performance.now()
|
|
100
|
+
const now = eventPerfTime(event, performance.now())
|
|
101
101
|
actions.startSessionTiming(sessionID, now)
|
|
102
102
|
actions.clearHoldTimer(sessionID)
|
|
103
103
|
const existingTurn = state.turns.get(sessionID)
|
|
@@ -118,7 +118,7 @@ export function registerEventHandlers(ctx: EventHandlerContext): Array<() => voi
|
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
if (type === "idle") {
|
|
121
|
-
completeRequest({ state, actions, sessionID, now: performance.now(), holdDurationMs: config.holdDurationMs, log })
|
|
121
|
+
completeRequest({ state, actions, sessionID, now: eventPerfTime(event, performance.now()), holdDurationMs: config.holdDurationMs, log })
|
|
122
122
|
}
|
|
123
123
|
})
|
|
124
124
|
|
|
@@ -126,7 +126,7 @@ export function registerEventHandlers(ctx: EventHandlerContext): Array<() => voi
|
|
|
126
126
|
const sessionID = stringEventProperty(event, "sessionID")
|
|
127
127
|
if (sessionID.length === 0) return
|
|
128
128
|
recordEventAlias(event, sessionID)
|
|
129
|
-
completeRequest({ state, actions, sessionID, now: performance.now(), holdDurationMs: config.holdDurationMs, log })
|
|
129
|
+
completeRequest({ state, actions, sessionID, now: eventPerfTime(event, performance.now()), holdDurationMs: config.holdDurationMs, log })
|
|
130
130
|
})
|
|
131
131
|
|
|
132
132
|
// ── message.part.delta ──
|
|
@@ -139,9 +139,9 @@ export function registerEventHandlers(ctx: EventHandlerContext): Array<() => voi
|
|
|
139
139
|
if (sessionID.length === 0 || messageID.length === 0) return
|
|
140
140
|
recordEventAlias(event, sessionID)
|
|
141
141
|
if (field !== "text") return
|
|
142
|
-
actions.startSessionTiming(sessionID, performance.now())
|
|
142
|
+
actions.startSessionTiming(sessionID, eventPerfTime(event, performance.now()))
|
|
143
143
|
|
|
144
|
-
const now = performance.now()
|
|
144
|
+
const now = eventPerfTime(event, performance.now())
|
|
145
145
|
ensureTurn(state.turns, sessionID, now)
|
|
146
146
|
applyAssistantPartDelta(ctx, sessionID, messageID, partID, delta, now)
|
|
147
147
|
})
|
|
@@ -150,7 +150,7 @@ export function registerEventHandlers(ctx: EventHandlerContext): Array<() => voi
|
|
|
150
150
|
const step = parseAssistantStepStarted(eventProperties(event), eventID(event))
|
|
151
151
|
if (!step) return
|
|
152
152
|
recordEventAlias(event, step.sessionID)
|
|
153
|
-
const now = performance.now()
|
|
153
|
+
const now = eventPerfTime(event, performance.now())
|
|
154
154
|
actions.startSessionTiming(step.sessionID, now)
|
|
155
155
|
const existingTurn = state.turns.get(step.sessionID)
|
|
156
156
|
if (!existingTurn || existingTurn.isComplete) startTurn(state.turns, step.sessionID, now)
|
|
@@ -169,13 +169,40 @@ export function registerEventHandlers(ctx: EventHandlerContext): Array<() => voi
|
|
|
169
169
|
|
|
170
170
|
on("session.next.reasoning.delta", (event) => applyAssistantDelta(ctx, event))
|
|
171
171
|
|
|
172
|
+
// ── tool execution spans (V2 tool.* events; V1 has none) ──
|
|
173
|
+
// Feeds per-turn tool intervals so frozen TPS can subtract tool time from
|
|
174
|
+
// the turn-anchored window. Unknown shapes are ignored defensively.
|
|
175
|
+
on("session.tool.called", (event) => {
|
|
176
|
+
const sessionID = stringEventProperty(event, "sessionID")
|
|
177
|
+
if (sessionID.length === 0) return
|
|
178
|
+
recordEventAlias(event, sessionID)
|
|
179
|
+
recordToolCalled(state.turns, sessionID, stringEventProperty(event, "id"), eventPerfTime(event, performance.now()))
|
|
180
|
+
actions.notify()
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
on("session.tool.success", (event) => {
|
|
184
|
+
const sessionID = stringEventProperty(event, "sessionID")
|
|
185
|
+
if (sessionID.length === 0) return
|
|
186
|
+
recordEventAlias(event, sessionID)
|
|
187
|
+
recordToolSettled(state.turns, sessionID, stringEventProperty(event, "id"), eventPerfTime(event, performance.now()))
|
|
188
|
+
actions.notify()
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
on("session.tool.failed", (event) => {
|
|
192
|
+
const sessionID = stringEventProperty(event, "sessionID")
|
|
193
|
+
if (sessionID.length === 0) return
|
|
194
|
+
recordEventAlias(event, sessionID)
|
|
195
|
+
recordToolSettled(state.turns, sessionID, stringEventProperty(event, "id"), eventPerfTime(event, performance.now()))
|
|
196
|
+
actions.notify()
|
|
197
|
+
})
|
|
198
|
+
|
|
172
199
|
on("session.next.step.ended", (event) => {
|
|
173
200
|
const sessionID = stringEventProperty(event, "sessionID")
|
|
174
201
|
const fallbackMessageID = sessionID ? state.assistantMessageIds.get(sessionID) ?? eventID(event) : eventID(event)
|
|
175
202
|
const step = parseAssistantStepEnded(eventProperties(event), fallbackMessageID)
|
|
176
203
|
if (!step) return
|
|
177
204
|
recordEventAlias(event, step.sessionID)
|
|
178
|
-
const now = performance.now()
|
|
205
|
+
const now = eventPerfTime(event, performance.now())
|
|
179
206
|
actions.startSessionTiming(step.sessionID, now)
|
|
180
207
|
const turn = ensureTurn(state.turns, step.sessionID, now)
|
|
181
208
|
const existing = state.requests.get(step.sessionID)
|
|
@@ -215,7 +242,7 @@ export function registerEventHandlers(ctx: EventHandlerContext): Array<() => voi
|
|
|
215
242
|
recordEventAlias(event, sessionID)
|
|
216
243
|
const rawPart = eventProperty(event, "part")
|
|
217
244
|
const part = parseTextPart(rawPart)
|
|
218
|
-
const now = performance.now()
|
|
245
|
+
const now = eventPerfTime(event, performance.now())
|
|
219
246
|
const msgIds = state.userMessageIds.get(sessionID)
|
|
220
247
|
if (part && msgIds?.has(part.messageID)) {
|
|
221
248
|
const current = state.requests.get(sessionID)
|
|
@@ -246,7 +273,7 @@ export function registerEventHandlers(ctx: EventHandlerContext): Array<() => voi
|
|
|
246
273
|
const nextModel = mergeAssistantModel(state.sessionModels.get(sessionID), assistant)
|
|
247
274
|
if (nextModel) state.sessionModels.set(sessionID, nextModel)
|
|
248
275
|
|
|
249
|
-
const now = performance.now()
|
|
276
|
+
const now = eventPerfTime(event, performance.now())
|
|
250
277
|
const tokens = assistant.tokens
|
|
251
278
|
const existing = state.requests.get(sessionID)
|
|
252
279
|
const turn = ensureTurn(state.turns, sessionID, now)
|
|
@@ -288,7 +315,7 @@ export function registerEventHandlers(ctx: EventHandlerContext): Array<() => voi
|
|
|
288
315
|
if (userMsgID && state.userMessageIds.get(sessionID)?.has(userMsgID)) {
|
|
289
316
|
return
|
|
290
317
|
}
|
|
291
|
-
const now = performance.now()
|
|
318
|
+
const now = eventPerfTime(event, performance.now())
|
|
292
319
|
const previous = state.requests.get(sessionID)
|
|
293
320
|
const previousTurn = state.turns.get(sessionID)
|
|
294
321
|
if (previous && previousTurn) retireRequestIntoTurn(previousTurn, previous)
|
package/src/metrics.ts
CHANGED
|
@@ -127,6 +127,22 @@ export function getTtft(m: RequestMetrics): number | null {
|
|
|
127
127
|
return gateTtft(m.firstTokenTime - m.requestStartTime)
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Upper bound for a believable token rate. Mirrors the live-TPS sanity gate:
|
|
132
|
+
* per-delta times may be delivery-stamped (batched flushes), collapsing the
|
|
133
|
+
* generation window to milliseconds and inflating the quotient absurdly
|
|
134
|
+
* (e.g. 61 tokens / 42ms = 1450 t/s — physically impossible decoding).
|
|
135
|
+
* Windows implying a faster rate fall back to turn-anchored timing.
|
|
136
|
+
*/
|
|
137
|
+
export const MAX_CREDIBLE_TPS = 300
|
|
138
|
+
|
|
139
|
+
export function credibleTps(tokens: number, windowMs: number): number | null {
|
|
140
|
+
if (!(tokens > 0) || !(windowMs > 0)) return null
|
|
141
|
+
const tps = Math.round((tokens / (windowMs / 1000)) * 10) / 10
|
|
142
|
+
if (!Number.isFinite(tps) || tps < 0 || tps > MAX_CREDIBLE_TPS) return null
|
|
143
|
+
return tps
|
|
144
|
+
}
|
|
145
|
+
|
|
130
146
|
/**
|
|
131
147
|
* Calculate average token speed (tokens/second).
|
|
132
148
|
* Based on cumulative streaming time (now - firstTokenTime).
|
package/src/tui-v2.tsx
CHANGED
|
@@ -114,6 +114,9 @@ const V2_EVENT_MAP: Readonly<Record<string, string | null>> = {
|
|
|
114
114
|
"session.next.reasoning.delta": "session.reasoning.delta",
|
|
115
115
|
"session.next.step.started": "session.step.started",
|
|
116
116
|
"session.next.step.ended": "session.step.ended",
|
|
117
|
+
"session.tool.called": "session.tool.called",
|
|
118
|
+
"session.tool.success": "session.tool.success",
|
|
119
|
+
"session.tool.failed": "session.tool.failed",
|
|
117
120
|
}
|
|
118
121
|
|
|
119
122
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -133,7 +136,14 @@ export function translateExecutionStartedToBusy(event: V2EventLike): unknown {
|
|
|
133
136
|
const data = isRecord(event.data) ? event.data : {}
|
|
134
137
|
const sessionID = str(data.sessionID)
|
|
135
138
|
if (!sessionID) return null
|
|
136
|
-
|
|
139
|
+
// Preserve the host stamp so downstream timing uses generation time,
|
|
140
|
+
// not the (possibly batched) delivery time.
|
|
141
|
+
const created = typeof event.created === "number" ? event.created : undefined
|
|
142
|
+
return {
|
|
143
|
+
type: "session.status",
|
|
144
|
+
...(created !== undefined ? { created } : {}),
|
|
145
|
+
properties: { sessionID, status: { type: "busy" } },
|
|
146
|
+
}
|
|
137
147
|
}
|
|
138
148
|
|
|
139
149
|
/**
|
|
@@ -145,7 +155,12 @@ export function translateExecutionSettledToIdle(event: V2EventLike): unknown {
|
|
|
145
155
|
const data = isRecord(event.data) ? event.data : {}
|
|
146
156
|
const sessionID = str(data.sessionID)
|
|
147
157
|
if (!sessionID) return null
|
|
148
|
-
|
|
158
|
+
const created = typeof event.created === "number" ? event.created : undefined
|
|
159
|
+
return {
|
|
160
|
+
type: "session.idle",
|
|
161
|
+
...(created !== undefined ? { created } : {}),
|
|
162
|
+
properties: { sessionID },
|
|
163
|
+
}
|
|
149
164
|
}
|
|
150
165
|
|
|
151
166
|
function eventSessionID(event: unknown): string {
|
package/src/turn-state.ts
CHANGED
|
@@ -14,6 +14,7 @@ export function createTurnMetrics(sessionID: string, now: number): TurnMetrics {
|
|
|
14
14
|
hasStickyContextTokens: false,
|
|
15
15
|
stickyCacheReadTokens: 0,
|
|
16
16
|
hasStickyCacheReadTokens: false,
|
|
17
|
+
toolIntervals: [],
|
|
17
18
|
isComplete: false,
|
|
18
19
|
}
|
|
19
20
|
}
|
|
@@ -95,6 +96,75 @@ export function recordTurnFirstToken(turn: TurnMetrics, now: number): void {
|
|
|
95
96
|
if (turn.firstTokenTime === null) turn.firstTokenTime = now
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Record a tool execution span on the session's turn. Tools in one turn may
|
|
101
|
+
* overlap (parallel calls), so every span is kept and the union is computed
|
|
102
|
+
* at query time — a plain sum would double-count overlaps.
|
|
103
|
+
*/
|
|
104
|
+
export function recordToolCalled(
|
|
105
|
+
turns: Map<string, TurnMetrics>,
|
|
106
|
+
sessionID: string,
|
|
107
|
+
toolID: string,
|
|
108
|
+
now: number,
|
|
109
|
+
): void {
|
|
110
|
+
const turn = ensureTurn(turns, sessionID, now)
|
|
111
|
+
turn.toolIntervals.push({ id: toolID, start: now, end: null })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function recordToolSettled(
|
|
115
|
+
turns: Map<string, TurnMetrics>,
|
|
116
|
+
sessionID: string,
|
|
117
|
+
toolID: string,
|
|
118
|
+
now: number,
|
|
119
|
+
): void {
|
|
120
|
+
const turn = turns.get(sessionID)
|
|
121
|
+
if (!turn) return
|
|
122
|
+
if (toolID) {
|
|
123
|
+
const open = turn.toolIntervals.find((interval) => interval.id === toolID && interval.end === null)
|
|
124
|
+
if (open) {
|
|
125
|
+
open.end = Math.max(now, open.start)
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Unknown id (or unsettled shape): close the most recent open span so a
|
|
130
|
+
// lost settle event cannot leak an infinite interval.
|
|
131
|
+
for (let i = turn.toolIntervals.length - 1; i >= 0; i--) {
|
|
132
|
+
if (turn.toolIntervals[i]!.end === null) {
|
|
133
|
+
turn.toolIntervals[i]!.end = Math.max(now, turn.toolIntervals[i]!.start)
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Milliseconds of tool execution overlapping [from, to], unioned so parallel
|
|
141
|
+
* tool calls count once. Open spans are clamped at `to`.
|
|
142
|
+
*/
|
|
143
|
+
export function toolOverlapMs(turn: TurnMetrics | undefined, from: number, to: number): number {
|
|
144
|
+
if (!turn || to <= from) return 0
|
|
145
|
+
const spans: Array<{ start: number; end: number }> = []
|
|
146
|
+
for (const interval of turn.toolIntervals) {
|
|
147
|
+
const start = Math.max(interval.start, from)
|
|
148
|
+
const end = Math.min(interval.end ?? to, to)
|
|
149
|
+
if (end > start) spans.push({ start, end })
|
|
150
|
+
}
|
|
151
|
+
spans.sort((a, b) => a.start - b.start)
|
|
152
|
+
let total = 0
|
|
153
|
+
let cursor: number | null = null
|
|
154
|
+
let cursorEnd = 0
|
|
155
|
+
for (const span of spans) {
|
|
156
|
+
if (cursor === null || span.start > cursorEnd) {
|
|
157
|
+
if (cursor !== null) total += cursorEnd - cursor
|
|
158
|
+
cursor = span.start
|
|
159
|
+
cursorEnd = span.end
|
|
160
|
+
} else if (span.end > cursorEnd) {
|
|
161
|
+
cursorEnd = span.end
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (cursor !== null) total += cursorEnd - cursor
|
|
165
|
+
return total
|
|
166
|
+
}
|
|
167
|
+
|
|
98
168
|
/**
|
|
99
169
|
* Turn-level TTFT (user message → first token), gated. Falls back to the
|
|
100
170
|
* supplied per-request TTFT when the turn has no first-token timing. This is
|
package/src/types.ts
CHANGED
|
@@ -34,6 +34,12 @@ export interface FinalizedStepUsage {
|
|
|
34
34
|
exact: boolean
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
export interface ToolInterval {
|
|
38
|
+
readonly id: string
|
|
39
|
+
readonly start: number
|
|
40
|
+
end: number | null // null = still running
|
|
41
|
+
}
|
|
42
|
+
|
|
37
43
|
export interface TurnMetrics {
|
|
38
44
|
sessionID: string
|
|
39
45
|
turnStartTime: number
|
|
@@ -46,6 +52,7 @@ export interface TurnMetrics {
|
|
|
46
52
|
hasStickyContextTokens: boolean
|
|
47
53
|
stickyCacheReadTokens: number
|
|
48
54
|
hasStickyCacheReadTokens: boolean
|
|
55
|
+
toolIntervals: ToolInterval[] // Tool execution spans (V2 tool.* events; empty on V1)
|
|
49
56
|
isComplete: boolean
|
|
50
57
|
}
|
|
51
58
|
|