gloomberb 0.13.1 → 0.13.3

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.
Files changed (107) hide show
  1. package/README.md +2 -0
  2. package/package.json +1 -1
  3. package/src/api-client/index.test.ts +34 -0
  4. package/src/api-client/request.ts +4 -1
  5. package/src/cli/desktop-pane-shot.ts +72 -2
  6. package/src/cli/pane-functions/cloud-session.test.ts +77 -0
  7. package/src/cli/pane-functions/cloud-session.ts +47 -0
  8. package/src/cli/pane-functions/index.ts +5 -1
  9. package/src/cli/pane-functions/screenshot.test.ts +27 -0
  10. package/src/cli/pane-functions/screenshot.ts +85 -20
  11. package/src/components/command-bar/assist/runtime.ts +38 -30
  12. package/src/components/command-bar/surface/assist.test.tsx +43 -0
  13. package/src/news/types.ts +1 -0
  14. package/src/plugins/builtin/chat/controller/channels.ts +2 -1
  15. package/src/plugins/builtin/chat/controller/index.ts +13 -5
  16. package/src/plugins/builtin/chat/controller/realtime.ts +24 -0
  17. package/src/plugins/builtin/chat/controller/session-runtime.ts +76 -8
  18. package/src/plugins/builtin/chat/controller/state.ts +1 -0
  19. package/src/plugins/builtin/chat/controller.test.ts +260 -4
  20. package/src/plugins/builtin/cloud/askg/executor.test.ts +160 -0
  21. package/src/plugins/builtin/cloud/askg/executor.ts +429 -0
  22. package/src/plugins/builtin/cloud/askg/manifest.test.ts +153 -0
  23. package/src/plugins/builtin/cloud/askg/manifest.ts +314 -0
  24. package/src/plugins/builtin/cloud/askg/protocol.test.ts +3 -2
  25. package/src/plugins/builtin/cloud/askg/protocol.ts +16 -4
  26. package/src/plugins/builtin/cloud/askg/undo.test.ts +44 -0
  27. package/src/plugins/builtin/cloud/askg/undo.ts +277 -0
  28. package/src/plugins/builtin/cloud/askg/verify.ts +130 -0
  29. package/src/plugins/builtin/cloud/plugin.tsx +2 -0
  30. package/src/plugins/builtin/congress-trades/client.ts +11 -0
  31. package/src/plugins/builtin/congress-trades/detail.tsx +3 -3
  32. package/src/plugins/builtin/congress-trades/headless.test.ts +98 -0
  33. package/src/plugins/builtin/congress-trades/headless.ts +145 -0
  34. package/src/plugins/builtin/congress-trades/pane.tsx +3 -3
  35. package/src/plugins/builtin/dividend-yield/headless.test.ts +88 -0
  36. package/src/plugins/builtin/dividend-yield/headless.ts +135 -0
  37. package/src/plugins/builtin/dividend-yield/index.tsx +12 -8
  38. package/src/plugins/builtin/dividend-yield/model.ts +4 -22
  39. package/src/plugins/builtin/dividend-yield/view.ts +17 -0
  40. package/src/plugins/builtin/earnings/headless.test.ts +82 -0
  41. package/src/plugins/builtin/earnings/headless.ts +162 -0
  42. package/src/plugins/builtin/earnings/index.tsx +35 -9
  43. package/src/plugins/builtin/earnings/model.test.ts +29 -1
  44. package/src/plugins/builtin/earnings-calls/data.ts +29 -8
  45. package/src/plugins/builtin/earnings-calls/headless.test.ts +192 -0
  46. package/src/plugins/builtin/earnings-calls/headless.ts +300 -0
  47. package/src/plugins/builtin/earnings-calls/index.tsx +19 -9
  48. package/src/plugins/builtin/earnings-calls/model.ts +238 -0
  49. package/src/plugins/builtin/earnings-calls/transcript-view.tsx +8 -9
  50. package/src/plugins/builtin/holders/client.ts +29 -0
  51. package/src/plugins/builtin/holders/headless.test.ts +58 -0
  52. package/src/plugins/builtin/holders/headless.ts +162 -0
  53. package/src/plugins/builtin/holders/index.tsx +14 -8
  54. package/src/plugins/builtin/holders/pane.tsx +7 -1
  55. package/src/plugins/builtin/insider/client.ts +50 -0
  56. package/src/plugins/builtin/insider/headless.test.ts +122 -0
  57. package/src/plugins/builtin/insider/headless.ts +117 -0
  58. package/src/plugins/builtin/insider/index.tsx +21 -50
  59. package/src/plugins/builtin/insider/insider-data.ts +1 -1
  60. package/src/plugins/builtin/insider/model.ts +72 -0
  61. package/src/plugins/builtin/ipo-calendar/client.ts +15 -0
  62. package/src/plugins/builtin/ipo-calendar/headless.test.ts +89 -0
  63. package/src/plugins/builtin/ipo-calendar/headless.ts +127 -0
  64. package/src/plugins/builtin/ipo-calendar/index.tsx +2 -0
  65. package/src/plugins/builtin/ipo-calendar/model.ts +3 -15
  66. package/src/plugins/builtin/news/headless.test.ts +138 -0
  67. package/src/plugins/builtin/news/headless.ts +190 -0
  68. package/src/plugins/builtin/news/index.tsx +13 -9
  69. package/src/plugins/builtin/news/wire/index.tsx +2 -1
  70. package/src/plugins/builtin/research/analyst-headless.test.ts +60 -0
  71. package/src/plugins/builtin/research/analyst-headless.ts +172 -0
  72. package/src/plugins/builtin/research/analyst-model.ts +219 -0
  73. package/src/plugins/builtin/research/analyst-pane.test.tsx +145 -0
  74. package/src/plugins/builtin/research/analyst-pane.tsx +31 -221
  75. package/src/plugins/builtin/research/client.ts +12 -0
  76. package/src/plugins/builtin/research/corporate-actions-pane.tsx +27 -1
  77. package/src/plugins/builtin/research/event-model.ts +56 -0
  78. package/src/plugins/builtin/research/headless.test.ts +93 -0
  79. package/src/plugins/builtin/research/headless.ts +161 -0
  80. package/src/plugins/builtin/research/index.test.ts +43 -0
  81. package/src/plugins/builtin/research/index.tsx +28 -18
  82. package/src/plugins/builtin/sec/client.ts +16 -0
  83. package/src/plugins/builtin/sec/headless.test.ts +72 -0
  84. package/src/plugins/builtin/sec/headless.ts +69 -0
  85. package/src/plugins/builtin/sec/index.tsx +20 -75
  86. package/src/plugins/builtin/sec/model.ts +89 -0
  87. package/src/plugins/builtin/short-interest/client.ts +5 -2
  88. package/src/plugins/builtin/short-interest/headless.test.ts +96 -0
  89. package/src/plugins/builtin/short-interest/headless.ts +111 -0
  90. package/src/plugins/builtin/short-interest/index.tsx +15 -9
  91. package/src/plugins/builtin/short-interest/model.ts +15 -4
  92. package/src/plugins/builtin/short-interest/pane.tsx +1 -1
  93. package/src/plugins/builtin/thirteenf/data.ts +13 -11
  94. package/src/plugins/builtin/thirteenf/headless.test.ts +112 -0
  95. package/src/plugins/builtin/thirteenf/headless.ts +286 -0
  96. package/src/plugins/builtin/thirteenf/index.tsx +4 -0
  97. package/src/plugins/builtin/thirteenf/model.ts +13 -0
  98. package/src/plugins/builtin/thirteenf/table.ts +3 -15
  99. package/src/plugins/prediction-markets/headless.test.ts +119 -0
  100. package/src/plugins/prediction-markets/headless.ts +255 -0
  101. package/src/plugins/prediction-markets/index.tsx +2 -0
  102. package/src/renderers/browser/config-host.test.ts +70 -0
  103. package/src/renderers/browser/config-host.ts +74 -10
  104. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +180 -19
  105. package/src/sources/gloomberb-cloud/news.ts +1 -0
  106. package/src/updater.test.ts +17 -1
  107. package/src/updater.ts +4 -2
