assistant-cloud 0.1.39 → 0.1.41

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 (31) hide show
  1. package/dist/AssistantCloudAuthStrategy.d.ts.map +1 -1
  2. package/dist/AssistantCloudAuthStrategy.js +35 -12
  3. package/dist/AssistantCloudAuthStrategy.js.map +1 -1
  4. package/dist/AssistantCloudAuthTokens.d.ts.map +1 -1
  5. package/dist/AssistantCloudAuthTokens.js +3 -1
  6. package/dist/AssistantCloudAuthTokens.js.map +1 -1
  7. package/dist/AssistantCloudFiles.d.ts.map +1 -1
  8. package/dist/AssistantCloudFiles.js +16 -4
  9. package/dist/AssistantCloudFiles.js.map +1 -1
  10. package/dist/AssistantCloudRuns.d.ts.map +1 -1
  11. package/dist/AssistantCloudRuns.js +10 -2
  12. package/dist/AssistantCloudRuns.js.map +1 -1
  13. package/dist/AssistantCloudThreadMessages.d.ts.map +1 -1
  14. package/dist/AssistantCloudThreadMessages.js +3 -2
  15. package/dist/AssistantCloudThreadMessages.js.map +1 -1
  16. package/dist/AssistantCloudThreads.d.ts.map +1 -1
  17. package/dist/AssistantCloudThreads.js +8 -3
  18. package/dist/AssistantCloudThreads.js.map +1 -1
  19. package/package.json +4 -4
  20. package/src/AssistantCloudAuthStrategy.ts +49 -12
  21. package/src/AssistantCloudAuthTokens.test.ts +30 -0
  22. package/src/AssistantCloudAuthTokens.ts +7 -1
  23. package/src/AssistantCloudFiles.test.ts +71 -0
  24. package/src/AssistantCloudFiles.ts +56 -10
  25. package/src/AssistantCloudRuns.ts +34 -1
  26. package/src/AssistantCloudThreadMessages.test.ts +20 -0
  27. package/src/AssistantCloudThreadMessages.ts +10 -3
  28. package/src/AssistantCloudThreads.test.ts +32 -0
  29. package/src/AssistantCloudThreads.ts +11 -2
  30. package/src/tests/AssistantCloudAuthStrategy.test.ts +278 -5
  31. package/src/tests/AssistantCloudRuns.test.ts +115 -0
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { AssistantCloudAPI } from "./AssistantCloudAPI";
3
+ import { AssistantCloudFiles } from "./AssistantCloudFiles";
4
+
5
+ const createCloudFiles = () => {
6
+ const makeRequest = vi.fn();
7
+ const api = { makeRequest } as unknown as AssistantCloudAPI;
8
+ return { files: new AssistantCloudFiles(api), makeRequest };
9
+ };
10
+
11
+ describe("AssistantCloudFiles responses", () => {
12
+ it("decodes PDF conversion responses", async () => {
13
+ const { files, makeRequest } = createCloudFiles();
14
+ makeRequest.mockResolvedValue({
15
+ success: true,
16
+ urls: ["https://example.com/page-1.png"],
17
+ message: "converted",
18
+ });
19
+
20
+ await expect(
21
+ files.pdfToImages({ file_url: "https://example.com/file.pdf" }),
22
+ ).resolves.toEqual({
23
+ success: true,
24
+ urls: ["https://example.com/page-1.png"],
25
+ message: "converted",
26
+ });
27
+ });
28
+
29
+ it("rejects malformed PDF conversion responses", async () => {
30
+ const { files, makeRequest } = createCloudFiles();
31
+ makeRequest.mockResolvedValue({
32
+ success: true,
33
+ urls: [42],
34
+ message: "converted",
35
+ });
36
+
37
+ await expect(files.pdfToImages({ file_blob: "data" })).rejects.toThrow(
38
+ 'Invalid Assistant Cloud response for "PDF conversion response.urls[0]": expected a string',
39
+ );
40
+ });
41
+
42
+ it("decodes presigned upload responses", async () => {
43
+ const { files, makeRequest } = createCloudFiles();
44
+ makeRequest.mockResolvedValue({
45
+ success: true,
46
+ signedUrl: "https://uploads.example.com/file",
47
+ expiresAt: "2026-08-16T12:00:00.000Z",
48
+ publicUrl: "https://cdn.example.com/file",
49
+ });
50
+
51
+ await expect(
52
+ files.generatePresignedUploadUrl({ filename: "notes.txt" }),
53
+ ).resolves.toEqual({
54
+ success: true,
55
+ signedUrl: "https://uploads.example.com/file",
56
+ expiresAt: "2026-08-16T12:00:00.000Z",
57
+ publicUrl: "https://cdn.example.com/file",
58
+ });
59
+ });
60
+
61
+ it("rejects malformed presigned upload responses", async () => {
62
+ const { files, makeRequest } = createCloudFiles();
63
+ makeRequest.mockResolvedValue({});
64
+
65
+ await expect(
66
+ files.generatePresignedUploadUrl({ filename: "notes.txt" }),
67
+ ).rejects.toThrow(
68
+ 'Invalid Assistant Cloud response for "presigned upload response.success": expected a boolean',
69
+ );
70
+ });
71
+ });
@@ -1,4 +1,10 @@
1
1
  import type { AssistantCloudAPI } from "./AssistantCloudAPI";
