pi-lilac-provider 1.2.0 → 1.3.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/README.md CHANGED
@@ -77,7 +77,7 @@ pi
77
77
  | GLM 5.2 | 524K | ❌ | ✅ | $0.90 | $0.27 | $3.00 |
78
78
  | Kimi K2.6 | 262K | ✅ | ✅ | $0.70 | $0.20 | $3.50 |
79
79
  | MiniMax M2.7 | 205K | ❌ | ✅ | $0.30 | $0.06 | $1.20 |
80
- | MiniMax M3 | 1.0M | | ✅ | $0.28 | $0.05 | $1.10 |
80
+ | MiniMax M3 | 1.0M | | ✅ | $0.28 | $0.05 | $1.10 |
81
81
 
82
82
  *Costs are per million tokens. Prices subject to change — check [getlilac.com](https://getlilac.com/) for current pricing.*
83
83
 
package/index.ts CHANGED
@@ -479,6 +479,41 @@ function dimStatus(ctx: any, text: string): string {
479
479
  }
480
480
  }
481
481
 
482
+ /**
483
+ * Paint the footer status from the LIVE session model — the single source of
484
+ * truth for the display. ctx.model is a lazy getter (not a snapshot), so this
485
+ * reflects the currently-selected model even when called from a deferred
486
+ * post-await callback that captured a different (stale) model at hook start.
487
+ *
488
+ * Set for lilac models, cleared for everything else. Every status paint site
489
+ * calls this, so whichever handler runs last wins — including after a switch
490
+ * to a non-lilac model, fixing a race where deferred post-await setStatus()
491
+ * calls re-painted a stale captured lilac model's discount over the clear
492
+ * that model_select had just issued.
493
+ *
494
+ * Only the DISPLAY follows the live model. Cost mutation (applyDiscountInPlace)
495
+ * and registerProvider() in before_provider_request still target the turn's
496
+ * captured in-flight model — that's the object pi bound and whose .cost
497
+ * calculateCost() reads, and it may legitimately differ from the live model
498
+ * after a mid-turn /model switch.
499
+ *
500
+ * Wrapped in try/catch: ctx.model / ctx.ui assert the extension runner is
501
+ * still active and throw if the session ended mid-fetch, so a late deferred
502
+ * callback after session_shutdown no-ops instead of throwing.
503
+ */
504
+ function syncStatus(ctx: any): void {
505
+ try {
506
+ const model = ctx.model;
507
+ if (model?.provider === "lilac") {
508
+ ctx.ui.setStatus("lilac", dimStatus(ctx, formatDiscountStatus(model.id)));
509
+ } else {
510
+ ctx.ui.setStatus("lilac", undefined);
511
+ }
512
+ } catch {
513
+ // Runner stale (session ended mid-fetch) — nothing to paint.
514
+ }
515
+ }
516
+
482
517
  function discountsChanged(
483
518
  a: Map<string, JsonDiscount> | null,
484
519
  b: Map<string, JsonDiscount> | null,
@@ -503,7 +538,19 @@ let cachedApiKey: string | undefined;
503
538
  let revalidateAbort: AbortController | null = null;
504
539
  let latestDiscounts: Map<string, JsonDiscount> | null = null;
505
540
  let lastDiscountFetchTime = 0;
506
- const STATUS_CACHE_TTL_MS = 30000;
541
+ // Turn-initiated /status fetches are throttled to once per TTL window so a burst
542
+ // of messages doesn't hammer the endpoint. Background polling (see
543
+ // STATUS_POLL_INTERVAL_MS) and turn fetches both stamp lastDiscountFetchTime, so
544
+ // they cooperate: a poll that just ran lets the next turn skip its own fetch
545
+ // within the TTL.
546
+ const STATUS_CACHE_TTL_MS = 60000;
547
+ // Lilac refreshes discounts ~every 10 minutes (per their docs: "Discounts refresh
548
+ // approximately every 10 minutes and are locked in when a request starts"). Poll
549
+ // on that cadence during idle so a long-idle session still catches supply/sub
550
+ // changes without waiting for the user to send a message — turn fetches alone
551
+ // only refresh on a user message and are TTL-throttled to 1/min.
552
+ const STATUS_POLL_INTERVAL_MS = 10 * 60 * 1000;
553
+ let pollInterval: ReturnType<typeof setInterval> | null = null;
507
554
  // List-price (patch-applied, pre-discount) models, cached until the base set
508
555
  // changes. Reset in cacheModels() so the next getListModels() rebuilds from the
509
556
  // refreshed disk cache / embedded set.
@@ -564,19 +611,56 @@ export default function (pi: ExtensionAPI) {
564
611
  }
565
612
  }
