@moxxy/plugin-usage-stats 0.28.0 → 0.29.0
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/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/index.test.ts +217 -74
- package/src/index.ts +1 -1
package/dist/index.js
CHANGED
|
@@ -111,7 +111,7 @@ export function buildUsageStatsPlugin(opts = {}) {
|
|
|
111
111
|
// An unsubscribe that throws must not block the merge below.
|
|
112
112
|
}
|
|
113
113
|
clearUnsubs.delete(ctx.sessionId);
|
|
114
|
-
const initMaxSeq = cursors.
|
|
114
|
+
const initMaxSeq = cursors.get(ctx.sessionId) ?? null;
|
|
115
115
|
cursors.delete(ctx.sessionId);
|
|
116
116
|
// Best-effort: a throwing reader or a failed fold degrades to "record
|
|
117
117
|
// nothing for this run" rather than rejecting the hook (which the host
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,YAAY,EACZ,sBAAsB,GAMvB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,eAAe,EAAE,eAAe,EAAwB,MAAM,aAAa,CAAC;AAuBrF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,MAAM,UAAU,qBAAqB,CAAC,OAAqC,EAAE;IAC3E,8EAA8E;IAC9E,wEAAwE;IACxE,2EAA2E;IAC3E,0EAA0E;IAC1E,2EAA2E;IAC3E,0EAA0E;IAC1E,+EAA+E;IAC/E,uDAAuD;IACvD,EAAE;IACF,wEAAwE;IACxE,8EAA8E;IAC9E,2EAA2E;IAC3E,kEAAkE;IAClE,MAAM,OAAO,GAAG,IAAI,GAAG,EAA4B,CAAC;IACpD,uEAAuE;IACvE,6CAA6C;IAC7C,MAAM,WAAW,GAAG,IAAI,GAAG,EAAyB,CAAC;IACrD,OAAO,YAAY,CAAC;QAClB,IAAI,EAAE,2BAA2B;QACjC,KAAK,EAAE;YACL,MAAM,CAAC,GAAG;gBACR,uEAAuE;gBACvE,qEAAqE;gBACrE,6BAA6B;gBAC7B,EAAE;gBACF,yEAAyE;gBACzE,mEAAmE;gBACnE,wEAAwE;gBACxE,wEAAwE;gBACxE,iEAAiE;gBACjE,iEAAiE;gBACjE,iEAAiE;gBACjE,0EAA0E;gBAC1E,IAAI,CAAC;oBACH,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,SAAS,EAAE,WAAW,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;gBACnD,CAAC;gBAAC,MAAM,CAAC;oBACP,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC;gBACnC,CAAC;gBACD,IAAI,CAAC;oBACH,MAAM,MAAM,GAAG,GAAG,CAAC,GAAsC,CAAC;oBAC1D,IAAI,OAAO,MAAM,CAAC,OAAO,KAAK,UAAU,EAAE,CAAC;wBACzC,uEAAuE;wBACvE,WAAW,CAAC,GAAG,CAAC,GAAG,CAAC,SAAS,CAAC,EAAE,EAAE,CAAC;wBACnC,MAAM,KAAK,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,EAAE;4BAChC,+DAA+D;4BAC/D,oEAAoE;4BACpE,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC;wBACnC,CAAC,CAAC,CAAC;wBACH,WAAW,CAAC,GAAG,CAAC,GAAG,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC;oBACxC,CAAC;gBACH,CAAC;gBAAC,MAAM,CAAC;oBACP,mEAAmE;oBACnE,qEAAqE;oBACrE,qEAAqE;oBACrE,iDAAiD;gBACnD,CAAC;YACH,CAAC;YACD,KAAK,CAAC,UAAU,CAAC,GAAG;gBAClB,wEAAwE;gBACxE,yEAAyE;gBACzE,0EAA0E;gBAC1E,wDAAwD;gBACxD,IAAI,CAAC;oBACH,WAAW,CAAC,GAAG,CAAC,GAAG,CAAC,SAAS,CAAC,EAAE,EAAE,CAAC;gBACrC,CAAC;gBAAC,MAAM,CAAC;oBACP,6DAA6D;gBAC/D,CAAC;gBACD,WAAW,CAAC,MAAM,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;gBAClC,MAAM,UAAU,GAAG,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,SAAS,CAAC,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moxxy/plugin-usage-stats",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"description": "Records cross-session token usage. On session close it folds this run's provider_response events by provider/model and merges the delta into ~/.moxxy/usage.json (a forward-going aggregate surfaced in the /usage panel and reset by /usage clear).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"moxxy",
|
|
@@ -43,15 +43,15 @@
|
|
|
43
43
|
}
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@moxxy/
|
|
47
|
-
"@moxxy/
|
|
46
|
+
"@moxxy/sdk": "0.29.0",
|
|
47
|
+
"@moxxy/core": "0.29.0"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@types/node": "^22.10.0",
|
|
51
51
|
"typescript": "^5.7.3",
|
|
52
52
|
"vitest": "^2.1.8",
|
|
53
|
-
"@moxxy/
|
|
54
|
-
"@moxxy/
|
|
53
|
+
"@moxxy/tsconfig": "0.0.0",
|
|
54
|
+
"@moxxy/vitest-preset": "0.0.0"
|
|
55
55
|
},
|
|
56
56
|
"scripts": {
|
|
57
57
|
"build": "tsc -p tsconfig.json",
|
package/src/index.test.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
asSessionId,
|
|
8
8
|
asSkillId,
|
|
9
9
|
asTurnId,
|
|
10
|
+
assertDefined,
|
|
10
11
|
type AppContext,
|
|
11
12
|
type EventLogReader,
|
|
12
13
|
type MoxxyEvent,
|
|
@@ -69,7 +70,8 @@ function reader(events: ReadonlyArray<MoxxyEvent>): EventLogReader {
|
|
|
69
70
|
// Faithfully model the real EventLog: `length` is a COUNT of held events,
|
|
70
71
|
// while `slice(from)`/`at(seq)` are SEQ-addressed (translated through the
|
|
71
72
|
// log's base). On a rebased mirror the held events' seqs start above 0.
|
|
72
|
-
const
|
|
73
|
+
const first = events[0];
|
|
74
|
+
const base = first !== undefined ? first.seq : 0;
|
|
73
75
|
return {
|
|
74
76
|
length: events.length,
|
|
75
77
|
at: (seq: number) => events[seq - base],
|
|
@@ -97,7 +99,8 @@ class FakeLog {
|
|
|
97
99
|
private readonly clearListeners = new Set<() => void>();
|
|
98
100
|
constructor(seed: ReadonlyArray<MoxxyEvent> = []) {
|
|
99
101
|
this.events = [...seed];
|
|
100
|
-
|
|
102
|
+
const first = seed[0];
|
|
103
|
+
this.base = first !== undefined ? first.seq : 0;
|
|
101
104
|
}
|
|
102
105
|
get length() {
|
|
103
106
|
return this.events.length;
|
|
@@ -165,12 +168,20 @@ describe('usage-stats plugin', () => {
|
|
|
165
168
|
const plugin = buildUsageStatsPlugin({ statsPath });
|
|
166
169
|
const events = [resp(0, 'opus', 100), resp(1, 'opus', 50)];
|
|
167
170
|
|
|
168
|
-
|
|
169
|
-
|
|
171
|
+
const hooks = plugin.hooks;
|
|
172
|
+
assertDefined(hooks, 'usage-stats plugin hooks');
|
|
173
|
+
const onInit = hooks.onInit;
|
|
174
|
+
assertDefined(onInit, 'onInit hook');
|
|
175
|
+
const onShutdown = hooks.onShutdown;
|
|
176
|
+
assertDefined(onShutdown, 'onShutdown hook');
|
|
177
|
+
await onInit(ctxFor([])); // boots with empty log
|
|
178
|
+
await onShutdown(ctxFor(events));
|
|
170
179
|
|
|
171
180
|
const file = await loadUsageStats(statsPath);
|
|
172
|
-
|
|
173
|
-
|
|
181
|
+
const opus = file.models['anthropic/opus'];
|
|
182
|
+
assertDefined(opus, 'anthropic/opus model stats');
|
|
183
|
+
expect(opus.calls).toBe(2);
|
|
184
|
+
expect(opus.inputTokens).toBe(150);
|
|
174
185
|
});
|
|
175
186
|
|
|
176
187
|
it('skips restored events on resume and folds only the live suffix', async () => {
|
|
@@ -178,21 +189,35 @@ describe('usage-stats plugin', () => {
|
|
|
178
189
|
const liveSuffix = [resp(2, 'opus', 30)];
|
|
179
190
|
const plugin = buildUsageStatsPlugin({ statsPath });
|
|
180
191
|
|
|
192
|
+
const hooks = plugin.hooks;
|
|
193
|
+
assertDefined(hooks, 'usage-stats plugin hooks');
|
|
194
|
+
const onInit = hooks.onInit;
|
|
195
|
+
assertDefined(onInit, 'onInit hook');
|
|
196
|
+
const onShutdown = hooks.onShutdown;
|
|
197
|
+
assertDefined(onShutdown, 'onShutdown hook');
|
|
181
198
|
// onInit fires after restored events are already seeded into the log.
|
|
182
|
-
await
|
|
199
|
+
await onInit(ctxFor(restored));
|
|
183
200
|
// onShutdown sees restored + live.
|
|
184
|
-
await
|
|
201
|
+
await onShutdown(ctxFor([...restored, ...liveSuffix]));
|
|
185
202
|
|
|
186
203
|
const file = await loadUsageStats(statsPath);
|
|
187
204
|
// Only the single live call (30 tokens) is counted — not the 3000 restored.
|
|
188
|
-
|
|
189
|
-
|
|
205
|
+
const opus = file.models['anthropic/opus'];
|
|
206
|
+
assertDefined(opus, 'anthropic/opus model stats');
|
|
207
|
+
expect(opus.calls).toBe(1);
|
|
208
|
+
expect(opus.inputTokens).toBe(30);
|
|
190
209
|
});
|
|
191
210
|
|
|
192
211
|
it('writes nothing when no live events were produced', async () => {
|
|
193
212
|
const plugin = buildUsageStatsPlugin({ statsPath });
|
|
194
|
-
|
|
195
|
-
|
|
213
|
+
const hooks = plugin.hooks;
|
|
214
|
+
assertDefined(hooks, 'usage-stats plugin hooks');
|
|
215
|
+
const onInit = hooks.onInit;
|
|
216
|
+
assertDefined(onInit, 'onInit hook');
|
|
217
|
+
const onShutdown = hooks.onShutdown;
|
|
218
|
+
assertDefined(onShutdown, 'onShutdown hook');
|
|
219
|
+
await onInit(ctxFor([resp(0, 'opus', 100)]));
|
|
220
|
+
await onShutdown(ctxFor([resp(0, 'opus', 100)]));
|
|
196
221
|
// No new events past the cursor → file never created.
|
|
197
222
|
expect((await loadUsageStats(statsPath)).models).toEqual({});
|
|
198
223
|
});
|
|
@@ -206,13 +231,21 @@ describe('usage-stats plugin', () => {
|
|
|
206
231
|
const liveSuffix = [resp(103, 'opus', 30)];
|
|
207
232
|
const plugin = buildUsageStatsPlugin({ statsPath });
|
|
208
233
|
|
|
209
|
-
|
|
210
|
-
|
|
234
|
+
const hooks = plugin.hooks;
|
|
235
|
+
assertDefined(hooks, 'usage-stats plugin hooks');
|
|
236
|
+
const onInit = hooks.onInit;
|
|
237
|
+
assertDefined(onInit, 'onInit hook');
|
|
238
|
+
const onShutdown = hooks.onShutdown;
|
|
239
|
+
assertDefined(onShutdown, 'onShutdown hook');
|
|
240
|
+
await onInit(ctxFor(restored));
|
|
241
|
+
await onShutdown(ctxFor([...restored, ...liveSuffix]));
|
|
211
242
|
|
|
212
243
|
const file = await loadUsageStats(statsPath);
|
|
213
244
|
// Only the single live call (30 tokens) — never the 7000 restored tokens.
|
|
214
|
-
|
|
215
|
-
|
|
245
|
+
const opus = file.models['anthropic/opus'];
|
|
246
|
+
assertDefined(opus, 'anthropic/opus model stats');
|
|
247
|
+
expect(opus.calls).toBe(1);
|
|
248
|
+
expect(opus.inputTokens).toBe(30);
|
|
216
249
|
});
|
|
217
250
|
|
|
218
251
|
it('counts the post-/new (log clear/reset) suffix that restarts at seq 0', async () => {
|
|
@@ -225,16 +258,24 @@ describe('usage-stats plugin', () => {
|
|
|
225
258
|
const plugin = buildUsageStatsPlugin({ statsPath });
|
|
226
259
|
const log = new FakeLog([resp(100, 'opus', 1000), resp(101, 'opus', 2000)]);
|
|
227
260
|
|
|
228
|
-
|
|
261
|
+
const hooks = plugin.hooks;
|
|
262
|
+
assertDefined(hooks, 'usage-stats plugin hooks');
|
|
263
|
+
const onInit = hooks.onInit;
|
|
264
|
+
assertDefined(onInit, 'onInit hook');
|
|
265
|
+
const onShutdown = hooks.onShutdown;
|
|
266
|
+
assertDefined(onShutdown, 'onShutdown hook');
|
|
267
|
+
await onInit(ctxForLog(log)); // boundary captured at seq 101
|
|
229
268
|
log.clear(); // /new — events emptied, base reset to 0, onClear fires
|
|
230
269
|
log.push(resp(0, 'opus', 40), resp(1, 'opus', 60)); // post-/new live events
|
|
231
270
|
|
|
232
|
-
await
|
|
271
|
+
await onShutdown(ctxForLog(log));
|
|
233
272
|
|
|
234
273
|
const file = await loadUsageStats(statsPath);
|
|
235
274
|
// Both post-/new calls counted (100 tokens) — not dropped as <= old boundary.
|
|
236
|
-
|
|
237
|
-
|
|
275
|
+
const opus = file.models['anthropic/opus'];
|
|
276
|
+
assertDefined(opus, 'anthropic/opus model stats');
|
|
277
|
+
expect(opus.calls).toBe(2);
|
|
278
|
+
expect(opus.inputTokens).toBe(100);
|
|
238
279
|
});
|
|
239
280
|
|
|
240
281
|
it('isolates the cursor per session so concurrent sessions on one instance never clobber each other', async () => {
|
|
@@ -247,19 +288,27 @@ describe('usage-stats plugin', () => {
|
|
|
247
288
|
const logA = new FakeLog([resp(0, 'opus', 1000)]); // A restored 1 event (seq 0)
|
|
248
289
|
const logB = new FakeLog([]); // B is a fresh run
|
|
249
290
|
|
|
250
|
-
|
|
251
|
-
|
|
291
|
+
const hooks = plugin.hooks;
|
|
292
|
+
assertDefined(hooks, 'usage-stats plugin hooks');
|
|
293
|
+
const onInit = hooks.onInit;
|
|
294
|
+
assertDefined(onInit, 'onInit hook');
|
|
295
|
+
const onShutdown = hooks.onShutdown;
|
|
296
|
+
assertDefined(onShutdown, 'onShutdown hook');
|
|
297
|
+
await onInit(ctxForLog(logA, sidA)); // A boundary = 0
|
|
298
|
+
await onInit(ctxForLog(logB, sidB)); // B boundary = null (would clobber A's scalar)
|
|
252
299
|
|
|
253
300
|
logA.push(resp(1, 'opus', 5)); // A's single live call
|
|
254
301
|
logB.push(resp(0, 'opus', 7)); // B's single live call
|
|
255
302
|
|
|
256
|
-
await
|
|
257
|
-
await
|
|
303
|
+
await onShutdown(ctxForLog(logA, sidA));
|
|
304
|
+
await onShutdown(ctxForLog(logB, sidB));
|
|
258
305
|
|
|
259
306
|
const file = await loadUsageStats(statsPath);
|
|
260
307
|
// A counts only its live call (5), B counts its only call (7) → 2 calls, 12.
|
|
261
|
-
|
|
262
|
-
|
|
308
|
+
const opus = file.models['anthropic/opus'];
|
|
309
|
+
assertDefined(opus, 'anthropic/opus model stats');
|
|
310
|
+
expect(opus.calls).toBe(2);
|
|
311
|
+
expect(opus.inputTokens).toBe(12);
|
|
263
312
|
});
|
|
264
313
|
|
|
265
314
|
it('folds the whole log when onShutdown fires without a preceding onInit (cursor defaults to null)', async () => {
|
|
@@ -271,11 +320,17 @@ describe('usage-stats plugin', () => {
|
|
|
271
320
|
const plugin = buildUsageStatsPlugin({ statsPath });
|
|
272
321
|
const events = [resp(0, 'opus', 100), resp(1, 'opus', 50)];
|
|
273
322
|
|
|
274
|
-
|
|
323
|
+
const hooks = plugin.hooks;
|
|
324
|
+
assertDefined(hooks, 'usage-stats plugin hooks');
|
|
325
|
+
const onShutdown = hooks.onShutdown;
|
|
326
|
+
assertDefined(onShutdown, 'onShutdown hook');
|
|
327
|
+
await onShutdown(ctxFor(events)); // no onInit called
|
|
275
328
|
|
|
276
329
|
const file = await loadUsageStats(statsPath);
|
|
277
|
-
|
|
278
|
-
|
|
330
|
+
const opus = file.models['anthropic/opus'];
|
|
331
|
+
assertDefined(opus, 'anthropic/opus model stats');
|
|
332
|
+
expect(opus.calls).toBe(2);
|
|
333
|
+
expect(opus.inputTokens).toBe(150);
|
|
279
334
|
});
|
|
280
335
|
|
|
281
336
|
it('onShutdown degrades to a no-op (resolves, writes nothing) when the reader throws', async () => {
|
|
@@ -296,7 +351,11 @@ describe('usage-stats plugin', () => {
|
|
|
296
351
|
} as unknown as EventLogReader;
|
|
297
352
|
const ctx = { sessionId: sid, cwd: '/tmp', log: hostile, env: {} } as AppContext;
|
|
298
353
|
|
|
299
|
-
|
|
354
|
+
const hooks = plugin.hooks;
|
|
355
|
+
assertDefined(hooks, 'usage-stats plugin hooks');
|
|
356
|
+
const onShutdown = hooks.onShutdown;
|
|
357
|
+
assertDefined(onShutdown, 'onShutdown hook');
|
|
358
|
+
await expect(onShutdown(ctx)).resolves.toBeUndefined();
|
|
300
359
|
// Nothing folded → file never created.
|
|
301
360
|
expect((await loadUsageStats(statsPath)).models).toEqual({});
|
|
302
361
|
});
|
|
@@ -322,16 +381,24 @@ describe('usage-stats plugin', () => {
|
|
|
322
381
|
} as unknown as EventLogReader;
|
|
323
382
|
const initCtx = { sessionId: sid, cwd: '/tmp', log: throwingInit, env: {} } as AppContext;
|
|
324
383
|
|
|
384
|
+
const hooks = plugin.hooks;
|
|
385
|
+
assertDefined(hooks, 'usage-stats plugin hooks');
|
|
386
|
+
const onInit = hooks.onInit;
|
|
387
|
+
assertDefined(onInit, 'onInit hook');
|
|
388
|
+
const onShutdown = hooks.onShutdown;
|
|
389
|
+
assertDefined(onShutdown, 'onShutdown hook');
|
|
325
390
|
// onInit is synchronous; it must not throw despite ofType + onClear both
|
|
326
391
|
// throwing. (`Promise.resolve` tolerates the sync void return.)
|
|
327
|
-
expect(() =>
|
|
392
|
+
expect(() => onInit(initCtx)).not.toThrow();
|
|
328
393
|
|
|
329
394
|
// Now shut down with a healthy reader: null cursor folds the whole log.
|
|
330
|
-
await
|
|
395
|
+
await onShutdown(ctxFor([resp(0, 'opus', 10), resp(1, 'opus', 20)]));
|
|
331
396
|
|
|
332
397
|
const file = await loadUsageStats(statsPath);
|
|
333
|
-
|
|
334
|
-
|
|
398
|
+
const opus = file.models['anthropic/opus'];
|
|
399
|
+
assertDefined(opus, 'anthropic/opus model stats');
|
|
400
|
+
expect(opus.calls).toBe(2);
|
|
401
|
+
expect(opus.inputTokens).toBe(30);
|
|
335
402
|
});
|
|
336
403
|
|
|
337
404
|
it('onShutdown tears down the onClear listener even when the fold/merge path throws (no leak)', async () => {
|
|
@@ -357,9 +424,15 @@ describe('usage-stats plugin', () => {
|
|
|
357
424
|
} as unknown as EventLogReader;
|
|
358
425
|
const ctx = { sessionId: sid, cwd: '/tmp', log, env: {} } as AppContext;
|
|
359
426
|
|
|
360
|
-
|
|
427
|
+
const hooks = plugin.hooks;
|
|
428
|
+
assertDefined(hooks, 'usage-stats plugin hooks');
|
|
429
|
+
const onInit = hooks.onInit;
|
|
430
|
+
assertDefined(onInit, 'onInit hook');
|
|
431
|
+
const onShutdown = hooks.onShutdown;
|
|
432
|
+
assertDefined(onShutdown, 'onShutdown hook');
|
|
433
|
+
await onInit(ctx); // registers the clear listener (ofType ok: empty log path)
|
|
361
434
|
throwFromOfType = true; // now the fold path will throw on shutdown
|
|
362
|
-
await expect(
|
|
435
|
+
await expect(onShutdown(ctx)).resolves.toBeUndefined();
|
|
363
436
|
// Listener was unsubscribed despite the fold throwing → no leak.
|
|
364
437
|
expect(unsubscribed).toBe(true);
|
|
365
438
|
});
|
|
@@ -371,6 +444,10 @@ describe('usage-stats plugin', () => {
|
|
|
371
444
|
// non-array — it must be swallowed by the same best-effort guard, resolving
|
|
372
445
|
// the hook and writing nothing rather than crashing the timeboxed shutdown.
|
|
373
446
|
const plugin = buildUsageStatsPlugin({ statsPath });
|
|
447
|
+
const hooks = plugin.hooks;
|
|
448
|
+
assertDefined(hooks, 'usage-stats plugin hooks');
|
|
449
|
+
const onShutdown = hooks.onShutdown;
|
|
450
|
+
assertDefined(onShutdown, 'onShutdown hook');
|
|
374
451
|
for (const bogus of [42, null, 'nope', { length: 1 }] as unknown[]) {
|
|
375
452
|
const garbageReader = {
|
|
376
453
|
length: 1,
|
|
@@ -381,7 +458,7 @@ describe('usage-stats plugin', () => {
|
|
|
381
458
|
toJSON: () => [],
|
|
382
459
|
} as unknown as EventLogReader;
|
|
383
460
|
const ctx = { sessionId: sid, cwd: '/tmp', log: garbageReader, env: {} } as AppContext;
|
|
384
|
-
await expect(
|
|
461
|
+
await expect(onShutdown(ctx)).resolves.toBeUndefined();
|
|
385
462
|
}
|
|
386
463
|
// No call ever produced a fold-able array → file never created.
|
|
387
464
|
expect((await loadUsageStats(statsPath)).models).toEqual({});
|
|
@@ -397,15 +474,21 @@ describe('usage-stats plugin', () => {
|
|
|
397
474
|
const plugin = buildUsageStatsPlugin({ statsPath });
|
|
398
475
|
const events = [resp(0, 'opus', 100)];
|
|
399
476
|
|
|
400
|
-
|
|
477
|
+
const hooks = plugin.hooks;
|
|
478
|
+
assertDefined(hooks, 'usage-stats plugin hooks');
|
|
479
|
+
const onShutdown = hooks.onShutdown;
|
|
480
|
+
assertDefined(onShutdown, 'onShutdown hook');
|
|
481
|
+
await expect(onShutdown(ctxFor(events))).resolves.toBeUndefined();
|
|
401
482
|
// A repeat shutdown re-folds the same whole log (cursor still absent → null),
|
|
402
483
|
// so it must not throw; the aggregate doubles, which is the documented
|
|
403
484
|
// whole-log-fold default, asserted so a future guard change is deliberate.
|
|
404
|
-
await expect(
|
|
485
|
+
await expect(onShutdown(ctxFor(events))).resolves.toBeUndefined();
|
|
405
486
|
|
|
406
487
|
const file = await loadUsageStats(statsPath);
|
|
407
|
-
|
|
408
|
-
|
|
488
|
+
const opus = file.models['anthropic/opus'];
|
|
489
|
+
assertDefined(opus, 'anthropic/opus model stats');
|
|
490
|
+
expect(opus.calls).toBe(2);
|
|
491
|
+
expect(opus.inputTokens).toBe(200);
|
|
409
492
|
});
|
|
410
493
|
|
|
411
494
|
it('a re-init of the same session id replaces (does not stack) the onClear listener', async () => {
|
|
@@ -419,12 +502,18 @@ describe('usage-stats plugin', () => {
|
|
|
419
502
|
const plugin = buildUsageStatsPlugin({ statsPath });
|
|
420
503
|
const log = new FakeLog([resp(0, 'opus', 10)]);
|
|
421
504
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
505
|
+
const hooks = plugin.hooks;
|
|
506
|
+
assertDefined(hooks, 'usage-stats plugin hooks');
|
|
507
|
+
const onInit = hooks.onInit;
|
|
508
|
+
assertDefined(onInit, 'onInit hook');
|
|
509
|
+
const onShutdown = hooks.onShutdown;
|
|
510
|
+
assertDefined(onShutdown, 'onShutdown hook');
|
|
511
|
+
await onInit(ctxForLog(log));
|
|
512
|
+
await onInit(ctxForLog(log));
|
|
513
|
+
await onInit(ctxForLog(log));
|
|
425
514
|
expect(log.clearListenerCount).toBe(1); // replaced each time, never stacked
|
|
426
515
|
|
|
427
|
-
await
|
|
516
|
+
await onShutdown(ctxForLog(log));
|
|
428
517
|
expect(log.clearListenerCount).toBe(0); // fully torn down
|
|
429
518
|
});
|
|
430
519
|
|
|
@@ -452,14 +541,22 @@ describe('usage-stats plugin', () => {
|
|
|
452
541
|
} as unknown as EventLogReader;
|
|
453
542
|
const initCtx = { sessionId: sid, cwd: '/tmp', log: onClearThrows, env: {} } as AppContext;
|
|
454
543
|
|
|
455
|
-
|
|
544
|
+
const hooks = plugin.hooks;
|
|
545
|
+
assertDefined(hooks, 'usage-stats plugin hooks');
|
|
546
|
+
const onInit = hooks.onInit;
|
|
547
|
+
assertDefined(onInit, 'onInit hook');
|
|
548
|
+
const onShutdown = hooks.onShutdown;
|
|
549
|
+
assertDefined(onShutdown, 'onShutdown hook');
|
|
550
|
+
expect(() => onInit(initCtx)).not.toThrow();
|
|
456
551
|
// Shut down with restored prefix + one live call. The preserved boundary (102)
|
|
457
552
|
// must exclude every restored response and include only the live one.
|
|
458
|
-
await
|
|
553
|
+
await onShutdown(ctxFor([...restored, resp(103, 'opus', 30)]));
|
|
459
554
|
|
|
460
555
|
const file = await loadUsageStats(statsPath);
|
|
461
|
-
|
|
462
|
-
|
|
556
|
+
const opus = file.models['anthropic/opus'];
|
|
557
|
+
assertDefined(opus, 'anthropic/opus model stats');
|
|
558
|
+
expect(opus.calls).toBe(1);
|
|
559
|
+
expect(opus.inputTokens).toBe(30); // not 7030
|
|
463
560
|
});
|
|
464
561
|
|
|
465
562
|
it('actually tears down the real onClear listener on shutdown (no leaked subscription)', async () => {
|
|
@@ -471,23 +568,31 @@ describe('usage-stats plugin', () => {
|
|
|
471
568
|
const plugin = buildUsageStatsPlugin({ statsPath });
|
|
472
569
|
const log = new FakeLog([resp(0, 'opus', 100)]);
|
|
473
570
|
|
|
474
|
-
|
|
571
|
+
const hooks = plugin.hooks;
|
|
572
|
+
assertDefined(hooks, 'usage-stats plugin hooks');
|
|
573
|
+
const onInit = hooks.onInit;
|
|
574
|
+
assertDefined(onInit, 'onInit hook');
|
|
575
|
+
const onShutdown = hooks.onShutdown;
|
|
576
|
+
assertDefined(onShutdown, 'onShutdown hook');
|
|
577
|
+
await onInit(ctxForLog(log));
|
|
475
578
|
expect(log.clearListenerCount).toBe(1); // listener registered
|
|
476
579
|
|
|
477
580
|
log.push(resp(1, 'opus', 50));
|
|
478
|
-
await
|
|
581
|
+
await onShutdown(ctxForLog(log));
|
|
479
582
|
expect(log.clearListenerCount).toBe(0); // torn down — no leak
|
|
480
583
|
|
|
481
584
|
// A clear() now fires no plugin listener; a second shutdown sees an absent
|
|
482
585
|
// cursor (null) and folds the (post-clear, empty) log → still no crash.
|
|
483
586
|
log.clear();
|
|
484
|
-
await expect(
|
|
587
|
+
await expect(onShutdown(ctxForLog(log))).resolves.toBeUndefined();
|
|
485
588
|
|
|
486
589
|
const file = await loadUsageStats(statsPath);
|
|
487
590
|
// Only the first run's live call (50) — the restored 100 was excluded, and
|
|
488
591
|
// the post-shutdown clear added nothing.
|
|
489
|
-
|
|
490
|
-
|
|
592
|
+
const opus = file.models['anthropic/opus'];
|
|
593
|
+
assertDefined(opus, 'anthropic/opus model stats');
|
|
594
|
+
expect(opus.calls).toBe(1);
|
|
595
|
+
expect(opus.inputTokens).toBe(50);
|
|
491
596
|
});
|
|
492
597
|
});
|
|
493
598
|
|
|
@@ -497,15 +602,21 @@ describe('summarizeSkillEvents', () => {
|
|
|
497
602
|
[skillInvoked(0, 'deploy', 1000), skillInvoked(1, 'deploy', 3000), skillInvoked(2, 'lint', 2000)],
|
|
498
603
|
[],
|
|
499
604
|
);
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
expect(
|
|
605
|
+
const deploy = delta['deploy'];
|
|
606
|
+
assertDefined(deploy, 'deploy skill delta');
|
|
607
|
+
expect(deploy.invocations).toBe(2);
|
|
608
|
+
expect(deploy.lastInvokedAt).toBe(new Date(3000).toISOString());
|
|
609
|
+
const lint = delta['lint'];
|
|
610
|
+
assertDefined(lint, 'lint skill delta');
|
|
611
|
+
expect(lint.invocations).toBe(1);
|
|
503
612
|
});
|
|
504
613
|
|
|
505
614
|
it('records createdAt for a created-but-never-invoked skill (invocations 0)', () => {
|
|
506
615
|
const delta = summarizeSkillEvents([], [skillCreated(0, 'fresh', 5000)]);
|
|
507
|
-
|
|
508
|
-
|
|
616
|
+
const fresh = delta['fresh'];
|
|
617
|
+
assertDefined(fresh, 'fresh skill delta');
|
|
618
|
+
expect(fresh.invocations).toBe(0);
|
|
619
|
+
expect(fresh.createdAt).toBe(new Date(5000).toISOString());
|
|
509
620
|
});
|
|
510
621
|
|
|
511
622
|
it('is empty for no events', () => {
|
|
@@ -523,14 +634,24 @@ describe('usage-stats plugin — skill folding', () => {
|
|
|
523
634
|
skillInvoked(3, 'lint', 3000),
|
|
524
635
|
];
|
|
525
636
|
|
|
526
|
-
|
|
527
|
-
|
|
637
|
+
const hooks = plugin.hooks;
|
|
638
|
+
assertDefined(hooks, 'usage-stats plugin hooks');
|
|
639
|
+
const onInit = hooks.onInit;
|
|
640
|
+
assertDefined(onInit, 'onInit hook');
|
|
641
|
+
const onShutdown = hooks.onShutdown;
|
|
642
|
+
assertDefined(onShutdown, 'onShutdown hook');
|
|
643
|
+
await onInit(ctxFor([]));
|
|
644
|
+
await onShutdown(ctxFor(events));
|
|
528
645
|
|
|
529
646
|
const file = await loadSkillUsage(skillUsagePath);
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
expect(
|
|
533
|
-
expect(
|
|
647
|
+
const deploy = file.skills['deploy'];
|
|
648
|
+
assertDefined(deploy, 'deploy skill usage');
|
|
649
|
+
expect(deploy.invocations).toBe(2);
|
|
650
|
+
expect(deploy.createdAt).toBe(new Date(1000).toISOString());
|
|
651
|
+
expect(deploy.lastInvokedAt).toBe(new Date(4000).toISOString());
|
|
652
|
+
const lint = file.skills['lint'];
|
|
653
|
+
assertDefined(lint, 'lint skill usage');
|
|
654
|
+
expect(lint.invocations).toBe(1);
|
|
534
655
|
});
|
|
535
656
|
|
|
536
657
|
it('counts only the live skill suffix on resume (skips restored skill events)', async () => {
|
|
@@ -538,11 +659,19 @@ describe('usage-stats plugin — skill folding', () => {
|
|
|
538
659
|
const live = [skillInvoked(2, 'deploy', 3000)];
|
|
539
660
|
const plugin = buildUsageStatsPlugin({ statsPath, skillUsagePath });
|
|
540
661
|
|
|
541
|
-
|
|
542
|
-
|
|
662
|
+
const hooks = plugin.hooks;
|
|
663
|
+
assertDefined(hooks, 'usage-stats plugin hooks');
|
|
664
|
+
const onInit = hooks.onInit;
|
|
665
|
+
assertDefined(onInit, 'onInit hook');
|
|
666
|
+
const onShutdown = hooks.onShutdown;
|
|
667
|
+
assertDefined(onShutdown, 'onShutdown hook');
|
|
668
|
+
await onInit(ctxFor(restored));
|
|
669
|
+
await onShutdown(ctxFor([...restored, ...live]));
|
|
543
670
|
|
|
544
671
|
// Only the single live invocation is counted — not the 2 restored.
|
|
545
|
-
|
|
672
|
+
const deploy = (await loadSkillUsage(skillUsagePath)).skills['deploy'];
|
|
673
|
+
assertDefined(deploy, 'deploy skill usage');
|
|
674
|
+
expect(deploy.invocations).toBe(1);
|
|
546
675
|
});
|
|
547
676
|
|
|
548
677
|
it('folds skill usage even when the run produced no provider_response (no token early-return)', async () => {
|
|
@@ -551,18 +680,32 @@ describe('usage-stats plugin — skill folding', () => {
|
|
|
551
680
|
const plugin = buildUsageStatsPlugin({ statsPath, skillUsagePath });
|
|
552
681
|
const events = [skillInvoked(0, 'deploy', 1000)];
|
|
553
682
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
683
|
+
const hooks = plugin.hooks;
|
|
684
|
+
assertDefined(hooks, 'usage-stats plugin hooks');
|
|
685
|
+
const onInit = hooks.onInit;
|
|
686
|
+
assertDefined(onInit, 'onInit hook');
|
|
687
|
+
const onShutdown = hooks.onShutdown;
|
|
688
|
+
assertDefined(onShutdown, 'onShutdown hook');
|
|
689
|
+
await onInit(ctxFor([]));
|
|
690
|
+
await onShutdown(ctxFor(events));
|
|
691
|
+
|
|
692
|
+
const deploy = (await loadSkillUsage(skillUsagePath)).skills['deploy'];
|
|
693
|
+
assertDefined(deploy, 'deploy skill usage');
|
|
694
|
+
expect(deploy.invocations).toBe(1);
|
|
558
695
|
// No token usage was recorded (no provider_response events).
|
|
559
696
|
expect((await loadUsageStats(statsPath)).models).toEqual({});
|
|
560
697
|
});
|
|
561
698
|
|
|
562
699
|
it('writes no skill usage file when the run exercised no skills', async () => {
|
|
563
700
|
const plugin = buildUsageStatsPlugin({ statsPath, skillUsagePath });
|
|
564
|
-
|
|
565
|
-
|
|
701
|
+
const hooks = plugin.hooks;
|
|
702
|
+
assertDefined(hooks, 'usage-stats plugin hooks');
|
|
703
|
+
const onInit = hooks.onInit;
|
|
704
|
+
assertDefined(onInit, 'onInit hook');
|
|
705
|
+
const onShutdown = hooks.onShutdown;
|
|
706
|
+
assertDefined(onShutdown, 'onShutdown hook');
|
|
707
|
+
await onInit(ctxFor([]));
|
|
708
|
+
await onShutdown(ctxFor([resp(0, 'opus', 100)]));
|
|
566
709
|
// Empty skill delta → merge never touches disk.
|
|
567
710
|
await expect(fs.access(skillUsagePath)).rejects.toThrow();
|
|
568
711
|
});
|
package/src/index.ts
CHANGED
|
@@ -138,7 +138,7 @@ export function buildUsageStatsPlugin(opts: BuildUsageStatsPluginOptions = {}):
|
|
|
138
138
|
// An unsubscribe that throws must not block the merge below.
|
|
139
139
|
}
|
|
140
140
|
clearUnsubs.delete(ctx.sessionId);
|
|
141
|
-
const initMaxSeq = cursors.
|
|
141
|
+
const initMaxSeq = cursors.get(ctx.sessionId) ?? null;
|
|
142
142
|
cursors.delete(ctx.sessionId);
|
|
143
143
|
// Best-effort: a throwing reader or a failed fold degrades to "record
|
|
144
144
|
// nothing for this run" rather than rejecting the hook (which the host
|