@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.
- package/LICENSE +21 -0
- package/README.md +406 -0
- package/extensions/pi-ledger/__tests__/helpers.ts +326 -0
- package/extensions/pi-ledger/__tests__/ledger.test.ts +2528 -0
- package/extensions/pi-ledger/index.ts +2698 -0
- package/package.json +70 -0
|
@@ -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
|
+
});
|