2
+ import {
3
+ readCloudArray,
4
+ readCloudBoolean,
5
+ readCloudRecord,
6
+ readCloudString,
7
+ } from "./cloudResponse";
2
8
 
3
9
  type PdfToImagesRequestBody = {
4
10
  file_blob?: string | undefined;
@@ -32,21 +38,61 @@ export class AssistantCloudFiles {
32
38
  public async pdfToImages(
33
39
  body: PdfToImagesRequestBody,
34
40
  ): Promise<PdfToImagesResponse> {
35
- return this.cloud.makeRequest("/files/pdf-to-images", {
36
- method: "POST",
37
- body,
38
- });
41
+ const response = readCloudRecord(
42
+ await this.cloud.makeRequest("/files/pdf-to-images", {
43
+ method: "POST",
44
+ body,
45
+ }),
46
+ "PDF conversion response",
47
+ );
48
+
49
+ return {
50
+ success: readCloudBoolean(
51
+ response.success,
52
+ "PDF conversion response.success",
53
+ ),
54
+ urls: readCloudArray(response.urls, "PDF conversion response.urls").map(
55
+ (url, index) =>
56
+ readCloudString(url, `PDF conversion response.urls[${index}]`),
57
+ ),
58
+ message: readCloudString(
59
+ response.message,
60
+ "PDF conversion response.message",
61
+ ),
62
+ };
39
63
  }
40
64
 
41
65
  public async generatePresignedUploadUrl(
42
66
  body: GeneratePresignedUploadUrlRequestBody,
43
67
  ): Promise<GeneratePresignedUploadUrlResponse> {
44
- return this.cloud.makeRequest(
45
- "/files/attachments/generate-presigned-upload-url",
46
- {
47
- method: "POST",
48
- body,
49
- },
68
+ const response = readCloudRecord(
69
+ await this.cloud.makeRequest(
70
+ "/files/attachments/generate-presigned-upload-url",
71
+ {
72
+ method: "POST",
73
+ body,
74
+ },
75
+ ),
76
+ "presigned upload response",
50
77
  );
78
+
79
+ return {
80
+ success: readCloudBoolean(
81
+ response.success,
82
+ "presigned upload response.success",
83
+ ),
84
+ signedUrl: readCloudString(
85
+ response.signedUrl,
86
+ "presigned upload response.signedUrl",
87
+ ),
88
+ expiresAt: readCloudString(
89
+ response.expiresAt,
90
+ "presigned upload response.expiresAt",
91
+ ),
92
+ publicUrl: readCloudString(
93
+ response.publicUrl,
94
+ "presigned upload response.publicUrl",
95
+ ),
96
+ };
51
97
  }
52
98
  }
@@ -1,6 +1,11 @@
1
1
  import type { AssistantCloudAPI } from "./AssistantCloudAPI";
2
2
  import type { SamplingCallData } from "./instrumentMcpSampling";
3
3
  import { AssistantStream, PlainTextDecoder } from "assistant-stream";
4
+ import {
5
+ CloudResponseError,
6
+ readCloudRecord,
7
+ readCloudString,
8
+ } from "./cloudResponse";
4
9
 
5
10
  type AssistantCloudRunsStreamBody = {
6
11
  thread_id: string;
@@ -83,12 +88,40 @@ export class AssistantCloudRuns {
83
88
  },
84
89
  body,
85
90
  });
91
+
92
+ if (!response.body) {
93
+ throw new CloudResponseError(
94
+ 'Invalid Assistant Cloud response for "run stream": expected a response body',
95
+ );
96
+ }
97
+
98
+ const receivedContentType = response.headers.get("content-type");
99
+ const contentType = receivedContentType
100
+ ?.split(";", 1)[0]
101
+ ?.trim()
102
+ .toLowerCase();
103
+ if (contentType !== "text/plain") {
104
+ await response.body.cancel().catch(() => undefined);
105
+ throw new CloudResponseError(
106
+ `Invalid Assistant Cloud response for "run stream": expected a "text/plain" content type, received ${
107
+ receivedContentType
108
+ ? `"${receivedContentType}"`
109
+ : "no Content-Type header"
110
+ }`,
111
+ );
112
+ }
113
+
86
114
  return AssistantStream.fromResponse(response, new PlainTextDecoder());
87
115
  }