@@ -1,8 +1,16 @@
1
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
1
+ import { afterEach, beforeEach, describe, expect, jest, test } from "bun:test";
2
2
  import type { AppNotificationRequest } from "../../../types/plugin";
3
3
  import { MemoryPluginPersistence as MemoryPersistence } from "../../../test-support/plugin-persistence";
4
- import { apiClient, type ChatChannel, type ChatMessage, type ChatNotification } from "../../../api-client";
4
+ import {
5
+ apiClient,
6
+ type ChatChannel,
7
+ type ChatMessage,
8
+ type ChatNotification,
9
+ type ChatStateResponse,
10
+ } from "../../../api-client";
11
+ import { ApiRequestError } from "../../../api-client/errors";
5
12
  import { ChatController } from "./controller";
13
+ import { SESSION_RETRY_MS } from "./controller/state";
6
14
 
7
15
  const TRANSCRIPT_KIND = "channel-transcript";
8
16
  const TRANSCRIPT_KEY = "everyone";
@@ -14,6 +22,7 @@ const originalGetMessages = apiClient.getMessages.bind(apiClient);
14
22
  const originalGetChannels = apiClient.getChannels.bind(apiClient);
15
23
  const originalGetChatPresence = apiClient.getChatPresence.bind(apiClient);
16
24
  const originalGetChatState = apiClient.getChatState.bind(apiClient);
