gloomberb 0.13.1 → 0.13.2

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 (92) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/index.test.ts +34 -0
  3. package/src/api-client/request.ts +4 -1
  4. package/src/cli/pane-functions/cloud-session.test.ts +54 -0
  5. package/src/cli/pane-functions/cloud-session.ts +38 -0
  6. package/src/cli/pane-functions/index.ts +5 -1
  7. package/src/cli/pane-functions/screenshot.ts +2 -19
  8. package/src/components/command-bar/assist/runtime.ts +38 -30
  9. package/src/components/command-bar/surface/assist.test.tsx +43 -0
  10. package/src/news/types.ts +1 -0
  11. package/src/plugins/builtin/chat/controller/channels.ts +2 -1
  12. package/src/plugins/builtin/chat/controller/index.ts +13 -5
  13. package/src/plugins/builtin/chat/controller/realtime.ts +24 -0
  14. package/src/plugins/builtin/chat/controller/session-runtime.ts +76 -8
  15. package/src/plugins/builtin/chat/controller/state.ts +1 -0
  16. package/src/plugins/builtin/chat/controller.test.ts +260 -4
  17. package/src/plugins/builtin/cloud/askg/executor.test.ts +160 -0
  18. package/src/plugins/builtin/cloud/askg/executor.ts +429 -0
  19. package/src/plugins/builtin/cloud/askg/manifest.test.ts +153 -0
  20. package/src/plugins/builtin/cloud/askg/manifest.ts +314 -0
  21. package/src/plugins/builtin/cloud/askg/protocol.test.ts +3 -2
  22. package/src/plugins/builtin/cloud/askg/protocol.ts +16 -4
  23. package/src/plugins/builtin/cloud/askg/undo.test.ts +44 -0
  24. package/src/plugins/builtin/cloud/askg/undo.ts +277 -0
  25. package/src/plugins/builtin/cloud/askg/verify.ts +130 -0
  26. package/src/plugins/builtin/cloud/plugin.tsx +2 -0
  27. package/src/plugins/builtin/congress-trades/client.ts +11 -0
  28. package/src/plugins/builtin/congress-trades/detail.tsx +3 -3
  29. package/src/plugins/builtin/congress-trades/headless.test.ts +98 -0
  30. package/src/plugins/builtin/congress-trades/headless.ts +145 -0
  31. package/src/plugins/builtin/congress-trades/pane.tsx +3 -3
  32. package/src/plugins/builtin/dividend-yield/headless.test.ts +88 -0
  33. package/src/plugins/builtin/dividend-yield/headless.ts +135 -0
  34. package/src/plugins/builtin/dividend-yield/index.tsx +12 -8
  35. package/src/plugins/builtin/dividend-yield/model.ts +4 -22
  36. package/src/plugins/builtin/dividend-yield/view.ts +17 -0
  37. package/src/plugins/builtin/earnings/headless.test.ts +82 -0
  38. package/src/plugins/builtin/earnings/headless.ts +162 -0
  39. package/src/plugins/builtin/earnings/index.tsx +2 -0
  40. package/src/plugins/builtin/earnings-calls/data.ts +29 -8
  41. package/src/plugins/builtin/earnings-calls/headless.test.ts +192 -0
  42. package/src/plugins/builtin/earnings-calls/headless.ts +300 -0
  43. package/src/plugins/builtin/earnings-calls/index.tsx +19 -9
  44. package/src/plugins/builtin/earnings-calls/model.ts +238 -0
  45. package/src/plugins/builtin/earnings-calls/transcript-view.tsx +8 -9
  46. package/src/plugins/builtin/holders/client.ts +29 -0
  47. package/src/plugins/builtin/holders/headless.test.ts +58 -0
  48. package/src/plugins/builtin/holders/headless.ts +162 -0
  49. package/src/plugins/builtin/holders/index.tsx +14 -8
  50. package/src/plugins/builtin/holders/pane.tsx +7 -1
  51. package/src/plugins/builtin/insider/client.ts +50 -0
  52. package/src/plugins/builtin/insider/headless.test.ts +122 -0
  53. package/src/plugins/builtin/insider/headless.ts +117 -0
  54. package/src/plugins/builtin/insider/index.tsx +21 -50
  55. package/src/plugins/builtin/insider/insider-data.ts +1 -1
  56. package/src/plugins/builtin/insider/model.ts +72 -0
  57. package/src/plugins/builtin/ipo-calendar/client.ts +15 -0
  58. package/src/plugins/builtin/ipo-calendar/headless.test.ts +89 -0
  59. package/src/plugins/builtin/ipo-calendar/headless.ts +127 -0
  60. package/src/plugins/builtin/ipo-calendar/index.tsx +2 -0
  61. package/src/plugins/builtin/ipo-calendar/model.ts +3 -15
  62. package/src/plugins/builtin/news/headless.test.ts +138 -0
  63. package/src/plugins/builtin/news/headless.ts +190 -0
  64. package/src/plugins/builtin/news/index.tsx +13 -9
  65. package/src/plugins/builtin/news/wire/index.tsx +2 -1
  66. package/src/plugins/builtin/research/analyst-headless.test.ts +60 -0
  67. package/src/plugins/builtin/research/analyst-headless.ts +172 -0
  68. package/src/plugins/builtin/research/analyst-model.ts +219 -0
  69. package/src/plugins/builtin/research/analyst-pane.tsx +31 -221
  70. package/src/plugins/builtin/research/client.ts +12 -0
  71. package/src/plugins/builtin/research/headless.test.ts +93 -0
  72. package/src/plugins/builtin/research/headless.ts +161 -0
  73. package/src/plugins/builtin/research/index.tsx +28 -18
  74. package/src/plugins/builtin/sec/client.ts +16 -0
  75. package/src/plugins/builtin/sec/headless.test.ts +72 -0
  76. package/src/plugins/builtin/sec/headless.ts +69 -0
  77. package/src/plugins/builtin/sec/index.tsx +20 -75
  78. package/src/plugins/builtin/sec/model.ts +89 -0
  79. package/src/plugins/builtin/short-interest/client.ts +5 -2
  80. package/src/plugins/builtin/short-interest/headless.test.ts +96 -0
  81. package/src/plugins/builtin/short-interest/headless.ts +111 -0
  82. package/src/plugins/builtin/short-interest/index.tsx +15 -9
  83. package/src/plugins/builtin/thirteenf/data.ts +13 -11
  84. package/src/plugins/builtin/thirteenf/headless.test.ts +112 -0
  85. package/src/plugins/builtin/thirteenf/headless.ts +286 -0
  86. package/src/plugins/builtin/thirteenf/index.tsx +4 -0
  87. package/src/plugins/builtin/thirteenf/model.ts +13 -0
  88. package/src/plugins/builtin/thirteenf/table.ts +3 -15
  89. package/src/plugins/prediction-markets/headless.test.ts +119 -0
  90. package/src/plugins/prediction-markets/headless.ts +255 -0
  91. package/src/plugins/prediction-markets/index.tsx +2 -0
  92. package/src/sources/gloomberb-cloud/news.ts +1 -0
