assistant-cloud 0.1.42 → 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.
Files changed (81) hide show
  1. package/README.md +40 -0
  2. package/dist/AssistantCloud.d.ts +4 -0
  3. package/dist/AssistantCloud.d.ts.map +1 -1
  4. package/dist/AssistantCloud.js +11 -5
  5. package/dist/AssistantCloud.js.map +1 -1
  6. package/dist/AssistantCloudAPI.d.ts +17 -1
  7. package/dist/AssistantCloudAPI.d.ts.map +1 -1
  8. package/dist/AssistantCloudAPI.js +20 -9
  9. package/dist/AssistantCloudAPI.js.map +1 -1
  10. package/dist/AssistantCloudAuthStrategy.d.ts +4 -1
  11. package/dist/AssistantCloudAuthStrategy.d.ts.map +1 -1
  12. package/dist/AssistantCloudAuthStrategy.js +79 -18
  13. package/dist/AssistantCloudAuthStrategy.js.map +1 -1
  14. package/dist/AssistantCloudEvents.d.ts +32 -0
  15. package/dist/AssistantCloudEvents.d.ts.map +1 -0
  16. package/dist/AssistantCloudEvents.js +115 -0
  17. package/dist/AssistantCloudEvents.js.map +1 -0
  18. package/dist/AssistantCloudFiles.d.ts +12 -1
  19. package/dist/AssistantCloudFiles.d.ts.map +1 -1
  20. package/dist/AssistantCloudFiles.js +13 -1
  21. package/dist/AssistantCloudFiles.js.map +1 -1
  22. package/dist/AssistantCloudRuns.d.ts +11 -0
  23. package/dist/AssistantCloudRuns.d.ts.map +1 -1
  24. package/dist/AssistantCloudRuns.js.map +1 -1
  25. package/dist/AssistantCloudScores.d.ts +27 -0
  26. package/dist/AssistantCloudScores.d.ts.map +1 -0
  27. package/dist/AssistantCloudScores.js +30 -0
  28. package/dist/AssistantCloudScores.js.map +1 -0
  29. package/dist/AssistantCloudThreadMessages.d.ts +11 -1
  30. package/dist/AssistantCloudThreadMessages.d.ts.map +1 -1
  31. package/dist/AssistantCloudThreadMessages.js +12 -1
  32. package/dist/AssistantCloudThreadMessages.js.map +1 -1
  33. package/dist/AssistantCloudThreads.d.ts +8 -0
  34. package/dist/AssistantCloudThreads.d.ts.map +1 -1
  35. package/dist/AssistantCloudThreads.js +9 -1
  36. package/dist/AssistantCloudThreads.js.map +1 -1
  37. package/dist/CloudMessagePersistence.d.ts +4 -0
  38. package/dist/CloudMessagePersistence.d.ts.map +1 -1
  39. package/dist/CloudMessagePersistence.js +53 -18
  40. package/dist/CloudMessagePersistence.js.map +1 -1
  41. package/dist/cloudResponse.d.ts +4 -1
  42. package/dist/cloudResponse.d.ts.map +1 -1
  43. package/dist/cloudResponse.js +14 -1
  44. package/dist/cloudResponse.js.map +1 -1
  45. package/dist/index.d.ts +7 -3
  46. package/dist/index.js +5 -2
  47. package/dist/runTelemetry.d.ts +79 -9
  48. package/dist/runTelemetry.d.ts.map +1 -1
  49. package/dist/runTelemetry.js +152 -6
  50. package/dist/runTelemetry.js.map +1 -1
  51. package/dist/telemetry/index.d.ts +32 -0
  52. package/dist/telemetry/index.d.ts.map +1 -0
  53. package/dist/telemetry/index.js +62 -0
  54. package/dist/telemetry/index.js.map +1 -0
  55. package/package.json +28 -4
  56. package/src/AssistantCloud.ts +20 -8
  57. package/src/AssistantCloudAPI.ts +42 -10
  58. package/src/AssistantCloudAuthStrategy.ts +153 -46
  59. package/src/AssistantCloudEvents.test.ts +124 -0
  60. package/src/AssistantCloudEvents.ts +185 -0
  61. package/src/AssistantCloudFiles.test.ts +59 -0
  62. package/src/AssistantCloudFiles.ts +39 -0
  63. package/src/AssistantCloudRuns.ts +12 -1
  64. package/src/AssistantCloudScores.test.ts +113 -0
  65. package/src/AssistantCloudScores.ts +61 -0
  66. package/src/AssistantCloudThreadMessages.test.ts +51 -0
  67. package/src/AssistantCloudThreadMessages.ts +33 -0
  68. package/src/AssistantCloudThreads.test.ts +21 -0
  69. package/src/AssistantCloudThreads.ts +21 -0
  70. package/src/CloudMessagePersistence.ts +65 -29
  71. package/src/cloudResponse.test.ts +34 -1
  72. package/src/cloudResponse.ts +30 -0
  73. package/src/index.ts +24 -1
  74. package/src/runTelemetry.test.ts +366 -0
  75. package/src/runTelemetry.ts +271 -16
  76. package/src/telemetry/index.test.ts +252 -0
  77. package/src/telemetry/index.ts +98 -0
  78. package/src/tests/AssistantCloud.test.ts +22 -0
  79. package/src/tests/AssistantCloudAPI.test.ts +44 -4
  80. package/src/tests/AssistantCloudAuthStrategy.test.ts +354 -11
  81. package/src/tests/CloudMessagePersistence.test.ts +208 -0