88
116
 
89
117
  public async report(
90
118
  body: AssistantCloudRunReport,
91
119
  ): Promise<{ run_id: string }> {
92
- return this.cloud.makeRequest("/runs", { method: "POST", body });
120
+ const response = readCloudRecord(
121
+ await this.cloud.makeRequest("/runs", { method: "POST", body }),
122
+ "run report response",
123
+ );
124
+
125
+ return { run_id: readCloudString(response.run_id, "run_id") };
93
126
  }
94
127
  }
@@ -12,6 +12,26 @@ const createCloudThreadMessages = () => {
12
12
  };
13
13
 
14
14
  describe("AssistantCloudThreadMessages responses", () => {
15
+ it("validates created message IDs", async () => {
16
+ const { messages, makeRequest } = createCloudThreadMessages();
17
+ const body = {
18
+ parent_id: null,
19
+ format: "aui/v0",
20
+ content: {},
21
+ };
22
+ makeRequest.mockResolvedValueOnce({ message_id: "message-1" });
23
+
24
+ await expect(messages.create("thread-1", body)).resolves.toEqual({
25
+ message_id: "message-1",
26
+ });
27
+
28
+ makeRequest.mockResolvedValueOnce({});
29
+
30
+ await expect(messages.create("thread-1", body)).rejects.toThrow(
31
+ 'Invalid Assistant Cloud response for "message_id": expected a string',
32
+ );
33
+ });
34
+
15
35
  it("decodes canonical message responses without changing content", async () => {
16
36
  const { messages, makeRequest } = createCloudThreadMessages();
17
37
  makeRequest.mockResolvedValue({
@@ -89,10 +89,17 @@ export class AssistantCloudThreadMessages {
89
89
  threadId: string,
90
90
  body: AssistantCloudThreadMessageCreateBody,
91
91
  ): Promise<AssistantCloudMessageCreateResponse> {
92
- return this.cloud.makeRequest(
93
- `/threads/${encodeURIComponent(threadId)}/messages`,
94
- { method: "POST", body },
92
+ const response = readCloudRecord(
93
+ await this.cloud.makeRequest(
94
+ `/threads/${encodeURIComponent(threadId)}/messages`,
95
+ { method: "POST", body },
96
+ ),
97
+ "thread message create response",
95
98
  );
99
+
100
+ return {
101
+ message_id: readCloudString(response.message_id, "message_id"),
102
+ };
96
103
  }
97
104
 
98
105
  public async update(
@@ -22,6 +22,38 @@ const threadResponse = {
22
22
  };
23
23
 
24
24
  describe("AssistantCloudThreads responses", () => {
25
+ it("validates created thread IDs", async () => {
26
+ const { threads, makeRequest } = createCloudThreads();
27
+ makeRequest.mockResolvedValueOnce({ thread_id: "thread-1" });
28
+
29
+ await expect(
30
+ threads.create({ last_message_at: new Date() }),
31
+ ).resolves.toEqual({ thread_id: "thread-1" });
32
+
33
+ makeRequest.mockResolvedValueOnce({});
34
+
35
+ await expect(
36
+ threads.create({ last_message_at: new Date() }),
37
+ ).rejects.toThrow(
38
+ 'Invalid Assistant Cloud response for "thread_id": expected a string',
39
+ );
40
+ });
41
+
42
+ it("forwards both archive filter values", async () => {
43
+ const { threads, makeRequest } = createCloudThreads();
44
+ makeRequest.mockResolvedValue({ threads: [] });
45
+
46
+ await threads.list({ is_archived: false });
47
+ expect(makeRequest).toHaveBeenLastCalledWith("/threads", {
48
+ query: { is_archived: "false" },
49
+ });
50
+
51
+ await threads.list({ is_archived: true });
52
+ expect(makeRequest).toHaveBeenLastCalledWith("/threads", {
53
+ query: { is_archived: "true" },
54
+ });
55
+ });
56
+
25
57
  it("decodes canonical thread list responses", async () => {
26
58
  const { threads, makeRequest } = createCloudThreads();
27
59
  makeRequest.mockResolvedValue({ threads: [threadResponse] });
@@ -88,8 +88,12 @@ export class AssistantCloudThreads {
88
88
  public async list(
89
89
  query?: AssistantCloudThreadsListQuery,
90
90
  ): Promise<AssistantCloudThreadsListResponse> {
91
+ const requestQuery =
92
+ query?.is_archived === undefined
93
+ ? query
94
+ : { ...query, is_archived: String(query.is_archived) };
91
95
  const response = readCloudRecord(
92
- await this.cloud.makeRequest("/threads", { query }),
96
+ await this.cloud.makeRequest("/threads", { query: requestQuery }),
93
97
  "thread list response",
94
98
  );
95
99
  const threads = readCloudArray(response.threads, "threads");
@@ -113,7 +117,12 @@ export class AssistantCloudThreads {
113
117
  public async create(
114
118
  body: AssistantCloudThreadsCreateBody,
115
119
  ): Promise<AssistantCloudThreadsCreateResponse> {
116
- return this.cloud.makeRequest("/threads", { method: "POST", body });
120
+ const response = readCloudRecord(
121
+ await this.cloud.makeRequest("/threads", { method: "POST", body }),
122
+ "thread create response",
123
+ );
124
+
125
+ return { thread_id: readCloudString(response.thread_id, "thread_id") };
117
126
  }
118
127
 
119
128
  public async update(
@@ -11,6 +11,7 @@ const refreshToken = {
11
11
  token: "r1",
12
12
  expires_at: "2099-01-01",
13
13
  };
14
+ const refreshTokenKey = `aui:refresh_token:${baseUrl}`;
14
15
 
15
16
  let originalLocalStorageDescriptor: PropertyDescriptor | undefined;
16
17
 
@@ -79,9 +80,7 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
79
80
  await expect(strategy.getAuthHeaders()).resolves.toEqual({
80
81
  Authorization: `Bearer ${accessToken}`,
81
82
  });
82
- expect(values.get("aui:refresh_token")).toBe(
83
- JSON.stringify(nextRefreshToken),
84
- );
83
+ expect(values.get(refreshTokenKey)).toBe(JSON.stringify(nextRefreshToken));
85
84
  expect(fetchMock).toHaveBeenCalledWith(
86
85
  `${baseUrl}/v1/auth/tokens/anonymous`,
87
86
  { method: "POST" },
@@ -107,6 +106,183 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
107
106
  expect(fetchMock).toHaveBeenCalledTimes(1);
108
107
  });
109
108
 
109
+ it("scopes anonymous refresh tokens by backend", async () => {
110
+ const secondBaseUrl = "https://other.example.com";
111
+ const values = new Map<string, string>();
112
+ installLocalStorage({
113
+ getItem: (key) => values.get(key) ?? null,
114
+ setItem: (key, value) => {
115
+ values.set(key, value);
116
+ },
117
+ removeItem: (key) => {
118
+ values.delete(key);
119
+ },
120
+ } as Storage);
121
+ const secondRefreshToken = {
122
+ token: "r2",
123
+ expires_at: "2099-01-01",
124
+ };
125
+ const fetchMock = vi
126
+ .fn()
127
+ .mockResolvedValueOnce({
128
+ ok: true,
129
+ json: vi.fn().mockResolvedValue({
130
+ access_token: accessToken,
131
+ refresh_token: refreshToken,
132
+ }),
133
+ })
134
+ .mockResolvedValueOnce({
135
+ ok: true,
136
+ json: vi.fn().mockResolvedValue({
137
+ access_token: accessToken,
138
+ refresh_token: secondRefreshToken,
139
+ }),
140
+ });
141
+ vi.stubGlobal("fetch", fetchMock);
142
+
143
+ await new AssistantCloudAnonymousAuthStrategy(baseUrl).getAuthHeaders();
144
+ await new AssistantCloudAnonymousAuthStrategy(
145
+ secondBaseUrl,
146
+ ).getAuthHeaders();
147
+
148
+ expect(fetchMock).toHaveBeenNthCalledWith(
149
+ 1,
150
+ `${baseUrl}/v1/auth/tokens/anonymous`,
151
+ { method: "POST" },
152
+ );
153
+ expect(fetchMock).toHaveBeenNthCalledWith(
154
+ 2,
155
+ `${secondBaseUrl}/v1/auth/tokens/anonymous`,
156
+ { method: "POST" },
157
+ );
158
+ expect(values.get(refreshTokenKey)).toBe(JSON.stringify(refreshToken));
159
+ expect(values.get(`aui:refresh_token:${secondBaseUrl}`)).toBe(
160
+ JSON.stringify(secondRefreshToken),
161
+ );
162
+ });
163
+
164
+ it("migrates the legacy refresh token to the first backend", async () => {
165
+ const secondBaseUrl = "https://other.example.com";
166
+ const secondRefreshToken = {
167
+ token: "r2",
168
+ expires_at: "2099-01-01",
169
+ };
170
+ const values = new Map([
171
+ ["aui:refresh_token", JSON.stringify(refreshToken)],
172
+ ]);
173
+ installLocalStorage({
174
+ getItem: (key) => values.get(key) ?? null,
175
+ setItem: (key, value) => {
176
+ values.set(key, value);
177
+ },
178
+ removeItem: (key) => {
179
+ values.delete(key);
180
+ },
181
+ } as Storage);
182
+ const fetchMock = vi
183
+ .fn()
184
+ .mockResolvedValueOnce({
185
+ ok: true,
186
+ json: vi.fn().mockResolvedValue({ access_token: accessToken }),
187
+ })
188
+ .mockResolvedValueOnce({
189
+ ok: true,
190
+ json: vi.fn().mockResolvedValue({
191
+ access_token: accessToken,
192
+ refresh_token: secondRefreshToken,
193
+ }),
194
+ });
195
+ vi.stubGlobal("fetch", fetchMock);
196
+
197
+ await new AssistantCloudAnonymousAuthStrategy(baseUrl).getAuthHeaders();
198
+ await new AssistantCloudAnonymousAuthStrategy(
199
+ secondBaseUrl,
200
+ ).getAuthHeaders();
201
+
202
+ expect(fetchMock).toHaveBeenNthCalledWith(
203
+ 1,
204
+ `${baseUrl}/v1/auth/tokens/refresh`,
205
+ {
206
+ method: "POST",
207
+ headers: { "Content-Type": "application/json" },
208
+ body: JSON.stringify({ refresh_token: refreshToken.token }),
209
+ },
210
+ );
211
+ expect(fetchMock).toHaveBeenNthCalledWith(
212
+ 2,
213
+ `${secondBaseUrl}/v1/auth/tokens/anonymous`,
214
+ { method: "POST" },
215
+ );
216
+ expect(values.get(refreshTokenKey)).toBe(JSON.stringify(refreshToken));
217
+ expect(values.get(`aui:refresh_token:${secondBaseUrl}`)).toBe(
218
+ JSON.stringify(secondRefreshToken),
219
+ );
220
+ expect(values.has("aui:refresh_token")).toBe(false);
221
+ });
222
+
223
+ it("retires the legacy refresh token when a scoped token exists", async () => {
224
+ const secondBaseUrl = "https://other.example.com";
225
+ const scopedRefreshToken = {
226
+ token: "scoped-r1",
227
+ expires_at: "2099-01-01",
228
+ };
229
+ const secondRefreshToken = {
230
+ token: "r2",
231
+ expires_at: "2099-01-01",
232
+ };
233
+ const values = new Map([
234
+ ["aui:refresh_token", JSON.stringify(refreshToken)],
235
+ [refreshTokenKey, JSON.stringify(scopedRefreshToken)],
236
+ ]);
237
+ installLocalStorage({
238
+ getItem: (key) => values.get(key) ?? null,
239
+ setItem: (key, value) => {
240
+ values.set(key, value);
241
+ },
242
+ removeItem: (key) => {
243
+ values.delete(key);
244
+ },
245
+ } as Storage);
246
+ const fetchMock = vi
247
+ .fn()
248
+ .mockResolvedValueOnce({
249
+ ok: true,
250
+ json: vi.fn().mockResolvedValue({ access_token: accessToken }),
251
+ })
252
+ .mockResolvedValueOnce({
253
+ ok: true,
254
+ json: vi.fn().mockResolvedValue({
255
+ access_token: accessToken,
256
+ refresh_token: secondRefreshToken,
257
+ }),
258
+ });
259
+ vi.stubGlobal("fetch", fetchMock);
260
+
261
+ await new AssistantCloudAnonymousAuthStrategy(baseUrl).getAuthHeaders();
262
+ await new AssistantCloudAnonymousAuthStrategy(
263
+ secondBaseUrl,
264
+ ).getAuthHeaders();
265
+
266
+ expect(fetchMock).toHaveBeenNthCalledWith(
267
+ 1,
268
+ `${baseUrl}/v1/auth/tokens/refresh`,
269
+ {
270
+ method: "POST",
271
+ headers: { "Content-Type": "application/json" },
272
+ body: JSON.stringify({ refresh_token: scopedRefreshToken.token }),
273
+ },
274
+ );
275
+ expect(fetchMock).toHaveBeenNthCalledWith(
276
+ 2,
277
+ `${secondBaseUrl}/v1/auth/tokens/anonymous`,
278
+ { method: "POST" },
279
+ );
280
+ expect(values.has("aui:refresh_token")).toBe(false);
281
+ expect(values.get(`aui:refresh_token:${secondBaseUrl}`)).toBe(
282
+ JSON.stringify(secondRefreshToken),
283
+ );
284
+ });
285
+
110
286
  it("returns an anonymous access token without localStorage", async () => {
111
287
  delete (globalThis as { localStorage?: Storage }).localStorage;
112
288
  mockAnonymousTokenFetch();
@@ -160,7 +336,7 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
160
336
  ).resolves.toEqual({ Authorization: `Bearer ${accessToken}` });
161
337
  expect(getItem).toHaveBeenCalledTimes(2);
162
338
  expect(setItem).toHaveBeenCalledTimes(2);
163
- expect(removeItem).toHaveBeenCalledTimes(1);
339
+ expect(removeItem).toHaveBeenCalledTimes(2);
164
340
  });
165
341
 
166
342
  it("treats corrupted refresh token JSON as absent", async () => {
@@ -181,7 +357,8 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
181
357
  await expect(strategy.getAuthHeaders()).resolves.toEqual({
182
358
  Authorization: `Bearer ${accessToken}`,
183
359
  });
184
- expect(values.get("aui:refresh_token")).toBe(JSON.stringify(refreshToken));
360
+ expect(values.get(refreshTokenKey)).toBe(JSON.stringify(refreshToken));
361
+ expect(values.has("aui:refresh_token")).toBe(false);
185
362
  });
186
363
 
187
364
  it("rejects malformed anonymous token responses without persisting them", async () => {
@@ -298,6 +475,102 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
298
475
  expect(setItem).not.toHaveBeenCalled();
299
476
  });
300
477
 
478
+ it.each([429, 500, 503])(
479
+ "preserves the anonymous identity after transient status %i",
480
+ async (status) => {
481
+ const values = new Map([[refreshTokenKey, JSON.stringify(refreshToken)]]);
482
+ installLocalStorage({
483
+ getItem: (key) => values.get(key) ?? null,
484
+ setItem: (key, value) => {
485
+ values.set(key, value);
486
+ },
487
+ removeItem: (key) => {
488
+ values.delete(key);
489
+ },
490
+ } as Storage);
491
+ const fetchMock = vi.fn().mockResolvedValue({ ok: false, status });
492
+ vi.stubGlobal("fetch", fetchMock);
493
+
494
+ const strategy = new AssistantCloudAnonymousAuthStrategy(baseUrl);
495
+
496
+ await expect(strategy.getAuthHeaders()).rejects.toThrow(
497
+ `Assistant Cloud token refresh failed with status ${status}`,
498
+ );
499
+ expect(fetchMock).toHaveBeenCalledTimes(1);
500
+ expect(values.get(refreshTokenKey)).toBe(JSON.stringify(refreshToken));
501
+ },
502
+ );
503
+
504
+ it.each([401, 403])(
505
+ "replaces an anonymous identity after refresh is rejected with %i",
506
+ async (status) => {
507
+ const replacementRefreshToken = {
508
+ token: "r2",
509
+ expires_at: "2099-02-01",
510
+ };
511
+ const values = new Map([[refreshTokenKey, JSON.stringify(refreshToken)]]);
512
+ installLocalStorage({
513
+ getItem: (key) => values.get(key) ?? null,
514
+ setItem: (key, value) => {
515
+ values.set(key, value);
516
+ },
517
+ removeItem: (key) => {
518
+ values.delete(key);
519
+ },
520
+ } as Storage);
521
+ const fetchMock = vi
522
+ .fn()
523
+ .mockResolvedValueOnce({ ok: false, status })
524
+ .mockResolvedValueOnce({
525
+ ok: true,
526
+ json: vi.fn().mockResolvedValue({
527
+ access_token: accessToken,
528
+ refresh_token: replacementRefreshToken,
529
+ }),
530
+ });
531
+ vi.stubGlobal("fetch", fetchMock);
532
+
533
+ const strategy = new AssistantCloudAnonymousAuthStrategy(baseUrl);
534
+
535
+ await expect(strategy.getAuthHeaders()).resolves.toEqual({
536
+ Authorization: `Bearer ${accessToken}`,
537
+ });
538
+ expect(fetchMock).toHaveBeenNthCalledWith(
539
+ 2,
540
+ `${baseUrl}/v1/auth/tokens/anonymous`,
541
+ { method: "POST" },
542
+ );
543
+ expect(values.get(refreshTokenKey)).toBe(
544
+ JSON.stringify(replacementRefreshToken),
545
+ );
546
+ },
547
+ );
548
+
549
+ it("preserves a rejected refresh token until its replacement succeeds", async () => {
550
+ const values = new Map([[refreshTokenKey, JSON.stringify(refreshToken)]]);
551
+ installLocalStorage({
552
+ getItem: (key) => values.get(key) ?? null,
553
+ setItem: (key, value) => {
554
+ values.set(key, value);
555
+ },
556
+ removeItem: (key) => {
557
+ values.delete(key);
558
+ },
559
+ } as Storage);
560
+ vi.stubGlobal(
561
+ "fetch",
562
+ vi
563
+ .fn()
564
+ .mockResolvedValueOnce({ ok: false, status: 403 })
565
+ .mockResolvedValueOnce({ ok: false, status: 503 }),
566
+ );
567
+
568
+ const strategy = new AssistantCloudAnonymousAuthStrategy(baseUrl);
569
+
570
+ await expect(strategy.getAuthHeaders()).resolves.toBe(false);
571
+ expect(values.get(refreshTokenKey)).toBe(JSON.stringify(refreshToken));
572
+ });
573
+
301
574
  it("contextualizes invalid JSON token responses", async () => {
302
575
  delete (globalThis as { localStorage?: Storage }).localStorage;
303
576
  vi.stubGlobal(