assistant-cloud 0.1.34 → 0.1.36

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 (74) hide show
  1. package/dist/AssistantCloud.d.ts +2 -1
  2. package/dist/AssistantCloud.d.ts.map +1 -1
  3. package/dist/AssistantCloud.js +4 -1
  4. package/dist/AssistantCloud.js.map +1 -1
  5. package/dist/AssistantCloudAPI.d.ts +5 -2
  6. package/dist/AssistantCloudAPI.d.ts.map +1 -1
  7. package/dist/AssistantCloudAPI.js +21 -11
  8. package/dist/AssistantCloudAPI.js.map +1 -1
  9. package/dist/AssistantCloudAuthStrategy.d.ts +2 -0
  10. package/dist/AssistantCloudAuthStrategy.d.ts.map +1 -1
  11. package/dist/AssistantCloudAuthStrategy.js +51 -10
  12. package/dist/AssistantCloudAuthStrategy.js.map +1 -1
  13. package/dist/AssistantCloudAuthTokens.d.ts +0 -1
  14. package/dist/AssistantCloudAuthTokens.d.ts.map +1 -1
  15. package/dist/AssistantCloudFiles.d.ts +0 -1
  16. package/dist/AssistantCloudFiles.d.ts.map +1 -1
  17. package/dist/AssistantCloudProjectThreadMessages.d.ts +19 -0
  18. package/dist/AssistantCloudProjectThreadMessages.d.ts.map +1 -0
  19. package/dist/AssistantCloudProjectThreadMessages.js +16 -0
  20. package/dist/AssistantCloudProjectThreadMessages.js.map +1 -0
  21. package/dist/AssistantCloudProjectThreads.d.ts +21 -0
  22. package/dist/AssistantCloudProjectThreads.d.ts.map +1 -0
  23. package/dist/AssistantCloudProjectThreads.js +22 -0
  24. package/dist/AssistantCloudProjectThreads.js.map +1 -0
  25. package/dist/AssistantCloudProjects.d.ts +10 -0
  26. package/dist/AssistantCloudProjects.d.ts.map +1 -0
  27. package/dist/AssistantCloudProjects.js +12 -0
  28. package/dist/AssistantCloudProjects.js.map +1 -0
  29. package/dist/AssistantCloudRuns.d.ts +0 -1
  30. package/dist/AssistantCloudRuns.d.ts.map +1 -1
  31. package/dist/AssistantCloudThreadMessages.d.ts +2 -2
  32. package/dist/AssistantCloudThreadMessages.d.ts.map +1 -1
  33. package/dist/AssistantCloudThreadMessages.js +15 -2
  34. package/dist/AssistantCloudThreadMessages.js.map +1 -1
  35. package/dist/AssistantCloudThreads.d.ts +2 -2
  36. package/dist/AssistantCloudThreads.d.ts.map +1 -1
  37. package/dist/AssistantCloudThreads.js +20 -3
  38. package/dist/AssistantCloudThreads.js.map +1 -1
  39. package/dist/CloudMessagePersistence.d.ts +2 -2
  40. package/dist/CloudMessagePersistence.d.ts.map +1 -1
  41. package/dist/CloudMessagePersistence.js +11 -6
  42. package/dist/CloudMessagePersistence.js.map +1 -1
  43. package/dist/FormattedCloudPersistence.d.ts +0 -1
  44. package/dist/FormattedCloudPersistence.d.ts.map +1 -1
  45. package/dist/cloudResponse.d.ts +16 -0
  46. package/dist/cloudResponse.d.ts.map +1 -0
  47. package/dist/cloudResponse.js +43 -0
  48. package/dist/cloudResponse.js.map +1 -0
  49. package/dist/index.d.ts +3 -2
  50. package/dist/index.js +3 -1
  51. package/dist/instrumentMcpSampling.d.ts.map +1 -1
  52. package/dist/tests/setup.d.ts +1 -1
  53. package/dist/tests/setup.js +2 -4
  54. package/dist/tests/setup.js.map +1 -1
  55. package/package.json +5 -5
  56. package/src/AssistantCloud.ts +3 -0
  57. package/src/AssistantCloudAPI.ts +31 -13
  58. package/src/AssistantCloudAuthStrategy.ts +68 -24
  59. package/src/AssistantCloudProjectThreadMessages.ts +40 -0
  60. package/src/AssistantCloudProjectThreads.ts +47 -0
  61. package/src/AssistantCloudProjects.ts +10 -0
  62. package/src/AssistantCloudThreadMessages.test.ts +41 -0
  63. package/src/AssistantCloudThreadMessages.ts +38 -3
  64. package/src/AssistantCloudThreads.test.ts +74 -0
  65. package/src/AssistantCloudThreads.ts +51 -3
  66. package/src/CloudMessagePersistence.ts +18 -5
  67. package/src/cloudResponse.test.ts +28 -0
  68. package/src/cloudResponse.ts +72 -0
  69. package/src/index.ts +2 -0
  70. package/src/tests/AssistantCloudAPI.test.ts +147 -8
  71. package/src/tests/AssistantCloudAuthStrategy.test.ts +191 -0
  72. package/src/tests/AssistantCloudProjects.test.ts +121 -0
  73. package/src/tests/CloudMessagePersistence.test.ts +40 -0
  74. package/src/tests/setup.ts +2 -9