566
613
 
614
+ /**
615
+ * Background /status poll, fired every STATUS_POLL_INTERVAL_MS (10 min) from
616
+ * session_start to cover idle sessions. Mirrors the discount half of
617
+ * before_provider_request, but without an in-flight turn model to mutate: it
618
+ * only refreshes latestDiscounts, re-registers (so the next turn's models carry
619
+ * the new price), and re-paints the footer from the LIVE model. Passes the
620
+ * session AbortSignal so the fetch dies on session_shutdown or a subsequent
621
+ * session_start; bails on a missing API key or an aborted signal.
622
+ */
623
+ function pollStatusDiscounts(ctx: any, signal: AbortSignal): void {
624
+ if (signal.aborted || !cachedApiKey) return;
625
+ fetchStatusDiscounts(cachedApiKey, signal).then(discounts => {
626
+ if (signal.aborted || !discounts) return;
627
+ lastDiscountFetchTime = Date.now();
628
+ if (!discountsChanged(latestDiscounts, discounts)) {
629
+ syncStatus(ctx);
630
+ return;
631
+ }
632
+ cacheDiscounts(discounts);
633
+ latestDiscounts = discounts;
634
+ const freshList = getListModels();
635
+ pi.registerProvider("lilac", {
636
+ baseUrl: BASE_URL,
637
+ apiKey: "$LILAC_API_KEY",
638
+ api: "openai-completions",
639
+ models: applyDiscounts(freshList, discounts),
640
+ });
641
+ syncStatus(ctx);
642
+ }).catch(() => { /* network errors are non-fatal */ });
643
+ }
644
+
567
645
  pi.on("session_start", async (_event, ctx) => {
568
646
  revalidateAbort?.abort();
569
647
  revalidateAbort = new AbortController();
570
648
  const signal = revalidateAbort.signal;
571
649
 
650
+ // Tear down any poll interval left from a prior session before starting a
651
+ // fresh one (defensive; session_shutdown normally handles this).
652
+ if (pollInterval) {
653
+ clearInterval(pollInterval);
654
+ pollInterval = null;
655
+ }
656
+
572
657
  // Replay persisted discount state from session JSONL (synchronous, zero-latency)
573
658
  replayDiscountEvents(ctx);
574
659
 
575
- // Show status immediately with replayed/cached data — don't block pi startup
576
- const model = ctx.model;
577
- if (model?.provider === "lilac") {
578
- ctx.ui.setStatus("lilac", dimStatus(ctx, formatDiscountStatus(model.id)));
579
- }
660
+ // Show status immediately with replayed/cached data — don't block pi startup.
661
+ // syncStatus reads the LIVE ctx.model so a switch away from lilac before the
662
+ // background fetch resolves never leaves a stale discount painted.
663
+ syncStatus(ctx);
580
664
 
581
665
  // Fire-and-forget: resolve API key, then fetch live data in background.
582
666
  // Provider and status are hot-swapped when results arrive.
@@ -613,11 +697,21 @@ export default function (pi: ExtensionAPI) {
613
697
  });
614
698
  }
615
699
 
616
- if (model?.provider === "lilac") {
617
- ctx.ui.setStatus("lilac", dimStatus(ctx, formatDiscountStatus(model.id)));
618
- }
700
+ // Re-paint from the LIVE model: if the user switched to a non-lilac
701
+ // model (or a different lilac model) during the fetch, this reflects
702
+ // the new selection instead of re-showing the stale captured model.
703
+ syncStatus(ctx);
619
704
  }).catch(() => { /* network errors are non-fatal */ });
620
705
  });
706
+
707
+ // Background poll for idle sessions: Lilac refreshes discounts ~every 10
708
+ // minutes, so poll on that cadence to catch supply/sub changes while the
709
+ // user is idle (turn fetches only run when a message is sent). The callback
710
+ // bails on a missing API key or an aborted/shut-down session. Cleared in
711
+ // session_shutdown and at the top of the next session_start.
712
+ pollInterval = setInterval(() => pollStatusDiscounts(ctx, signal), STATUS_POLL_INTERVAL_MS);
713
+ // Don't keep the process alive solely for discount polling.
714
+ pollInterval.unref?.();
621
715
  });
622
716
 