@@ -23,6 +23,13 @@ describe("AssistantCloud telemetry config", () => {
23
23
  expect(createCloud({ enabled: false }).telemetry.enabled).toBe(false);
24
24
  });
25
25
 
26
+ it("can disable engagement events without disabling run reports", () => {
27
+ expect(createCloud({ events: false }).telemetry).toEqual({
28
+ enabled: true,
29
+ events: false,
30
+ });
31
+ });
32
+
26
33
  it("stays enabled when the config object carries an undefined enabled", () => {
27
34
  const beforeReport: NonNullable<
28
35
  AssistantCloudTelemetryConfig["beforeReport"]
@@ -36,4 +43,19 @@ describe("AssistantCloud telemetry config", () => {
36
43
  expect(telemetry.enabled).toBe(true);
37
44
  expect(telemetry.beforeReport).toBe(beforeReport);
38
45
  });
46
+
47
+ it("preserves configured run report dimensions", () => {
48
+ expect(
49
+ createCloud({
50
+ release: "web-2026.09.08",
51
+ environment: "production",
52
+ tags: ["region:sg", "tier:paid"],
53
+ }).telemetry,
54
+ ).toEqual({
55
+ enabled: true,
56
+ release: "web-2026.09.08",
57
+ environment: "production",
58
+ tags: ["region:sg", "tier:paid"],
59
+ });
60
+ });
39
61
  });
@@ -143,7 +143,7 @@ describe("AssistantCloudAPI", () => {
143
143
  );
144
144
  });
145
145
 