@@ -1,11 +1,22 @@
1
- import { beforeEach, describe, expect, it, vi } from "vitest";
2
- import { AssistantCloudAPI } from "../AssistantCloudAPI";
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { AssistantCloudAPI, CloudAPIError } from "../AssistantCloudAPI";
3
+
4
+ const createJwt = () => {
5
+ const payload = Buffer.from(JSON.stringify({ exp: 4_102_444_800 })).toString(
6
+ "base64url",
7
+ );
8
+ return `header.${payload}.signature`;
9
+ };
3
10
 
4
11
  describe("AssistantCloudAPI", () => {
5
12
  beforeEach(() => {
6
13
  vi.restoreAllMocks();
7
14
  });
8
15
 
16
+ afterEach(() => {
17
+ vi.unstubAllGlobals();
18
+ });
19
+
9
20
  it("serializes query params, merges auth headers, and sends JSON body", async () => {
10
21
  const fetchMock = vi.fn().mockResolvedValue({
11
22
  ok: true,
@@ -90,6 +101,65 @@ describe("AssistantCloudAPI", () => {
90
101
  expect(url.toString()).toBe("https://custom.example.com/v1/threads");
91
102
  });
92
103
 
104
+ it("strips a trailing slash from a JWT baseUrl", async () => {
105
+ const fetchMock = vi.fn().mockResolvedValue({
106
+ ok: true,
107
+ headers: new Headers(),
108
+ });
109
+ vi.stubGlobal("fetch", fetchMock);
110
+
111
+ const api = new AssistantCloudAPI({
112
+ baseUrl: "https://custom.example.com/",
113
+ authToken: async () => createJwt(),
114
+ });
115
+
116
+ await api.makeRawRequest("/threads");
117
+
118
+ const [url] = fetchMock.mock.calls[0]!;
119
+ expect(url.toString()).toBe("https://custom.example.com/v1/threads");
120
+ });
121
+
122
+ it("strips a trailing slash from anonymous auth URLs", async () => {
123
+ const storage = {
124
+ getItem: vi.fn().mockReturnValue(null),
125
+ setItem: vi.fn(),
126
+ removeItem: vi.fn(),
127
+ };
128
+ vi.stubGlobal("localStorage", storage);
129
+
130
+ const fetchMock = vi
131
+ .fn()
132
+ .mockResolvedValueOnce({
133
+ ok: true,
134
+ json: vi.fn().mockResolvedValue({
135
+ access_token: createJwt(),
136
+ refresh_token: {
137
+ token: "refresh-token",
138
+ expires_at: "2100-01-01T00:00:00.000Z",
139
+ },
140
+ }),
141
+ })
142
+ .mockResolvedValueOnce({
143
+ ok: true,
144
+ headers: new Headers(),
145
+ });
146
+ vi.stubGlobal("fetch", fetchMock);
147
+
148
+ const api = new AssistantCloudAPI({
149
+ baseUrl: "https://custom.example.com/",
150
+ anonymous: true,
151
+ });
152
+
153
+ await api.makeRawRequest("/threads");
154
+
155
+ expect(fetchMock.mock.calls[0]![0]).toBe(
156
+ "https://custom.example.com/v1/auth/tokens/anonymous",
157
+ );
158
+ expect(fetchMock.mock.calls[1]![0].toString()).toBe(
159
+ "https://custom.example.com/v1/threads",
160
+ );
161
+ });
162
+
93
163
  it("rejects before fetch when auth token callback returns null", async () => {
94
164
  const fetchMock = vi.fn();
95
165
  vi.stubGlobal("fetch", fetchMock);
@@ -105,6 +175,15 @@ describe("AssistantCloudAPI", () => {
105
175
  expect(fetchMock).not.toHaveBeenCalled();
106
176
  });
107
177
 
178
+ it("returns false from initializeAuth when auth token callback returns null", async () => {
179
+ const api = new AssistantCloudAPI({
180
+ baseUrl: "https://test.example.com",
181
+ authToken: async () => null,
182
+ });
183
+
184
+ await expect(api.initializeAuth()).resolves.toBe(false);
185
+ });
186
+
108
187
  it("throws APIError with parsed message for JSON error responses", async () => {
109
188
  const fetchMock = vi.fn().mockResolvedValue({
110
189
  ok: false,
@@ -125,9 +204,10 @@ describe("AssistantCloudAPI", () => {
125
204
  });
126
205
 
127
206
  const error = await api.makeRawRequest("/threads").catch((e) => e);
128
- expect(error).toBeInstanceOf(Error);
129
- expect(error.name).toBe("APIError");
207
+ expect(error).toBeInstanceOf(CloudAPIError);
208
+ expect(error.name).toBe("CloudAPIError");
130
209
  expect(error.message).toBe("invalid request payload");
210
+ expect(error.status).toBe(400);
131
211
  });
132
212
 
133
213
  it("throws generic error with status for non-JSON error responses", async () => {
@@ -145,9 +225,10 @@ describe("AssistantCloudAPI", () => {
145
225
  workspaceId: "w-1",
146
226
  });
147
227
 
148
- await expect(api.makeRawRequest("/threads")).rejects.toThrow(
149
- "Request failed with status 502, Bad Gateway",
150
- );
228
+ const error = await api.makeRawRequest("/threads").catch((e) => e);
229
+ expect(error).toBeInstanceOf(CloudAPIError);
230
+ expect(error.message).toBe("Request failed with status 502, Bad Gateway");
231
+ expect(error.status).toBe(502);
151
232
  });
152
233
 
153
234
  it("makeRequest returns parsed JSON from a successful response", async () => {
@@ -155,7 +236,7 @@ describe("AssistantCloudAPI", () => {
155
236
  const fetchMock = vi.fn().mockResolvedValue({
156
237
  ok: true,
157
238
  headers: new Headers(),
158
- json: vi.fn().mockResolvedValue(responseData),
239
+ text: vi.fn().mockResolvedValue(JSON.stringify(responseData)),
159
240
  });
160
241
  vi.stubGlobal("fetch", fetchMock);
161
242
 
@@ -168,4 +249,62 @@ describe("AssistantCloudAPI", () => {
168
249
  const result = await api.makeRequest("/threads");
169
250
  expect(result).toEqual(responseData);
170
251
  });
252
+
253
+ it("makeRequest returns undefined from a successful empty response", async () => {
254
+ const fetchMock = vi.fn().mockResolvedValue({
255
+ ok: true,
256
+ status: 204,
257
+ headers: new Headers(),
258
+ text: vi.fn().mockResolvedValue(""),
259
+ });
260
+ vi.stubGlobal("fetch", fetchMock);
261
+
262
+ const api = new AssistantCloudAPI({
263
+ apiKey: "test-key",
264
+ userId: "u-1",
265
+ workspaceId: "w-1",
266
+ });
267
+
268
+ await expect(
269
+ api.makeRequest("/threads/t-1", { method: "DELETE" }),
270
+ ).resolves.toBeUndefined();
271
+ });
272
+
273
+ it("makeRequest returns undefined when content-length is zero", async () => {
274
+ const text = vi.fn().mockResolvedValue("");
275
+ const fetchMock = vi.fn().mockResolvedValue({
276
+ ok: true,
277
+ status: 200,
278
+ headers: new Headers({ "content-length": "0" }),
279
+ text,
280
+ });
281
+ vi.stubGlobal("fetch", fetchMock);
282
+
283
+ const api = new AssistantCloudAPI({
284
+ apiKey: "test-key",
285
+ userId: "u-1",
286
+ workspaceId: "w-1",
287
+ });
288
+
289
+ await expect(api.makeRequest("/threads/t-1")).resolves.toBeUndefined();
290
+ expect(text).not.toHaveBeenCalled();
291
+ });
292
+
293
+ it("makeRequest returns undefined from a whitespace-only success body", async () => {
294
+ const fetchMock = vi.fn().mockResolvedValue({
295
+ ok: true,
296
+ status: 200,
297
+ headers: new Headers(),
298
+ text: vi.fn().mockResolvedValue(" "),
299
+ });
300
+ vi.stubGlobal("fetch", fetchMock);
301
+
302
+ const api = new AssistantCloudAPI({
303
+ apiKey: "test-key",
304
+ userId: "u-1",
305
+ workspaceId: "w-1",
306
+ });
307
+
308
+ await expect(api.makeRequest("/threads/t-1")).resolves.toBeUndefined();
309
+ });
171
310
  });
@@ -0,0 +1,191 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ AssistantCloudAnonymousAuthStrategy,
4
+ AssistantCloudJWTAuthStrategy,
5
+ } from "../AssistantCloudAuthStrategy";
6
+
7
+ const baseUrl = "https://test.example.com";
8
+ const accessToken = `${Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url")}.${Buffer.from(JSON.stringify({ exp: 4102444800 })).toString("base64url")}.sig`;
9
+ const refreshToken = { token: "r1", expires_at: "2099-01-01" };
10
+
11
+ let originalLocalStorageDescriptor: PropertyDescriptor | undefined;
12
+
13
+ const installLocalStorage = (storage: Storage): void => {
14
+ Object.defineProperty(globalThis, "localStorage", {
15
+ configurable: true,
16
+ value: storage,
17
+ });
18
+ };
19
+
20
+ const mockAnonymousTokenFetch = () => {
21
+ const fetchMock = vi.fn().mockResolvedValue({
22
+ ok: true,
23
+ json: vi.fn().mockResolvedValue({
24
+ access_token: accessToken,
25
+ refresh_token: refreshToken,
26
+ }),
27
+ });
28
+ vi.stubGlobal("fetch", fetchMock);
29
+ return fetchMock;
30
+ };
31
+
32
+ describe("AssistantCloudAnonymousAuthStrategy", () => {
33
+ beforeEach(() => {
34
+ originalLocalStorageDescriptor = Object.getOwnPropertyDescriptor(
35
+ globalThis,
36
+ "localStorage",
37
+ );
38
+ });
39
+
40
+ afterEach(() => {
41
+ vi.unstubAllGlobals();
42
+ if (originalLocalStorageDescriptor) {
43
+ Object.defineProperty(
44
+ globalThis,
45
+ "localStorage",
46
+ originalLocalStorageDescriptor,
47
+ );
48
+ } else {
49
+ delete (globalThis as { localStorage?: Storage }).localStorage;
50
+ }
51
+ });
52
+
53
+ it("persists the refresh token and returns the anonymous access token", async () => {
54
+ const values = new Map<string, string>();
55
+ installLocalStorage({
56
+ getItem: (key) => values.get(key) ?? null,
57
+ setItem: (key, value) => {
58
+ values.set(key, value);
59
+ },
60
+ removeItem: (key) => {
61
+ values.delete(key);
62
+ },
63
+ } as Storage);
64
+ const fetchMock = mockAnonymousTokenFetch();
65
+
66
+ const strategy = new AssistantCloudAnonymousAuthStrategy(baseUrl);
67
+
68
+ await expect(strategy.getAuthHeaders()).resolves.toEqual({
69
+ Authorization: `Bearer ${accessToken}`,
70
+ });
71
+ expect(values.get("aui:refresh_token")).toBe(JSON.stringify(refreshToken));
72
+ expect(fetchMock).toHaveBeenCalledWith(
73
+ `${baseUrl}/v1/auth/tokens/anonymous`,
74
+ { method: "POST" },
75
+ );
76
+ });
77
+
78
+ it("deduplicates concurrent anonymous token requests", async () => {
79
+ delete (globalThis as { localStorage?: Storage }).localStorage;
80
+ const fetchMock = mockAnonymousTokenFetch();
81
+ const strategy = new AssistantCloudAnonymousAuthStrategy(baseUrl);
82
+
83
+ await expect(
84
+ Promise.all([
85
+ strategy.getAuthHeaders(),
86
+ strategy.getAuthHeaders(),
87
+ strategy.getAuthHeaders(),
88
+ ]),
89
+ ).resolves.toEqual([
90
+ { Authorization: `Bearer ${accessToken}` },
91
+ { Authorization: `Bearer ${accessToken}` },
92
+ { Authorization: `Bearer ${accessToken}` },
93
+ ]);
94
+ expect(fetchMock).toHaveBeenCalledTimes(1);
95
+ });
96
+
97
+ it("returns an anonymous access token without localStorage", async () => {
98
+ delete (globalThis as { localStorage?: Storage }).localStorage;
99
+ mockAnonymousTokenFetch();
100
+
101
+ const strategy = new AssistantCloudAnonymousAuthStrategy(baseUrl);
102
+
103
+ await expect(strategy.getAuthHeaders()).resolves.toEqual({
104
+ Authorization: `Bearer ${accessToken}`,
105
+ });
106
+ });
107
+
108
+ it("returns an anonymous access token when localStorage access is blocked", async () => {
109
+ Object.defineProperty(globalThis, "localStorage", {
110
+ configurable: true,
111
+ get() {
112
+ throw new DOMException("blocked", "SecurityError");
113
+ },
114
+ });
115
+ mockAnonymousTokenFetch();
116
+
117
+ const strategy = new AssistantCloudAnonymousAuthStrategy(baseUrl);
118
+
119
+ await expect(strategy.getAuthHeaders()).resolves.toEqual({
120
+ Authorization: `Bearer ${accessToken}`,
121
+ });
122
+ });
123
+
124
+ it("returns an anonymous access token when storage methods throw", async () => {
125
+ const getItem = vi
126
+ .fn<(key: string) => string | null>()
127
+ .mockReturnValueOnce(
128
+ JSON.stringify({ token: "expired", expires_at: "2022-01-01" }),
129
+ )
130
+ .mockImplementation(() => {
131
+ throw new DOMException("blocked", "SecurityError");
132
+ });
133
+ const setItem = vi.fn(() => {
134
+ throw new DOMException("blocked", "SecurityError");
135
+ });
136
+ const removeItem = vi.fn(() => {
137
+ throw new DOMException("blocked", "SecurityError");
138
+ });
139
+ installLocalStorage({ getItem, setItem, removeItem } as Storage);
140
+ mockAnonymousTokenFetch();
141
+
142
+ await expect(
143
+ new AssistantCloudAnonymousAuthStrategy(baseUrl).getAuthHeaders(),
144
+ ).resolves.toEqual({ Authorization: `Bearer ${accessToken}` });
145
+ await expect(
146
+ new AssistantCloudAnonymousAuthStrategy(baseUrl).getAuthHeaders(),
147
+ ).resolves.toEqual({ Authorization: `Bearer ${accessToken}` });
148
+ expect(getItem).toHaveBeenCalledTimes(2);
149
+ expect(setItem).toHaveBeenCalledTimes(2);
150
+ expect(removeItem).toHaveBeenCalledTimes(1);
151
+ });
152
+
153
+ it("treats corrupted refresh token JSON as absent", async () => {
154
+ const values = new Map([["aui:refresh_token", "not-json{"]]);
155
+ installLocalStorage({
156
+ getItem: (key) => values.get(key) ?? null,
157
+ setItem: (key, value) => {
158
+ values.set(key, value);
159
+ },
160
+ removeItem: (key) => {
161
+ values.delete(key);
162
+ },
163
+ } as Storage);
164
+ mockAnonymousTokenFetch();
165
+
166
+ const strategy = new AssistantCloudAnonymousAuthStrategy(baseUrl);
167
+
168
+ await expect(strategy.getAuthHeaders()).resolves.toEqual({
169
+ Authorization: `Bearer ${accessToken}`,
170
+ });
171
+ expect(values.get("aui:refresh_token")).toBe(JSON.stringify(refreshToken));
172
+ });
173
+ });
174
+
175
+ describe("AssistantCloudJWTAuthStrategy", () => {
176
+ it("retries token acquisition after a failed request", async () => {
177
+ const authToken = vi
178
+ .fn<() => Promise<string | null>>()
179
+ .mockRejectedValueOnce(new Error("authentication unavailable"))
180
+ .mockResolvedValueOnce(accessToken);
181
+ const strategy = new AssistantCloudJWTAuthStrategy(authToken);
182
+
183
+ await expect(strategy.getAuthHeaders()).rejects.toThrow(
184
+ "authentication unavailable",
185
+ );
186
+ await expect(strategy.getAuthHeaders()).resolves.toEqual({
187
+ Authorization: `Bearer ${accessToken}`,
188
+ });
189
+ expect(authToken).toHaveBeenCalledTimes(2);
190
+ });
191
+ });
@@ -0,0 +1,121 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { AssistantCloudProjects } from "../AssistantCloudProjects";
3
+ import type { AssistantCloudAPI } from "../AssistantCloudAPI";
4
+
5
+ vi.mock("../AssistantCloudAPI");
6
+
7
+ describe("AssistantCloudProjects", () => {
8
+ let projects: AssistantCloudProjects;
9
+ let mockApi: AssistantCloudAPI;
10
+
11
+ beforeEach(() => {
12
+ mockApi = {
13
+ makeRequest: vi.fn().mockResolvedValue({}),
14
+ makeRawRequest: vi.fn(),
15
+ _auth: { getAuthHeaders: vi.fn() },
16
+ _baseUrl: "https://backend.assistant-api.com",
17
+ } as unknown as AssistantCloudAPI;
18
+
19
+ projects = new AssistantCloudProjects(mockApi);
20
+ });
21
+
22
+ afterEach(() => {
23
+ vi.clearAllMocks();
24
+ });
25
+
26
+ it("lists project threads with the query", async () => {
27
+ vi.mocked(mockApi.makeRequest).mockResolvedValue({ threads: [] });
28
+
29
+ await projects.threads.list({ limit: 10, after: "t1" });
30
+
31
+ expect(mockApi.makeRequest).toHaveBeenCalledWith("/projects/threads", {
32
+ query: { limit: 10, after: "t1" },
33
+ });
34
+ });
35
+
36
+ it("sends both archive filter values as strings", async () => {
37
+ vi.mocked(mockApi.makeRequest).mockResolvedValue({ threads: [] });
38
+
39
+ await projects.threads.list({ is_archived: false });
40
+ expect(mockApi.makeRequest).toHaveBeenLastCalledWith("/projects/threads", {
41
+ query: { is_archived: "false" },
42
+ });
43
+
44
+ await projects.threads.list({ is_archived: true });
45
+ expect(mockApi.makeRequest).toHaveBeenLastCalledWith("/projects/threads", {
46
+ query: { is_archived: "true" },
47
+ });
48
+ });
49
+
50
+ it("lists project thread messages with the query", async () => {
51
+ vi.mocked(mockApi.makeRequest).mockResolvedValue({ messages: [] });
52
+
53
+ await projects.threads.messages.list("thread_123", {
54
+ format: "ai-sdk/v5",
55
+ limit: 100,
56
+ after: "msg_9",
57
+ });
58
+
59
+ expect(mockApi.makeRequest).toHaveBeenCalledWith(
60
+ "/projects/threads/thread_123/messages",
61
+ { query: { format: "ai-sdk/v5", limit: 100, after: "msg_9" } },
62
+ );
63
+ });
64
+
65
+ it("url-encodes the thread id in the messages path", async () => {
66
+ vi.mocked(mockApi.makeRequest).mockResolvedValue({ messages: [] });
67
+
68
+ await projects.threads.messages.list("thread/with space");
69
+
70
+ expect(mockApi.makeRequest).toHaveBeenCalledWith(
71
+ "/projects/threads/thread%2Fwith%20space/messages",
72
+ { query: undefined },
73
+ );
74
+ });
75
+
76
+ it("decodes project thread list responses", async () => {
77
+ vi.mocked(mockApi.makeRequest).mockResolvedValue({
78
+ threads: [
79
+ {
80
+ title: "Project thread",
81
+ last_message_at: "2026-07-16T12:30:00.000Z",
82
+ metadata: {},
83
+ external_id: null,
84
+ id: "thread_1",
85
+ project_id: "project_1",
86
+ created_at: "2026-07-16T12:00:00.000Z",
87
+ updated_at: "2026-07-16T12:15:00.000Z",
88
+ workspace_id: "workspace_1",
89
+ is_archived: false,
90
+ },
91
+ ],
92
+ });
93
+
94
+ const result = await projects.threads.list();
95
+ const thread = result.threads[0]!;
96
+
97
+ expect(thread.last_message_at instanceof Date).toBe(true);
98
+ expect(thread.is_archived).toBeTypeOf("boolean");
99
+ });
100
+
101
+ it("decodes project thread message list responses", async () => {
102
+ vi.mocked(mockApi.makeRequest).mockResolvedValue({
103
+ messages: [
104
+ {
105
+ id: "message_1",
106
+ parent_id: null,
107
+ height: 0,
108
+ created_at: "2026-07-16T13:00:00.000Z",
109
+ updated_at: "2026-07-16T13:05:00.000Z",
110
+ format: "aui/v0",
111
+ content: {},
112
+ },
113
+ ],
114
+ });
115
+
116
+ const result = await projects.threads.messages.list("thread_1");
117
+ const message = result.messages[0]!;
118
+
119
+ expect(message.created_at instanceof Date).toBe(true);
120
+ });
121
+ });
@@ -37,6 +37,33 @@ describe("CloudMessagePersistence", () => {
37
37
  expect(await persistence.getRemoteId("local-1")).toBe("remote-1");
38
38
  });
39
39
 
40
+ it("uses the current client without losing ID mappings", async () => {
41
+ const firstCloud = createMockCloud();
42
+ const secondCloud = createMockCloud();
43
+ let currentCloud = firstCloud;
44
+ persistence = new CloudMessagePersistence(() => currentCloud);
45
+ vi.mocked(firstCloud.threads.messages.create).mockResolvedValue({
46
+ message_id: "remote-parent",
47
+ });
48
+ vi.mocked(secondCloud.threads.messages.create).mockResolvedValue({
49
+ message_id: "remote-child",
50
+ });
51
+
52
+ await persistence.append("thread-1", "parent", null, "aui/v0", {
53
+ text: "parent",
54
+ });
55
+ currentCloud = secondCloud;
56
+ await persistence.append("thread-1", "child", "parent", "aui/v0", {
57
+ text: "child",
58
+ });
59
+
60
+ expect(secondCloud.threads.messages.create).toHaveBeenCalledWith(
61
+ "thread-1",
62
+ expect.objectContaining({ parent_id: "remote-parent" }),
63
+ );
64
+ expect(await persistence.getRemoteId("child")).toBe("remote-child");
65
+ });
66
+
40
67
  it("resolves parent ID from a concurrent append", async () => {
41
68
  // Parent creation is delayed — the promise won't resolve immediately
42
69
  let resolveParent!: (v: { message_id: string }) => void;
@@ -121,6 +148,19 @@ describe("CloudMessagePersistence", () => {
121
148
  );
122
149
  });
123
150
 
151
+ it("warns and skips update when no remote id is mapped", async () => {
152
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
153
+
154
+ await persistence.update("thread-1", "unmapped-1", "aui/v0", {
155
+ text: "x",
156
+ });
157
+
158
+ expect(cloud.threads.messages.update).not.toHaveBeenCalled();
159
+ expect(warn).toHaveBeenCalledWith(
160
+ "Skipping update for message unmapped-1: no remote id is mapped.",
161
+ );
162
+ });
163
+
124
164
  it("cleans up ID mapping on append failure", async () => {
125
165
  vi.mocked(cloud.threads.messages.create).mockRejectedValue(
126
166
  new Error("network error"),
@@ -1,11 +1,4 @@
1
- // This file contains setup code for tests
2
1
  import { vi } from "vitest";
3
2
 
4
- // Set up globalThis mocks if needed
5
- // Using a fixed date to avoid recursive calls
6
- const OriginalDate = globalThis.Date;
7
- const fixedDate = new OriginalDate("2023-01-01");
8
- globalThis.Date = vi.fn(() => fixedDate) as any;
9
- globalThis.Date.now = vi.fn(() => fixedDate.getTime());
10
-
11
- // Add any other globalThis setup needed for tests
3
+ vi.useFakeTimers({ toFake: ["Date"] });
4
+ vi.setSystemTime(new Date("2023-01-01T00:00:00.000Z"));