623
717
  pi.on("turn_end", async (_event, ctx) => {
@@ -652,7 +746,10 @@ export default function (pi: ExtensionAPI) {
652
746
  // returned unchanged) and recomputes from list price so it never compounds
653
747
  // a previously-applied factor.
654
748
  applyDiscountInPlace(inFlightModel, listModels, latestDiscounts);
655
- ctx.ui.setStatus("lilac", dimStatus(ctx, formatDiscountStatus(inFlightModel.id)));
749
+ // Display follows the LIVE model (clears if the user switched away during
750
+ // this turn); the cost mutation above still targets the captured in-flight
751
+ // model pi bound for this turn.
752
+ syncStatus(ctx);
656
753
 
657
754
  if (!cachedApiKey) return;
658
755
 
@@ -667,7 +764,7 @@ export default function (pi: ExtensionAPI) {
667
764
  lastDiscountFetchTime = now;
668
765
 
669
766
  if (!discountsChanged(latestDiscounts, discounts)) {
670
- ctx.ui.setStatus("lilac", dimStatus(ctx, formatDiscountStatus(inFlightModel.id)));
767
+ syncStatus(ctx);
671
768
  return;
672
769
  }
673
770
 
@@ -687,23 +784,22 @@ export default function (pi: ExtensionAPI) {
687
784
  api: "openai-completions",
688
785
  models: applyDiscounts(freshList, discounts),
689
786
  });
690
- ctx.ui.setStatus("lilac", dimStatus(ctx, formatDiscountStatus(inFlightModel.id)));
787
+ // Display reflects the LIVE model: post-await the user may have switched to
788
+ // a non-lilac model, so syncStatus clears instead of re-painting the stale
789
+ // captured in-flight lilac model's discount.
790
+ syncStatus(ctx);
691
791
  });
692
792
 
693
- pi.on("model_select", async (event, ctx) => {
694
- if (event.model.provider === "lilac") {
695
- ctx.ui.setStatus("lilac", dimStatus(ctx, formatDiscountStatus(event.model.id)));
696
- } else {
697
- ctx.ui.setStatus("lilac", undefined);
698
- }
793
+ pi.on("model_select", async (_event, ctx) => {
794
+ // ctx.model is the live session model (pi sets state.model before emitting
795
+ // this event), so syncStatus paints/clears consistently with every other
796
+ // handler — one source of truth for the footer.
797
+ syncStatus(ctx);
699
798
  });
700
799
 
701
800
  pi.on("session_tree", async (_event, ctx) => {
702
801
  replayDiscountEvents(ctx);
703
- const model = ctx.model;
704
- if (model?.provider === "lilac") {
705
- ctx.ui.setStatus("lilac", dimStatus(ctx, formatDiscountStatus(model.id)));
706
- }
802
+ syncStatus(ctx);
707
803
  });
708
804
 
709
805
  // vLLM's streaming parser intermittently emits finish_reason: "tool_calls" without
@@ -739,6 +835,10 @@ export default function (pi: ExtensionAPI) {
739
835
 
740
836
  pi.on("session_shutdown", () => {
741
837
  revalidateAbort?.abort();
838
+ if (pollInterval) {
839
+ clearInterval(pollInterval);
840
+ pollInterval = null;
841
+ }
742
842
  });
743
843
  }
744
844
 
package/models.json CHANGED
@@ -119,7 +119,8 @@
119
119
  "name": "MiniMax M3",
120
120
  "reasoning": true,
121
121
  "input": [
122
- "text"
122
+ "text",
123
+ "image"
123
124
  ],
124
125
  "cost": {
125
126
  "input": 0.28,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lilac-provider",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Lilac provider extension for pi - Access Kimi K2.6, GLM 5.1, and Gemma 4 models through Lilac's OpenAI-compatible API on idle GPUs",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -11,12 +11,14 @@
11
11
  * 5. session_start replays persisted discount events and sets footer status.
12
12
  * 6. model_select sets/clears footer status for lilac/non-lilac models.
13
13
  * 7. turn_end appends discount entry to session JSONL.
14
- * 8. before_provider_request refreshes discounts with a 30s cache.
14
+ * 8. before_provider_request refreshes discounts with a 60s cache.
15
15
  * 9. formatDiscountStatus returns fallbacks when data is missing.
16
16
  * 10. applyDiscountInPlace mutates the in-flight model's cost in place,
17
17
  * recomputed from list price so re-applied discounts never compound.
18
18
  * 11. before_provider_request mutates the bound (in-flight) model object so the
19
19
  * current turn's cost calc sees the discount in real time.
20
+ * 12. session_start schedules a 10-minute background /status poll to cover idle
21
+ * sessions; session_shutdown clears it.
20
22
  */
21
23
 
22
24
  import type { ExtensionAPI, ModelRegistry } from "@earendil-works/pi-coding-agent";
@@ -286,7 +288,7 @@ for (const handler of handlers.get("model_select") || []) {
286
288
  previousModel: undefined,
287
289
  source: "set",
288
290
  },
289
- { ui: mockUi }
291
+ { ui: mockUi, model: { id: "moonshotai/kimi-k2.6", provider: "lilac" } }
290
292
  );
291
293
  }
292
294
  assert(statuses.get("lilac") === "supply: healthy · sub-discount: 25%", "model_select keeps status for lilac model");
@@ -302,7 +304,7 @@ for (const handler of handlers.get("model_select") || []) {
302
304
  previousModel: undefined,
303
305
  source: "set",
304
306
  },
305
- { ui: mockUi }
307
+ { ui: mockUi, model: { id: "claude-sonnet-4", provider: "anthropic" } }
306
308
  );
307
309
  }
