@monotykamary/pi-ledger 0.2.0 → 0.4.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.
@@ -23,6 +23,7 @@ import {
23
23
  fmtMoney,
24
24
  rehydrateFromSidecar,
25
25
  resolveExtensionBudget,
26
+ retryEventId,
26
27
  sidecarPathFor,
27
28
  type LedgerSettings,
28
29
  type ReceiptData,
@@ -2526,3 +2527,127 @@ describe('rehydrateFromSidecar — skip-billing guard', () => {
2526
2527
  expect(rehydrateFromSidecar([]).billingPaused).toBe(false);
2527
2528
  });
2528
2529
  });
2530
+
2531
+ // ─── pi-retry capture (wizard gating) ────────────────────────────────────────
2532
+ // The engagement prompt (no rolling credit) lives at agent_settled. When
2533
+ // @monotykamary/pi-retry is installed, agent_settled can fire during a retry's
2534
+ // backoff sleep — before pi-retry has decided whether to re-prompt. pi-ledger
2535
+ // captures pi-retry's started/completed/cancelled events and DEFERS the prompt
2536
+ // until the retry settles, so the backoff is never billed as human time.
2537
+ // Mirrors localterm's agent-notify contract.
2538
+
2539
+ describe('pi-retry capture (wizard gating)', () => {
2540
+ let fixture: TestFixture;
2541
+ let cacheDir: string;
2542
+
2543
+ beforeEach(async () => {
2544
+ vi.useFakeTimers();
2545
+ cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-ledger-test-'));
2546
+ process.env.XDG_CACHE_HOME = cacheDir;
2547
+ fixture = createTestFixture();
2548
+ await activateExtension(fixture);
2549
+ });
2550
+
2551
+ afterEach(() => {
2552
+ vi.useRealTimers();
2553
+ delete process.env.XDG_CACHE_HOME;
2554
+ fs.rmSync(cacheDir, { recursive: true, force: true });
2555
+ });
2556
+
2557
+ const STARTED = 'pi-retry:started';
2558
+ const COMPLETED = 'pi-retry:completed';
2559
+ const CANCELLED = 'pi-retry:cancelled';
2560
+
2561
+ it('pops the wizard at agent_settled when no pi-retry is active', () => {
2562
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2563
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
2564
+ expect(fixture.customSpy).not.toHaveBeenCalled();
2565
+ fixture.run('agent_settled', { type: 'agent_settled' });
2566
+ expect(fixture.customSpy).toHaveBeenCalledTimes(1); // no credit → pop
2567
+ });
2568
+
2569
+ it('defers the wizard while a pi-retry is in flight; pops on completed', () => {
2570
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2571
+ fixture.run('agent_start', { type: 'agent_start' });
2572
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
2573
+ fixture.emitEvent(STARTED, { retryId: 1 });
2574
+ fixture.run('agent_settled', { type: 'agent_settled' }); // retry in flight → defer
2575
+ expect(fixture.customSpy).not.toHaveBeenCalled();
2576
+ fixture.emitEvent(COMPLETED, { retryId: 1 }); // retry settled → pop the deferred prompt
2577
+ expect(fixture.customSpy).toHaveBeenCalledTimes(1);
2578
+ expect(fixture.lastSidecarEvent('human-open')).toBeUndefined(); // still engagement-gated
2579
+ });
2580
+
2581
+ it('waits for the final agent_settled when retry completion arrives first', () => {
2582
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2583
+ fixture.run('agent_start', { type: 'agent_start' });
2584
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
2585
+ fixture.emitEvent(STARTED, { retryId: 2 });
2586
+ fixture.run('agent_settled', { type: 'agent_settled' }); // retry in flight → defer
2587
+ // the retry turn fires (a new run supersedes the stale deferred prompt)
2588
+ fixture.run('agent_start', { type: 'agent_start' });
2589
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
2590
+ // retry completes BEFORE the run settles → pending was cleared by agent_start → no pop
2591
+ fixture.emitEvent(COMPLETED, { retryId: 2 });
2592
+ expect(fixture.customSpy).not.toHaveBeenCalled();
2593
+ fixture.run('agent_settled', { type: 'agent_settled' }); // now settled, no retry active → pop
2594
+ expect(fixture.customSpy).toHaveBeenCalledTimes(1);
2595
+ });
2596
+
2597
+ it('never pops when a pi-retry is cancelled', () => {
2598
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2599
+ fixture.run('agent_start', { type: 'agent_start' });
2600
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
2601
+ fixture.emitEvent(STARTED, { retryId: 3 });
2602
+ fixture.run('agent_settled', { type: 'agent_settled' }); // retry in flight → defer
2603
+ expect(fixture.customSpy).not.toHaveBeenCalled();
2604
+ fixture.emitEvent(CANCELLED, { retryId: 3 }); // abort/session-change → drop the prompt
2605
+ expect(fixture.customSpy).not.toHaveBeenCalled();
2606
+ });
2607
+
2608
+ it('ignores a completed event that does not match the active retry', () => {
2609
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2610
+ fixture.run('agent_start', { type: 'agent_start' });
2611
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
2612
+ fixture.emitEvent(STARTED, { retryId: 4 });
2613
+ fixture.run('agent_settled', { type: 'agent_settled' }); // defer (active id 4)
2614
+ fixture.emitEvent(COMPLETED, { retryId: 999 }); // stray → ignored
2615
+ expect(fixture.customSpy).not.toHaveBeenCalled();
2616
+ fixture.emitEvent(COMPLETED, { retryId: 4 }); // matching → pop
2617
+ expect(fixture.customSpy).toHaveBeenCalledTimes(1);
2618
+ });
2619
+
2620
+ it('treats a completed with no prior started as a no-op', () => {
2621
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2622
+ fixture.emitEvent(COMPLETED, { retryId: 7 }); // no retry active → stray event
2623
+ expect(fixture.customSpy).not.toHaveBeenCalled();
2624
+ });
2625
+
2626
+ it('defers auto-extend too while a pi-retry is in flight; extends on completed', () => {
2627
+ fixture.seedSidecar([
2628
+ { kind: 'settings', settings: { ...DEFAULTS, autoExtend: true }, timestamp: 0 },
2629
+ ]);
2630
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' }); // rehydrate → autoExtend on
2631
+ fixture.run('agent_start', { type: 'agent_start' });
2632
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
2633
+ fixture.emitEvent(STARTED, { retryId: 5 });
2634
+ fixture.run('agent_settled', { type: 'agent_settled' }); // retry in flight → defer auto-extend
2635
+ expect(fixture.readSidecarEvents().filter((e) => e.kind === 'human-open')).toHaveLength(0);
2636
+ fixture.emitEvent(COMPLETED, { retryId: 5 }); // settled → auto-extend provisions a block + engages
2637
+ const open = fixture.lastSidecarEvent('human-open');
2638
+ expect(open).toBeDefined();
2639
+ expect(open!.engagedVia).toBe('extension'); // auto-extend engages via extension
2640
+ });
2641
+ });
2642
+
2643
+ describe('retryEventId', () => {
2644
+ it('returns the retryId of a pi-retry event', () => {
2645
+ expect(retryEventId({ retryId: 42 })).toBe(42);
2646
+ });
2647
+ it('returns undefined when retryId is absent or malformed', () => {
2648
+ expect(retryEventId(undefined)).toBeUndefined();
2649
+ expect(retryEventId(null)).toBeUndefined();
2650
+ expect(retryEventId({})).toBeUndefined();
2651
+ expect(retryEventId({ retryId: 'oops' })).toBeUndefined();
2652
+ });
2653
+ });