@monotykamary/pi-ledger 0.2.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.
@@ -0,0 +1,2528 @@
1
+ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
2
+ import * as fs from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import {
6
+ activateExtension,
7
+ createTestFixture,
8
+ makeAssistantMessage,
9
+ makeTpsTelemetry,
10
+ type TestFixture,
11
+ } from './helpers';
12
+ import {
13
+ applySettingValue,
14
+ buildReceiptHtml,
15
+ closeWindowBudget,
16
+ computeAgentMs,
17
+ computeBurstMs,
18
+ computeBilling,
19
+ consumeExtensionBudget,
20
+ convertTpsEntries,
21
+ extractTpsEntries,
22
+ fmtHours,
23
+ fmtMoney,
24
+ rehydrateFromSidecar,
25
+ resolveExtensionBudget,
26
+ sidecarPathFor,
27
+ type LedgerSettings,
28
+ type ReceiptData,
29
+ type SidecarEvent,
30
+ type SteerEvent,
31
+ } from '../index';
32
+
33
+ // Stub the browser opener so /ledger-receipt never launches anything during tests.
34
+ vi.mock('node:child_process', () => ({ execSync: vi.fn() }));
35
+
36
+ const DEFAULTS: LedgerSettings = {
37
+ agentRatePerHour: 60,
38
+ humanRatePerHour: 60,
39
+ pomodoroMinutes: 20,
40
+ referenceTps: 75,
41
+ project: '',
42
+ author: '',
43
+ currency: 'USD',
44
+ autoWizard: true,
45
+ autoExtend: false,
46
+ };
47
+
48
+ const KIND_BY_CUSTOM_TYPE: Record<string, SidecarEvent['kind']> = {
49
+ 'ledger-agent': 'agent',
50
+ 'ledger-human': 'human-close',
51
+ };
52
+
53
+ function lastEntry(fixture: TestFixture, customType: string): any {
54
+ return fixture.lastSidecarEvent(
55
+ KIND_BY_CUSTOM_TYPE[customType] ?? (customType as SidecarEvent['kind'])
56
+ );
57
+ }
58
+
59
+ function entryCount(fixture: TestFixture, customType: string): number {
60
+ const kind = KIND_BY_CUSTOM_TYPE[customType] ?? (customType as SidecarEvent['kind']);
61
+ return fixture.readSidecarEvents().filter((e) => e.kind === kind).length;
62
+ }
63
+
64
+ // ─── Pure helpers ───────────────────────────────────────────────────────────
65
+
66
+ describe('computeAgentMs', () => {
67
+ it('bills output tokens at the reference TPS plus tool time', () => {
68
+ // 750 output tokens ÷ 75 TPS = 10s = 10_000ms; + 300ms tools
69
+ expect(computeAgentMs(750, 300, 75)).toBe(10_300);
70
+ });
71
+ it('bills only tool time when there are no output tokens', () => {
72
+ // generation is billed by tokens; no tokens → no generation bill (stalls moot)
73
+ expect(computeAgentMs(0, 300, 75)).toBe(300);
74
+ });
75
+ it('ignores negative tool time', () => {
76
+ expect(computeAgentMs(750, -50, 75)).toBe(10_000);
77
+ });
78
+ it('scales the generation bill by the reference TPS (higher TPS → lower bill)', () => {
79
+ // same 750 tokens: at 150 TPS the normalized time halves vs 75 TPS
80
+ expect(computeAgentMs(750, 0, 150)).toBe(5_000);
81
+ expect(computeAgentMs(750, 0, 75)).toBe(10_000);
82
+ });
83
+ });
84
+
85
+ describe('closeWindowBudget', () => {
86
+ it('bills the full idle when under budget', () => {
87
+ expect(closeWindowBudget(0, 30_000, 60_000)).toEqual({ idleMs: 30_000, billedMs: 30_000 });
88
+ });
89
+ it('caps billing at the granted budget', () => {
90
+ expect(closeWindowBudget(0, 90_000, 60_000)).toEqual({ idleMs: 90_000, billedMs: 60_000 });
91
+ });
92
+ it('clamps negative durations', () => {
93
+ expect(closeWindowBudget(100, 50, 60_000)).toEqual({ idleMs: 0, billedMs: 0 });
94
+ });
95
+ });
96
+
97
+ describe('consumeExtensionBudget', () => {
98
+ it('consumes the billed time up to the provisioned budget', () => {
99
+ expect(consumeExtensionBudget(30_000, 20 * 60_000)).toBe(30_000); // 30s billed → 30s consumed
100
+ expect(consumeExtensionBudget(5 * 60_000, 20 * 60_000)).toBe(5 * 60_000); // 5m → 5m
101
+ });
102
+ it('caps consumption at the remaining provisioned budget', () => {
103
+ // billed 10m, only 2m provisioned → consume 2m
104
+ expect(consumeExtensionBudget(10 * 60_000, 2 * 60_000)).toBe(2 * 60_000);
105
+ });
106
+ it('clamps zero/empty budgets', () => {
107
+ expect(consumeExtensionBudget(5 * 60_000, 0)).toBe(0);
108
+ expect(consumeExtensionBudget(0, 20 * 60_000)).toBe(0);
109
+ });
110
+ });
111
+
112
+ describe('resolveExtensionBudget', () => {
113
+ it('reads the recorded field when present (open + close)', () => {
114
+ const open = {
115
+ kind: 'human-open',
116
+ openedAt: 0,
117
+ grantedBudgetMs: 20 * 60_000,
118
+ extensions: 1,
119
+ extensionBudgetMs: 20 * 60_000,
120
+ timestamp: 0,
121
+ } as SidecarEvent;
122
+ expect(resolveExtensionBudget(open)).toBe(20 * 60_000);
123
+ const close = {
124
+ kind: 'human-close',
125
+ openedAt: 0,
126
+ closedAt: 1000,
127
+ billedMs: 1000,
128
+ idleMs: 1000,
129
+ grantedBudgetMs: 20 * 60_000,
130
+ extensions: 1,
131
+ extensionBudgetMs: 19 * 60_000,
132
+ timestamp: 1,
133
+ } as SidecarEvent;
134
+ expect(resolveExtensionBudget(close)).toBe(19 * 60_000);
135
+ });
136
+ it('backfills an open legacy event as the recorded cap (no field)', () => {
137
+ const open = {
138
+ kind: 'human-open',
139
+ openedAt: 0,
140
+ grantedBudgetMs: 20 * 60_000,
141
+ extensions: 1,
142
+ timestamp: 0,
143
+ } as SidecarEvent;
144
+ expect(resolveExtensionBudget(open)).toBe(20 * 60_000);
145
+ });
146
+ it('backfills a close legacy event as cap − billed', () => {
147
+ // billed 5m, cap 20m → remaining = 20m − 5m = 15m
148
+ const close = {
149
+ kind: 'human-close',
150
+ openedAt: 0,
151
+ closedAt: 5 * 60_000,
152
+ billedMs: 5 * 60_000,
153
+ idleMs: 5 * 60_000,
154
+ grantedBudgetMs: 20 * 60_000,
155
+ extensions: 1,
156
+ timestamp: 1,
157
+ } as SidecarEvent;
158
+ expect(resolveExtensionBudget(close)).toBe(15 * 60_000);
159
+ });
160
+ });
161
+
162
+ describe('computeBilling', () => {
163
+ it('splits agent/human costs and blends', () => {
164
+ const s = { ...DEFAULTS, agentRatePerHour: 100, humanRatePerHour: 50 };
165
+ // 1h agent, 0.5h human
166
+ const b = computeBilling(3_600_000, 1_800_000, s);
167
+ expect(b.agentHours).toBe(1);
168
+ expect(b.humanHours).toBe(0.5);
169
+ expect(b.agentCost).toBe(100);
170
+ expect(b.humanCost).toBe(25);
171
+ expect(b.total).toBe(125);
172
+ expect(b.totalHours).toBe(1.5);
173
+ });
174
+ });
175
+
176
+ describe('formatting', () => {
177
+ it('fmtHours', () => {
178
+ expect(fmtHours(3_600_000)).toBe('1.00h');
179
+ expect(fmtHours(1_800_000)).toBe('0.50h');
180
+ });
181
+ it('fmtMoney uses currency symbol', () => {
182
+ expect(fmtMoney(125, 'USD')).toBe('$125.00');
183
+ expect(fmtMoney(125, 'EUR')).toBe('€125.00');
184
+ expect(fmtMoney(125, 'VND')).toBe('₫125.00');
185
+ expect(fmtMoney(125, 'XYZ')).toBe('125.00'); // unknown → no symbol
186
+ });
187
+ });
188
+
189
+ describe('applySettingValue', () => {
190
+ it('parses numeric rates and clamps negatives', () => {
191
+ expect(applySettingValue(DEFAULTS, 'agentRatePerHour', '100').agentRatePerHour).toBe(100);
192
+ expect(
193
+ applySettingValue({ ...DEFAULTS, agentRatePerHour: 50 }, 'agentRatePerHour', '-5')
194
+ .agentRatePerHour
195
+ ).toBe(50); // reject negative → keeps old
196
+ expect(applySettingValue(DEFAULTS, 'humanRatePerHour', '12.5').humanRatePerHour).toBe(12.5);
197
+ });
198
+ it('clamps pomodoro to a sane integer (min 1)', () => {
199
+ expect(applySettingValue(DEFAULTS, 'pomodoroMinutes', '0').pomodoroMinutes).toBe(1); // min 1
200
+ expect(applySettingValue(DEFAULTS, 'pomodoroMinutes', '15.7').pomodoroMinutes).toBe(16);
201
+ });
202
+ it('accepts a positive reference TPS and rejects non-positive', () => {
203
+ expect(applySettingValue(DEFAULTS, 'referenceTps', '100').referenceTps).toBe(100);
204
+ expect(applySettingValue(DEFAULTS, 'referenceTps', '12.5').referenceTps).toBe(12.5);
205
+ expect(
206
+ applySettingValue({ ...DEFAULTS, referenceTps: 75 }, 'referenceTps', '0').referenceTps
207
+ ).toBe(75); // reject 0 → keeps old
208
+ expect(
209
+ applySettingValue({ ...DEFAULTS, referenceTps: 75 }, 'referenceTps', '-5').referenceTps
210
+ ).toBe(75); // reject negative → keeps old
211
+ });
212
+ it('sets text fields and currency/toggle', () => {
213
+ expect(applySettingValue(DEFAULTS, 'project', 'app.inloop.studio').project).toBe(
214
+ 'app.inloop.studio'
215
+ );
216
+ expect(applySettingValue(DEFAULTS, 'author', 'tom').author).toBe('tom');
217
+ expect(applySettingValue(DEFAULTS, 'currency', 'EUR').currency).toBe('EUR');
218
+ expect(applySettingValue(DEFAULTS, 'autoWizard', 'off').autoWizard).toBe(false);
219
+ expect(applySettingValue(DEFAULTS, 'autoWizard', 'on').autoWizard).toBe(true);
220
+ expect(applySettingValue(DEFAULTS, 'autoExtend', 'on').autoExtend).toBe(true);
221
+ expect(applySettingValue(DEFAULTS, 'autoExtend', 'off').autoExtend).toBe(false);
222
+ });
223
+ it('ignores non-numeric input for numeric fields', () => {
224
+ expect(
225
+ applySettingValue({ ...DEFAULTS, agentRatePerHour: 50 }, 'agentRatePerHour', 'nope')
226
+ .agentRatePerHour
227
+ ).toBe(50);
228
+ });
229
+ });
230
+
231
+ describe('rehydrateFromSidecar', () => {
232
+ it('replays agent + human events and restores last settings', () => {
233
+ const events: SidecarEvent[] = [
234
+ { kind: 'settings', settings: { ...DEFAULTS, agentRatePerHour: 100 }, timestamp: 0 },
235
+ {
236
+ kind: 'agent',
237
+ id: 'a1',
238
+ turnIndex: 0,
239
+ agentMs: 3_600_000,
240
+ generationMs: 3_000_000,
241
+ stallMs: 0,
242
+ toolMs: 600_000,
243
+ tokens: { input: 100, output: 50, total: 150 },
244
+ model: { provider: 'openai', modelId: 'gpt-4' },
245
+ source: 'tps',
246
+ timestamp: 1000,
247
+ },
248
+ {
249
+ kind: 'human-close',
250
+ openedAt: 0,
251
+ closedAt: 1_800_000,
252
+ billedMs: 1_800_000,
253
+ idleMs: 1_800_000,
254
+ grantedBudgetMs: 60_000,
255
+ extensions: 0,
256
+ timestamp: 2000,
257
+ },
258
+ ];
259
+ const r = rehydrateFromSidecar(events);
260
+ expect(r.settings.agentRatePerHour).toBe(100);
261
+ expect(r.totals.agentMs).toBe(3_600_000);
262
+ expect(r.totals.humanMs).toBe(1_800_000);
263
+ expect(r.totals.agentTurns).toBe(1);
264
+ expect(r.totals.humanWindows).toBe(1);
265
+ expect(r.totals.agentTokens.total).toBe(150);
266
+ expect(r.humanWindow).toBeNull();
267
+ });
268
+ it('replays itemized sub-totals (agent gen/tool/stall, human idle/steer/queue/abandoned, extensions)', () => {
269
+ const events: SidecarEvent[] = [
270
+ { kind: 'settings', settings: { ...DEFAULTS, agentRatePerHour: 100 }, timestamp: 0 },
271
+ {
272
+ kind: 'agent',
273
+ id: 'a1',
274
+ turnIndex: 0,
275
+ agentMs: 3_600_000,
276
+ generationMs: 3_000_000,
277
+ stallMs: 500,
278
+ toolMs: 600_000,
279
+ tokens: { input: 100, output: 50, total: 150 },
280
+ model: { provider: 'openai', modelId: 'gpt-4' },
281
+ source: 'tps',
282
+ timestamp: 1000,
283
+ },
284
+ {
285
+ kind: 'agent',
286
+ id: 'a2',
287
+ turnIndex: 1,
288
+ agentMs: 2_000_000,
289
+ generationMs: 2_000_000,
290
+ stallMs: 0,
291
+ toolMs: 0,
292
+ tokens: { input: 200, output: 100, total: 300 },
293
+ model: { provider: 'openai', modelId: 'gpt-4' },
294
+ source: 'tps',
295
+ timestamp: 2000,
296
+ },
297
+ // idle window engaged via extension: grants one 20m block (1_200_000ms)
298
+ {
299
+ kind: 'human-open',
300
+ openedAt: 10_000,
301
+ grantedBudgetMs: 1_200_000,
302
+ extensions: 1,
303
+ engagedVia: 'extension',
304
+ extensionBudgetMs: 1_200_000,
305
+ timestamp: 10_000,
306
+ },
307
+ // committed idle: bills 120s (consumes 120s of the 20m credit)
308
+ {
309
+ kind: 'human-close',
310
+ openedAt: 10_000,
311
+ closedAt: 130_000,
312
+ billedMs: 120_000,
313
+ idleMs: 120_000,
314
+ keystrokes: 50,
315
+ committed: true,
316
+ grantedBudgetMs: 1_200_000,
317
+ extensions: 1,
318
+ extensionBudgetMs: 1_080_000,
319
+ timestamp: 130_000,
320
+ },
321
+ // a steer (30s, consumes 30s) and a followUp (10s, consumes 10s)
322
+ {
323
+ kind: 'steer',
324
+ startedAt: 200_000,
325
+ submittedAt: 230_000,
326
+ durationMs: 30_000,
327
+ billedMs: 30_000,
328
+ keystrokes: 20,
329
+ behavior: 'steer',
330
+ grantedBudgetMs: 1_080_000,
331
+ extensionBudgetMs: 1_050_000,
332
+ timestamp: 230_000,
333
+ },
334
+ {
335
+ kind: 'steer',
336
+ startedAt: 300_000,
337
+ submittedAt: 310_000,
338
+ durationMs: 10_000,
339
+ billedMs: 10_000,
340
+ keystrokes: 5,
341
+ behavior: 'followUp',
342
+ grantedBudgetMs: 1_050_000,
343
+ extensionBudgetMs: 1_040_000,
344
+ timestamp: 310_000,
345
+ },
346
+ // an abandoned window (walked away; no submit → 0 billed, consumes nothing)
347
+ {
348
+ kind: 'human-open',
349
+ openedAt: 400_000,
350
+ grantedBudgetMs: 1_040_000,
351
+ extensions: 0,
352
+ engagedVia: 'keystroke',
353
+ extensionBudgetMs: 1_040_000,
354
+ timestamp: 400_000,
355
+ },
356
+ {
357
+ kind: 'human-close',
358
+ openedAt: 400_000,
359
+ closedAt: 600_000,
360
+ billedMs: 0,
361
+ idleMs: 200_000,
362
+ committed: false,
363
+ grantedBudgetMs: 1_040_000,
364
+ extensions: 0,
365
+ extensionBudgetMs: 1_040_000,
366
+ timestamp: 600_000,
367
+ },
368
+ ];
369
+ const r = rehydrateFromSidecar(events);
370
+ // agent split: generation (token-normalized) vs tool, plus unbilled stall
371
+ expect(r.totals.agentMs).toBe(5_600_000);
372
+ expect(r.totals.agentGenMs).toBe(5_000_000); // (3.6m−600k) + 2m
373
+ expect(r.totals.agentToolMs).toBe(600_000);
374
+ expect(r.totals.stallMs).toBe(500);
375
+ expect(r.totals.toolTurns).toBe(1);
376
+ expect(r.totals.stalledTurns).toBe(1);
377
+ expect(r.totals.agentTurns).toBe(2);
378
+ // human split: committed idle vs steering vs queuing, plus abandoned
379
+ expect(r.totals.humanMs).toBe(160_000); // 120k + 30k + 10k (abandoned bills 0)
380
+ expect(r.totals.humanWindows).toBe(3);
381
+ expect(r.totals.humanIdleMs).toBe(120_000);
382
+ expect(r.totals.idleWindows).toBe(1);
383
+ expect(r.totals.idleKeystrokes).toBe(50);
384
+ expect(r.totals.humanSteerMs).toBe(30_000);
385
+ expect(r.totals.steerCount).toBe(1);
386
+ expect(r.totals.steerKeystrokes).toBe(20);
387
+ expect(r.totals.humanQueueMs).toBe(10_000);
388
+ expect(r.totals.queueCount).toBe(1);
389
+ expect(r.totals.queueKeystrokes).toBe(5);
390
+ expect(r.totals.abandonedWindows).toBe(1);
391
+ expect(r.totals.abandonedMs).toBe(200_000);
392
+ // extensions: 1 block granted (1_200_000), 160s consumed (120+30+10), 1_040_000 remaining
393
+ expect(r.totals.extensionsGranted).toBe(1);
394
+ expect(r.totals.extensionCreditMs).toBe(1_200_000);
395
+ expect(r.totals.extensionConsumedMs).toBe(160_000);
396
+ expect(r.extensionBudgetMs).toBe(1_040_000);
397
+ expect(r.totals.extensionCreditMs - r.totals.extensionConsumedMs).toBe(r.extensionBudgetMs);
398
+ });
399
+ it('defaults settings when none persisted', () => {
400
+ const r = rehydrateFromSidecar([]);
401
+ expect(r.settings).toEqual(DEFAULTS);
402
+ expect(r.totals.agentMs).toBe(0);
403
+ expect(r.humanWindow).toBeNull();
404
+ });
405
+ it('supersedes a fallback agent event with the later tps event for the same turn (no double-count)', () => {
406
+ const events: SidecarEvent[] = [
407
+ {
408
+ kind: 'agent',
409
+ id: 'fb',
410
+ turnIndex: 0,
411
+ agentMs: 800,
412
+ generationMs: 800,
413
+ stallMs: 0,
414
+ toolMs: 0,
415
+ tokens: { input: 0, output: 0, total: 0 },
416
+ model: { provider: 'openai', modelId: 'gpt-4' },
417
+ source: 'fallback',
418
+ timestamp: 1,
419
+ },
420
+ {
421
+ kind: 'agent',
422
+ id: 'tps',
423
+ turnIndex: 0,
424
+ agentMs: 1500,
425
+ generationMs: 2000,
426
+ stallMs: 500,
427
+ toolMs: 0,
428
+ tokens: { input: 10, output: 5, total: 15 },
429
+ model: { provider: 'openai', modelId: 'gpt-4' },
430
+ source: 'tps',
431
+ supersedes: 'fb',
432
+ timestamp: 2,
433
+ },
434
+ ];
435
+ const r = rehydrateFromSidecar(events);
436
+ expect(r.totals.agentMs).toBe(1500);
437
+ expect(r.totals.agentTurns).toBe(1);
438
+ expect(r.totals.agentTokens.total).toBe(15);
439
+ });
440
+ it('reconstructs the open human window from the last unclosed human-open', () => {
441
+ const events: SidecarEvent[] = [
442
+ {
443
+ kind: 'human-open',
444
+ openedAt: 5000,
445
+ grantedBudgetMs: 60_000,
446
+ extensions: 0,
447
+ timestamp: 5000,
448
+ },
449
+ ];
450
+ const r = rehydrateFromSidecar(events);
451
+ expect(r.humanWindow).toEqual({
452
+ openedAt: 5000,
453
+ grantedBudgetMs: 60_000,
454
+ extensions: 0,
455
+ engagedVia: 'keystroke',
456
+ });
457
+ });
458
+ it('does not reconstruct a human window that was closed', () => {
459
+ const events: SidecarEvent[] = [
460
+ {
461
+ kind: 'human-open',
462
+ openedAt: 5000,
463
+ grantedBudgetMs: 60_000,
464
+ extensions: 0,
465
+ timestamp: 5000,
466
+ },
467
+ {
468
+ kind: 'human-close',
469
+ openedAt: 5000,
470
+ closedAt: 9000,
471
+ billedMs: 4000,
472
+ idleMs: 4000,
473
+ grantedBudgetMs: 60_000,
474
+ extensions: 0,
475
+ timestamp: 9000,
476
+ },
477
+ ];
478
+ const r = rehydrateFromSidecar(events);
479
+ expect(r.humanWindow).toBeNull();
480
+ expect(r.totals.humanMs).toBe(4000);
481
+ expect(r.totals.humanWindows).toBe(1);
482
+ });
483
+ it('reconstructs the rolling extension budget carried into an open window', () => {
484
+ const events: SidecarEvent[] = [
485
+ { kind: 'settings', settings: { ...DEFAULTS }, timestamp: 0 },
486
+ {
487
+ kind: 'human-open',
488
+ openedAt: 1000,
489
+ grantedBudgetMs: 21 * 60_000,
490
+ extensions: 1,
491
+ extensionBudgetMs: 20 * 60_000,
492
+ timestamp: 1000,
493
+ },
494
+ ];
495
+ const r = rehydrateFromSidecar(events);
496
+ expect(r.humanWindow).toEqual({
497
+ openedAt: 1000,
498
+ grantedBudgetMs: 21 * 60_000,
499
+ extensions: 1,
500
+ engagedVia: 'keystroke',
501
+ });
502
+ expect(r.extensionBudgetMs).toBe(20 * 60_000);
503
+ });
504
+ it('reconstructs the rolling budget remaining after a closed window', () => {
505
+ const events: SidecarEvent[] = [
506
+ { kind: 'settings', settings: { ...DEFAULTS }, timestamp: 0 },
507
+ {
508
+ kind: 'human-open',
509
+ openedAt: 1000,
510
+ grantedBudgetMs: 21 * 60_000,
511
+ extensions: 1,
512
+ extensionBudgetMs: 20 * 60_000,
513
+ timestamp: 1000,
514
+ },
515
+ {
516
+ kind: 'human-close',
517
+ openedAt: 1000,
518
+ closedAt: 3 * 60_000,
519
+ billedMs: 3 * 60_000,
520
+ idleMs: 3 * 60_000,
521
+ grantedBudgetMs: 21 * 60_000,
522
+ extensions: 1,
523
+ extensionBudgetMs: 18 * 60_000,
524
+ timestamp: 3000,
525
+ },
526
+ ];
527
+ const r = rehydrateFromSidecar(events);
528
+ expect(r.humanWindow).toBeNull();
529
+ expect(r.extensionBudgetMs).toBe(18 * 60_000);
530
+ });
531
+ it('backfills the rolling budget for legacy events missing the field', () => {
532
+ // open with cap 20m (20m ext), no extensionBudgetMs field
533
+ const events: SidecarEvent[] = [
534
+ { kind: 'settings', settings: { ...DEFAULTS }, timestamp: 0 },
535
+ {
536
+ kind: 'human-open',
537
+ openedAt: 1000,
538
+ grantedBudgetMs: 20 * 60_000,
539
+ extensions: 1,
540
+ timestamp: 1000,
541
+ },
542
+ ];
543
+ const r = rehydrateFromSidecar(events);
544
+ expect(r.extensionBudgetMs).toBe(20 * 60_000);
545
+ });
546
+ });
547
+
548
+ describe('extractTpsEntries + convertTpsEntries', () => {
549
+ it('extracts only tps markers', () => {
550
+ const entries = [
551
+ { type: 'custom', customType: 'ledger-settings', data: {} },
552
+ {
553
+ type: 'custom',
554
+ customType: 'tps',
555
+ data: {
556
+ timing: { generationMs: 1, stallMs: 0, totalMs: 1 },
557
+ tokens: { input: 0, output: 0, total: 0 },
558
+ model: { provider: 'p', modelId: 'm' },
559
+ timestamp: 10,
560
+ },
561
+ },
562
+ { type: 'custom', customType: 'ledger-agent', data: {} },
563
+ ];
564
+ const tps = extractTpsEntries(entries);
565
+ expect(tps).toHaveLength(1);
566
+ expect(tps[0]!.timestamp).toBe(10);
567
+ });
568
+
569
+ it('converts markers to agent time (no human estimate — markers carry no credit/commit info)', () => {
570
+ const c = convertTpsEntries(
571
+ [
572
+ {
573
+ timing: { generationMs: 2000, stallMs: 0, totalMs: 2500 },
574
+ tokens: { input: 100, output: 50, total: 150 },
575
+ model: { provider: 'p', modelId: 'm' },
576
+ timestamp: 0,
577
+ },
578
+ {
579
+ timing: { generationMs: 3000, stallMs: 500, totalMs: 4000 },
580
+ tokens: { input: 200, output: 100, total: 300 },
581
+ model: { provider: 'p', modelId: 'm' },
582
+ timestamp: 70000,
583
+ },
584
+ {
585
+ timing: { generationMs: 1000, stallMs: 0, totalMs: 1500 },
586
+ tokens: { input: 50, output: 25, total: 75 },
587
+ model: { provider: 'p', modelId: 'm' },
588
+ timestamp: 90000,
589
+ },
590
+ ],
591
+ 75
592
+ );
593
+ // agent = (50 + 100 + 25) output tokens ÷ 75 TPS = 667 + 1333 + 333 = 2333
594
+ expect(c.agentMs).toBe(2333);
595
+ expect(c.agentTurns).toBe(3);
596
+ expect(c.agentTokens.total).toBe(525);
597
+ // idle bills only against credit; markers carry none → 0 human time
598
+ expect(c.humanMs).toBe(0);
599
+ expect(c.humanWindows).toBe(0);
600
+ expect(c.startedAt).toBe(0);
601
+ });
602
+
603
+ it('returns zeros for no markers', () => {
604
+ const c = convertTpsEntries([], 75);
605
+ expect(c.agentMs).toBe(0);
606
+ expect(c.humanMs).toBe(0);
607
+ expect(c.startedAt).toBe(0);
608
+ });
609
+ });
610
+
611
+ describe('buildReceiptHtml', () => {
612
+ const data: ReceiptData = {
613
+ project: 'app.inloop.studio',
614
+ author: 'Tom Nguyen',
615
+ sessionId: '019fabcd',
616
+ currency: 'USD',
617
+ agentRate: 100,
618
+ humanRate: 50,
619
+ agentHours: 1.2,
620
+ humanHours: 0.5,
621
+ agentCost: 120,
622
+ humanCost: 25,
623
+ total: 145,
624
+ agentTurns: 3,
625
+ humanWindows: 2,
626
+ agentTokens: { input: 1000, output: 500, total: 1500 },
627
+ startedAt: 1_700_000_000_000,
628
+ generatedAt: 1_700_000_180_000,
629
+ };
630
+
631
+ it('is a standalone document with Geist Mono', () => {
632
+ const html = buildReceiptHtml(data);
633
+ expect(html.startsWith('<!doctype html>')).toBe(true);
634
+ expect(html).toContain('Geist+Mono');
635
+ });
636
+ it('carries the brand, tagline, and project', () => {
637
+ const html = buildReceiptHtml(data);
638
+ expect(html).toContain('pi-ledger');
639
+ expect(html).toContain('billed like serverless');
640
+ expect(html).toContain('app.inloop.studio');
641
+ expect(html).toContain('Tom Nguyen');
642
+ });
643
+ it('renders agent + human line items and the total', () => {
644
+ const html = buildReceiptHtml(data);
645
+ expect(html).toContain('data-reveal="Agent"');
646
+ expect(html).toContain('data-reveal="Human"');
647
+ expect(html).toContain('$120.00');
648
+ expect(html).toContain('$25.00');
649
+ expect(html).toContain('$145.00');
650
+ });
651
+ it('marks values as autoregressively revealable', () => {
652
+ const html = buildReceiptHtml(data);
653
+ const reveals = html.match(/data-reveal="/g);
654
+ expect(reveals && reveals.length).toBeGreaterThan(10);
655
+ expect(html).toContain('nextBlock'); // block-by-block typewriter engine
656
+ expect(html).toContain('r-block r-hidden'); // starts blank, grows line by line
657
+ });
658
+ it('renders the itemized invoice: sub-items, $0 nuances, subtotals, footer', () => {
659
+ const MS = 3_600_000;
660
+ const h = (n: number) => n * MS;
661
+ const full: ReceiptData = {
662
+ ...data,
663
+ agentRate: 20,
664
+ humanRate: 60,
665
+ agentHours: 1.23,
666
+ humanHours: 0.97,
667
+ agentCost: 24.6,
668
+ humanCost: 58.2,
669
+ total: 82.8,
670
+ agentTurns: 42,
671
+ humanWindows: 3 + 9 + 5,
672
+ agentTokens: { input: 4100, output: 14300, total: 18400 },
673
+ agentGenMs: h(0.95),
674
+ agentToolMs: h(0.28),
675
+ stallMs: h(0.3),
676
+ toolTurns: 14,
677
+ stalledTurns: 3,
678
+ humanIdleMs: h(0.85),
679
+ humanSteerMs: h(0.1),
680
+ humanQueueMs: h(0.02),
681
+ idleWindows: 3,
682
+ steerCount: 9,
683
+ queueCount: 5,
684
+ idleKeystrokes: 412,
685
+ steerKeystrokes: 88,
686
+ queueKeystrokes: 24,
687
+ abandonedWindows: 2,
688
+ abandonedMs: h(0.15),
689
+ extensionsGranted: 3,
690
+ extensionCreditMs: 3 * 20 * 60_000,
691
+ extensionConsumedMs: 50 * 60_000,
692
+ startedAt: 1_700_000_000_000,
693
+ generatedAt: 1_700_000_000_000 + h(3.4),
694
+ };
695
+ const html = buildReceiptHtml(full);
696
+ // group headers carry the hourly rate (corroborating the pricing)
697
+ expect(html).toContain('data-reveal="Agent"');
698
+ expect(html).toContain('data-reveal="@ $20.00/h"');
699
+ expect(html).toContain('data-reveal="@ $60.00/h"');
700
+ // agent sub-items at the agent rate, summing to the subtotal
701
+ expect(html).toContain('data-reveal="Compute (generation)"');
702
+ expect(html).toContain('data-reveal="Tool execution"');
703
+ expect(html).toContain('data-reveal="$19.00"'); // 0.95h × $20
704
+ expect(html).toContain('data-reveal="$5.60"'); // 0.28h × $20
705
+ // human sub-items at the human rate
706
+ expect(html).toContain('data-reveal="Review / think"');
707
+ expect(html).toContain('data-reveal="Steering"');
708
+ expect(html).toContain('data-reveal="Queuing"');
709
+ expect(html).toContain('data-reveal="$51.00"'); // 0.85h × $60
710
+ expect(html).toContain('data-reveal="$6.00"'); // 0.10h × $60
711
+ expect(html).toContain('data-reveal="$1.20"'); // 0.02h × $60
712
+ // $0 nuance lines prove what we DON'T bill
713
+ expect(html).toContain('data-reveal="Stalls"');
714
+ expect(html).toContain('data-reveal="Idle abandoned"');
715
+ expect(html).toContain('data-reveal="$0.00"');
716
+ expect(html).toContain('data-reveal="not billed"');
717
+ // subtotals + grand total reconcile
718
+ expect(html).toContain('data-reveal="$24.60"'); // agent subtotal
719
+ expect(html).toContain('data-reveal="$58.20"'); // human subtotal
720
+ expect(html).toContain('data-reveal="$82.80"'); // total
721
+ // footer: provisioned capacity + session span
722
+ expect(html).toContain('Extensions: 3 granted · 60m total · 50m used · 10m remaining');
723
+ expect(html).toContain('Session span 3.40 h · billed 2.20 h');
724
+ });
725
+ });
726
+
727
+ // ─── Integration (fake timers) ──────────────────────────────────────────────
728
+
729
+ describe('extension integration', () => {
730
+ let fixture: TestFixture;
731
+
732
+ let cacheDir: string;
733
+ beforeEach(async () => {
734
+ vi.useFakeTimers();
735
+ cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-ledger-test-'));
736
+ process.env.XDG_CACHE_HOME = cacheDir;
737
+ fixture = createTestFixture();
738
+ await activateExtension(fixture);
739
+ });
740
+
741
+ afterEach(() => {
742
+ vi.useRealTimers();
743
+ delete process.env.XDG_CACHE_HOME;
744
+ fs.rmSync(cacheDir, { recursive: true, force: true });
745
+ });
746
+
747
+ it('registers the four commands', () => {
748
+ expect(fixture.commands['ledger']).toBeDefined();
749
+ expect(fixture.commands['ledger-extend']).toBeDefined();
750
+ expect(fixture.commands['ledger-settings']).toBeDefined();
751
+ expect(fixture.commands['ledger-receipt']).toBeDefined();
752
+ });
753
+
754
+ it('records an agent segment from tps:telemetry + tool time (stalls excluded)', async () => {
755
+ fixture.run('turn_start', { type: 'turn_start', turnIndex: 1, timestamp: Date.now() });
756
+ fixture.run('tool_execution_start', {
757
+ type: 'tool_execution_start',
758
+ toolCallId: 'a',
759
+ toolName: 'bash',
760
+ args: {},
761
+ });
762
+ await vi.advanceTimersByTimeAsync(800);
763
+ fixture.run('tool_execution_end', {
764
+ type: 'tool_execution_end',
765
+ toolCallId: 'a',
766
+ toolName: 'bash',
767
+ result: {},
768
+ isError: false,
769
+ });
770
+ fixture.emitEvent(
771
+ 'tps:telemetry',
772
+ makeTpsTelemetry({ generationMs: 2000, stallMs: 500, input: 1000, output: 400, total: 1400 })
773
+ );
774
+
775
+ const seg = lastEntry(fixture, 'ledger-agent');
776
+ expect(seg).toBeDefined();
777
+ // 400 output tokens ÷ 75 TPS = 5333ms + 800ms tools = 6133 (speed-invariant;
778
+ // the 500ms stall is recorded below but not billed)
779
+ expect(seg.agentMs).toBe(6133);
780
+ expect(seg.toolMs).toBe(800);
781
+ expect(seg.generationMs).toBe(2000);
782
+ expect(seg.stallMs).toBe(500);
783
+ expect(seg.turnIndex).toBe(1);
784
+ expect(seg.tokens.total).toBe(1400);
785
+ });
786
+
787
+ it('bills the same agent time for a fast and a slow model with equal output (speed-invariant)', async () => {
788
+ fixture.run('turn_start', { type: 'turn_start', turnIndex: 0, timestamp: Date.now() });
789
+ // slow model: 30s real generation for 1500 output tokens (50 TPS)
790
+ fixture.emitEvent(
791
+ 'tps:telemetry',
792
+ makeTpsTelemetry({ generationMs: 30_000, stallMs: 0, output: 1500, total: 1500 })
793
+ );
794
+ fixture.run('turn_start', { type: 'turn_start', turnIndex: 1, timestamp: Date.now() });
795
+ // fast model: 5s real generation for the same 1500 output tokens (300 TPS)
796
+ fixture.emitEvent(
797
+ 'tps:telemetry',
798
+ makeTpsTelemetry({ generationMs: 5_000, stallMs: 0, output: 1500, total: 1500 })
799
+ );
800
+
801
+ const events = fixture.readSidecarEvents().filter((e) => e.kind === 'agent');
802
+ // both normalize to 1500 ÷ 75 × 1000 = 20_000ms regardless of real speed
803
+ expect(events[0]!.agentMs).toBe(20_000);
804
+ expect(events[1]!.agentMs).toBe(20_000);
805
+ // the real wall-clock generation is preserved for audit
806
+ expect(events[0]!.generationMs).toBe(30_000);
807
+ expect(events[1]!.generationMs).toBe(5_000);
808
+ });
809
+
810
+ it('measures parallel tool execution as a union span (no double-count)', async () => {
811
+ fixture.run('turn_start', { type: 'turn_start', turnIndex: 0, timestamp: Date.now() });
812
+ fixture.run('tool_execution_start', {
813
+ type: 'tool_execution_start',
814
+ toolCallId: 'a',
815
+ toolName: 'bash',
816
+ args: {},
817
+ });
818
+ await vi.advanceTimersByTimeAsync(200);
819
+ fixture.run('tool_execution_start', {
820
+ type: 'tool_execution_start',
821
+ toolCallId: 'b',
822
+ toolName: 'read',
823
+ args: {},
824
+ });
825
+ await vi.advanceTimersByTimeAsync(300);
826
+ fixture.run('tool_execution_end', {
827
+ type: 'tool_execution_end',
828
+ toolCallId: 'b',
829
+ toolName: 'read',
830
+ result: {},
831
+ isError: false,
832
+ });
833
+ await vi.advanceTimersByTimeAsync(100);
834
+ fixture.run('tool_execution_end', {
835
+ type: 'tool_execution_end',
836
+ toolCallId: 'a',
837
+ toolName: 'bash',
838
+ result: {},
839
+ isError: false,
840
+ });
841
+ fixture.emitEvent('tps:telemetry', makeTpsTelemetry({ generationMs: 1000, stallMs: 0 }));
842
+
843
+ const seg = lastEntry(fixture, 'ledger-agent');
844
+ expect(seg.toolMs).toBe(600); // union [0,600], not 200+400+... summed
845
+ });
846
+
847
+ it('records a fallback agent segment at turn_end when pi-tps is absent', async () => {
848
+ const msg = makeAssistantMessage();
849
+ fixture.run('turn_start', { type: 'turn_start', turnIndex: 0, timestamp: Date.now() });
850
+ fixture.run('message_start', { type: 'message_start', message: msg });
851
+ fixture.run('message_update', { type: 'message_update', message: msg }); // first token
852
+ await vi.advanceTimersByTimeAsync(400);
853
+ fixture.run('message_update', { type: 'message_update', message: msg });
854
+ await vi.advanceTimersByTimeAsync(400);
855
+ fixture.run('message_update', { type: 'message_update', message: msg });
856
+ await vi.advanceTimersByTimeAsync(400);
857
+ fixture.run('message_end', { type: 'message_end', message: msg });
858
+ // no tps:telemetry → fallback fires at turn_end
859
+ fixture.run('turn_end', { type: 'turn_end', turnIndex: 0, message: msg, toolResults: [] });
860
+
861
+ const seg = lastEntry(fixture, 'ledger-agent');
862
+ expect(seg).toBeDefined();
863
+ expect(seg.source).toBe('fallback');
864
+ // 50 output tokens ÷ 75 TPS = 667ms (speed-invariant; real 1200ms below is audit)
865
+ expect(seg.agentMs).toBe(667);
866
+ expect(seg.generationMs).toBe(1200);
867
+ expect(seg.stallMs).toBe(0);
868
+ expect(seg.model.modelId).toBe('gpt-4');
869
+ });
870
+
871
+ it('excludes stalls in the fallback measurement', async () => {
872
+ const msg = makeAssistantMessage();
873
+ fixture.run('turn_start', { type: 'turn_start', turnIndex: 0, timestamp: Date.now() });
874
+ fixture.run('message_start', { type: 'message_start', message: msg });
875
+ fixture.run('message_update', { type: 'message_update', message: msg }); // first token
876
+ await vi.advanceTimersByTimeAsync(400);
877
+ fixture.run('message_update', { type: 'message_update', message: msg }); // gap 400, no stall
878
+ await vi.advanceTimersByTimeAsync(1500);
879
+ fixture.run('message_update', { type: 'message_update', message: msg }); // gap 1500 → stall
880
+ await vi.advanceTimersByTimeAsync(400);
881
+ fixture.run('message_end', { type: 'message_end', message: msg });
882
+ fixture.run('turn_end', { type: 'turn_end', turnIndex: 0, message: msg, toolResults: [] });
883
+
884
+ const seg = lastEntry(fixture, 'ledger-agent');
885
+ // 50 output tokens ÷ 75 TPS = 667ms — stalls are recorded (1500ms below) but
886
+ // excluded from billing automatically (a stall produces no tokens)
887
+ expect(seg.source).toBe('fallback');
888
+ expect(seg.generationMs).toBe(2300);
889
+ expect(seg.stallMs).toBe(1500);
890
+ expect(seg.agentMs).toBe(667);
891
+ });
892
+
893
+ it('corrects a fallback with the later tps segment for the same turn (no double-count)', async () => {
894
+ const msg = makeAssistantMessage();
895
+ fixture.run('turn_start', { type: 'turn_start', turnIndex: 0, timestamp: Date.now() });
896
+ fixture.run('message_start', { type: 'message_start', message: msg });
897
+ fixture.run('message_update', { type: 'message_update', message: msg });
898
+ await vi.advanceTimersByTimeAsync(400);
899
+ fixture.run('message_update', { type: 'message_update', message: msg });
900
+ await vi.advanceTimersByTimeAsync(400);
901
+ fixture.run('message_end', { type: 'message_end', message: msg });
902
+ // ledger-before-tps load order: fallback first...
903
+ fixture.run('turn_end', { type: 'turn_end', turnIndex: 0, message: msg, toolResults: [] });
904
+ expect(lastEntry(fixture, 'ledger-agent').source).toBe('fallback');
905
+ expect(lastEntry(fixture, 'ledger-agent').agentMs).toBe(667); // 50 tokens ÷ 75 TPS
906
+ // ...then tps arrives and corrects it
907
+ fixture.emitEvent('tps:telemetry', makeTpsTelemetry({ generationMs: 2000, stallMs: 500 }));
908
+
909
+ const seg = lastEntry(fixture, 'ledger-agent');
910
+ expect(seg.source).toBe('tps');
911
+ expect(seg.agentMs).toBe(6667); // 500 tokens ÷ 75 TPS = 6667ms
912
+ expect(entryCount(fixture, 'ledger-agent')).toBe(2); // fallback + tps entries kept
913
+ // rehydrate dedups → keeps tps
914
+ // rehydrate from the sidecar: the fallback is superseded → only tps counts
915
+ const r = rehydrateFromSidecar(fixture.readSidecarEvents());
916
+ expect(r.totals.agentMs).toBe(6667);
917
+ expect(r.totals.agentTurns).toBe(1);
918
+ });
919
+
920
+ it('writes no fallback once pi-tps has been seen (skipped turns stay skipped)', async () => {
921
+ const msg = makeAssistantMessage();
922
+ fixture.run('turn_start', { type: 'turn_start', turnIndex: 0, timestamp: Date.now() });
923
+ fixture.emitEvent('tps:telemetry', makeTpsTelemetry({ generationMs: 1000, stallMs: 0 }));
924
+ fixture.run('turn_start', { type: 'turn_start', turnIndex: 1, timestamp: Date.now() });
925
+ fixture.run('message_start', { type: 'message_start', message: msg });
926
+ fixture.run('message_update', { type: 'message_update', message: msg });
927
+ await vi.advanceTimersByTimeAsync(400);
928
+ fixture.run('message_end', { type: 'message_end', message: msg });
929
+ fixture.run('turn_end', { type: 'turn_end', turnIndex: 1, message: msg, toolResults: [] });
930
+ // turn 1 had no tps:telemetry (skipped); tpsEverSeen is true → no fallback
931
+ const agentEntries = fixture.readSidecarEvents().filter((e) => e.kind === 'agent');
932
+ expect(agentEntries).toHaveLength(1); // only the turn-0 tps entry
933
+ });
934
+
935
+ it('pops the wizard at agent_settled (no credit); engaging without extending bills 0', async () => {
936
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' }); // install the editor (no pop)
937
+ fixture.run('agent_end', { type: 'agent_end', messages: [] }); // per-run cleanup; no pop here
938
+ fixture.run('agent_settled', { type: 'agent_settled' }); // no credit → wizard pops (engagement prompt)
939
+ expect(fixture.customSpy).toHaveBeenCalledTimes(1);
940
+ await vi.advanceTimersByTimeAsync(0); // flush the dismissed wizard promise
941
+ fixture.sendEditorKey('k'); // ENGAGE the idle window at onset (no credit → cap 0)
942
+ await vi.advanceTimersByTimeAsync(5000); // 5s idle, but cap is 0
943
+ fixture.run('agent_start', { type: 'agent_start' }); // COMMIT → bills 0 (no credit provisioned)
944
+
945
+ const seg = lastEntry(fixture, 'ledger-human');
946
+ expect(seg).toBeDefined();
947
+ expect(seg.billedMs).toBe(0);
948
+ expect(seg.grantedBudgetMs).toBe(0);
949
+ expect(seg.extensions).toBe(0);
950
+ expect(seg.committed).toBe(true);
951
+ expect(fixture.lastSidecarEvent('human-open')!.engagedVia).toBe('keystroke');
952
+ });
953
+
954
+ // ── Retry/queue turns (pi-retry backoff) are not human idle ─────────────
955
+
956
+ it('does not open a human window during a provider-error retry (agent_settled not fired)', async () => {
957
+ fixture.run('agent_end', {
958
+ type: 'agent_end',
959
+ messages: [{ role: 'assistant', stopReason: 'error' }],
960
+ });
961
+ // the engagement wizard now pops at agent_settled, which hasn't fired (a
962
+ // retry is in flight) → no wizard, no human-open, no engagement
963
+ expect(fixture.customSpy).not.toHaveBeenCalled();
964
+ expect(fixture.lastSidecarEvent('human-open')).toBeUndefined();
965
+ // the retry's backoff sleep is NOT billed: agent_start has no window to close
966
+ await vi.advanceTimersByTimeAsync(60_000); // a full minute of backoff
967
+ fixture.run('agent_start', { type: 'agent_start' });
968
+ expect(fixture.lastSidecarEvent('human-close')).toBeUndefined();
969
+ });
970
+
971
+ it('does not bill the retry backoff between an errored turn and the retry agent_start', async () => {
972
+ // turn 1 errors → retry in flight (no agent_settled → no wizard, no engagement)
973
+ fixture.run('agent_end', {
974
+ type: 'agent_end',
975
+ messages: [{ role: 'assistant', stopReason: 'error' }],
976
+ });
977
+ await vi.advanceTimersByTimeAsync(60_000); // 60s backoff — would bill if a window were open
978
+ fixture.run('agent_start', { type: 'agent_start' }); // retry runs; nothing to close (no window)
979
+ expect(fixture.lastSidecarEvent('human-close')).toBeUndefined(); // backoff NOT billed
980
+ // retry succeeds → the agent hands back control, but no window opens until the
981
+ // human engages (idle is engagement-gated now); nothing is billed yet.
982
+ // (agent_settled would pop the no-credit wizard here — covered by the
983
+ // dedicated test below; this one isolates the backoff-not-billed path.)
984
+ fixture.run('agent_end', {
985
+ type: 'agent_end',
986
+ messages: [{ role: 'assistant', stopReason: 'stop' }],
987
+ });
988
+ expect(fixture.readSidecarEvents().filter((e) => e.kind === 'human-open')).toHaveLength(0);
989
+ expect(fixture.lastSidecarEvent('human-close')).toBeUndefined(); // still nothing billed
990
+ });
991
+
992
+ it('does not bill backoff across a multi-retry storm (one error turn per retry)', async () => {
993
+ // simulate 3 errored retries, each separated by a 60s backoff. agent_settled
994
+ // never fires (each agent_end is followed by a retry agent_start), so the
995
+ // engagement wizard never pops and no window opens during the storm.
996
+ for (let i = 0; i < 3; i++) {
997
+ fixture.run('agent_end', {
998
+ type: 'agent_end',
999
+ messages: [{ role: 'assistant', stopReason: 'error' }],
1000
+ });
1001
+ await vi.advanceTimersByTimeAsync(60_000);
1002
+ fixture.run('agent_start', { type: 'agent_start' }); // retry fires
1003
+ }
1004
+ // no human window was ever opened or billed across the whole storm
1005
+ expect(fixture.readSidecarEvents().filter((e) => e.kind === 'human-open')).toHaveLength(0);
1006
+ expect(fixture.readSidecarEvents().filter((e) => e.kind === 'human-close')).toHaveLength(0);
1007
+ });
1008
+
1009
+ it('pops the wizard at agent_settled after a retry storm exhausts (re-offers engagement)', async () => {
1010
+ // A storm of 2 errored retries, then a final errored turn with no retry left.
1011
+ // The old stopReason "error" heuristic suppressed the wizard on every errored
1012
+ // agent_end, so after the storm exhausted the human got no engagement prompt.
1013
+ // agent_settled fires once after the final (exhausted) agent_end and re-offers
1014
+ // the prompt — the human must take over.
1015
+ for (let i = 0; i < 2; i++) {
1016
+ fixture.run('agent_end', {
1017
+ type: 'agent_end',
1018
+ messages: [{ role: 'assistant', stopReason: 'error' }],
1019
+ });
1020
+ await vi.advanceTimersByTimeAsync(60_000);
1021
+ fixture.run('agent_start', { type: 'agent_start' }); // retry fires
1022
+ }
1023
+ fixture.run('agent_end', {
1024
+ type: 'agent_end',
1025
+ messages: [{ role: 'assistant', stopReason: 'error' }],
1026
+ }); // final exhaustion — no retry left
1027
+ fixture.run('agent_settled', { type: 'agent_settled' }); // the storm has settled
1028
+ expect(fixture.customSpy).toHaveBeenCalledTimes(1); // engagement prompt re-offered
1029
+ expect(fixture.lastSidecarEvent('human-open')).toBeUndefined(); // still engagement-gated
1030
+ });
1031
+
1032
+ it('does not pop the wizard at agent_end during a queued follow-up; pops once at agent_settled', async () => {
1033
+ // A normal stop agent_end with a queued follow-up: pi-core continues
1034
+ // automatically, so agent_settled does NOT fire yet. The wizard must not pop
1035
+ // at agent_end (it used to, spuriously, since stopReason was 'stop'). Only
1036
+ // after the follow-up run ends and the run settles does the wizard pop once.
1037
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
1038
+ fixture.run('agent_end', {
1039
+ type: 'agent_end',
1040
+ messages: [{ role: 'assistant', stopReason: 'stop' }],
1041
+ }); // a follow-up is queued → not settled
1042
+ expect(fixture.customSpy).not.toHaveBeenCalled(); // no spurious pop during the continuation
1043
+ fixture.run('agent_start', { type: 'agent_start' }); // the follow-up run auto-starts
1044
+ fixture.run('agent_end', {
1045
+ type: 'agent_end',
1046
+ messages: [{ role: 'assistant', stopReason: 'stop' }],
1047
+ }); // follow-up done; nothing left
1048
+ fixture.run('agent_settled', { type: 'agent_settled' }); // now settled
1049
+ expect(fixture.customSpy).toHaveBeenCalledTimes(1); // pops exactly once, at settle
1050
+ });
1051
+
1052
+ it('pops the wizard for a normal (stop) turn at agent_settled; a keystroke then engages the window', async () => {
1053
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
1054
+ fixture.run('agent_end', {
1055
+ type: 'agent_end',
1056
+ messages: [{ role: 'assistant', stopReason: 'stop' }],
1057
+ });
1058
+ fixture.run('agent_settled', { type: 'agent_settled' });
1059
+ expect(fixture.customSpy).toHaveBeenCalledTimes(1); // wizard pops (engagement prompt)
1060
+ expect(fixture.lastSidecarEvent('human-open')).toBeUndefined(); // no window until engaged
1061
+ fixture.sendEditorKey('k'); // engage → opens the idle window
1062
+ expect(fixture.lastSidecarEvent('human-open')).toBeDefined();
1063
+ });
1064
+
1065
+ it('still pops the wizard for an aborted turn at agent_settled (a retry is not in flight)', async () => {
1066
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
1067
+ fixture.run('agent_end', {
1068
+ type: 'agent_end',
1069
+ messages: [{ role: 'assistant', stopReason: 'aborted' }],
1070
+ });
1071
+ fixture.run('agent_settled', { type: 'agent_settled' });
1072
+ expect(fixture.customSpy).toHaveBeenCalledTimes(1); // wizard pops (not a retry in flight)
1073
+ expect(fixture.lastSidecarEvent('human-open')).toBeUndefined(); // no window until engaged
1074
+ fixture.sendEditorKey('k');
1075
+ expect(fixture.lastSidecarEvent('human-open')).toBeDefined();
1076
+ });
1077
+
1078
+ it('pops the wizard at agent_settled when no assistant message is present; a keystroke engages', async () => {
1079
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
1080
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
1081
+ fixture.run('agent_settled', { type: 'agent_settled' });
1082
+ expect(fixture.customSpy).toHaveBeenCalledTimes(1); // wizard pops
1083
+ expect(fixture.lastSidecarEvent('human-open')).toBeUndefined();
1084
+ fixture.sendEditorKey('k');
1085
+ expect(fixture.lastSidecarEvent('human-open')).toBeDefined();
1086
+ });
1087
+
1088
+ it('bills 0 for engaged idle after the wizard is dismissed (no credit)', async () => {
1089
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
1090
+ fixture.setCustomResult('stop');
1091
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
1092
+ fixture.run('agent_settled', { type: 'agent_settled' }); // wizard pops, dismissed ('stop' → no engagement)
1093
+ expect(fixture.customSpy).toHaveBeenCalledTimes(1);
1094
+ await vi.advanceTimersByTimeAsync(0); // flush the dismissed wizard
1095
+ fixture.sendEditorKey('k'); // engage the idle window at onset (no credit → cap 0)
1096
+ await vi.advanceTimersByTimeAsync(90_000); // 90s engaged idle, no credit
1097
+ fixture.run('agent_start', { type: 'agent_start' }); // commit
1098
+
1099
+ const seg = lastEntry(fixture, 'ledger-human');
1100
+ expect(seg.billedMs).toBe(0); // no credit → bills 0
1101
+ expect(seg.extensions).toBe(0);
1102
+ });
1103
+
1104
+ it('extends the budget when the wizard is accepted', async () => {
1105
+ fixture.setCustomResult('extend');
1106
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
1107
+ fixture.run('agent_settled', { type: 'agent_settled' }); // wizard pops immediately, accepted → +20m, engages, re-armed at 20m
1108
+ expect(fixture.customSpy).toHaveBeenCalledTimes(1);
1109
+ await vi.advanceTimersByTimeAsync(360_000); // 6m idle, under 20m budget → re-armed wizard not fired
1110
+ fixture.run('agent_start', { type: 'agent_start' });
1111
+
1112
+ const seg = lastEntry(fixture, 'ledger-human');
1113
+ expect(seg.grantedBudgetMs).toBe(20 * 60_000);
1114
+ expect(seg.extensions).toBe(1);
1115
+ expect(seg.billedMs).toBe(360_000); // 6 min
1116
+ });
1117
+
1118
+ it('bills the thinking span for extend + extend + extend + type-and-go (the nuance pattern)', async () => {
1119
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
1120
+ fixture.setCustomResult('extend'); // every wizard pop → extend (engages + grants capacity)
1121
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
1122
+ fixture.run('agent_settled', { type: 'agent_settled' }); // no credit → 1st pop → engage + +20m
1123
+ await vi.advanceTimersByTimeAsync(0); // flush the 1st extend → openIdleWindow('extension', 20m), cap 20m
1124
+ // idle to the first exhaustion (20m credit) → 2nd pop → +20m (cap 40m)
1125
+ await vi.advanceTimersByTimeAsync(20 * 60_000);
1126
+ // idle to the next exhaustion (another 20m) → 3rd pop → +20m (cap 60m)
1127
+ await vi.advanceTimersByTimeAsync(20 * 60_000);
1128
+ // 10m more thinking (under the 60m cap), then type and go (submit → agent_start commits)
1129
+ await vi.advanceTimersByTimeAsync(10 * 60_000);
1130
+ fixture.run('agent_start', { type: 'agent_start' });
1131
+
1132
+ const seg = lastEntry(fixture, 'ledger-human');
1133
+ expect(seg).toBeDefined();
1134
+ expect(seg.extensions).toBe(3); // three pomodoro blocks granted
1135
+ expect(seg.billedMs).toBe(50 * 60_000); // the whole thinking span (20 + 20 + 10) min, under the 60m cap
1136
+ expect(seg.committed).toBe(true); // committed by the submit (agent action), not abandoned
1137
+ expect(fixture.lastSidecarEvent('human-open')!.engagedVia).toBe('extension'); // engaged via the first extend
1138
+
1139
+ // live accumulation round-trips through rehydrate: 3 blocks granted (60m),
1140
+ // 50m billed (all consumed against credit), 10m credit remaining.
1141
+ const r = rehydrateFromSidecar(fixture.readSidecarEvents());
1142
+ expect(r.totals.extensionsGranted).toBe(3);
1143
+ expect(r.totals.extensionCreditMs).toBe(3 * 20 * 60_000);
1144
+ expect(r.totals.extensionConsumedMs).toBe(50 * 60_000);
1145
+ expect(r.extensionBudgetMs).toBe(10 * 60_000);
1146
+ expect(r.totals.humanIdleMs).toBe(50 * 60_000);
1147
+ expect(r.totals.idleWindows).toBe(1);
1148
+ expect(r.totals.humanSteerMs).toBe(0);
1149
+ expect(r.totals.abandonedWindows).toBe(0);
1150
+ });
1151
+
1152
+ it('suppresses the wizard at the next agent_settled while rolling extension credit remains', async () => {
1153
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
1154
+ fixture.setCustomResult('extend');
1155
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
1156
+ fixture.run('agent_settled', { type: 'agent_settled' }); // pops → +20m, engages via extension
1157
+ expect(fixture.customSpy).toHaveBeenCalledTimes(1);
1158
+ await vi.advanceTimersByTimeAsync(120_000); // 2m idle (under 20m cap); flushes the extend
1159
+ fixture.run('agent_start', { type: 'agent_start' }); // commit: 2m billed, 2m ext consumed → 18m left
1160
+
1161
+ const seg1 = lastEntry(fixture, 'ledger-human');
1162
+ expect(seg1.billedMs).toBe(120_000);
1163
+ expect(seg1.extensionBudgetMs).toBe(18 * 60_000);
1164
+
1165
+ // next settle: 18m credit remains → wizard is NOT shown (no pop, no auto-open)
1166
+ fixture.customSpy.mockClear();
1167
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
1168
+ fixture.run('agent_settled', { type: 'agent_settled' });
1169
+ expect(fixture.customSpy).not.toHaveBeenCalled();
1170
+ // the idle window opens only on engagement; engage via keystroke → cap = 18m credit
1171
+ fixture.sendEditorKey('k');
1172
+ const open = fixture.lastSidecarEvent('human-open');
1173
+ expect(open!.grantedBudgetMs).toBe(18 * 60_000); // 18m rolling credit
1174
+ expect(open!.extensionBudgetMs).toBe(18 * 60_000);
1175
+ });
1176
+
1177
+ it('rolls unused extension credit across multiple agent turns', async () => {
1178
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
1179
+ fixture.setCustomResult('extend');
1180
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
1181
+ fixture.run('agent_settled', { type: 'agent_settled' }); // pops → +20m, engages via extension
1182
+ await vi.advanceTimersByTimeAsync(120_000); // 2m idle (flushes the extend)
1183
+ fixture.run('agent_start', { type: 'agent_start' }); // commit → 2m billed, 2m consumed → 18m left
1184
+
1185
+ // turn 2: 18m credit remains → no pop at agent_settled; engage via keystroke
1186
+ fixture.customSpy.mockClear();
1187
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
1188
+ fixture.run('agent_settled', { type: 'agent_settled' });
1189
+ expect(fixture.customSpy).not.toHaveBeenCalled();
1190
+ fixture.sendEditorKey('k'); // engage the new window (cap = 18m credit)
1191
+ await vi.advanceTimersByTimeAsync(120_000); // 2m idle
1192
+ fixture.run('agent_start', { type: 'agent_start' }); // commit → 2m consumed → 16m left
1193
+ expect(lastEntry(fixture, 'ledger-human').extensionBudgetMs).toBe(16 * 60_000);
1194
+
1195
+ // turn 3: still 16m credit → still suppressed
1196
+ fixture.customSpy.mockClear();
1197
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
1198
+ fixture.run('agent_settled', { type: 'agent_settled' });
1199
+ expect(fixture.customSpy).not.toHaveBeenCalled();
1200
+ });
1201
+
1202
+ it('pops the wizard at agent_settled when no rolling credit remains', async () => {
1203
+ fixture.seedSidecar([{ kind: 'settings', settings: { ...DEFAULTS }, timestamp: 0 }]);
1204
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' }); // rehydrate settings; no pop (startup)
1205
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
1206
+ fixture.run('agent_settled', { type: 'agent_settled' });
1207
+ expect(fixture.customSpy).toHaveBeenCalledTimes(1); // no credit → pop immediately
1208
+ });
1209
+
1210
+ it('pops the wizard on /resume (and /reload) to prompt engagement for review', async () => {
1211
+ fixture.seedSidecar([{ kind: 'settings', settings: { ...DEFAULTS }, timestamp: 0 }]);
1212
+ fixture.run('session_start', { type: 'session_start', reason: 'resume' }); // resume → pop
1213
+ expect(fixture.customSpy).toHaveBeenCalledTimes(1);
1214
+ fixture.customSpy.mockClear();
1215
+ fixture.run('session_start', { type: 'session_start', reason: 'reload' }); // reload → pop
1216
+ expect(fixture.customSpy).toHaveBeenCalledTimes(1);
1217
+ fixture.customSpy.mockClear();
1218
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' }); // startup → no pop
1219
+ expect(fixture.customSpy).not.toHaveBeenCalled();
1220
+ });
1221
+
1222
+ it('suppresses the wizard at agent_end when rolling credit is rehydrated', async () => {
1223
+ // A prior idle window left 18m of rolling pomodoro credit on the sidecar.
1224
+ fixture.seedSidecar([
1225
+ { kind: 'settings', settings: { ...DEFAULTS }, timestamp: 0 },
1226
+ {
1227
+ kind: 'human-open',
1228
+ openedAt: 0,
1229
+ grantedBudgetMs: 20 * 60_000,
1230
+ extensions: 1,
1231
+ extensionBudgetMs: 20 * 60_000,
1232
+ timestamp: 1000,
1233
+ },
1234
+ {
1235
+ kind: 'human-close',
1236
+ openedAt: 0,
1237
+ closedAt: 2 * 60_000,
1238
+ billedMs: 2 * 60_000,
1239
+ idleMs: 2 * 60_000,
1240
+ grantedBudgetMs: 20 * 60_000,
1241
+ extensions: 1,
1242
+ extensionBudgetMs: 18 * 60_000,
1243
+ timestamp: 2000,
1244
+ },
1245
+ ]);
1246
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' }); // rehydrate 18m credit; no pop
1247
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
1248
+ fixture.run('agent_settled', { type: 'agent_settled' });
1249
+ expect(fixture.customSpy).not.toHaveBeenCalled(); // 18m credit → suppressed
1250
+ fixture.sendEditorKey('k'); // engage → open window with cap = 18m credit
1251
+ const open = fixture.lastSidecarEvent('human-open');
1252
+ expect(open!.grantedBudgetMs).toBe(18 * 60_000); // 18m credit
1253
+ expect(open!.extensionBudgetMs).toBe(18 * 60_000);
1254
+ });
1255
+
1256
+ it('/ledger-extend opens the wizard; confirming extends by the given minutes', async () => {
1257
+ fixture.seedSidecar([
1258
+ { kind: 'settings', settings: { ...DEFAULTS, autoWizard: false }, timestamp: 0 },
1259
+ ]);
1260
+ fixture.setCustomResult('extend');
1261
+ fixture.run('session_start', { type: 'session_start', reason: 'resume' }); // apply autoWizard: false
1262
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
1263
+ fixture.run('agent_settled', { type: 'agent_settled' }); // autoWizard off → no auto-pop
1264
+ expect(fixture.customSpy).not.toHaveBeenCalled();
1265
+
1266
+ await fixture.commands['ledger-extend'].handler('5', fixture.mockCtx); // opens the wizard → confirm → +5m
1267
+ expect(fixture.customSpy).toHaveBeenCalledTimes(1);
1268
+
1269
+ await vi.advanceTimersByTimeAsync(100_000); // 100s idle, under 5m budget
1270
+ fixture.run('agent_start', { type: 'agent_start' });
1271
+
1272
+ const seg = lastEntry(fixture, 'ledger-human');
1273
+ expect(seg.grantedBudgetMs).toBe(5 * 60_000);
1274
+ expect(seg.extensions).toBe(1);
1275
+ expect(seg.billedMs).toBe(100_000);
1276
+ });
1277
+
1278
+ it('/ledger-extend opens the wizard with no window open (engage via extension)', async () => {
1279
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
1280
+ fixture.setCustomResult('extend');
1281
+ await fixture.commands['ledger-extend'].handler('5', fixture.mockCtx); // no window → wizard → extend engages
1282
+ expect(fixture.customSpy).toHaveBeenCalledTimes(1);
1283
+ await vi.advanceTimersByTimeAsync(0); // flush the extend → openIdleWindow('extension', 5m)
1284
+ const open = fixture.lastSidecarEvent('human-open');
1285
+ expect(open).toBeDefined();
1286
+ expect(open!.engagedVia).toBe('extension');
1287
+ expect(open!.grantedBudgetMs).toBe(5 * 60_000); // 5m credit
1288
+ });
1289
+
1290
+ it('shows a `select` dialog (not the TUI custom wizard) in RPC mode at agent_settled', async () => {
1291
+ (fixture.mockCtx as unknown as { mode: string }).mode = 'rpc';
1292
+ fixture.setSelectResult('Extend +20m');
1293
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
1294
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
1295
+ fixture.run('agent_settled', { type: 'agent_settled' }); // no credit -> RPC select pops
1296
+ expect(fixture.customSpy).not.toHaveBeenCalled();
1297
+ expect(fixture.selectSpy).toHaveBeenCalledTimes(1);
1298
+ await vi.advanceTimersByTimeAsync(0); // flush the select resolve -> applyWizardChoice('extend')
1299
+ const open = fixture.lastSidecarEvent('human-open');
1300
+ expect(open).toBeDefined();
1301
+ expect(open!.engagedVia).toBe('extension');
1302
+ expect(open!.grantedBudgetMs).toBe(20 * 60_000); // +20m block
1303
+ });
1304
+
1305
+ it('auto-extends a pomodoro block silently (no dialog) when credit runs out', async () => {
1306
+ fixture.seedSidecar([
1307
+ { kind: 'settings', settings: { ...DEFAULTS, autoExtend: true }, timestamp: 0 },
1308
+ ]);
1309
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' }); // rehydrate autoExtend; no pop
1310
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
1311
+ fixture.run('agent_settled', { type: 'agent_settled' }); // no credit -> auto-extend (no pop)
1312
+ expect(fixture.customSpy).not.toHaveBeenCalled();
1313
+ expect(fixture.selectSpy).not.toHaveBeenCalled();
1314
+ await vi.advanceTimersByTimeAsync(0); // flush
1315
+ const open = fixture.lastSidecarEvent('human-open');
1316
+ expect(open).toBeDefined();
1317
+ expect(open!.engagedVia).toBe('extension');
1318
+ expect(open!.grantedBudgetMs).toBe(20 * 60_000);
1319
+ expect(fixture.notifySpy).toHaveBeenCalledWith(
1320
+ 'Auto-extended billable human time by 20m.',
1321
+ 'info'
1322
+ );
1323
+ });
1324
+
1325
+ it('auto-extends even in a headless (print) mode with no dialog UI', async () => {
1326
+ (fixture.mockCtx as unknown as { mode: string }).mode = 'print';
1327
+ fixture.seedSidecar([
1328
+ { kind: 'settings', settings: { ...DEFAULTS, autoExtend: true }, timestamp: 0 },
1329
+ ]);
1330
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
1331
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
1332
+ fixture.run('agent_settled', { type: 'agent_settled' });
1333
+ expect(fixture.customSpy).not.toHaveBeenCalled();
1334
+ const open = fixture.lastSidecarEvent('human-open');
1335
+ expect(open).toBeDefined();
1336
+ expect(open!.grantedBudgetMs).toBe(20 * 60_000);
1337
+ });
1338
+
1339
+ it('/ledger-extend opens the select wizard in RPC mode (Stop billing arms the guard)', async () => {
1340
+ (fixture.mockCtx as unknown as { mode: string }).mode = 'rpc';
1341
+ fixture.seedSidecar([
1342
+ { kind: 'settings', settings: { ...DEFAULTS, autoWizard: false }, timestamp: 0 },
1343
+ ]);
1344
+ fixture.run('session_start', { type: 'session_start', reason: 'resume' });
1345
+ fixture.setSelectResult('Stop billing');
1346
+ await fixture.commands['ledger-extend'].handler('5', fixture.mockCtx);
1347
+ expect(fixture.customSpy).not.toHaveBeenCalled();
1348
+ expect(fixture.selectSpy).toHaveBeenCalledTimes(1);
1349
+ await vi.advanceTimersByTimeAsync(0); // flush -> applyWizardChoice('stop')
1350
+ const pause = fixture.lastSidecarEvent('billing-pause');
1351
+ expect(pause).toBeDefined();
1352
+ expect(pause!.paused).toBe(true);
1353
+ });
1354
+
1355
+ it('/ledger-settings edits a scalar via select -> input in RPC mode', async () => {
1356
+ (fixture.mockCtx as unknown as { mode: string }).mode = 'rpc';
1357
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
1358
+ fixture.setSelectResult('Agent rate: 60');
1359
+ fixture.setInputResult('120');
1360
+ await fixture.commands['ledger-settings'].handler('', fixture.mockCtx);
1361
+ expect(fixture.customSpy).not.toHaveBeenCalled();
1362
+ expect(fixture.selectSpy).toHaveBeenCalledTimes(1);
1363
+ expect(fixture.inputSpy).toHaveBeenCalledTimes(1);
1364
+ const settingsEvt = fixture.lastSidecarEvent('settings');
1365
+ expect(settingsEvt).toBeDefined();
1366
+ expect(settingsEvt!.settings.agentRatePerHour).toBe(120);
1367
+ });
1368
+
1369
+ it('rehydrates totals + settings on session_start and /ledger reports them', async () => {
1370
+ fixture.seedSidecar([
1371
+ {
1372
+ kind: 'settings',
1373
+ settings: {
1374
+ ...DEFAULTS,
1375
+ agentRatePerHour: 100,
1376
+ humanRatePerHour: 50,
1377
+ project: 'app',
1378
+ author: 'tom',
1379
+ },
1380
+ timestamp: 0,
1381
+ },
1382
+ {
1383
+ kind: 'agent',
1384
+ id: 'a1',
1385
+ turnIndex: 0,
1386
+ agentMs: 3_600_000,
1387
+ generationMs: 3_000_000,
1388
+ stallMs: 0,
1389
+ toolMs: 600_000,
1390
+ tokens: { input: 0, output: 0, total: 0 },
1391
+ model: { provider: 'openai', modelId: 'gpt-4' },
1392
+ source: 'tps',
1393
+ timestamp: 1000,
1394
+ },
1395
+ {
1396
+ kind: 'human-close',
1397
+ openedAt: 0,
1398
+ closedAt: 1_800_000,
1399
+ billedMs: 1_800_000,
1400
+ idleMs: 1_800_000,
1401
+ grantedBudgetMs: 60_000,
1402
+ extensions: 0,
1403
+ timestamp: 2000,
1404
+ },
1405
+ ]);
1406
+ fixture.run('session_start', { type: 'session_start', reason: 'resume' });
1407
+ await fixture.commands['ledger'].handler('', fixture.mockCtx);
1408
+
1409
+ const msg = fixture.notifySpy.mock.calls.at(-1)![0] as string;
1410
+ expect(msg).toContain('agent 1.00h (1 turns)');
1411
+ // 1 rehydrated closed human window — no initial window opens at session_start
1412
+ // (engagement-gated), so just 1 window.
1413
+ expect(msg).toContain('human 0.50h (1 windows)');
1414
+ expect(msg).toContain('total $125.00');
1415
+ // status line shows the live hours, grey (dim) like pi-core's footer
1416
+ expect(fixture.setStatusSpy).toHaveBeenCalledWith(
1417
+ 'ledger',
1418
+ expect.stringContaining('agent 1.00h')
1419
+ );
1420
+ });
1421
+
1422
+ it('derives the footer + /ledger hours from pi-tps markers for a tps-only session', async () => {
1423
+ fixture.seedSidecar([
1424
+ {
1425
+ kind: 'settings',
1426
+ settings: { ...DEFAULTS, agentRatePerHour: 120, humanRatePerHour: 60 },
1427
+ timestamp: 0,
1428
+ },
1429
+ ]);
1430
+ fixture.mockEntries.push({
1431
+ type: 'custom',
1432
+ customType: 'tps',
1433
+ data: {
1434
+ timing: { generationMs: 3_600_000, stallMs: 0, totalMs: 3_700_000 },
1435
+ tokens: { input: 0, output: 270000, total: 270000 },
1436
+ model: { provider: 'openai', modelId: 'gpt-4' },
1437
+ timestamp: 1000,
1438
+ },
1439
+ });
1440
+ fixture.run('session_start', { type: 'session_start', reason: 'resume' });
1441
+
1442
+ // status line derives agent hours from the tps marker, grey like pi-core
1443
+ expect(fixture.setStatusSpy).toHaveBeenCalledWith(
1444
+ 'ledger',
1445
+ expect.stringContaining('agent 1.00h')
1446
+ );
1447
+
1448
+ await fixture.commands['ledger'].handler('', fixture.mockCtx);
1449
+ const msg = fixture.notifySpy.mock.calls.at(-2)![0] as string;
1450
+ expect(msg).toContain('agent 1.00h (1 turns)');
1451
+ expect(
1452
+ fixture.notifySpy.mock.calls.some(
1453
+ (c) => typeof c[0] === 'string' && c[0].includes('Derived from 1 pi-tps markers')
1454
+ )
1455
+ ).toBe(true);
1456
+ });
1457
+
1458
+ it('counts the in-progress open human window in /ledger (entire session up to now)', async () => {
1459
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
1460
+ fixture.setCustomResult('extend');
1461
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
1462
+ fixture.run('agent_settled', { type: 'agent_settled' }); // no credit → wizard → extend +20m (engages)
1463
+ await vi.advanceTimersByTimeAsync(0); // flush the extend → open window, cap 20m
1464
+ await vi.advanceTimersByTimeAsync(30_000); // 30s idle, window still open (uncommitted)
1465
+ await fixture.commands['ledger'].handler('', fixture.mockCtx);
1466
+ const msg = fixture.notifySpy.mock.calls.at(-1)![0] as string;
1467
+ // the open window's 30s idle is counted now (against the 20m credit), not deferred to close
1468
+ expect(msg).toContain('human 0.01h (1 windows)');
1469
+ });
1470
+
1471
+ it('defaults agent and human rates to $60/h', async () => {
1472
+ // no settings entry → the extension defaults ($60/h) apply
1473
+ fixture.seedSidecar([
1474
+ {
1475
+ kind: 'agent',
1476
+ id: 'a1',
1477
+ turnIndex: 0,
1478
+ agentMs: 3_600_000,
1479
+ generationMs: 3_600_000,
1480
+ stallMs: 0,
1481
+ toolMs: 0,
1482
+ tokens: { input: 0, output: 0, total: 0 },
1483
+ model: { provider: 'openai', modelId: 'gpt-4' },
1484
+ source: 'tps',
1485
+ timestamp: 1000,
1486
+ },
1487
+ ]);
1488
+ fixture.run('session_start', { type: 'session_start', reason: 'resume' });
1489
+ await fixture.commands['ledger'].handler('', fixture.mockCtx);
1490
+ const msg = fixture.notifySpy.mock.calls.at(-1)![0] as string;
1491
+ // 1h agent @ $60/h = $60; 0h human; total $60
1492
+ expect(msg).toContain('= $60.00');
1493
+ expect(msg).toContain('total $60.00');
1494
+ });
1495
+
1496
+ it('notifies (once, info) that built-in timing is in use when pi-tps is absent', async () => {
1497
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
1498
+ expect(fixture.notifySpy).toHaveBeenCalledWith(
1499
+ expect.stringContaining('built-in timing'),
1500
+ 'info'
1501
+ );
1502
+ fixture.notifySpy.mockClear();
1503
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
1504
+ const noted = fixture.notifySpy.mock.calls.some(
1505
+ (c) => typeof c[0] === 'string' && c[0].includes('built-in timing')
1506
+ );
1507
+ expect(noted).toBe(false); // one-time only
1508
+ });
1509
+
1510
+ it('does not notify built-in timing after telemetry has been seen', async () => {
1511
+ fixture.run('turn_start', { type: 'turn_start', turnIndex: 0, timestamp: Date.now() });
1512
+ fixture.emitEvent('tps:telemetry', makeTpsTelemetry());
1513
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
1514
+ const noted = fixture.notifySpy.mock.calls.some(
1515
+ (c) => typeof c[0] === 'string' && c[0].includes('built-in timing')
1516
+ );
1517
+ expect(noted).toBe(false);
1518
+ });
1519
+
1520
+ it('/ledger-receipt writes an HTML file with the totals', async () => {
1521
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-ledger-test-'));
1522
+ process.env.XDG_CACHE_HOME = tmp;
1523
+ try {
1524
+ fixture.seedSidecar([
1525
+ {
1526
+ kind: 'settings',
1527
+ settings: {
1528
+ ...DEFAULTS,
1529
+ agentRatePerHour: 100,
1530
+ humanRatePerHour: 50,
1531
+ project: 'app.inloop.studio',
1532
+ author: 'tom',
1533
+ },
1534
+ timestamp: 0,
1535
+ },
1536
+ {
1537
+ kind: 'agent',
1538
+ id: 'a1',
1539
+ turnIndex: 0,
1540
+ agentMs: 3_600_000,
1541
+ generationMs: 3_600_000,
1542
+ stallMs: 0,
1543
+ toolMs: 0,
1544
+ tokens: { input: 0, output: 0, total: 1500 },
1545
+ model: { provider: 'openai', modelId: 'gpt-4' },
1546
+ source: 'tps',
1547
+ timestamp: 1000,
1548
+ },
1549
+ ]);
1550
+ fixture.run('session_start', { type: 'session_start', reason: 'resume' });
1551
+
1552
+ await fixture.commands['ledger-receipt'].handler('', fixture.mockCtx);
1553
+
1554
+ const dir = path.join(tmp, 'pi-ledger');
1555
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith('.html'));
1556
+ expect(files).toHaveLength(1);
1557
+ const html = fs.readFileSync(path.join(dir, files[0]!), 'utf8');
1558
+ expect(html).toContain('pi-ledger');
1559
+ expect(html).toContain('billed like serverless');
1560
+ expect(html).toContain('Geist+Mono');
1561
+ expect(html).toContain('app.inloop.studio');
1562
+ expect(html).toContain('data-reveal');
1563
+ expect(html).toContain('$100.00'); // agent cost for 1h @ $100
1564
+ expect(
1565
+ fixture.notifySpy.mock.calls.some(
1566
+ (c) => typeof c[0] === 'string' && c[0].startsWith('Receipt →')
1567
+ )
1568
+ ).toBe(true);
1569
+ } finally {
1570
+ delete process.env.XDG_CACHE_HOME;
1571
+ fs.rmSync(tmp, { recursive: true, force: true });
1572
+ }
1573
+ });
1574
+
1575
+ it('/ledger-receipt converts pi-tps markers when there is no live ledger data', async () => {
1576
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-ledger-test-'));
1577
+ process.env.XDG_CACHE_HOME = tmp;
1578
+ try {
1579
+ // A session with only pi-tps markers — pi-ledger wasn't tracking.
1580
+ fixture.seedSidecar([
1581
+ {
1582
+ kind: 'settings',
1583
+ settings: { ...DEFAULTS, agentRatePerHour: 100, humanRatePerHour: 50, project: 'demo' },
1584
+ timestamp: 0,
1585
+ },
1586
+ ]);
1587
+ fixture.mockEntries.push({
1588
+ type: 'custom',
1589
+ customType: 'tps',
1590
+ data: {
1591
+ timing: { generationMs: 3_600_000, stallMs: 0, totalMs: 3_700_000 },
1592
+ tokens: { input: 0, output: 270000, total: 270000 },
1593
+ model: { provider: 'openai', modelId: 'gpt-4' },
1594
+ timestamp: 1000,
1595
+ },
1596
+ });
1597
+ fixture.run('session_start', { type: 'session_start', reason: 'resume' }); // settings + empty totals
1598
+
1599
+ await fixture.commands['ledger-receipt'].handler('', fixture.mockCtx);
1600
+
1601
+ const dir = path.join(tmp, 'pi-ledger');
1602
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith('.html'));
1603
+ expect(files).toHaveLength(1);
1604
+ const html = fs.readFileSync(path.join(dir, files[0]!), 'utf8');
1605
+ // 1h agent @ $100 = $100; markers carry no credit/commit info → 0 human time
1606
+ expect(html).toContain('$100.00');
1607
+ expect(html).toContain('demo');
1608
+ expect(
1609
+ fixture.notifySpy.mock.calls.some(
1610
+ (c) => typeof c[0] === 'string' && c[0].includes('pi-tps markers')
1611
+ )
1612
+ ).toBe(true);
1613
+ } finally {
1614
+ delete process.env.XDG_CACHE_HOME;
1615
+ fs.rmSync(tmp, { recursive: true, force: true });
1616
+ }
1617
+ });
1618
+
1619
+ it('session_shutdown abandons an uncommitted idle window (exit recorded, bills 0)', async () => {
1620
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
1621
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
1622
+ fixture.run('agent_settled', { type: 'agent_settled' }); // wizard pops (no credit)
1623
+ await vi.advanceTimersByTimeAsync(0); // flush dismissed wizard
1624
+ fixture.sendEditorKey('k'); // engage the idle window
1625
+ await vi.advanceTimersByTimeAsync(30_000); // 30s idle, uncommitted (no submit)
1626
+ fixture.run('session_shutdown', { type: 'session_shutdown' }); // exit → close, ABANDONED
1627
+ const close = fixture.lastSidecarEvent('human-close');
1628
+ expect(close).toBeDefined();
1629
+ expect(close!.billedMs).toBe(0); // uncommitted idle bills nothing
1630
+ expect(close!.committed).toBe(false);
1631
+ // re-entering: the abandoned (0-billed) window is closed, not in-progress; idle
1632
+ // was NOT retained (idle with no output is wasted). No initial window opens.
1633
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
1634
+ await fixture.commands['ledger'].handler('', fixture.mockCtx);
1635
+ const msg = fixture.notifySpy.mock.calls.at(-1)![0] as string;
1636
+ expect(msg).toContain('human 0.00h (0 windows)');
1637
+ });
1638
+
1639
+ it('rehydrates from the sidecar, so compaction (empty JSONL) does not reset totals', async () => {
1640
+ // The session JSONL is empty (as if compaction dropped it); the sidecar holds the history.
1641
+ fixture.mockEntries.length = 0;
1642
+ fixture.seedSidecar([
1643
+ { kind: 'settings', settings: { ...DEFAULTS, agentRatePerHour: 60 }, timestamp: 0 },
1644
+ {
1645
+ kind: 'agent',
1646
+ id: 'a1',
1647
+ turnIndex: 0,
1648
+ agentMs: 3_600_000,
1649
+ generationMs: 3_600_000,
1650
+ stallMs: 0,
1651
+ toolMs: 0,
1652
+ tokens: { input: 0, output: 0, total: 0 },
1653
+ model: { provider: 'openai', modelId: 'gpt-4' },
1654
+ source: 'tps',
1655
+ timestamp: 1000,
1656
+ },
1657
+ ]);
1658
+ fixture.run('session_start', { type: 'session_start', reason: 'resume' });
1659
+ await fixture.commands['ledger'].handler('', fixture.mockCtx);
1660
+ const msg = fixture.notifySpy.mock.calls.at(-1)![0] as string;
1661
+ expect(msg).toContain('agent 1.00h (1 turns)'); // totals survive from the sidecar
1662
+ });
1663
+
1664
+ it('session_tree (/tree go-back) keeps the live totals — does not reset to $0', async () => {
1665
+ fixture.run('turn_start', { type: 'turn_start', turnIndex: 0, timestamp: Date.now() });
1666
+ fixture.emitEvent(
1667
+ 'tps:telemetry',
1668
+ makeTpsTelemetry({ generationMs: 3_600_000, stallMs: 0, output: 270000, total: 270000 })
1669
+ );
1670
+ // branching (/tree → "go back to an earlier message") fires session_tree
1671
+ fixture.run('session_tree', { type: 'session_tree' });
1672
+ // the live in-memory total is kept — not reset to $0
1673
+ expect(fixture.setStatusSpy.mock.calls.at(-1)![1]).toContain('agent 1.00h');
1674
+ });
1675
+
1676
+ it('session_tree keeps the open human window idle (the growing-idle case)', async () => {
1677
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
1678
+ fixture.setCustomResult('extend');
1679
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
1680
+ fixture.run('agent_settled', { type: 'agent_settled' }); // no credit → wizard → extend +20m (engages)
1681
+ await vi.advanceTimersByTimeAsync(0); // flush the extend → open window, cap 20m
1682
+ await vi.advanceTimersByTimeAsync(30_000); // 30s idle, window open
1683
+ fixture.run('session_tree', { type: 'session_tree' }); // /tree → "go back"
1684
+ // status keeps the open window's idle — not reset to $0
1685
+ expect(fixture.setStatusSpy.mock.calls.at(-1)![1]).toContain('human 0.01h');
1686
+ });
1687
+
1688
+ it('session_start keeps live totals if the sidecar read is empty (no reset to $0)', async () => {
1689
+ fixture.run('turn_start', { type: 'turn_start', turnIndex: 0, timestamp: Date.now() });
1690
+ fixture.emitEvent(
1691
+ 'tps:telemetry',
1692
+ makeTpsTelemetry({ generationMs: 3_600_000, stallMs: 0, output: 270000, total: 270000 })
1693
+ );
1694
+ fixture.clearSidecar(); // simulate a missing/failed sidecar read
1695
+ fixture.run('session_start', { type: 'session_start', reason: 'reload' });
1696
+ expect(fixture.setStatusSpy.mock.calls.at(-1)![1]).toContain('agent 1.00h');
1697
+ });
1698
+
1699
+ // ── Initial human window (first-prompt composition) ────────────────────
1700
+
1701
+ it('engages an initial human window on first keystroke; with no credit it bills 0 (no wizard at startup)', async () => {
1702
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
1703
+ // no window opens at session_start — engagement is gated on the first keystroke
1704
+ expect(fixture.customSpy).not.toHaveBeenCalled();
1705
+ expect(fixture.lastSidecarEvent('human-open')).toBeUndefined();
1706
+ fixture.sendEditorKey('k'); // first keystroke ENGAGES the initial window at onset
1707
+ const open = fixture.lastSidecarEvent('human-open');
1708
+ expect(open).toBeDefined();
1709
+ expect(open!.grantedBudgetMs).toBe(0); // no credit provisioned → cap 0
1710
+ expect(open!.engagedVia).toBe('keystroke');
1711
+ expect(open!.extensionBudgetMs).toBe(0);
1712
+
1713
+ await vi.advanceTimersByTimeAsync(30_000); // 30s composing the first prompt
1714
+ fixture.run('agent_start', { type: 'agent_start' }); // submitted → commit
1715
+
1716
+ const close = fixture.lastSidecarEvent('human-close');
1717
+ expect(close).toBeDefined();
1718
+ expect(close!.billedMs).toBe(0); // no credit → bills 0
1719
+ expect(close!.committed).toBe(true);
1720
+ expect(close!.extensions).toBe(0);
1721
+ });
1722
+
1723
+ it('a long engaged idle with no credit bills 0', async () => {
1724
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
1725
+ fixture.sendEditorKey('k'); // engage the initial window at onset (no credit → cap 0)
1726
+ await vi.advanceTimersByTimeAsync(5 * 60_000); // 5m idle after engagement (no credit)
1727
+ fixture.run('agent_start', { type: 'agent_start' }); // commit
1728
+
1729
+ const close = fixture.lastSidecarEvent('human-close');
1730
+ expect(close!.billedMs).toBe(0); // no credit provisioned → bills 0
1731
+ expect(close!.idleMs).toBe(5 * 60_000); // the 5m span is recorded for audit, unbilled
1732
+ expect(close!.extensions).toBe(0);
1733
+ });
1734
+
1735
+ it('records the idle keystroke count on the human-close event (composition-density analytics)', async () => {
1736
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
1737
+ // engage via keystroke, then type 4 more distinct keys (no held-key collapse)
1738
+ fixture.sendEditorKey('h'); // engage (count 1)
1739
+ fixture.sendEditorKey('e');
1740
+ fixture.sendEditorKey('l');
1741
+ fixture.sendEditorKey('o');
1742
+ fixture.sendEditorKey('!');
1743
+ await vi.advanceTimersByTimeAsync(10_000);
1744
+ fixture.run('agent_start', { type: 'agent_start' }); // commit
1745
+
1746
+ const close = fixture.lastSidecarEvent('human-close');
1747
+ expect(close!.keystrokes).toBe(5); // all five idle keystrokes counted
1748
+ expect(fixture.lastSidecarEvent('human-open')!.engagedVia).toBe('keystroke');
1749
+ });
1750
+
1751
+ it('records 0 idle keystrokes when the window engaged via extension (no typing)', async () => {
1752
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
1753
+ fixture.setCustomResult('extend');
1754
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
1755
+ fixture.run('agent_settled', { type: 'agent_settled' }); // pop → extend engages (no keystroke)
1756
+ await vi.advanceTimersByTimeAsync(0); // flush the extend → openIdleWindow('extension')
1757
+ await vi.advanceTimersByTimeAsync(10_000); // idle, no typing
1758
+ fixture.run('agent_start', { type: 'agent_start' }); // commit
1759
+
1760
+ const open = fixture.lastSidecarEvent('human-open');
1761
+ expect(open!.engagedVia).toBe('extension');
1762
+ expect(fixture.lastSidecarEvent('human-close')!.keystrokes).toBe(0); // no typing
1763
+ });
1764
+
1765
+ it('collapses a held key in the idle keystroke count (no auto-repeat inflation)', async () => {
1766
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
1767
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
1768
+ fixture.run('agent_settled', { type: 'agent_settled' });
1769
+ await vi.advanceTimersByTimeAsync(0); // flush dismissed wizard
1770
+ // hold one key for ~1s: auto-repeat fires the same data every 10ms
1771
+ for (let i = 0; i < 100; i++) {
1772
+ fixture.sendEditorKey('a');
1773
+ await vi.advanceTimersByTimeAsync(10);
1774
+ }
1775
+ await vi.advanceTimersByTimeAsync(1000);
1776
+ fixture.run('agent_start', { type: 'agent_start' }); // commit
1777
+
1778
+ // 100 auto-repeats collapse to one keystroke — the count stays meaningful
1779
+ expect(fixture.lastSidecarEvent('human-close')!.keystrokes).toBe(1);
1780
+ });
1781
+
1782
+ it('does not open a second initial window when rehydrate restores an open one (crashed prior process)', async () => {
1783
+ const openedAt = 5_000_000;
1784
+ fixture.seedSidecar([
1785
+ { kind: 'settings', settings: { ...DEFAULTS }, timestamp: 0 },
1786
+ {
1787
+ kind: 'human-open',
1788
+ openedAt,
1789
+ grantedBudgetMs: 60_000,
1790
+ extensions: 0,
1791
+ extensionBudgetMs: 0,
1792
+ timestamp: openedAt,
1793
+ },
1794
+ ]);
1795
+ fixture.run('session_start', { type: 'session_start', reason: 'resume' });
1796
+ // rehydrate does NOT restore the stale unclosed window (idle with no committed
1797
+ // agent action is wasted) — it abandons it (a human-close committed:false, 0)
1798
+ // and opens no second window.
1799
+ const opens = fixture.readSidecarEvents().filter((e) => e.kind === 'human-open');
1800
+ expect(opens).toHaveLength(1); // only the seeded one; no new human-open appended
1801
+ const abandon = fixture.lastSidecarEvent('human-close');
1802
+ expect(abandon).toBeDefined();
1803
+ expect(abandon!.billedMs).toBe(0); // uncommitted → unbilled
1804
+ expect(abandon!.committed).toBe(false);
1805
+ });
1806
+
1807
+ it('carries rehydrated rolling credit into the initial window (cap = credit), wizard silent', async () => {
1808
+ // A prior idle window left 18m of rolling pomodoro credit on the sidecar.
1809
+ fixture.seedSidecar([
1810
+ { kind: 'settings', settings: { ...DEFAULTS }, timestamp: 0 },
1811
+ {
1812
+ kind: 'human-open',
1813
+ openedAt: 0,
1814
+ grantedBudgetMs: 20 * 60_000,
1815
+ extensions: 1,
1816
+ extensionBudgetMs: 20 * 60_000,
1817
+ timestamp: 1000,
1818
+ },
1819
+ {
1820
+ kind: 'human-close',
1821
+ openedAt: 0,
1822
+ closedAt: 2 * 60_000,
1823
+ billedMs: 2 * 60_000,
1824
+ idleMs: 2 * 60_000,
1825
+ grantedBudgetMs: 20 * 60_000,
1826
+ extensions: 1,
1827
+ extensionBudgetMs: 18 * 60_000,
1828
+ timestamp: 2000,
1829
+ },
1830
+ ]);
1831
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' }); // rehydrate 18m credit; no pop
1832
+ // engaging inherits the 18m rolling credit: cap = 18m
1833
+ fixture.sendEditorKey('k');
1834
+ const open = fixture.lastSidecarEvent('human-open');
1835
+ expect(open!.grantedBudgetMs).toBe(18 * 60_000);
1836
+ expect(open!.extensionBudgetMs).toBe(18 * 60_000);
1837
+ // silent — the wizard never auto-pops at startup
1838
+ expect(fixture.customSpy).not.toHaveBeenCalled();
1839
+ });
1840
+
1841
+ it('/ledger-extend extends the initial window before the first prompt is submitted', async () => {
1842
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
1843
+ expect(fixture.customSpy).not.toHaveBeenCalled(); // initial window is silent
1844
+ fixture.setCustomResult('extend');
1845
+ await fixture.commands['ledger-extend'].handler('5', fixture.mockCtx); // manually provision +5m
1846
+ expect(fixture.customSpy).toHaveBeenCalledTimes(1);
1847
+ await vi.advanceTimersByTimeAsync(4 * 60_000); // 4m composing (under the 5m cap)
1848
+ fixture.run('agent_start', { type: 'agent_start' });
1849
+
1850
+ const close = fixture.lastSidecarEvent('human-close');
1851
+ expect(close!.grantedBudgetMs).toBe(5 * 60_000);
1852
+ expect(close!.extensions).toBe(1);
1853
+ expect(close!.billedMs).toBe(4 * 60_000); // 4m, under the 5m cap
1854
+ });
1855
+
1856
+ it('the silent initial window does not suppress the wizard at the following agent_settled', async () => {
1857
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
1858
+ expect(fixture.customSpy).not.toHaveBeenCalled(); // initial window silent
1859
+ fixture.run('agent_start', { type: 'agent_start' }); // first prompt → close initial (~0 billed)
1860
+ fixture.run('agent_end', { type: 'agent_end', messages: [] }); // per-run cleanup; no pop
1861
+ fixture.run('agent_settled', { type: 'agent_settled' }); // post-turn window → wizard pops
1862
+ // the wizard pops at agent_settled (where it belongs), not at session_start
1863
+ expect(fixture.customSpy).toHaveBeenCalledTimes(1);
1864
+ });
1865
+ });
1866
+
1867
+ // ─── Steering composition (human types while the agent runs) ────────────────
1868
+
1869
+ function lastSteer(fixture: TestFixture): SteerEvent | undefined {
1870
+ return fixture.lastSidecarEvent('steer') as SteerEvent | undefined;
1871
+ }
1872
+
1873
+ function steerInput(behavior: 'steer' | 'followUp', source = 'interactive', text = 'h') {
1874
+ return { type: 'input' as const, text, source, streamingBehavior: behavior };
1875
+ }
1876
+
1877
+ /** Simulate a queued steer/followUp being DELIVERED to the agent — the
1878
+ * `message_start` user message that commits a pending composition (the agent
1879
+ * outcome). A no-op when no steer is pending (e.g. the initial prompt). */
1880
+ function deliverSteer(fixture: TestFixture) {
1881
+ fixture.run('message_start', {
1882
+ type: 'message_start',
1883
+ message: { role: 'user', content: [{ type: 'text', text: 'steered' }], timestamp: Date.now() },
1884
+ });
1885
+ }
1886
+
1887
+ /** Type a sustained burst: keys at `gapMs` intervals spanning ~`durationMs`, so
1888
+ * they cluster into one typing burst (gaps under STEER_GAP_MS). A single key,
1889
+ * or keys spread minutes apart, bill nothing — only a burst like this bills. */
1890
+ async function typeBurst(fixture: TestFixture, durationMs: number, gapMs = 1000) {
1891
+ const steps = Math.round(durationMs / gapMs);
1892
+ for (let i = 0; i <= steps; i++) {
1893
+ fixture.sendEditorKey('k');
1894
+ if (i < steps) await vi.advanceTimersByTimeAsync(gapMs);
1895
+ }
1896
+ }
1897
+
1898
+ /** Seed `ms` of rolling extension credit from a prior (0-billed) idle window,
1899
+ * so a fresh session rehydrates with billable credit already provisioned.
1900
+ * Steering/idle bill against credit, so billing tests provision credit first. */
1901
+ function seedCredit(fixture: TestFixture, ms: number) {
1902
+ fixture.seedSidecar([
1903
+ { kind: 'settings', settings: { ...DEFAULTS }, timestamp: 0 },
1904
+ {
1905
+ kind: 'human-open',
1906
+ openedAt: 0,
1907
+ grantedBudgetMs: ms,
1908
+ extensions: 1,
1909
+ extensionBudgetMs: ms,
1910
+ timestamp: 1000,
1911
+ },
1912
+ {
1913
+ kind: 'human-close',
1914
+ openedAt: 0,
1915
+ closedAt: 1000,
1916
+ billedMs: 0,
1917
+ idleMs: 0,
1918
+ committed: true,
1919
+ grantedBudgetMs: ms,
1920
+ extensions: 1,
1921
+ extensionBudgetMs: ms,
1922
+ timestamp: 2000,
1923
+ },
1924
+ ]);
1925
+ }
1926
+
1927
+ describe('computeBurstMs', () => {
1928
+ it('returns 0 for no keystrokes', () => {
1929
+ expect(computeBurstMs([], 3000)).toBe(0);
1930
+ });
1931
+ it('a single keystroke is a zero-length burst (bills nothing)', () => {
1932
+ expect(computeBurstMs([1000], 3000)).toBe(0);
1933
+ });
1934
+ it('sums one continuous burst as last − first', () => {
1935
+ expect(computeBurstMs([1000, 2000, 3000, 4000], 3000)).toBe(3000);
1936
+ });
1937
+ it('splits at gaps over the threshold and sums each burst', () => {
1938
+ // [1000..3000] = 2000, 5000 gap, [8000..9000] = 1000 → 3000
1939
+ expect(computeBurstMs([1000, 2000, 3000, 8000, 9000], 3000)).toBe(3000);
1940
+ });
1941
+ it('isolated keystrokes (gaps over the threshold) bill nothing', () => {
1942
+ expect(computeBurstMs([0, 60_000, 120_000, 180_000], 3000)).toBe(0);
1943
+ });
1944
+ it('clamps a descending/non-positive sum to 0', () => {
1945
+ expect(computeBurstMs([5000, 1000], 3000)).toBe(0);
1946
+ });
1947
+ });
1948
+
1949
+ describe('steering composition (human types while the agent runs)', () => {
1950
+ let fixture: TestFixture;
1951
+ let cacheDir: string;
1952
+
1953
+ beforeEach(async () => {
1954
+ vi.useFakeTimers();
1955
+ cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-ledger-test-'));
1956
+ process.env.XDG_CACHE_HOME = cacheDir;
1957
+ fixture = createTestFixture();
1958
+ await activateExtension(fixture);
1959
+ });
1960
+
1961
+ afterEach(() => {
1962
+ vi.useRealTimers();
1963
+ delete process.env.XDG_CACHE_HOME;
1964
+ fs.rmSync(cacheDir, { recursive: true, force: true });
1965
+ });
1966
+
1967
+ it('installs an editor wrapper at session_start (TUI) to observe keystrokes', () => {
1968
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
1969
+ expect(fixture.setEditorComponentSpy).toHaveBeenCalledTimes(1);
1970
+ });
1971
+
1972
+ it('does not install the editor in non-TUI modes (no composition to capture)', () => {
1973
+ (fixture.mockCtx as unknown as { mode: string }).mode = 'print';
1974
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
1975
+ expect(fixture.setEditorComponentSpy).not.toHaveBeenCalled();
1976
+ });
1977
+
1978
+ it('bills nothing for a steer with no credit provisioned', async () => {
1979
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
1980
+ fixture.run('agent_start', { type: 'agent_start' }); // run begins; closes the initial window
1981
+ await typeBurst(fixture, 30_000); // 30s of sustained typing mid-stream
1982
+ fixture.run('input', steerInput('steer')); // queued → pending (billed at delivery)
1983
+ deliverSteer(fixture); // delivered to the agent → commits the pending
1984
+
1985
+ const steer = lastSteer(fixture);
1986
+ expect(steer).toBeDefined();
1987
+ expect(steer!.billedMs).toBe(0); // no credit → bills 0
1988
+ expect(steer!.durationMs).toBe(30_000); // wall-clock span == burst (no idle gap)
1989
+ expect(steer!.keystrokes).toBe(31); // 30s at 1s intervals = 31 keys
1990
+ expect(steer!.behavior).toBe('steer');
1991
+ expect(steer!.grantedBudgetMs).toBe(0); // no credit
1992
+ expect(steer!.extensionBudgetMs).toBe(0);
1993
+ });
1994
+
1995
+ it('consumes rolling credit for a steer (all billed time consumes credit)', async () => {
1996
+ // Rehydrate 18m of rolling pomodoro credit from a prior idle window.
1997
+ seedCredit(fixture, 18 * 60_000);
1998
+ fixture.run('session_start', { type: 'session_start', reason: 'resume' });
1999
+ fixture.run('agent_start', { type: 'agent_start' }); // close the initial window; start the run
2000
+ await typeBurst(fixture, 3 * 60_000); // 3m of sustained typing
2001
+ fixture.run('input', steerInput('steer')); // queued → pending
2002
+ deliverSteer(fixture); // delivered → commits (bills 3m, consumes credit)
2003
+
2004
+ const steer = lastSteer(fixture);
2005
+ expect(steer!.billedMs).toBe(3 * 60_000); // under the 18m cap
2006
+ expect(steer!.grantedBudgetMs).toBe(18 * 60_000);
2007
+ expect(steer!.extensionBudgetMs).toBe(15 * 60_000); // 18m − 3m consumed
2008
+ });
2009
+
2010
+ it('bills a queued followUp the same as a mid-stream steer (both against credit)', async () => {
2011
+ seedCredit(fixture, 20 * 60_000);
2012
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2013
+ fixture.run('agent_start', { type: 'agent_start' });
2014
+ await typeBurst(fixture, 45_000); // 45s of sustained typing
2015
+ fixture.run('input', steerInput('followUp')); // queued → pending
2016
+ deliverSteer(fixture); // delivered → commits
2017
+
2018
+ const steer = lastSteer(fixture);
2019
+ expect(steer!.behavior).toBe('followUp');
2020
+ expect(steer!.billedMs).toBe(45_000);
2021
+ });
2022
+
2023
+ it('ignores steers from non-interactive sources (extension/rpc injected)', async () => {
2024
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2025
+ fixture.run('agent_start', { type: 'agent_start' });
2026
+ await typeBurst(fixture, 30_000);
2027
+ fixture.run('input', steerInput('steer', 'extension'));
2028
+ deliverSteer(fixture); // a delivery wouldn't bill it either — no pending was staged
2029
+ expect(fixture.lastSidecarEvent('steer')).toBeUndefined();
2030
+ });
2031
+
2032
+ it('does not record a steer for a normal idle prompt (submitted between turns)', async () => {
2033
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2034
+ fixture.run('agent_start', { type: 'agent_start' });
2035
+ fixture.run('agent_end', { type: 'agent_end', messages: [] }); // run ends; no window opens here (engagement-gated)
2036
+ await vi.advanceTimersByTimeAsync(30_000);
2037
+ fixture.run('input', { type: 'input', text: 'next', source: 'interactive' }); // no streamingBehavior
2038
+ expect(fixture.lastSidecarEvent('steer')).toBeUndefined();
2039
+ });
2040
+
2041
+ it('does not record a steer when no keystrokes preceded the submit (no onset)', async () => {
2042
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2043
+ fixture.run('agent_start', { type: 'agent_start' });
2044
+ // no sendEditorKey — steerComposeStart stays null
2045
+ await vi.advanceTimersByTimeAsync(30_000);
2046
+ fixture.run('input', steerInput('steer'));
2047
+ expect(fixture.lastSidecarEvent('steer')).toBeUndefined();
2048
+ });
2049
+
2050
+ it('records multiple steers in one run, each from its own typing burst', async () => {
2051
+ seedCredit(fixture, 20 * 60_000);
2052
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2053
+ fixture.run('agent_start', { type: 'agent_start' });
2054
+ await typeBurst(fixture, 10_000);
2055
+ fixture.run('input', steerInput('steer', 'interactive', 'a'));
2056
+ await typeBurst(fixture, 20_000); // a new burst after the first steer cleared staging
2057
+ fixture.run('input', steerInput('steer', 'interactive', 'b'));
2058
+ // both queued → pending; deliver each (FIFO) to commit at its own delivery
2059
+ deliverSteer(fixture);
2060
+ deliverSteer(fixture);
2061
+
2062
+ const steers = fixture.readSidecarEvents().filter((e) => e.kind === 'steer') as SteerEvent[];
2063
+ expect(steers).toHaveLength(2);
2064
+ expect(steers[0]!.billedMs).toBe(10_000);
2065
+ expect(steers[1]!.billedMs).toBe(20_000);
2066
+
2067
+ // steering bills as human time, distinct from idle (no idle window here).
2068
+ const r = rehydrateFromSidecar(fixture.readSidecarEvents());
2069
+ expect(r.totals.humanSteerMs).toBe(30_000); // 10s + 20s
2070
+ expect(r.totals.steerCount).toBe(2);
2071
+ expect(r.totals.humanWindows).toBe(2);
2072
+ expect(r.totals.humanIdleMs).toBe(0);
2073
+ expect(r.totals.idleWindows).toBe(0);
2074
+ });
2075
+
2076
+ it('restores itemized sub-totals on rehydrate (reload) so the receipt itemizes prior work', async () => {
2077
+ // A prior session's sidecar: one agent turn (generation + tool), one
2078
+ // committed idle window, and one steer — the kind of state a reload sees.
2079
+ const t0 = 1_700_000_000_000;
2080
+ fixture.seedSidecar([
2081
+ { kind: 'settings', settings: { ...DEFAULTS }, timestamp: t0 },
2082
+ {
2083
+ kind: 'agent',
2084
+ id: 'a1',
2085
+ turnIndex: 0,
2086
+ agentMs: 3_600_000,
2087
+ generationMs: 3_000_000,
2088
+ stallMs: 0,
2089
+ toolMs: 600_000,
2090
+ tokens: { input: 100, output: 50, total: 150 },
2091
+ model: { provider: 'openai', modelId: 'gpt-4' },
2092
+ source: 'tps',
2093
+ timestamp: t0 + 1000,
2094
+ },
2095
+ {
2096
+ kind: 'human-open',
2097
+ openedAt: t0 + 2000,
2098
+ grantedBudgetMs: 60_000,
2099
+ extensions: 0,
2100
+ engagedVia: 'keystroke',
2101
+ extensionBudgetMs: 0,
2102
+ timestamp: t0 + 2000,
2103
+ },
2104
+ {
2105
+ kind: 'human-close',
2106
+ openedAt: t0 + 2000,
2107
+ closedAt: t0 + 62_000,
2108
+ billedMs: 60_000,
2109
+ idleMs: 60_000,
2110
+ keystrokes: 10,
2111
+ committed: true,
2112
+ grantedBudgetMs: 60_000,
2113
+ extensions: 0,
2114
+ extensionBudgetMs: 0,
2115
+ timestamp: t0 + 62_000,
2116
+ },
2117
+ {
2118
+ kind: 'steer',
2119
+ startedAt: t0 + 70_000,
2120
+ submittedAt: t0 + 90_000,
2121
+ durationMs: 20_000,
2122
+ billedMs: 20_000,
2123
+ keystrokes: 30,
2124
+ behavior: 'steer',
2125
+ grantedBudgetMs: 60_000,
2126
+ extensionBudgetMs: 0,
2127
+ timestamp: t0 + 90_000,
2128
+ },
2129
+ ]);
2130
+ // Reload → session_start rehydrates the in-memory totals from the sidecar.
2131
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2132
+
2133
+ // The receipt itemizes from the sub-totals; they must be restored (not 0).
2134
+ await fixture.commands['ledger-receipt']!.handler('', fixture.mockCtx);
2135
+ const file = fs
2136
+ .readdirSync(path.join(cacheDir, 'pi-ledger'))
2137
+ .filter((f) => f.startsWith('receipt-019fabcd-'))
2138
+ .sort()
2139
+ .pop();
2140
+ expect(file).toBeDefined();
2141
+ const html = fs.readFileSync(path.join(cacheDir, 'pi-ledger', file!), 'utf8');
2142
+ // agent sub-items restored: generation 3m @ $60 = $50, tool 0.167h = $10
2143
+ expect(html).toContain('$50.00'); // Compute (generation)
2144
+ expect(html).toContain('$10.00'); // Tool execution
2145
+ // human sub-items restored: idle 1m @ $60 = $1, steer 20s = $0.33
2146
+ expect(html).toContain('$1.00'); // Review / think
2147
+ expect(html).toContain('$0.33'); // Steering
2148
+ // total reconciles from the restored sub-totals (not just the in-progress window)
2149
+ expect(html).toContain('$61.33');
2150
+ });
2151
+
2152
+ it('discards an unsubmitted in-run composition (no backdate — it never reached the agent)', async () => {
2153
+ seedCredit(fixture, 20 * 60_000);
2154
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2155
+ fixture.run('agent_start', { type: 'agent_start' });
2156
+ await typeBurst(fixture, 20_000); // 20s of typing mid-run, NOT submitted
2157
+ // the agent finishes before the human submits — no `input` fires mid-stream
2158
+ fixture.run('agent_end', { type: 'agent_end', messages: [] }); // discard staging
2159
+ fixture.run('agent_settled', { type: 'agent_settled' }); // credit remains → no wizard pop
2160
+
2161
+ // No steer (never submitted) and no NEW idle window (no engagement yet) — the
2162
+ // mid-run typing never reached the agent, so it bills nothing and opens nothing.
2163
+ expect(fixture.lastSidecarEvent('steer')).toBeUndefined();
2164
+ const opensAfterDiscard = fixture.readSidecarEvents().filter((e) => e.kind === 'human-open');
2165
+ expect(opensAfterDiscard).toHaveLength(1); // only the seeded (closed) window; the discard opened none
2166
+ expect(opensAfterDiscard[0]!.openedAt).toBe(0); // the seeded one, not a fresh engagement
2167
+
2168
+ // The human engages AFTER the run (a fresh keystroke) and commits — only the
2169
+ // post-run idle bills; the discarded mid-run typing isn't folded in.
2170
+ fixture.sendEditorKey('k'); // engage the idle window at onset (now, post-run), cap 20m
2171
+ await vi.advanceTimersByTimeAsync(10_000); // 10s of post-run idle
2172
+ fixture.run('agent_start', { type: 'agent_start' }); // commit → bills 10s
2173
+
2174
+ const close = fixture.lastSidecarEvent('human-close');
2175
+ expect(close).toBeDefined();
2176
+ expect(close!.billedMs).toBe(10_000); // only the 10s post-run idle — the 20s mid-run typing is unbilled
2177
+ });
2178
+
2179
+ it('bills a submitted steer from its burst; the post-turn idle bills only post-run idle', async () => {
2180
+ seedCredit(fixture, 20 * 60_000);
2181
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2182
+ fixture.run('agent_start', { type: 'agent_start' });
2183
+ await typeBurst(fixture, 15_000); // 15s typing burst mid-run
2184
+ fixture.run('input', steerInput('steer')); // queued → pending
2185
+ deliverSteer(fixture); // delivered mid-run → commits
2186
+ const steer = lastSteer(fixture);
2187
+ expect(steer!.billedMs).toBe(15_000); // the typing burst
2188
+ await vi.advanceTimersByTimeAsync(25_000); // agent keeps working (no typing)
2189
+ fixture.run('agent_end', { type: 'agent_end', messages: [] }); // no idle window opens here (engagement-gated)
2190
+ fixture.run('agent_settled', { type: 'agent_settled' }); // credit remains → no wizard pop
2191
+
2192
+ // No NEW idle window yet — the human hasn't engaged post-run (only the seeded, closed one exists).
2193
+ const opensBeforeEngage = fixture.readSidecarEvents().filter((e) => e.kind === 'human-open');
2194
+ expect(opensBeforeEngage).toHaveLength(1); // the seeded (closed) window; no post-run engagement yet
2195
+
2196
+ fixture.sendEditorKey('k'); // engage the post-run idle window at onset (cap = remaining credit)
2197
+ await vi.advanceTimersByTimeAsync(10_000); // 10s post-run idle
2198
+ fixture.run('agent_start', { type: 'agent_start' }); // commit → bills 10s
2199
+ const close = fixture.lastSidecarEvent('human-close');
2200
+ expect(close!.idleMs).toBe(10_000); // only post-run idle — no overlap with the steer's burst
2201
+ expect(close!.billedMs).toBe(10_000);
2202
+ });
2203
+
2204
+ it('shows in-progress steer typing in /ledger while the run is active', async () => {
2205
+ seedCredit(fixture, 20 * 60_000);
2206
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2207
+ fixture.run('agent_start', { type: 'agent_start' });
2208
+ await typeBurst(fixture, 30_000); // 30s of sustained typing (not yet submitted)
2209
+ await fixture.commands['ledger'].handler('', fixture.mockCtx);
2210
+ const msg = fixture.notifySpy.mock.calls.at(-1)![0] as string;
2211
+ expect(msg).toContain('human 0.01h (1 windows)'); // 30s active typing as 1 in-progress window
2212
+ expect(fixture.lastSidecarEvent('steer')).toBeUndefined(); // not submitted yet
2213
+ });
2214
+
2215
+ it('bills only active typing, not the idle wait before submit (billedMs < durationMs)', async () => {
2216
+ seedCredit(fixture, 20 * 60_000);
2217
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2218
+ fixture.run('agent_start', { type: 'agent_start' });
2219
+ await typeBurst(fixture, 30_000); // 30s of typing
2220
+ await vi.advanceTimersByTimeAsync(5 * 60_000); // 5m idle, no typing, before submitting
2221
+ fixture.run('input', steerInput('steer')); // queued → pending (submittedAt spans the 5m idle)
2222
+ deliverSteer(fixture); // delivered → commits
2223
+
2224
+ const steer = lastSteer(fixture);
2225
+ expect(steer!.billedMs).toBe(30_000); // only the typing burst
2226
+ expect(steer!.durationMs).toBe(30_000 + 5 * 60_000); // wall-clock span includes the 5m idle
2227
+ expect(steer!.billedMs).toBeLessThan(steer!.durationMs);
2228
+ });
2229
+
2230
+ it('bills nothing for isolated keystrokes (a key every minute — the abuse guard)', async () => {
2231
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2232
+ fixture.run('agent_start', { type: 'agent_start' });
2233
+ // press a key every 60s (gaps far over the 3s burst threshold) for 4 minutes
2234
+ for (let i = 0; i < 4; i++) {
2235
+ fixture.sendEditorKey('k');
2236
+ if (i < 3) await vi.advanceTimersByTimeAsync(60_000);
2237
+ }
2238
+ fixture.run('input', steerInput('steer')); // queued → pending
2239
+ deliverSteer(fixture); // delivered → commits (a $0 audit line — no burst)
2240
+
2241
+ const steer = lastSteer(fixture);
2242
+ expect(steer).toBeDefined(); // the steer was submitted (audit)…
2243
+ expect(steer!.billedMs).toBe(0); // …but isolated keys form zero-length bursts → bills nothing
2244
+ expect(steer!.keystrokes).toBe(4);
2245
+ });
2246
+
2247
+ it('collapses a held key (auto-repeat) so it cannot fabricate a sustained burst', async () => {
2248
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2249
+ fixture.run('agent_start', { type: 'agent_start' });
2250
+ // hold one key for ~1s: auto-repeat fires the SAME data every 10ms (well under
2251
+ // AUTO_REPEAT_MS). Each repeat collapses to one timestamp → a zero-length burst.
2252
+ for (let i = 0; i < 100; i++) {
2253
+ fixture.sendEditorKey('a');
2254
+ await vi.advanceTimersByTimeAsync(10);
2255
+ }
2256
+ fixture.run('input', steerInput('steer')); // queued → pending
2257
+ deliverSteer(fixture); // delivered → commits (a $0 audit line — no burst)
2258
+
2259
+ const steer = lastSteer(fixture);
2260
+ expect(steer).toBeDefined();
2261
+ expect(steer!.billedMs).toBe(0); // a held key bills nothing — no fabricated burst
2262
+ expect(steer!.keystrokes).toBe(1); // 100 auto-repeats collapsed to one staged keystroke
2263
+ });
2264
+
2265
+ it('rehydrates steer events into human time and rolling credit', async () => {
2266
+ fixture.seedSidecar([
2267
+ { kind: 'settings', settings: { ...DEFAULTS }, timestamp: 0 },
2268
+ {
2269
+ kind: 'steer',
2270
+ startedAt: 1000,
2271
+ submittedAt: 31_000,
2272
+ durationMs: 30_000,
2273
+ billedMs: 30_000,
2274
+ keystrokes: 31,
2275
+ behavior: 'steer',
2276
+ grantedBudgetMs: 60_000,
2277
+ extensionBudgetMs: 0,
2278
+ timestamp: 31_000,
2279
+ },
2280
+ ]);
2281
+ fixture.run('session_start', { type: 'session_start', reason: 'resume' });
2282
+ await vi.advanceTimersByTimeAsync(0); // flush the resume wizard pop (no engagement)
2283
+ await fixture.commands['ledger'].handler('', fixture.mockCtx);
2284
+ const msg = fixture.notifySpy.mock.calls.at(-1)![0] as string;
2285
+ expect(msg).toContain('human 0.01h'); // 30s steer rehydrated as human time
2286
+ expect(msg).toContain('(1 windows)'); // just the rehydrated steer — no initial window (engagement-gated)
2287
+ });
2288
+
2289
+ it('bills a reverted-then-re-steered composition once at the re-steer delivery', async () => {
2290
+ seedCredit(fixture, 20 * 60_000);
2291
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2292
+ fixture.run('agent_start', { type: 'agent_start' });
2293
+ await typeBurst(fixture, 30_000); // 30s typing mid-run
2294
+ fixture.run('input', steerInput('followUp')); // queued → pending (not billed)
2295
+ expect(fixture.lastSidecarEvent('steer')).toBeUndefined(); // not billed at submit
2296
+ fixture.sendEditorKey('dequeue'); // revert: composition back to the editor
2297
+ expect(fixture.lastSidecarEvent('steer')).toBeUndefined(); // reverting isn't a bill
2298
+ fixture.run('input', steerInput('steer')); // re-steer the restored text (no new typing)
2299
+ expect(fixture.lastSidecarEvent('steer')).toBeUndefined(); // still pending, not billed
2300
+ deliverSteer(fixture); // the re-steer is delivered → agent outcome
2301
+ const steer = lastSteer(fixture);
2302
+ expect(steer).toBeDefined();
2303
+ expect(steer!.billedMs).toBe(30_000); // the original 30s, billed once at delivery
2304
+ expect(steer!.behavior).toBe('steer'); // the re-steer's delivery behavior
2305
+ const all = fixture.readSidecarEvents().filter((e) => e.kind === 'steer');
2306
+ expect(all).toHaveLength(1); // billed exactly once — no free replay of the revert
2307
+ });
2308
+
2309
+ it('bills nothing when a queued composition is reverted and never re-sent', async () => {
2310
+ seedCredit(fixture, 20 * 60_000);
2311
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2312
+ fixture.run('agent_start', { type: 'agent_start' });
2313
+ await typeBurst(fixture, 30_000);
2314
+ fixture.run('input', steerInput('followUp')); // queued → pending
2315
+ fixture.sendEditorKey('dequeue'); // revert to the editor
2316
+ // never re-submitted → never delivered; agent_end does NOT abandon pending
2317
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
2318
+ fixture.run('session_shutdown', { type: 'session_shutdown' }); // abandon (no outcome → no bill)
2319
+ expect(fixture.lastSidecarEvent('steer')).toBeUndefined();
2320
+ const r = rehydrateFromSidecar(fixture.readSidecarEvents());
2321
+ expect(r.totals.humanMs).toBe(0);
2322
+ expect(r.totals.humanSteerMs).toBe(0);
2323
+ expect(r.totals.humanQueueMs).toBe(0);
2324
+ });
2325
+
2326
+ it('bills a pending followUp at a later delivery (survives agent_end)', async () => {
2327
+ seedCredit(fixture, 20 * 60_000);
2328
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2329
+ fixture.run('agent_start', { type: 'agent_start' });
2330
+ await typeBurst(fixture, 45_000);
2331
+ fixture.run('input', steerInput('followUp')); // queued → pending
2332
+ expect(fixture.lastSidecarEvent('steer')).toBeUndefined(); // not billed at submit
2333
+ fixture.run('agent_end', { type: 'agent_end', messages: [] }); // pending survives (delivers later)
2334
+ // the followUp auto-delivers in a new run (agent_start + a user message)
2335
+ fixture.run('agent_start', { type: 'agent_start' });
2336
+ deliverSteer(fixture); // delivered → bills
2337
+ const steer = lastSteer(fixture);
2338
+ expect(steer).toBeDefined();
2339
+ expect(steer!.billedMs).toBe(45_000);
2340
+ expect(steer!.behavior).toBe('followUp');
2341
+ });
2342
+
2343
+ it('does not bill a steer at the initial prompt (no pending staged)', async () => {
2344
+ seedCredit(fixture, 20 * 60_000);
2345
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2346
+ // initial prompt: agent_start (no prior steer pending) + a user message
2347
+ fixture.run('agent_start', { type: 'agent_start' });
2348
+ deliverSteer(fixture); // the initial user message — no pending → no-op
2349
+ expect(fixture.lastSidecarEvent('steer')).toBeUndefined();
2350
+ });
2351
+
2352
+ it('merges multiple pending compositions on dequeue; re-steer bills the sum once', async () => {
2353
+ seedCredit(fixture, 30 * 60_000);
2354
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2355
+ fixture.run('agent_start', { type: 'agent_start' });
2356
+ await typeBurst(fixture, 10_000);
2357
+ fixture.run('input', steerInput('steer', 'interactive', 'a')); // pending 10s
2358
+ await typeBurst(fixture, 20_000);
2359
+ fixture.run('input', steerInput('steer', 'interactive', 'b')); // pending 20s
2360
+ fixture.sendEditorKey('dequeue'); // revert BOTH → dequeuedBuffer (30s merged)
2361
+ fixture.run('input', steerInput('steer')); // re-steer the joined text (no new typing)
2362
+ deliverSteer(fixture); // one delivery → bills the merged 30s
2363
+ const steer = lastSteer(fixture);
2364
+ expect(steer!.billedMs).toBe(30_000);
2365
+ const all = fixture.readSidecarEvents().filter((e) => e.kind === 'steer');
2366
+ expect(all).toHaveLength(1);
2367
+ });
2368
+
2369
+ it('abandons a pending steer on reload (never delivered → bills 0)', async () => {
2370
+ seedCredit(fixture, 20 * 60_000);
2371
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2372
+ fixture.run('agent_start', { type: 'agent_start' });
2373
+ await typeBurst(fixture, 30_000);
2374
+ fixture.run('input', steerInput('steer')); // pending (not delivered)
2375
+ expect(fixture.lastSidecarEvent('steer')).toBeUndefined();
2376
+ // reload → session_start abandons the in-memory pending (bills 0)
2377
+ fixture.run('session_start', { type: 'session_start', reason: 'reload' });
2378
+ await vi.advanceTimersByTimeAsync(0); // flush the reload wizard pop (no engagement)
2379
+ deliverSteer(fixture); // nothing pending → no-op
2380
+ expect(fixture.lastSidecarEvent('steer')).toBeUndefined();
2381
+ const r = rehydrateFromSidecar(fixture.readSidecarEvents());
2382
+ expect(r.totals.humanSteerMs).toBe(0);
2383
+ });
2384
+
2385
+ it('shows a pending (submitted, undelivered) steer as in-progress in /ledger', async () => {
2386
+ seedCredit(fixture, 20 * 60_000);
2387
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2388
+ fixture.run('agent_start', { type: 'agent_start' });
2389
+ await typeBurst(fixture, 30_000);
2390
+ fixture.run('input', steerInput('steer')); // staged → pending (not delivered)
2391
+ await fixture.commands['ledger'].handler('', fixture.mockCtx);
2392
+ const msg = fixture.notifySpy.mock.calls.at(-1)![0] as string;
2393
+ expect(msg).toContain('human 0.01h (1 windows)'); // 30s pending as 1 in-progress window
2394
+ expect(fixture.lastSidecarEvent('steer')).toBeUndefined(); // not delivered yet
2395
+ });
2396
+ });
2397
+
2398
+ // ─── Skip-billing guard ─────────────────────────────────────────────────────
2399
+
2400
+ function plainInput(source: 'interactive' | 'extension' = 'interactive') {
2401
+ return { type: 'input' as const, text: 'hello', source };
2402
+ }
2403
+
2404
+ describe('skip-billing guard (choosing "Stop billing" blocks agent messages)', () => {
2405
+ let fixture: TestFixture;
2406
+ let cacheDir: string;
2407
+
2408
+ beforeEach(async () => {
2409
+ vi.useFakeTimers();
2410
+ cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-ledger-test-'));
2411
+ process.env.XDG_CACHE_HOME = cacheDir;
2412
+ fixture = createTestFixture();
2413
+ await activateExtension(fixture);
2414
+ });
2415
+
2416
+ afterEach(() => {
2417
+ vi.useRealTimers();
2418
+ delete process.env.XDG_CACHE_HOME;
2419
+ fs.rmSync(cacheDir, { recursive: true, force: true });
2420
+ });
2421
+
2422
+ /** Pop the engagement wizard (agent_settled, no credit) and flush the choice. */
2423
+ async function chooseWizard(choice: 'stop' | 'extend' | 'dismiss') {
2424
+ fixture.setCustomResult(choice);
2425
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
2426
+ fixture.run('agent_settled', { type: 'agent_settled' });
2427
+ await vi.advanceTimersByTimeAsync(0); // flush the wizard .then
2428
+ }
2429
+
2430
+ it('choosing "Stop billing" blocks interactive prompts to the agent', async () => {
2431
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2432
+ await chooseWizard('stop'); // skip billing → pause
2433
+
2434
+ const res = fixture.run('input', plainInput());
2435
+ expect(res).toEqual({ action: 'handled' }); // dropped — no agent turn
2436
+ expect(
2437
+ fixture.notifySpy.mock.calls.some(
2438
+ (c) => typeof c[0] === 'string' && c[0].includes('Billing is paused')
2439
+ )
2440
+ ).toBe(true);
2441
+ expect(fixture.lastSidecarEvent('billing-pause')!.paused).toBe(true);
2442
+ expect(fixture.setStatusSpy).toHaveBeenCalledWith('ledger', expect.stringContaining('paused'));
2443
+ });
2444
+
2445
+ it('also blocks a mid-run steer while billing is paused', async () => {
2446
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2447
+ await chooseWizard('stop');
2448
+ fixture.run('agent_start', { type: 'agent_start' }); // run begins
2449
+ await typeBurst(fixture, 20_000); // stage a steer
2450
+ const res = fixture.run('input', steerInput('steer'));
2451
+ expect(res).toEqual({ action: 'handled' }); // blocked before the steer bills
2452
+ expect(fixture.lastSidecarEvent('steer')).toBeUndefined(); // no steer recorded
2453
+ });
2454
+
2455
+ it('does not block programmatic (non-interactive) input', async () => {
2456
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2457
+ await chooseWizard('stop');
2458
+ fixture.notifySpy.mockClear();
2459
+ const res = fixture.run('input', plainInput('extension'));
2460
+ expect(res).toBeUndefined(); // guard scopes to interactive — passes through
2461
+ // the guard's block notify must not fire for programmatic input
2462
+ expect(fixture.notifySpy).not.toHaveBeenCalled();
2463
+ });
2464
+
2465
+ it('dismissing the wizard (esc) does NOT pause billing (no change)', async () => {
2466
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2467
+ await chooseWizard('dismiss'); // esc dismiss → no change (not "Stop billing")
2468
+ const res = fixture.run('input', plainInput());
2469
+ expect(res).toBeUndefined(); // allowed — not paused
2470
+ expect(fixture.lastSidecarEvent('billing-pause')).toBeUndefined();
2471
+ });
2472
+
2473
+ it('/ledger-extend → "Extend" resumes: clears the pause and unblocks input', async () => {
2474
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2475
+ await chooseWizard('stop'); // pause
2476
+ expect(fixture.run('input', plainInput())).toEqual({ action: 'handled' });
2477
+
2478
+ fixture.setCustomResult('extend');
2479
+ await fixture.commands['ledger-extend'].handler('5', fixture.mockCtx);
2480
+ await vi.advanceTimersByTimeAsync(0); // flush the extend → clears pause + grants 5m
2481
+
2482
+ expect(fixture.lastSidecarEvent('billing-pause')!.paused).toBe(false);
2483
+ expect(fixture.run('input', plainInput())).toBeUndefined(); // unblocked
2484
+ // a steer now bills again (no longer guarded)
2485
+ fixture.run('agent_start', { type: 'agent_start' });
2486
+ await typeBurst(fixture, 60_000);
2487
+ fixture.run('input', steerInput('steer')); // queued → pending
2488
+ deliverSteer(fixture); // delivered → bills again (no longer guarded)
2489
+ expect(fixture.lastSidecarEvent('steer')).toBeDefined();
2490
+ });
2491
+
2492
+ it('the pause survives a reload via the sidecar (autoWizard off)', async () => {
2493
+ fixture.seedSidecar([
2494
+ { kind: 'settings', settings: { ...DEFAULTS, autoWizard: false }, timestamp: 0 },
2495
+ ]);
2496
+ fixture.run('session_start', { type: 'session_start', reason: 'resume' }); // autoWizard off → no pop
2497
+ // pause via /ledger-extend → "Stop" (the wizard opens regardless of autoWizard)
2498
+ fixture.setCustomResult('stop');
2499
+ await fixture.commands['ledger-extend'].handler('5', fixture.mockCtx);
2500
+ await vi.advanceTimersByTimeAsync(0); // flush → pause (billing-pause: true)
2501
+ expect(fixture.run('input', plainInput())).toEqual({ action: 'handled' });
2502
+
2503
+ // reload: rehydrate restores the pause from the sidecar; autoWizard off → no re-pop
2504
+ fixture.run('session_start', { type: 'session_start', reason: 'reload' });
2505
+ expect(fixture.run('input', plainInput())).toEqual({ action: 'handled' }); // still paused
2506
+ expect(fixture.lastSidecarEvent('billing-pause')!.paused).toBe(true);
2507
+ });
2508
+ });
2509
+
2510
+ describe('rehydrateFromSidecar — skip-billing guard', () => {
2511
+ it('restores billingPaused from the last billing-pause event (last wins)', () => {
2512
+ const events: SidecarEvent[] = [
2513
+ { kind: 'billing-pause', paused: true, timestamp: 1 },
2514
+ { kind: 'billing-pause', paused: false, timestamp: 2 },
2515
+ { kind: 'billing-pause', paused: true, timestamp: 3 },
2516
+ ];
2517
+ expect(rehydrateFromSidecar(events).billingPaused).toBe(true); // last wins
2518
+ });
2519
+
2520
+ it('defaults billingPaused to false when no billing-pause event is present', () => {
2521
+ const events: SidecarEvent[] = [{ kind: 'settings', settings: { ...DEFAULTS }, timestamp: 0 }];
2522
+ expect(rehydrateFromSidecar(events).billingPaused).toBe(false);
2523
+ });
2524
+
2525
+ it('defaults billingPaused to false for an empty sidecar', () => {
2526
+ expect(rehydrateFromSidecar([]).billingPaused).toBe(false);
2527
+ });
2528
+ });