308
310
  assert(statuses.get("lilac") === undefined, "model_select clears status for non-lilac model");
@@ -359,7 +361,7 @@ for (const handler of handlers.get("model_select") || []) {
359
361
  previousModel: undefined,
360
362
  source: "set",
361
363
  },
362
- { ui: mockUi },
364
+ { ui: mockUi, model: { id: "some/unknown-model", provider: "lilac" } },
363
365
  );
364
366
  }
365
367
  assert(statuses.get("lilac") === "supply: —", "unknown model shows fallback dash");
@@ -373,7 +375,7 @@ for (const handler of handlers.get("model_select") || []) {
373
375
  previousModel: undefined,
374
376
  source: "set",
375
377
  },
376
- { ui: mockUi },
378
+ { ui: mockUi, model: { id: "moonshotai/kimi-k2.6", provider: "lilac" } },
377
379
  );
378
380
  }
379
381
  assert(
@@ -541,6 +543,243 @@ assert(boundModel.cost.input === 0.525, "bound model cost mutated in place to 0.
541
543
  assert(boundModel.cost.output === 2.625, "bound model output mutated to 3.50 * 0.75 = 2.625");
542
544
  assert(boundModel.cost.cacheRead === 0.15, "bound model cacheRead mutated to 0.20 * 0.75 = 0.15");
543
545
 
546
+ // ─── Test 14: session_start clears status after switch during fetch ─────────
547
+
548
+ console.log("\n--- Test 14: session_start clears status after switch ---");
549
+
550
+ // Regression: the session_start background fetch resolves up to ~8s after hook
551
+ // start. If the user switches to a non-lilac model during that window, the
552
+ // deferred callback must NOT re-paint the stale captured lilac model's discount
553
+ // over the clear that model_select issued. syncStatus() reads the LIVE ctx.model
554
+ // (a lazy getter in production), so mutating startCtx.model mid-flight mimics
555
+ // the session model changing and the deferred paint clears instead.
556
+ globalThis.fetch = mockFetch({
557
+ "/models": { body: { data: [] } }, // no live models → liveModels null
558
+ "/status": {
559
+ body: {
560
+ models: [{
561
+ id: "moonshotai/kimi-k2.6",
562
+ current_subscription_supply_state: "healthy",
563
+ current_subscription_discount_percent: 25,
564
+ current_subscription_credit_multiplier: "0.75",
565
+ }],
566
+ },
567
+ },
568
+ }) as any;
569
+
570
+ statuses.clear();
571
+ const startCtx: any = {
572
+ modelRegistry: mockRegistry,
573
+ ui: mockUi,
574
+ model: { id: "moonshotai/kimi-k2.6", provider: "lilac" }, // start on lilac
575
+ sessionManager: { getBranch: () => [] },
576
+ };
577
+ for (const handler of handlers.get("session_start") || []) {
578
+ await handler({}, startCtx);
579
+ }
580
+ // Immediate paint reflects the lilac model (still selected at hook start).
581
+ assert(
582
+ statuses.get("lilac") === "supply: healthy · sub-discount: 25%",
583
+ "immediate session_start paint shows lilac discount",
584
+ );
585
+
586
+ // While the background fetch is in flight, the user switches to a non-lilac model.
587
+ startCtx.model = { id: "claude-sonnet-4", provider: "anthropic" };
588
+
589
+ // Flush the deferred .then() chain (resolveApiKey → fetch → syncStatus).
590
+ await new Promise((r) => setTimeout(r, 100));
591
+
592
+ assert(
593
+ statuses.get("lilac") === undefined,
594
+ "deferred session_start clears status after switch to non-lilac (no stale re-paint)",
595
+ );
596
+
597
+ // ─── Test 15: before_provider_request clears status after switch ─────────────
598
+
599
+ console.log("\n--- Test 15: before_provider_request clears status after switch ---");
600
+
601
+ // Regression: every 60s (when the discount TTL expires) before_provider_request
602
+ // awaits a fresh /status fetch. If the user switches to a non-lilac model during
603
+ // that await, the post-await paint must clear (live model) instead of re-painting
604
+ // the stale captured in-flight lilac model. Cost mutation still targets the
605
+ // captured in-flight model (Tests 12-13); only the DISPLAY follows the live model.
606
+
607
+ // Force the 60s TTL to look expired so the fetch path executes. Offset 120s for
608
+ // a clear 2x margin over the 60s TTL (the original used 60s over a 30s TTL).
609
+ const realDateNow = Date.now;
610
+ Date.now = () => realDateNow.call(Date) + 120000;
611
+ try {
612
+ globalThis.fetch = mockFetch({
613
+ "/status": {
614
+ body: {
615
+ // CHANGED discount (low/10%) vs the cached healthy/25% → exercises the
616
+ // registerProvider + syncStatus path, not just the unchanged early-return.
617
+ models: [{
618
+ id: "moonshotai/kimi-k2.6",
619
+ current_subscription_supply_state: "low",
620
+ current_subscription_discount_percent: 10,
621
+ current_subscription_credit_multiplier: "0.90",
622
+ }],
623
+ },
624
+ },
625
+ }) as any;
626
+
627
+ statuses.clear();
628
+ // Live model holder; reassigning .model mid-await mimics ctx.model being a
629
+ // lazy getter to the session model.
630
+ const bprCtx: any = {
631
+ ui: mockUi,
632
+ model: { id: "moonshotai/kimi-k2.6", provider: "lilac" },
633
+ };
634
+
635
+ // Kick off before_provider_request; it captures inFlightModel=lilac, mutates
636
+ // cost, paints, then awaits the fetch (TTL expired).
637
+ const bprPromise = (async () => {
638
+ for (const handler of handlers.get("before_provider_request") || []) {
639
+ await handler({ type: "before_provider_request", payload: {} }, bprCtx);
640
+ }
641
+ })();
642
+
643
+ // Switch to non-lilac while the fetch is in flight (before the await resumes).
644
+ bprCtx.model = { id: "claude-sonnet-4", provider: "anthropic" };
645
+
646
+ await bprPromise;
647
+
648
+ assert(
649
+ statuses.get("lilac") === undefined,
650
+ "post-await syncStatus clears after switch to non-lilac (no stale re-paint)",
651
+ );
652
+ } finally {
653
+ Date.now = realDateNow;
654
+ }
655
+
656
+ // ─── Test 16: session_start shows new lilac model after switch ───────────────
657
+
658
+ console.log("\n--- Test 16: session_start shows new lilac model after switch ---");
659
+
660
+ // Companion to Test 14: switching to a DIFFERENT lilac model during the fetch
661
+ // must paint the NEW model's discount, proving syncStatus reads the live model
662
+ // (not a stale capture that would show the old model's discount).
663
+ globalThis.fetch = mockFetch({
664
+ "/models": { body: { data: [] } },
665
+ "/status": {
666
+ body: {
667
+ models: [
668
+ {
669
+ id: "moonshotai/kimi-k2.6",
670
+ current_subscription_supply_state: "healthy",
671
+ current_subscription_discount_percent: 25,
672
+ current_subscription_credit_multiplier: "0.75",
673
+ },
674
+ {
675
+ id: "zai-org/glm-5.1",
676
+ current_subscription_supply_state: "high",
677
+ current_subscription_discount_percent: 50,
678
+ current_subscription_credit_multiplier: "0.50",
679
+ },
680
+ ],
681
+ },
682
+ },
683
+ }) as any;
684
+
685
+ statuses.clear();
686
+ const startCtx2: any = {
687
+ modelRegistry: mockRegistry,
688
+ ui: mockUi,
689
+ model: { id: "moonshotai/kimi-k2.6", provider: "lilac" }, // start on kimi
690
+ sessionManager: { getBranch: () => [] },
691
+ };
692
+ for (const handler of handlers.get("session_start") || []) {
693
+ await handler({}, startCtx2);
694
+ }
695
+ // Switch to a different lilac model (glm) during the in-flight fetch.
696
+ startCtx2.model = { id: "zai-org/glm-5.1", provider: "lilac" };
697
+ await new Promise((r) => setTimeout(r, 100));
698
+ assert(
699
+ statuses.get("lilac") === "supply: high · sub-discount: 50%",
700
+ "deferred session_start paints the NEW lilac model's discount (glm), not the stale kimi capture",
701
+ );
702
+
703
+ // ─── Test 17: session_start schedules a 10-min idle poll; shutdown clears it ─
704
+
705
+ console.log("\n--- Test 17: session_start schedules idle poll; session_shutdown clears it ---");
706
+
707
+ // Lilac refreshes discounts ~every 10 minutes. session_start must schedule a
708
+ // background /status poll at that cadence to cover idle sessions (turn fetches
709
+ // only run when the user sends a message), and session_shutdown must clear it so
710
+ // it neither leaks nor keeps the process alive. Wrap the global timer APIs to
711
+ // capture the scheduled delay + handle, then confirm shutdown clears it.
712
+
713
+ const realSetInterval = globalThis.setInterval.bind(globalThis);
714
+ const realClearInterval = globalThis.clearInterval.bind(globalThis);
715
+ let scheduledDelay: number | null = null;
716
+ let scheduledHandle: ReturnType<typeof setInterval> | null = null;
717
+ const clearedHandles = new Set<ReturnType<typeof setInterval>>();
718
+
719
+ globalThis.setInterval = ((fn: (...args: any[]) => void, delay?: number, ...rest: any[]) => {
720
+ scheduledDelay = delay ?? null;
721
+ const h = realSetInterval(fn, delay as any, ...rest);
722
+ scheduledHandle = h;
723
+ return h;
724
+ }) as any;
725
+ globalThis.clearInterval = ((handle: ReturnType<typeof setInterval>) => {
726
+ clearedHandles.add(handle);
727
+ return realClearInterval(handle);
728
+ }) as any;
729
+
730
+ try {
731
+ // Benign fetch mock so the session_start fire-and-forget /models + /status
732
+ // fetch doesn't hit the network. (The poll itself never fires — 10 min — so
733
+ // only the startup fetch needs mocking here.)
734
+ globalThis.fetch = mockFetch({
735
+ "/models": { body: { data: [] } },
736
+ "/status": {
737
+ body: {
738
+ models: [
739
+ {
740
+ id: "moonshotai/kimi-k2.6",
741
+ current_subscription_supply_state: "healthy",
742
+ current_subscription_discount_percent: 25,
743
+ current_subscription_credit_multiplier: "0.75",
744
+ },
745
+ ],
746
+ },
747
+ },
748
+ }) as any;
749
+
750
+ const pollCtx: any = {
751
+ modelRegistry: mockRegistry,
752
+ ui: mockUi,
753
+ model: { id: "moonshotai/kimi-k2.6", provider: "lilac" },
754
+ sessionManager: { getBranch: () => [] },
755
+ };
756
+
757
+ for (const handler of handlers.get("session_start") || []) {
758
+ await handler({}, pollCtx);
759
+ }
760
+
761
+ assert(
762
+ scheduledDelay === 10 * 60 * 1000,
763
+ "session_start schedules a 10-minute (600000ms) /status poll for idle sessions",
764
+ );
765
+ assert(scheduledHandle !== null, "poll interval handle was captured");
766
+ const capturedHandle = scheduledHandle;
767
+
768
+ for (const handler of handlers.get("session_shutdown") || []) {
769
+ await handler({}, pollCtx);
770
+ }
771
+
772
+ assert(
773
+ capturedHandle !== null && clearedHandles.has(capturedHandle),
774
+ "session_shutdown clears the scheduled poll interval (no leak / process-hang)",
775
+ );
776
+ } finally {
777
+ // Restore globals; clear any interval this test scheduled before it can fire.
778
+ if (scheduledHandle) realClearInterval(scheduledHandle);
779
+ globalThis.setInterval = realSetInterval as any;
780
+ globalThis.clearInterval = realClearInterval as any;
781
+ }
782
+
544
783
  // ─── Cleanup ──────────────────────────────────────────────────────────────────
545
784
 
546
785
  globalThis.fetch = originalFetch;