assistant-cloud 0.1.35 → 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 (69) hide show
  1. package/dist/AssistantCloud.d.ts +2 -0
  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.map +1 -1
  6. package/dist/AssistantCloudAPI.js +8 -4
  7. package/dist/AssistantCloudAPI.js.map +1 -1
  8. package/dist/AssistantCloudAuthStrategy.d.ts +2 -0
  9. package/dist/AssistantCloudAuthStrategy.d.ts.map +1 -1
  10. package/dist/AssistantCloudAuthStrategy.js +15 -5
  11. package/dist/AssistantCloudAuthStrategy.js.map +1 -1
  12. package/dist/AssistantCloudAuthTokens.d.ts.map +1 -1
  13. package/dist/AssistantCloudFiles.d.ts.map +1 -1
  14. package/dist/AssistantCloudProjectThreadMessages.d.ts +19 -0
  15. package/dist/AssistantCloudProjectThreadMessages.d.ts.map +1 -0
  16. package/dist/AssistantCloudProjectThreadMessages.js +16 -0
  17. package/dist/AssistantCloudProjectThreadMessages.js.map +1 -0
  18. package/dist/AssistantCloudProjectThreads.d.ts +21 -0
  19. package/dist/AssistantCloudProjectThreads.d.ts.map +1 -0
  20. package/dist/AssistantCloudProjectThreads.js +22 -0
  21. package/dist/AssistantCloudProjectThreads.js.map +1 -0
  22. package/dist/AssistantCloudProjects.d.ts +10 -0
  23. package/dist/AssistantCloudProjects.d.ts.map +1 -0
  24. package/dist/AssistantCloudProjects.js +12 -0
  25. package/dist/AssistantCloudProjects.js.map +1 -0
  26. package/dist/AssistantCloudRuns.d.ts.map +1 -1
  27. package/dist/AssistantCloudThreadMessages.d.ts +2 -1
  28. package/dist/AssistantCloudThreadMessages.d.ts.map +1 -1
  29. package/dist/AssistantCloudThreadMessages.js +15 -2
  30. package/dist/AssistantCloudThreadMessages.js.map +1 -1
  31. package/dist/AssistantCloudThreads.d.ts +2 -1
  32. package/dist/AssistantCloudThreads.d.ts.map +1 -1
  33. package/dist/AssistantCloudThreads.js +20 -3
  34. package/dist/AssistantCloudThreads.js.map +1 -1
  35. package/dist/CloudMessagePersistence.d.ts +2 -1
  36. package/dist/CloudMessagePersistence.d.ts.map +1 -1
  37. package/dist/CloudMessagePersistence.js +11 -6
  38. package/dist/CloudMessagePersistence.js.map +1 -1
  39. package/dist/FormattedCloudPersistence.d.ts.map +1 -1
  40. package/dist/cloudResponse.d.ts +16 -0
  41. package/dist/cloudResponse.d.ts.map +1 -0
  42. package/dist/cloudResponse.js +43 -0
  43. package/dist/cloudResponse.js.map +1 -0
  44. package/dist/index.d.ts +2 -1
  45. package/dist/index.js +2 -1
  46. package/dist/instrumentMcpSampling.d.ts.map +1 -1
  47. package/dist/tests/setup.d.ts +1 -1
  48. package/dist/tests/setup.js +2 -4
  49. package/dist/tests/setup.js.map +1 -1
  50. package/package.json +3 -3
  51. package/src/AssistantCloud.ts +3 -0
  52. package/src/AssistantCloudAPI.ts +11 -6
  53. package/src/AssistantCloudAuthStrategy.ts +21 -6
  54. package/src/AssistantCloudProjectThreadMessages.ts +40 -0
  55. package/src/AssistantCloudProjectThreads.ts +47 -0
  56. package/src/AssistantCloudProjects.ts +10 -0
  57. package/src/AssistantCloudThreadMessages.test.ts +41 -0
  58. package/src/AssistantCloudThreadMessages.ts +38 -3
  59. package/src/AssistantCloudThreads.test.ts +74 -0
  60. package/src/AssistantCloudThreads.ts +51 -3
  61. package/src/CloudMessagePersistence.ts +18 -5
  62. package/src/cloudResponse.test.ts +28 -0
  63. package/src/cloudResponse.ts +72 -0
  64. package/src/index.ts +1 -0
  65. package/src/tests/AssistantCloudAPI.test.ts +71 -1
  66. package/src/tests/AssistantCloudAuthStrategy.test.ts +50 -22
  67. package/src/tests/AssistantCloudProjects.test.ts +121 -0
  68. package/src/tests/CloudMessagePersistence.test.ts +40 -0
  69. package/src/tests/setup.ts +2 -9