@@ -1,4 +1,5 @@
1
1
  import { apiClient } from "../../../../api-client";
2
+ import { ApiRequestError } from "../../../../api-client/errors";
2
3
  import {
3
4
  DEFAULT_CHAT_CHANNEL_ID,
4
5
  type ChannelRuntimeState,
@@ -93,7 +94,8 @@ interface RefreshChatControllerSessionOptions {
93
94
  ensureRealtimeSubscriptions: () => void;
94
95
  persistChannelState: (channelId: string) => void;
95
96
  persistSession: (sessionToken: string | null, user: ChatSessionUser | null) => void;
96
- refreshChatState: () => Promise<void>;
97
+ refreshChatState: (isCurrent?: () => boolean) => Promise<void>;
98
+ scheduleSessionRetry: () => void;
97
99
  session: ChatControllerSessionState;
98
100
  stopRealtimeSubscriptions: () => void;
99
101
  stopSafetyRefresh: () => void;
@@ -110,6 +112,7 @@ export async function refreshChatControllerSession({
110
112
  persistChannelState,
111
113
  persistSession,
112
114
  refreshChatState,
115
+ scheduleSessionRetry,
113
116
  session,
114
117
  stopRealtimeSubscriptions,
115
118
  stopSafetyRefresh,
@@ -119,20 +122,85 @@ export async function refreshChatControllerSession({
119
122
  session.sessionToken = apiClient.getSessionToken();
120
123
  const apiSession = await apiClient.getSession();
121
124
  if (!apiSession) {
122
- const persistedToken = apiClient.getSessionToken() || session.sessionToken;
123
- // A 200 with no user is what /auth/get-session returns when no cookie
124
- // reached the server. Desktop's backend process used to treat that as a
125
- // real sign-out and persist a wiped token, so the next launch was logged
126
- // out. Keep a captured native session; hard account-missing responses
127
- // already clear the token inside getSession().
125
+ const persistedToken = apiClient.getSessionToken();
126
+ // A 200 with no user used to mean the desktop backend had not received the
127
+ // restored cookie yet, so do not discard a captured native session on that
128
+ // response alone. Probe another authenticated endpoint: success validates
129
+ // the token, a 401 proves it expired, and a transient failure keeps offline
130
+ // startup from signing the user out.
128
131
  if (persistedToken) {
129
132
  session.sessionToken = persistedToken;
130
133
  session.user = session.user ?? normalizeSessionUser(apiClient.getCurrentUser());
134
+ // getSession() clears the API client's cached user when the endpoint
135
+ // answers null. Keep both session views aligned while the protected
136
+ // request validates the preserved native credential.
137
+ apiClient.restoreCachedUser(session.user);
131
138
  session.sessionChecked = true;
132
139
  persistSession(session.sessionToken, session.user);
133
140
  emit();
141
+
142
+ const credentialIsCurrent = () =>
143
+ apiClient.getSessionToken() === persistedToken && session.sessionToken === persistedToken;
144
+ const handleValidationError = (error: unknown) => {
145
+ if (!credentialIsCurrent()) return;
146
+ if (error instanceof ApiRequestError && error.status === 401) {
147
+ apiClient.setSessionToken(null);
148
+ session.sessionToken = null;
149
+ applySignedOut();
150
+ return;
151
+ }
152
+ if (error instanceof ApiRequestError && error.status === 403 && session.user) {
153
+ session.user = { ...session.user, emailVerified: false };
154
+ apiClient.restoreCachedUser(session.user);
155
+ persistSession(session.sessionToken, session.user);
156
+ emit();
157
+ syncVerificationPolling();
158
+ stopSafetyRefresh();
159
+ stopRealtimeSubscriptions();
160
+ return;
161
+ }
162
+ scheduleSessionRetry();
163
+ };
164
+
165
+ if (!session.user?.emailVerified) {
166
+ try {
167
+ const profile = await apiClient.getAccountProfile();
168
+ if (!credentialIsCurrent()) return;
169
+ const persistedProfile = {
170
+ ...profile,
171
+ updatedAt: profile.updatedAt ?? undefined,
172
+ };
173
+ session.user = normalizeSessionUser(persistedProfile);
174
+ apiClient.restoreCachedUser(persistedProfile);
175
+ persistSession(session.sessionToken, session.user);
176
+ emit();
177
+ } catch (error) {
178
+ handleValidationError(error);
179
+ return;
180
+ }
181
+
182
+ if (!session.user?.emailVerified) {
183
+ syncVerificationPolling();
184
+ stopSafetyRefresh();
185
+ stopRealtimeSubscriptions();
186
+ return;
187
+ }
188
+ }
189
+
190
+ try {
191
+ await refreshChatState(credentialIsCurrent);
192
+ } catch (error) {
193
+ handleValidationError(error);
194
+ return;
195
+ }
196
+ if (!credentialIsCurrent()) return;
197
+
198
+ stopVerificationPolling();
199
+ ensureRealtimeSubscriptions();
200
+ ensureOpenChannelConnections();
134
201
  return;
135
202
  }
203
+ session.sessionToken = null;
136
204
  applySignedOut();
137
205
  return;
138
206
  }
@@ -152,7 +220,7 @@ export async function refreshChatControllerSession({
152
220
  if (nextUser?.emailVerified) {
153
221
  stopVerificationPolling();
154
222
  ensureRealtimeSubscriptions();
155
- await refreshChatState().catch(() => {});
223
+ await refreshChatState().catch(() => scheduleSessionRetry());
156
224
  ensureOpenChannelConnections();
157
225
  return;
158
226
  }
@@ -21,6 +21,7 @@ export const TRANSCRIPT_CACHE_POLICY = {
21
21
  };
22
22
  export const DRAFT_SYNC_DEBOUNCE_MS = 250;
23
23
  export const VERIFICATION_POLL_MS = 5_000;
24
+ export const SESSION_RETRY_MS = 30_000;
24
25
  export const SAFETY_REFRESH_MS = 30_000;
25
26
  export const PENDING_RECONCILE_WINDOW_MS = 2 * 60_000;
26
27
 
@@ -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
+ });