146
- it("throws APIError with parsed message for JSON error responses", async () => {
146
+ it("preserves the current error shape for JSON error responses without a code", async () => {
147
147
  const fetchMock = vi.fn().mockResolvedValue({
148
148
  ok: false,
149
149
  status: 400,
@@ -167,16 +167,54 @@ describe("AssistantCloudAPI", () => {
167
167
  expect(error.name).toBe("CloudAPIError");
168
168
  expect(error.message).toBe("invalid request payload");
169
169
  expect(error.status).toBe(400);
170
+ expect(error.code).toBeUndefined();
171
+ expect(error.details).toBeUndefined();
170
172
  });
171
173
 
172
- it("falls back to the response text when the JSON error body has no message", async () => {
174
+ it("exposes the plan-limit error code and details", async () => {
175
+ const fetchMock = vi.fn().mockResolvedValue({
176
+ ok: false,
177
+ status: 402,
178
+ headers: new Headers(),
179
+ text: vi.fn().mockResolvedValue(
180
+ JSON.stringify({
181
+ error: "plan_limit_reached",
182
+ plan: "free",
183
+ period_end: "2026-10-01T00:00:00.000Z",
184
+ cap: 100,
185
+ }),
186
+ ),
187
+ });
188
+ vi.stubGlobal("fetch", fetchMock);
189
+
190
+ const api = new AssistantCloudAPI({
191
+ apiKey: "test-key",
192
+ userId: "u-1",
193
+ workspaceId: "w-1",
194
+ });
195
+
196
+ const error = await api.makeRawRequest("/threads").catch((e) => e);
197
+ expect(error).toBeInstanceOf(CloudAPIError);
198
+ expect(error.message).toBe(
199
+ 'Request failed with status 402, {"error":"plan_limit_reached","plan":"free","period_end":"2026-10-01T00:00:00.000Z","cap":100}',
200
+ );
201
+ expect(error.status).toBe(402);
202
+ expect(error.code).toBe("plan_limit_reached");
203
+ expect(error.details).toEqual({
204
+ plan: "free",
205
+ period_end: "2026-10-01T00:00:00.000Z",
206
+ cap: 100,
207
+ });
208
+ });
209
+
210
+ it("exposes rate-limit error codes with empty details", async () => {
173
211
  const fetchMock = vi.fn().mockResolvedValue({
174
212
  ok: false,
175
213
  status: 429,
176
214
  headers: new Headers(),
177
215
  text: vi
178
216
  .fn()
179
- .mockResolvedValue(JSON.stringify({ error: "rate limited" })),
217
+ .mockResolvedValue(JSON.stringify({ error: "rate_limited" })),
180
218
  });
181
219
  vi.stubGlobal("fetch", fetchMock);
182
220
 
@@ -189,9 +227,11 @@ describe("AssistantCloudAPI", () => {
189
227
  const error = await api.makeRawRequest("/threads").catch((e) => e);
190
228
  expect(error).toBeInstanceOf(CloudAPIError);
191
229
  expect(error.message).toBe(
192
- 'Request failed with status 429, {"error":"rate limited"}',
230
+ 'Request failed with status 429, {"error":"rate_limited"}',
193
231
  );
194
232
  expect(error.status).toBe(429);
233
+ expect(error.code).toBe("rate_limited");
234
+ expect(error.details).toEqual({});
195
235
  });
196
236
 
197
237
  it("throws generic error with status for non-JSON error responses", async () => {
@@ -1,7 +1,9 @@
1
+ /// <reference types="node" />
1
2
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
3
  import {
3
4
  AssistantCloudAnonymousAuthStrategy,
4
5
  AssistantCloudJWTAuthStrategy,
6
+ readAnonymousRefreshToken,
5
7
  } from "../AssistantCloudAuthStrategy";
6
8
  import { CloudResponseError } from "../cloudResponse";
7
9
 
@@ -43,6 +45,7 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
43
45
  });
44
46
 
45
47
  afterEach(() => {
48
+ vi.useRealTimers();
46
49
  vi.unstubAllGlobals();
47
50
  if (originalLocalStorageDescriptor) {
48
51
  Object.defineProperty(
@@ -55,6 +58,73 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
55
58
  }
56
59
  });
57
60
 
61
+ it("reads the stored anonymous refresh token", () => {
62
+ const values = new Map([[refreshTokenKey, JSON.stringify(refreshToken)]]);
63
+ installLocalStorage({
64
+ getItem: (key) => values.get(key) ?? null,
65
+ setItem: (key, value) => {
66
+ values.set(key, value);
67
+ },
68
+ removeItem: (key) => {
69
+ values.delete(key);
70
+ },
71
+ } as Storage);
72
+
73
+ expect(readAnonymousRefreshToken(baseUrl)).toBe(refreshToken.token);
74
+ });
75
+
76
+ it("reads the token stored for a base url given with a trailing slash", () => {
77
+ // The two-step cast below erases the contextual Storage typing the plain
78
+ // `as Storage` sites get, so this parameter needs its own annotation.
79
+ installLocalStorage({
80
+ getItem: (key: string) =>
81
+ key === refreshTokenKey ? JSON.stringify(refreshToken) : null,
82
+ setItem: vi.fn(),
83
+ removeItem: vi.fn(),
84
+ } as unknown as Storage);
85
+
86
+ expect(readAnonymousRefreshToken(`${baseUrl}/`)).toBe(refreshToken.token);
87
+ });
88
+
89
+ it("returns null when no anonymous refresh token is stored", () => {
90
+ installLocalStorage({
91
+ getItem: () => null,
92
+ setItem: vi.fn(),
93
+ removeItem: vi.fn(),
94
+ } as unknown as Storage);
95
+
96
+ expect(readAnonymousRefreshToken(baseUrl)).toBeNull();
97
+ });
98
+
99
+ it.each([30_000, 0, -1])(
100
+ "returns null when the stored anonymous refresh token expires in %i ms",
101
+ (expiresIn) => {
102
+ vi.useFakeTimers();
103
+ const now = Date.UTC(2026, 8, 4, 12, 0, 0);
104
+ vi.setSystemTime(now);
105
+ const values = new Map([
106
+ [
107
+ refreshTokenKey,
108
+ JSON.stringify({
109
+ token: refreshToken.token,
110
+ expires_at: new Date(now + expiresIn).toISOString(),
111
+ }),
112
+ ],
113
+ ]);
114
+ installLocalStorage({
115
+ getItem: (key) => values.get(key) ?? null,
116
+ setItem: (key, value) => {
117
+ values.set(key, value);
118
+ },
119
+ removeItem: (key) => {
120
+ values.delete(key);
121
+ },
122
+ } as Storage);
123
+
124
+ expect(readAnonymousRefreshToken(baseUrl)).toBeNull();
125
+ },
126
+ );
127
+
58
128
  it.each([
59
129
  "2099-01-01",
60
130
  "2099-01-01T00:00:00Z",
@@ -83,7 +153,7 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
83
153
  expect(values.get(refreshTokenKey)).toBe(JSON.stringify(nextRefreshToken));
84
154
  expect(fetchMock).toHaveBeenCalledWith(
85
155
  `${baseUrl}/v1/auth/tokens/anonymous`,
86
- { method: "POST" },
156
+ { method: "POST", signal: expect.any(AbortSignal) },
87
157
  );
88
158
  });
89
159
 
@@ -106,6 +176,276 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
106
176
  expect(fetchMock).toHaveBeenCalledTimes(1);
107
177
  });
108
178
 
179
+ it("deduplicates anonymous token requests across strategy instances", async () => {
180
+ const values = new Map<string, string>();
181
+ installLocalStorage({
182
+ getItem: (key) => values.get(key) ?? null,
183
+ setItem: (key, value) => {
184
+ values.set(key, value);
185
+ },
186
+ removeItem: (key) => {
187
+ values.delete(key);
188
+ },
189
+ } as Storage);
190
+ const fetchMock = mockAnonymousTokenFetch();
191
+ const first = new AssistantCloudAnonymousAuthStrategy(baseUrl);
192
+ const second = new AssistantCloudAnonymousAuthStrategy(baseUrl);
193
+
194
+ await expect(
195
+ Promise.all([first.getAuthHeaders(), second.getAuthHeaders()]),
196
+ ).resolves.toEqual([
197
+ { Authorization: `Bearer ${accessToken}` },
198
+ { Authorization: `Bearer ${accessToken}` },
199
+ ]);
200
+ expect(fetchMock).toHaveBeenCalledTimes(1);
201
+ expect(values.get(refreshTokenKey)).toBe(JSON.stringify(refreshToken));
202
+ });
203
+
204
+ it("coordinates anonymous token requests across realms", async () => {
205
+ const values = new Map<string, string>();
206
+ installLocalStorage({
207
+ getItem: (key) => values.get(key) ?? null,
208
+ setItem: (key, value) => {
209
+ values.set(key, value);
210
+ },
211
+ removeItem: (key) => {
212
+ values.delete(key);
213
+ },
214
+ } as Storage);
215
+ let lockTail: Promise<unknown> = Promise.resolve();
216
+ const lockRequest = vi.fn(
217
+ (_name: string, callback: () => Promise<string | null>) => {
218
+ const request = lockTail.then(callback);
219
+ lockTail = request.then(
220
+ () => undefined,
221
+ () => undefined,
222
+ );
223
+ return request;
224
+ },
225
+ );
226
+ vi.stubGlobal("navigator", { locks: { request: lockRequest } });
227
+ const rotatedRefreshToken = { token: "r2", expires_at: "2099-01-01" };
228
+ const response = (refreshTokenValue: typeof refreshToken) => ({
229
+ ok: true,
230
+ json: vi.fn().mockResolvedValue({
231
+ access_token: accessToken,
232
+ refresh_token: refreshTokenValue,
233
+ }),
234
+ });
235
+ const fetchMock = vi
236
+ .fn()
237
+ .mockResolvedValueOnce(response(refreshToken))
238
+ .mockResolvedValueOnce(response(rotatedRefreshToken));
239
+ vi.stubGlobal("fetch", fetchMock);
240
+
241
+ const first = new AssistantCloudAnonymousAuthStrategy(baseUrl);
242
+ vi.resetModules();
243
+ const { AssistantCloudAnonymousAuthStrategy: Second } =
244
+ await import("../AssistantCloudAuthStrategy");
245
+
246
+ await expect(
247
+ Promise.all([
248
+ first.getAuthHeaders(),
249
+ new Second(baseUrl).getAuthHeaders(),
250
+ ]),
251
+ ).resolves.toEqual([
252
+ { Authorization: `Bearer ${accessToken}` },
253
+ { Authorization: `Bearer ${accessToken}` },
254
+ ]);
255
+ expect(fetchMock).toHaveBeenNthCalledWith(
256
+ 1,
257
+ `${baseUrl}/v1/auth/tokens/anonymous`,
258
+ { method: "POST", signal: expect.any(AbortSignal) },
259
+ );
260
+ expect(fetchMock).toHaveBeenNthCalledWith(
261
+ 2,
262
+ `${baseUrl}/v1/auth/tokens/refresh`,
263
+ {
264
+ method: "POST",
265
+ headers: { "Content-Type": "application/json" },
266
+ body: JSON.stringify({ refresh_token: refreshToken.token }),
267
+ signal: expect.any(AbortSignal),
268
+ },
269
+ );
270
+ expect(lockRequest).toHaveBeenCalledTimes(2);
271
+ expect(values.get(refreshTokenKey)).toBe(
272
+ JSON.stringify(rotatedRefreshToken),
273
+ );
274
+ });
275
+
276
+ it("retries shared anonymous token requests after a failure", async () => {
277
+ const values = new Map<string, string>();
278
+ installLocalStorage({
279
+ getItem: (key) => values.get(key) ?? null,
280
+ setItem: (key, value) => {
281
+ values.set(key, value);
282
+ },
283
+ removeItem: (key) => {
284
+ values.delete(key);
285
+ },
286
+ } as Storage);
287
+ const failure = new Error("authentication unavailable");
288
+ const fetchMock = vi
289
+ .fn()
290
+ .mockRejectedValueOnce(failure)
291
+ .mockResolvedValueOnce({
292
+ ok: true,
293
+ json: vi.fn().mockResolvedValue({
294
+ access_token: accessToken,
295
+ refresh_token: refreshToken,
296
+ }),
297
+ });
298
+ vi.stubGlobal("fetch", fetchMock);
299
+ const first = new AssistantCloudAnonymousAuthStrategy(baseUrl);
300
+ const second = new AssistantCloudAnonymousAuthStrategy(baseUrl);
301
+
302
+ await Promise.all([
303
+ expect(first.getAuthHeaders()).rejects.toBe(failure),
304
+ expect(second.getAuthHeaders()).rejects.toBe(failure),
305
+ ]);
306
+ expect(fetchMock).toHaveBeenCalledTimes(1);
307
+
308
+ await expect(
309
+ new AssistantCloudAnonymousAuthStrategy(baseUrl).getAuthHeaders(),
310
+ ).resolves.toEqual({ Authorization: `Bearer ${accessToken}` });
311
+ expect(fetchMock).toHaveBeenCalledTimes(2);
312
+ });
313
+
314
+ it("aborts timed out shared anonymous token requests before retrying", async () => {
315
+ vi.useFakeTimers();
316
+ const values = new Map<string, string>();
317
+ installLocalStorage({
318
+ getItem: (key) => values.get(key) ?? null,
319
+ setItem: (key, value) => {
320
+ values.set(key, value);
321
+ },
322
+ removeItem: (key) => {
323
+ values.delete(key);
324
+ },
325
+ } as Storage);
326
+ let requestSignal: AbortSignal | null | undefined;
327
+ const fetchMock = vi
328
+ .fn()
329
+ .mockImplementationOnce((_input: RequestInfo | URL, init?: RequestInit) =>
330
+ Promise.resolve({
331
+ ok: true,
332
+ json: () =>
333
+ new Promise<never>((_resolve, reject) => {
334
+ requestSignal = init?.signal;
335
+ requestSignal?.addEventListener(
336
+ "abort",
337
+ () => reject(requestSignal?.reason),
338
+ { once: true },
339
+ );
340
+ }),
341
+ } as unknown as Response),
342
+ )
343
+ .mockResolvedValueOnce({
344
+ ok: true,
345
+ json: vi.fn().mockResolvedValue({
346
+ access_token: accessToken,
347
+ refresh_token: refreshToken,
348
+ }),
349
+ });
350
+ vi.stubGlobal("fetch", fetchMock);
351
+ const first = new AssistantCloudAnonymousAuthStrategy(baseUrl);
352
+ const second = new AssistantCloudAnonymousAuthStrategy(baseUrl);
353
+
354
+ const firstRequest = expect(first.getAuthHeaders()).rejects.toThrow(
355
+ "Assistant Cloud anonymous token request timed out after 30000ms",
356
+ );
357
+ const secondRequest = expect(second.getAuthHeaders()).rejects.toThrow(
358
+ "Assistant Cloud anonymous token request timed out after 30000ms",
359
+ );
360
+ await vi.advanceTimersByTimeAsync(0);
361
+ expect(fetchMock).toHaveBeenCalledTimes(1);
362
+ expect(requestSignal).toBeInstanceOf(AbortSignal);
363
+
364
+ await vi.advanceTimersByTimeAsync(30_000);
365
+ await Promise.all([firstRequest, secondRequest]);
366
+ expect(requestSignal?.aborted).toBe(true);
367
+ expect(values.has(refreshTokenKey)).toBe(false);
368
+
369
+ await expect(
370
+ new AssistantCloudAnonymousAuthStrategy(baseUrl).getAuthHeaders(),
371
+ ).resolves.toEqual({ Authorization: `Bearer ${accessToken}` });
372
+ expect(fetchMock).toHaveBeenCalledTimes(2);
373
+ });
374
+
375
+ it("aborts timed out refresh requests without replacing the identity", async () => {
376
+ vi.useFakeTimers();
377
+ const values = new Map([[refreshTokenKey, JSON.stringify(refreshToken)]]);
378
+ installLocalStorage({
379
+ getItem: (key) => values.get(key) ?? null,
380
+ setItem: (key, value) => {
381
+ values.set(key, value);
382
+ },
383
+ removeItem: (key) => {
384
+ values.delete(key);
385
+ },
386
+ } as Storage);
387
+ let requestSignal: AbortSignal | null | undefined;
388
+ const rotatedRefreshToken = { token: "r2", expires_at: "2099-02-01" };
389
+ const fetchMock = vi
390
+ .fn()
391
+ .mockImplementationOnce(
392
+ (_input: RequestInfo | URL, init?: RequestInit) =>
393
+ new Promise<Response>((_resolve, reject) => {
394
+ requestSignal = init?.signal;
395
+ requestSignal?.addEventListener(
396
+ "abort",
397
+ () => reject(requestSignal?.reason),
398
+ { once: true },
399
+ );
400
+ }),
401
+ )
402
+ .mockResolvedValueOnce({
403
+ ok: true,
404
+ json: vi.fn().mockResolvedValue({
405
+ access_token: accessToken,
406
+ refresh_token: rotatedRefreshToken,
407
+ }),
408
+ });
409
+ vi.stubGlobal("fetch", fetchMock);
410
+ const strategy = new AssistantCloudAnonymousAuthStrategy(baseUrl);
411
+
412
+ const request = expect(strategy.getAuthHeaders()).rejects.toThrow(
413
+ "Assistant Cloud refresh token request timed out after 30000ms",
414
+ );
415
+ expect(requestSignal).toBeInstanceOf(AbortSignal);
416
+
417
+ await vi.advanceTimersByTimeAsync(30_000);
418
+ await request;
419
+ expect(requestSignal?.aborted).toBe(true);
420
+ expect(values.get(refreshTokenKey)).toBe(JSON.stringify(refreshToken));
421
+
422
+ await expect(strategy.getAuthHeaders()).resolves.toEqual({
423
+ Authorization: `Bearer ${accessToken}`,
424
+ });
425
+ expect(fetchMock).toHaveBeenCalledTimes(2);
426
+ expect(values.get(refreshTokenKey)).toBe(
427
+ JSON.stringify(rotatedRefreshToken),
428
+ );
429
+ });
430
+
431
+ it("keeps anonymous token requests independent without localStorage", async () => {
432
+ delete (globalThis as { localStorage?: Storage }).localStorage;
433
+ const lockRequest = vi.fn();
434
+ vi.stubGlobal("navigator", { locks: { request: lockRequest } });
435
+ const fetchMock = mockAnonymousTokenFetch();
436
+ const first = new AssistantCloudAnonymousAuthStrategy(baseUrl);
437
+ const second = new AssistantCloudAnonymousAuthStrategy(baseUrl);
438
+
439
+ await expect(
440
+ Promise.all([first.getAuthHeaders(), second.getAuthHeaders()]),
441
+ ).resolves.toEqual([
442
+ { Authorization: `Bearer ${accessToken}` },
443
+ { Authorization: `Bearer ${accessToken}` },
444
+ ]);
445
+ expect(fetchMock).toHaveBeenCalledTimes(2);
446
+ expect(lockRequest).not.toHaveBeenCalled();
447
+ });
448
+
109
449
  it("scopes anonymous refresh tokens by backend", async () => {
110
450
  const secondBaseUrl = "https://other.example.com";
111
451
  const values = new Map<string, string>();
@@ -140,20 +480,20 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
140
480
  });
141
481
  vi.stubGlobal("fetch", fetchMock);
142
482
 
143
- await new AssistantCloudAnonymousAuthStrategy(baseUrl).getAuthHeaders();
144
- await new AssistantCloudAnonymousAuthStrategy(
145
- secondBaseUrl,
146
- ).getAuthHeaders();
483
+ await Promise.all([
484
+ new AssistantCloudAnonymousAuthStrategy(baseUrl).getAuthHeaders(),
485
+ new AssistantCloudAnonymousAuthStrategy(secondBaseUrl).getAuthHeaders(),
486
+ ]);
147
487
 
148
488
  expect(fetchMock).toHaveBeenNthCalledWith(
149
489
  1,
150
490
  `${baseUrl}/v1/auth/tokens/anonymous`,
151
- { method: "POST" },
491
+ { method: "POST", signal: expect.any(AbortSignal) },
152
492
  );
153
493
  expect(fetchMock).toHaveBeenNthCalledWith(
154
494
  2,
155
495
  `${secondBaseUrl}/v1/auth/tokens/anonymous`,
156
- { method: "POST" },
496
+ { method: "POST", signal: expect.any(AbortSignal) },
157
497
  );
158
498
  expect(values.get(refreshTokenKey)).toBe(JSON.stringify(refreshToken));
159
499
  expect(values.get(`aui:refresh_token:${secondBaseUrl}`)).toBe(
@@ -206,12 +546,13 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
206
546
  method: "POST",
207
547
  headers: { "Content-Type": "application/json" },
208
548
  body: JSON.stringify({ refresh_token: refreshToken.token }),
549
+ signal: expect.any(AbortSignal),
209
550
  },
210
551
  );
211
552
  expect(fetchMock).toHaveBeenNthCalledWith(
212
553
  2,
213
554
  `${secondBaseUrl}/v1/auth/tokens/anonymous`,
214
- { method: "POST" },
555
+ { method: "POST", signal: expect.any(AbortSignal) },
215
556
  );
216
557
  expect(values.get(refreshTokenKey)).toBe(JSON.stringify(refreshToken));
217
558
  expect(values.get(`aui:refresh_token:${secondBaseUrl}`)).toBe(
@@ -270,12 +611,13 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
270
611
  method: "POST",
271
612
  headers: { "Content-Type": "application/json" },
272
613
  body: JSON.stringify({ refresh_token: scopedRefreshToken.token }),
614
+ signal: expect.any(AbortSignal),
273
615
  },
274
616
  );
275
617
  expect(fetchMock).toHaveBeenNthCalledWith(
276
618
  2,
277
619
  `${secondBaseUrl}/v1/auth/tokens/anonymous`,
278
- { method: "POST" },
620
+ { method: "POST", signal: expect.any(AbortSignal) },
279
621
  );
280
622
  expect(values.has("aui:refresh_token")).toBe(false);
281
623
  expect(values.get(`aui:refresh_token:${secondBaseUrl}`)).toBe(
@@ -325,7 +667,7 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
325
667
  const removeItem = vi.fn(() => {
326
668
  throw new DOMException("blocked", "SecurityError");
327
669
  });
328
- installLocalStorage({ getItem, setItem, removeItem } as Storage);
670
+ installLocalStorage({ getItem, setItem, removeItem } as unknown as Storage);
329
671
  mockAnonymousTokenFetch();
330
672
 
331
673
  await expect(
@@ -470,6 +812,7 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
470
812
  method: "POST",
471
813
  headers: { "Content-Type": "application/json" },
472
814
  body: JSON.stringify({ refresh_token: refreshToken.token }),
815
+ signal: expect.any(AbortSignal),
473
816
  },
474
817
  );
475
818
  expect(setItem).not.toHaveBeenCalled();
@@ -538,7 +881,7 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
538
881
  expect(fetchMock).toHaveBeenNthCalledWith(
539
882
  2,
540
883
  `${baseUrl}/v1/auth/tokens/anonymous`,
541
- { method: "POST" },
884
+ { method: "POST", signal: expect.any(AbortSignal) },
542
885
  );
543
886
  expect(values.get(refreshTokenKey)).toBe(
544
887
  JSON.stringify(replacementRefreshToken),