25
+ const originalGetAccountProfile = apiClient.getAccountProfile.bind(apiClient);
17
26
  const originalUpdateChatChannelState = apiClient.updateChatChannelState.bind(apiClient);
18
27
  const originalMarkChatNotificationsDelivered = apiClient.markChatNotificationsDelivered.bind(apiClient);
19
28
  const originalSubscribeChatNotifications = apiClient.subscribeChatNotifications.bind(apiClient);
@@ -91,6 +100,7 @@ beforeEach(() => {
91
100
  });
92
101
 
93
102
  afterEach(() => {
103
+ jest.useRealTimers();
94
104
  apiClient.dispose();
95
105
  apiClient.setSessionToken(null);
96
106
  apiClient.setCookieSessionMode(false);
@@ -100,6 +110,7 @@ afterEach(() => {
100
110
  apiClient.getChannels = originalGetChannels;
101
111
  apiClient.getChatPresence = originalGetChatPresence;
102
112
  apiClient.getChatState = originalGetChatState;
113
+ apiClient.getAccountProfile = originalGetAccountProfile;
103
114
  apiClient.updateChatChannelState = originalUpdateChatChannelState;
104
115
  apiClient.markChatNotificationsDelivered = originalMarkChatNotificationsDelivered;
105
116
  apiClient.subscribeChatNotifications = originalSubscribeChatNotifications;
@@ -453,9 +464,15 @@ describe("ChatController", () => {
453
464
  }
454
465
  });
455
466
 
456
- test("keeps a persisted native session when get-session returns no user", async () => {
467
+ test("validates a persisted native session through protected chat state", async () => {
457
468
  const persistence = new MemoryPersistence();
458
469
  const controller = new ChatController();
470
+ const directChannel: ChatChannel = {
471
+ id: "dm:u2",
472
+ name: "u2",
473
+ kind: "direct",
474
+ created_at: "2026-03-28T00:00:00.000Z",
475
+ };
459
476
 
460
477
  persistence.setState("session", {
461
478
  sessionToken: "token-123",
@@ -463,11 +480,22 @@ describe("ChatController", () => {
463
480
  }, { schemaVersion: 1 });
464
481
 
465
482
  controller.attachPersistence(persistence);
466
- apiClient.getSession = async () => null;
483
+ apiClient.getSession = async () => {
484
+ apiClient.restoreCachedUser(null);
485
+ return null;
486
+ };
487
+ apiClient.getChatState = async () => ({
488
+ channels: [...SERVER_CHAT_CHANNELS, directChannel],
489
+ onlineCount: 0,
490
+ channelStates: [],
491
+ notifications: [],
492
+ });
467
493
 
468
494
  await controller.refreshSession();
469
495
 
470
496
  expect(apiClient.getSessionToken()).toBe("token-123");
497
+ expect(apiClient.getCurrentUser()?.id).toBe("u1");
498
+ expect(controller.getChannels()).toContainEqual(directChannel);
471
499
  expect(persistence.getState<{
472
500
  sessionToken: string;
473
501
  user: { id: string; username: string; emailVerified: boolean };
@@ -483,6 +511,232 @@ describe("ChatController", () => {
483
511
  controller.dispose();
484
512
  });
485
513
 
514
+ test("clears an expired persisted session when protected chat rejects it", async () => {
515
+ const persistence = new MemoryPersistence();
516
+ const controller = new ChatController();
517
+
518
+ persistence.setState("session", {
519
+ sessionToken: "expired-token",
520
+ user: { id: "u1", username: "vince", emailVerified: true },
521
+ }, { schemaVersion: 1 });
522
+
523
+ controller.attachPersistence(persistence);
524
+ apiClient.getSession = async () => {
525
+ apiClient.restoreCachedUser(null);
526
+ return null;
527
+ };
528
+ apiClient.getChatState = async () => {
529
+ throw new ApiRequestError("Unauthorized", 401);
530
+ };
531
+
532
+ await controller.refreshSession();
533
+
534
+ expect(apiClient.getSessionToken()).toBeNull();
535
+ expect(apiClient.getCurrentUser()).toBeNull();
536
+ expect(controller.getSnapshot().user).toBeNull();
537
+ expect(persistence.getState("session", { schemaVersion: 1 })).toEqual({
538
+ sessionToken: null,
539
+ user: null,
540
+ });
541
+ controller.dispose();
542
+ });
543
+
544
+ test("clears an expired persisted session for an unverified user", async () => {
545
+ const persistence = new MemoryPersistence();
546
+ const controller = new ChatController();
547
+ let profileRequests = 0;
548
+
549
+ persistence.setState("session", {
550
+ sessionToken: "expired-token",
551
+ user: { id: "u1", username: "vince", emailVerified: false },
552
+ }, { schemaVersion: 1 });
553
+
554
+ controller.attachPersistence(persistence);
555
+ apiClient.getSession = async () => {
556
+ apiClient.restoreCachedUser(null);
557
+ return null;
558
+ };
559
+ apiClient.getAccountProfile = async () => {
560
+ profileRequests += 1;
561
+ throw new ApiRequestError("Unauthorized", 401);
562
+ };
563
+
564
+ await controller.refreshSession();
565
+
566
+ expect(profileRequests).toBe(1);
567
+ expect(apiClient.getSessionToken()).toBeNull();
568
+ expect(apiClient.getCurrentUser()).toBeNull();
569
+ expect(controller.getSnapshot().user).toBeNull();
570
+ controller.dispose();
571
+ });
572
+
573
+ test("downgrades stale verification state when protected chat rejects it", async () => {
574
+ const persistence = new MemoryPersistence();
575
+ const controller = new ChatController();
576
+
577
+ persistence.setState("session", {
578
+ sessionToken: "token-123",
579
+ user: { id: "u1", username: "vince", emailVerified: true },
580
+ }, { schemaVersion: 1 });
581
+
582
+ controller.attachPersistence(persistence);
583
+ apiClient.getSession = async () => {
584
+ apiClient.restoreCachedUser(null);
585
+ return null;
586
+ };
587
+ apiClient.getChatState = async () => {
588
+ throw new ApiRequestError("Email verification required", 403);
589
+ };
590
+
591
+ await controller.refreshSession();
592
+
593
+ expect(apiClient.getSessionToken()).toBe("token-123");
594
+ expect(apiClient.getCurrentUser()?.emailVerified).toBe(false);
595
+ expect(controller.getSnapshot().user?.emailVerified).toBe(false);
596
+ expect(persistence.getState<{
597
+ sessionToken: string;
598
+ user: { id: string; username: string; emailVerified: boolean };
599
+ }>("session", { schemaVersion: 1 })?.user.emailVerified).toBe(false);
600
+ controller.dispose();
601
+ });
602
+
603
+ test("does not let a stale validation failure clear a replacement session", async () => {
604
+ const persistence = new MemoryPersistence();
605
+ const controller = new ChatController();
606
+ let rejectProbe: ((error: Error) => void) | null = null;
607
+ let markProbeStarted: (() => void) | null = null;
608
+ const probeStarted = new Promise<void>((resolve) => {
609
+ markProbeStarted = resolve;
610
+ });
611
+
612
+ persistence.setState("session", {
613
+ sessionToken: "old-token",
614
+ user: { id: "u1", username: "vince", emailVerified: true },
615
+ }, { schemaVersion: 1 });
616
+
617
+ controller.attachPersistence(persistence);
618
+ apiClient.getSession = async () => {
619
+ apiClient.restoreCachedUser(null);
620
+ return null;
621
+ };
622
+ apiClient.getChatState = () => new Promise((_, reject) => {
623
+ rejectProbe = reject;
624
+ markProbeStarted?.();
625
+ });
626
+
627
+ const refresh = controller.refreshSession();
628
+ await probeStarted;
629
+ controller.adoptSession("new-token", {
630
+ id: "u2",
631
+ username: "mara",
632
+ emailVerified: true,
633
+ });
634
+ rejectProbe?.(new ApiRequestError("Unauthorized", 401));
635
+ await refresh;
636
+
637
+ expect(apiClient.getSessionToken()).toBe("new-token");
638
+ expect(apiClient.getCurrentUser()?.id).toBe("u2");
639
+ expect(controller.getSnapshot().user?.id).toBe("u2");
640
+ expect(persistence.getState<{
641
+ sessionToken: string;
642
+ user: { id: string };
643
+ }>("session", { schemaVersion: 1 })).toMatchObject({
644
+ sessionToken: "new-token",
645
+ user: { id: "u2" },
646
+ });
647
+ controller.dispose();
648
+ });
649
+
650
+ test("does not apply stale chat state after a replacement session", async () => {
651
+ const persistence = new MemoryPersistence();
652
+ const controller = new ChatController();
653
+ let resolveProbe: ((state: ChatStateResponse) => void) | null = null;
654
+ let markProbeStarted: (() => void) | null = null;
655
+ const probeStarted = new Promise<void>((resolve) => {
656
+ markProbeStarted = resolve;
657
+ });
658
+ const oldDirectChannel: ChatChannel = {
659
+ id: "dm:old-account",
660
+ name: "old-account",
661
+ kind: "direct",
662
+ created_at: "2026-03-28T00:00:00.000Z",
663
+ };
664
+
665
+ persistence.setState("session", {
666
+ sessionToken: "old-token",
667
+ user: { id: "u1", username: "vince", emailVerified: true },
668
+ }, { schemaVersion: 1 });
669
+
670
+ controller.attachPersistence(persistence);
671
+ apiClient.getSession = async () => {
672
+ apiClient.restoreCachedUser(null);
673
+ return null;
674
+ };
675
+ apiClient.getChatState = () => new Promise<ChatStateResponse>((resolve) => {
676
+ resolveProbe = resolve;
677
+ markProbeStarted?.();
678
+ });
679
+
680
+ const refresh = controller.refreshSession();
681
+ await probeStarted;
682
+ controller.adoptSession("new-token", {
683
+ id: "u2",
684
+ username: "mara",
685
+ emailVerified: true,
686
+ });
687
+ resolveProbe?.({
688
+ channels: [...SERVER_CHAT_CHANNELS, oldDirectChannel],
689
+ onlineCount: 0,
690
+ channelStates: [],
691
+ notifications: [],
692
+ });
693
+ await refresh;
694
+
695
+ expect(apiClient.getSessionToken()).toBe("new-token");
696
+ expect(controller.getSnapshot().user?.id).toBe("u2");
697
+ expect(controller.getChannels()).not.toContainEqual(oldDirectChannel);
698
+ controller.dispose();
699
+ });
700
+
701
+ test("revalidates a persisted session after an offline validation probe", async () => {
702
+ jest.useFakeTimers();
703
+ const persistence = new MemoryPersistence();
704
+ const controller = new ChatController();
705
+
706
+ persistence.setState("session", {
707
+ sessionToken: "token-123",
708
+ user: { id: "u1", username: "vince", emailVerified: true },
709
+ }, { schemaVersion: 1 });
710
+
711
+ controller.attachPersistence(persistence);
712
+ apiClient.getSession = async () => {
713
+ apiClient.restoreCachedUser(null);
714
+ return null;
715
+ };
716
+ apiClient.getChatState = async () => {
717
+ throw new Error("network down");
718
+ };
719
+
720
+ await controller.refreshSession();
721
+
722
+ expect(apiClient.getSessionToken()).toBe("token-123");
723
+ expect(apiClient.getCurrentUser()?.id).toBe("u1");
724
+ expect(controller.getSnapshot().user?.id).toBe("u1");
725
+ expect((controller as any).realtime.sessionRetryTimer).not.toBeNull();
726
+
727
+ apiClient.getChatState = async () => {
728
+ throw new ApiRequestError("Unauthorized", 401);
729
+ };
730
+ jest.advanceTimersByTime(SESSION_RETRY_MS);
731
+ await controller.refreshSession();
732
+
733
+ expect(apiClient.getSessionToken()).toBeNull();
734
+ expect(controller.getSnapshot().user).toBeNull();
735
+ expect((controller as any).realtime.sessionRetryTimer).toBeNull();
736
+ controller.dispose();
737
+ jest.useRealTimers();
738
+ });
739
+
486
740
  test("signs out when get-session returns no user and no token is stored", async () => {
487
741
  const persistence = new MemoryPersistence();
488
742
  const controller = new ChatController();
@@ -530,6 +784,8 @@ describe("ChatController", () => {
530
784
  username: "vince",
531
785
  emailVerified: true,
532
786
  });
787
+ expect((controller as any).realtime.sessionRetryTimer).not.toBeNull();
788
+ controller.dispose();
533
789
  });
534
790
 
535
791
  test("refreshes the public transcript without requiring a session", async () => {
@@ -0,0 +1,160 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { MarketContext } from "../../../../cli/types";
3
+ import type { PaneFunctionCatalog } from "../../../../cli/pane-functions/catalog";
4
+ import type { RemoteControlResponse } from "../../../../remote/types";
5
+ import {
6
+ MAX_TOOL_RESULT_BYTES,
7
+ type ASKGToolCallEvent,
8
+ type ClientToolManifest,
9
+ } from "./protocol";
10
+ import { createASKGToolExecutor } from "./executor";
11
+
12
+ const emptyRegistry: PaneFunctionCatalog = {
13
+ panes: new Map(),
14
+ paneTemplates: new Map(),
15
+ destroy() {},
16
+ };
17
+ const emptyContext = {} as MarketContext;
18
+
19
+ const headlessManifest: ClientToolManifest = {
20
+ name: "val",
21
+ source: "headless",
22
+ title: "Valuation",
23
+ description: "Read valuation data.",
24
+ writeTier: "read",
25
+ shape: "rows",
26
+ argument: { kind: "none" },
27
+ options: [],
28
+ confirm: "never",
29
+ timeoutMs: 30_000,
30
+ };
31
+
32
+ const resourceManifest: ClientToolManifest = {
33
+ name: "app.get_resource",
34
+ source: "remote-op",
35
+ title: "App: Get resource",
36
+ description: "Read an app resource.",
37
+ writeTier: "read",
38
+ inputSchema: { type: "object" },
39
+ confirm: "never",
40
+ timeoutMs: 10_000,
41
+ };
42
+
43
+ function call(
44
+ manifest: ClientToolManifest,
45
+ overrides: Partial<ASKGToolCallEvent> = {},
46
+ ): ASKGToolCallEvent {
47
+ return {
48
+ seq: 1,
49
+ type: "tool-call",
50
+ turnId: "turn-1",
51
+ toolCallId: "tool-1",
52
+ name: manifest.name,
53
+ args: {},
54
+ writeTier: manifest.writeTier,
55
+ requiresConfirmation: false,
56
+ preview: null,
57
+ timeoutMs: 1_000,
58
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
59
+ ...overrides,
60
+ };
61
+ }
62
+
63
+ function executor(options: {
64
+ manifests?: ClientToolManifest[];
65
+ remoteHandler?: (request: unknown) => Promise<RemoteControlResponse>;
66
+ headlessExecutor?: Parameters<typeof createASKGToolExecutor>[0]["headlessExecutor"];
67
+ }) {
68
+ return createASKGToolExecutor({
69
+ manifests: options.manifests ?? [headlessManifest],
70
+ registry: emptyRegistry,
71
+ context: emptyContext,
72
+ remoteHandler: options.remoteHandler ?? (async () => ({ ok: true, data: {} })),
73
+ ...(options.headlessExecutor ? { headlessExecutor: options.headlessExecutor } : {}),
74
+ });
75
+ }
76
+
77
+ describe("ASKG delegated tool executor", () => {
78
+ test("denies a server tier that does not match the advertised tier", async () => {
79
+ let executions = 0;
80
+ const tools = executor({
81
+ headlessExecutor: async () => {
82
+ executions += 1;
83
+ return { result: { rows: [] }, rowCount: 0 };
84
+ },
85
+ });
86
+
87
+ const result = await tools.execute(call(headlessManifest, { writeTier: "ui-write" }));
88
+
89
+ expect(result.status).toBe("denied");
90
+ expect(result.note).toContain("tier mismatch");
91
+ expect(executions).toBe(0);
92
+ });
93
+
94
+ test("returns timeout without waiting for a stalled loader", async () => {
95
+ const tools = executor({
96
+ headlessExecutor: async () => new Promise(() => {}),
97
+ });
98
+
99
+ const result = await tools.execute(call(headlessManifest, { timeoutMs: 5 }));
100
+
101
+ expect(result.status).toBe("timeout");
102
+ expect(result.elapsedMs).toBeLessThan(500);
103
+ });
104
+
105
+ test("cancels an in-flight delegated load", async () => {
106
+ const started = Promise.withResolvers<void>();
107
+ const tools = executor({
108
+ headlessExecutor: async () => {
109
+ started.resolve();
110
+ return new Promise(() => {});
111
+ },
112
+ });
113
+ const controller = new AbortController();
114
+ const pending = tools.execute(call(headlessManifest), { signal: controller.signal });
115
+ await started.promise;
116
+ controller.abort();
117
+
118
+ const result = await pending;
119
+
120
+ expect(result.status).toBe("cancelled");
121
+ });
122
+
123
+ test("truncates the complete wire payload under the byte cap", async () => {
124
+ const tools = executor({
125
+ manifests: [resourceManifest],
126
+ remoteHandler: async () => ({ ok: true, data: { rows: [{ text: "x".repeat(MAX_TOOL_RESULT_BYTES * 2) }] } }),
127
+ });
128
+
129
+ const result = await tools.execute(call(resourceManifest, {
130
+ args: { resource: "app://snapshot" },
131
+ }));
132
+
133
+ expect(result.status).toBe("partial");
134
+ expect(result.truncated).toBe(true);
135
+ expect(result.rowCount).toBe(1);
136
+ expect(new TextEncoder().encode(JSON.stringify(result)).byteLength).toBeLessThanOrEqual(MAX_TOOL_RESULT_BYTES);
137
+ expect(result.result).toMatchObject({ originalBytes: expect.any(Number) });
138
+ });
139
+
140
+ test("returns remote failures as error values", async () => {
141
+ const tools = executor({
142
+ manifests: [resourceManifest],
143
+ remoteHandler: async () => ({
144
+ ok: false,
145
+ error: { code: "upstream_unavailable", message: "Upstream source is unavailable." },
146
+ }),
147
+ });
148
+
149
+ const result = await tools.execute(call(resourceManifest, {
150
+ args: { resource: "app://snapshot" },
151
+ }));
152
+
153
+ expect(result).toMatchObject({
154
+ status: "error",
155
+ result: { code: "upstream_unavailable" },
156
+ note: "Upstream source is unavailable.",
157
+ truncated: false,
158
+ });
159
+ });
160
+ });