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.
- package/dist/AssistantCloud.d.ts +2 -1
- package/dist/AssistantCloud.d.ts.map +1 -1
- package/dist/AssistantCloud.js +4 -1
- package/dist/AssistantCloud.js.map +1 -1
- package/dist/AssistantCloudAPI.d.ts +5 -2
- package/dist/AssistantCloudAPI.d.ts.map +1 -1
- package/dist/AssistantCloudAPI.js +21 -11
- package/dist/AssistantCloudAPI.js.map +1 -1
- package/dist/AssistantCloudAuthStrategy.d.ts +2 -0
- package/dist/AssistantCloudAuthStrategy.d.ts.map +1 -1
- package/dist/AssistantCloudAuthStrategy.js +51 -10
- package/dist/AssistantCloudAuthStrategy.js.map +1 -1
- package/dist/AssistantCloudAuthTokens.d.ts +0 -1
- package/dist/AssistantCloudAuthTokens.d.ts.map +1 -1
- package/dist/AssistantCloudFiles.d.ts +0 -1
- package/dist/AssistantCloudFiles.d.ts.map +1 -1
- package/dist/AssistantCloudProjectThreadMessages.d.ts +19 -0
- package/dist/AssistantCloudProjectThreadMessages.d.ts.map +1 -0
- package/dist/AssistantCloudProjectThreadMessages.js +16 -0
- package/dist/AssistantCloudProjectThreadMessages.js.map +1 -0
- package/dist/AssistantCloudProjectThreads.d.ts +21 -0
- package/dist/AssistantCloudProjectThreads.d.ts.map +1 -0
- package/dist/AssistantCloudProjectThreads.js +22 -0
- package/dist/AssistantCloudProjectThreads.js.map +1 -0
- package/dist/AssistantCloudProjects.d.ts +10 -0
- package/dist/AssistantCloudProjects.d.ts.map +1 -0
- package/dist/AssistantCloudProjects.js +12 -0
- package/dist/AssistantCloudProjects.js.map +1 -0
- package/dist/AssistantCloudRuns.d.ts +0 -1
- package/dist/AssistantCloudRuns.d.ts.map +1 -1
- package/dist/AssistantCloudThreadMessages.d.ts +2 -2
- package/dist/AssistantCloudThreadMessages.d.ts.map +1 -1
- package/dist/AssistantCloudThreadMessages.js +15 -2
- package/dist/AssistantCloudThreadMessages.js.map +1 -1
- package/dist/AssistantCloudThreads.d.ts +2 -2
- package/dist/AssistantCloudThreads.d.ts.map +1 -1
- package/dist/AssistantCloudThreads.js +20 -3
- package/dist/AssistantCloudThreads.js.map +1 -1
- package/dist/CloudMessagePersistence.d.ts +2 -2
- package/dist/CloudMessagePersistence.d.ts.map +1 -1
- package/dist/CloudMessagePersistence.js +11 -6
- package/dist/CloudMessagePersistence.js.map +1 -1
- package/dist/FormattedCloudPersistence.d.ts +0 -1
- package/dist/FormattedCloudPersistence.d.ts.map +1 -1
- package/dist/cloudResponse.d.ts +16 -0
- package/dist/cloudResponse.d.ts.map +1 -0
- package/dist/cloudResponse.js +43 -0
- package/dist/cloudResponse.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -1
- package/dist/instrumentMcpSampling.d.ts.map +1 -1
- package/dist/tests/setup.d.ts +1 -1
- package/dist/tests/setup.js +2 -4
- package/dist/tests/setup.js.map +1 -1
- package/package.json +5 -5
- package/src/AssistantCloud.ts +3 -0
- package/src/AssistantCloudAPI.ts +31 -13
- package/src/AssistantCloudAuthStrategy.ts +68 -24
- package/src/AssistantCloudProjectThreadMessages.ts +40 -0
- package/src/AssistantCloudProjectThreads.ts +47 -0
- package/src/AssistantCloudProjects.ts +10 -0
- package/src/AssistantCloudThreadMessages.test.ts +41 -0
- package/src/AssistantCloudThreadMessages.ts +38 -3
- package/src/AssistantCloudThreads.test.ts +74 -0
- package/src/AssistantCloudThreads.ts +51 -3
- package/src/CloudMessagePersistence.ts +18 -5
- package/src/cloudResponse.test.ts +28 -0
- package/src/cloudResponse.ts +72 -0
- package/src/index.ts +2 -0
- package/src/tests/AssistantCloudAPI.test.ts +147 -8
- package/src/tests/AssistantCloudAuthStrategy.test.ts +191 -0
- package/src/tests/AssistantCloudProjects.test.ts +121 -0
- package/src/tests/CloudMessagePersistence.test.ts +40 -0
- 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(
|
|
129
|
-
expect(error.name).toBe("
|
|
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
|
|
149
|
-
|
|
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
|
-
|
|
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"),
|
package/src/tests/setup.ts
CHANGED
|
@@ -1,11 +1,4 @@
|
|
|
1
|
-
// This file contains setup code for tests
|
|
2
1
|
import { vi } from "vitest";
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
|
|
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"));
|