@@ -1,5 +1,13 @@
1
1
  import type { AssistantCloudAPI } from "./AssistantCloudAPI";
2
2
  import { AssistantCloudThreadMessages } from "./AssistantCloudThreadMessages";
3
+ import {
4
+ readCloudArray,
5
+ readCloudBoolean,
6
+ readCloudNullableString,
7
+ readCloudRecord,
8
+ readCloudString,
9
+ readCloudTimestamp,
10
+ } from "./cloudResponse";
3
11
 
4
12
  type AssistantCloudThreadsListQuery = {
5
13
  is_archived?: boolean;
@@ -7,7 +15,7 @@ type AssistantCloudThreadsListQuery = {
7
15
  after?: string;
8
16
  };
9
17
 
10
- type CloudThread = {
18
+ export type CloudThread = {
11
19
  title: string;
12
20
  last_message_at: Date;
13
21
  metadata: unknown;
@@ -42,6 +50,31 @@ type AssistantCloudThreadsUpdateBody = {
42
50
  is_archived?: boolean | undefined;
43
51
  };
44
52
 
53
+ export const decodeCloudThread = (
54
+ value: unknown,
55
+ field: string,
56
+ ): CloudThread => {
57
+ const thread = readCloudRecord(value, field);
58
+ return {
59
+ title: readCloudNullableString(thread.title, `${field}.title`) ?? "",
60
+ last_message_at: readCloudTimestamp(
61
+ thread.last_message_at,
62
+ `${field}.last_message_at`,
63
+ ),
64
+ metadata: thread.metadata,
65
+ external_id: readCloudNullableString(
66
+ thread.external_id,
67
+ `${field}.external_id`,
68
+ ),
69
+ id: readCloudString(thread.id, `${field}.id`),
70
+ project_id: readCloudString(thread.project_id, `${field}.project_id`),
71
+ created_at: readCloudTimestamp(thread.created_at, `${field}.created_at`),
72
+ updated_at: readCloudTimestamp(thread.updated_at, `${field}.updated_at`),
73
+ workspace_id: readCloudString(thread.workspace_id, `${field}.workspace_id`),
74
+ is_archived: readCloudBoolean(thread.is_archived, `${field}.is_archived`),
75
+ };
76
+ };
77
+
45
78
  export class AssistantCloudThreads {
46
79
  public readonly messages: AssistantCloudThreadMessages;
47
80
 
@@ -52,11 +85,26 @@ export class AssistantCloudThreads {
52
85
  public async list(
53
86
  query?: AssistantCloudThreadsListQuery,
54
87
  ): Promise<AssistantCloudThreadsListResponse> {
55
- return this.cloud.makeRequest("/threads", { query });
88
+ const response = readCloudRecord(
89
+ await this.cloud.makeRequest("/threads", { query }),
90
+ "thread list response",
91
+ );
92
+ const threads = readCloudArray(response.threads, "threads");
93
+
94
+ return {
95
+ threads: threads.map((thread, index) =>
96
+ decodeCloudThread(thread, `threads[${index}]`),
97
+ ),
98
+ };
56
99
  }
57
100
 
58
101
  public async get(threadId: string): Promise<CloudThread> {
59
- return this.cloud.makeRequest(`/threads/${encodeURIComponent(threadId)}`);
102
+ const response = readCloudRecord(
103
+ await this.cloud.makeRequest(`/threads/${encodeURIComponent(threadId)}`),
104
+ "thread response",
105
+ );
106
+
107
+ return decodeCloudThread(response.thread, "thread");
60
108
  }
61
109
 
62
110
  public async create(
@@ -14,8 +14,13 @@ import type { AssistantCloud } from "./AssistantCloud";
14
14
  */
15
15
  export class CloudMessagePersistence {
16
16
  private idMapping: Record<string, string | Promise<string>> = {};
17
+ private getCloud: () => AssistantCloud;
17
18
 
18
- constructor(private cloud: AssistantCloud) {}
19
+ constructor(cloud: AssistantCloud);
20
+ constructor(getCloud: () => AssistantCloud);
21
+ constructor(cloud: AssistantCloud | (() => AssistantCloud)) {
22
+ this.getCloud = typeof cloud === "function" ? cloud : () => cloud;
23
+ }
19
24
 
20
25
  /**
21
26
  * Persist a message to the cloud.
@@ -33,12 +38,13 @@ export class CloudMessagePersistence {
33
38
  format: string,
34
39
  content: ReadonlyJSONObject,
35
40
  ): Promise<void> {
41
+ const cloud = this.getCloud();
36
42
  // Resolve parent's remote ID if it exists (may be a promise if concurrent)
37
43
  const resolvedParentId = parentId
38
44
  ? ((await this.idMapping[parentId]) ?? parentId)
39
45
  : null;
40
46
 
41
- const task = this.cloud.threads.messages
47
+ const task = cloud.threads.messages
42
48
  .create(threadId, {
43
49
  parent_id: resolvedParentId,
44
50
  format,
@@ -70,9 +76,15 @@ export class CloudMessagePersistence {
70
76
  _format: string,
71
77
  content: ReadonlyJSONObject,
72
78
  ): Promise<void> {
79
+ const cloud = this.getCloud();
73
80
  const remoteId = await this.getRemoteId(messageId);
74
- if (!remoteId) return; // not persisted yet, skip
75
- await this.cloud.threads.messages.update(threadId, remoteId, { content });
81
+ if (!remoteId) {
82
+ console.warn(
83
+ `Skipping update for message ${messageId}: no remote id is mapped.`,
84
+ );
85
+ return;
86
+ }
87
+ await cloud.threads.messages.update(threadId, remoteId, { content });
76
88
  }
77
89
 
78
90
  /**
@@ -103,7 +115,8 @@ export class CloudMessagePersistence {
103
115
  * @returns Array of cloud messages
104
116
  */
105
117
  async load(threadId: string, format?: string) {
106
- const { messages } = await this.cloud.threads.messages.list(
118
+ const cloud = this.getCloud();
119
+ const { messages } = await cloud.threads.messages.list(
107
120
  threadId,
108
121
  format ? { format } : undefined,
109
122
  );
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { CloudResponseError, readCloudTimestamp } from "./cloudResponse";
3
+
4
+ describe("readCloudTimestamp", () => {
5
+ it("decodes a canonical Cloud timestamp", () => {
6
+ expect(
7
+ readCloudTimestamp(
8
+ "2026-07-16T12:15:00.123Z",
9
+ "thread.updated_at",
10
+ ).toISOString(),
11
+ ).toBe("2026-07-16T12:15:00.123Z");
12
+ });
13
+
14
+ it.each([
15
+ "2026-02-30T12:15:00.000Z",
16
+ "2026-13-01T12:15:00Z",
17
+ "2026-07-16 12:15:00.123456",
18
+ "2026-07-16T12:15:00.123+00:00",
19
+ new Date("2026-07-16T12:15:00.123Z"),
20
+ "not-a-timestamp",
21
+ ])("rejects invalid timestamp %s", (value) => {
22
+ const decode = () => readCloudTimestamp(value, "thread.updated_at");
23
+ expect(decode).toThrow(CloudResponseError);
24
+ expect(decode).toThrow(
25
+ 'Invalid Assistant Cloud response for "thread.updated_at": expected a canonical ISO timestamp',
26
+ );
27
+ });
28
+ });
@@ -0,0 +1,72 @@
1
+ import type { ReadonlyJSONObject } from "assistant-stream/utils";
2
+
3
+ export class CloudResponseError extends Error {
4
+ constructor(message: string) {
5
+ super(message);
6
+ this.name = "CloudResponseError";
7
+ }
8
+ }
9
+
10
+ const invalidCloudResponse = (field: string, expected: string) =>
11
+ new CloudResponseError(
12
+ `Invalid Assistant Cloud response for "${field}": expected ${expected}`,
13
+ );
14
+
15
+ export const readCloudRecord = (
16
+ value: unknown,
17
+ field: string,
18
+ ): Record<string, unknown> => {
19
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
20
+ throw invalidCloudResponse(field, "an object");
21
+ }
22
+ return value as Record<string, unknown>;
23
+ };
24
+
25
+ export const readCloudArray = (value: unknown, field: string): unknown[] => {
26
+ if (!Array.isArray(value)) throw invalidCloudResponse(field, "an array");
27
+ return value;
28
+ };
29
+
30
+ export const readCloudString = (value: unknown, field: string): string => {
31
+ if (typeof value !== "string") throw invalidCloudResponse(field, "a string");
32
+ return value;
33
+ };
34
+
35
+ export const readCloudNullableString = (
36
+ value: unknown,
37
+ field: string,
38
+ ): string | null => {
39
+ if (value === null) return null;
40
+ return readCloudString(value, field);
41
+ };
42
+
43
+ export const readCloudBoolean = (value: unknown, field: string): boolean => {
44
+ if (typeof value !== "boolean") {
45
+ throw invalidCloudResponse(field, "a boolean");
46
+ }
47
+ return value;
48
+ };
49
+
50
+ export const readCloudInteger = (value: unknown, field: string): number => {
51
+ if (!Number.isInteger(value)) throw invalidCloudResponse(field, "an integer");
52
+ return value as number;
53
+ };
54
+
55
+ export const readCloudTimestamp = (value: unknown, field: string): Date => {
56
+ if (typeof value !== "string") {
57
+ throw invalidCloudResponse(field, "a canonical ISO timestamp");
58
+ }
59
+
60
+ const date = new Date(value);
61
+ // The round trip pins the backend DTO's Date#toISOString() wire contract.
62
+ if (Number.isNaN(date.getTime()) || date.toISOString() !== value) {
63
+ throw invalidCloudResponse(field, "a canonical ISO timestamp");
64
+ }
65
+
66
+ return date;
67
+ };
68
+
69
+ export const readCloudJSONObject = (
70
+ value: unknown,
71
+ field: string,
72
+ ): ReadonlyJSONObject => readCloudRecord(value, field) as ReadonlyJSONObject;
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export type { CloudMessage } from "./AssistantCloudThreadMessages";
2
2
  export type { AssistantCloudTelemetryConfig } from "./AssistantCloudAPI";
3
3
  export { CloudAPIError } from "./AssistantCloudAPI";
4
+ export { CloudResponseError } from "./cloudResponse";
4
5
  export type { AssistantCloudRunReport } from "./AssistantCloudRuns";
5
6
  export { AssistantCloud } from "./AssistantCloud";
6
7
  export { CloudMessagePersistence } from "./CloudMessagePersistence";
@@ -1,11 +1,22 @@
1
- import { beforeEach, describe, expect, it, vi } from "vitest";
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
  import { AssistantCloudAPI, CloudAPIError } from "../AssistantCloudAPI";
3
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
+ };
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);
@@ -1,5 +1,8 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
- import { AssistantCloudAnonymousAuthStrategy } from "../AssistantCloudAuthStrategy";
2
+ import {
3
+ AssistantCloudAnonymousAuthStrategy,
4
+ AssistantCloudJWTAuthStrategy,
5
+ } from "../AssistantCloudAuthStrategy";
3
6
 
4
7
  const baseUrl = "https://test.example.com";
5
8
  const accessToken = `${Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url")}.${Buffer.from(JSON.stringify({ exp: 4102444800 })).toString("base64url")}.sig`;
@@ -72,6 +75,25 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
72
75
  );
73
76
  });
74
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
+
75
97
  it("returns an anonymous access token without localStorage", async () => {
76
98
  delete (globalThis as { localStorage?: Storage }).localStorage;
77
99
  mockAnonymousTokenFetch();
@@ -102,7 +124,9 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
102
124
  it("returns an anonymous access token when storage methods throw", async () => {
103
125
  const getItem = vi
104
126
  .fn<(key: string) => string | null>()
105
- .mockReturnValueOnce(JSON.stringify(refreshToken))
127
+ .mockReturnValueOnce(
128
+ JSON.stringify({ token: "expired", expires_at: "2022-01-01" }),
129
+ )
106
130
  .mockImplementation(() => {
107
131
  throw new DOMException("blocked", "SecurityError");
108
132
  });
@@ -115,26 +139,12 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
115
139
  installLocalStorage({ getItem, setItem, removeItem } as Storage);
116
140
  mockAnonymousTokenFetch();
117
141
 
118
- const originalDate = globalThis.Date;
119
- const fixedDate = (originalDate as unknown as () => Date)();
120
- const fixedTime = originalDate.now();
121
- globalThis.Date = Object.assign(
122
- function Date() {
123
- return fixedDate;
124
- },
125
- { now: () => fixedTime },
126
- ) as unknown as DateConstructor;
127
-
128
- try {
129
- await expect(
130
- new AssistantCloudAnonymousAuthStrategy(baseUrl).getAuthHeaders(),
131
- ).resolves.toEqual({ Authorization: `Bearer ${accessToken}` });
132
- await expect(
133
- new AssistantCloudAnonymousAuthStrategy(baseUrl).getAuthHeaders(),
134
- ).resolves.toEqual({ Authorization: `Bearer ${accessToken}` });
135
- } finally {
136
- globalThis.Date = originalDate;
137
- }
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}` });
138
148
  expect(getItem).toHaveBeenCalledTimes(2);
139
149
  expect(setItem).toHaveBeenCalledTimes(2);
140
150
  expect(removeItem).toHaveBeenCalledTimes(1);
@@ -161,3 +171,21 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
161
171
  expect(values.get("aui:refresh_token")).toBe(JSON.stringify(refreshToken));
162
172
  });
163
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"));