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
@@ -39,6 +39,7 @@ export class AssistantCloudJWTAuthStrategy implements AssistantCloudAuthStrategy
39
39
 
40
40
  private cachedToken: string | null = null;
41
41
  private tokenExpiry: number | null = null;
42
+ private tokenRequest: Promise<Record<string, string> | false> | null = null;
42
43
  #authTokenCallback: () => Promise<string | null>;
43
44
 
44
45
  constructor(authTokenCallback: () => Promise<string | null>) {
@@ -57,14 +58,28 @@ export class AssistantCloudJWTAuthStrategy implements AssistantCloudAuthStrategy
57
58
  return { Authorization: `Bearer ${this.cachedToken}` };
58
59
  }
59
60
 
60
- // Fetch a new token via the callback
61
- const newToken = await this.#authTokenCallback();
62
- if (!newToken) return false;
61
+ if (!this.tokenRequest) {
62
+ this.tokenRequest = this.fetchAuthHeaders();
63
+ }
64
+
65
+ const tokenRequest = this.tokenRequest;
66
+ try {
67
+ return await tokenRequest;
68
+ } finally {
69
+ if (this.tokenRequest === tokenRequest) {
70
+ this.tokenRequest = null;
71
+ }
72
+ }
73
+ }
63
74
 
64
- this.cachedToken = newToken;
65
- this.tokenExpiry = getJwtExpiry(newToken);
75
+ private async fetchAuthHeaders(): Promise<Record<string, string> | false> {
76
+ const token = await this.#authTokenCallback();
77
+ if (!token) return false;
66
78
 
67
- return { Authorization: `Bearer ${newToken}` };
79
+ this.cachedToken = token;
80
+ this.tokenExpiry = getJwtExpiry(token);
81
+
82
+ return { Authorization: `Bearer ${token}` };
68
83
  }
69
84
 
70
85
  public readAuthHeaders(headers: Headers) {
@@ -109,6 +124,49 @@ export class AssistantCloudAPIKeyAuthStrategy implements AssistantCloudAuthStrat
109
124
 
110
125
  const AUI_REFRESH_TOKEN_NAME = "aui:refresh_token";
111
126
 
127
+ const getLocalStorage = (): Storage | null => {
128
+ if (!("localStorage" in globalThis)) return null;
129
+ try {
130
+ return (globalThis as { localStorage: Storage }).localStorage;
131
+ } catch {
132
+ return null;
133
+ }
134
+ };
135
+
136
+ const readRefreshToken = ():
137
+ | { token: string; expires_at: string }
138
+ | undefined => {
139
+ const storage = getLocalStorage();
140
+ if (!storage) return undefined;
141
+ try {
142
+ const value = storage.getItem(AUI_REFRESH_TOKEN_NAME);
143
+ return value
144
+ ? (JSON.parse(value) as { token: string; expires_at: string })
145
+ : undefined;
146
+ } catch {
147
+ return undefined;
148
+ }
149
+ };
150
+
151
+ const writeRefreshToken = (refreshToken: {
152
+ token: string;
153
+ expires_at: string;
154
+ }): void => {
155
+ const storage = getLocalStorage();
156
+ if (!storage) return;
157
+ try {
158
+ storage.setItem(AUI_REFRESH_TOKEN_NAME, JSON.stringify(refreshToken));
159
+ } catch {}
160
+ };
161
+
162
+ const removeRefreshToken = (): void => {
163
+ const storage = getLocalStorage();
164
+ if (!storage) return;
165
+ try {
166
+ storage.removeItem(AUI_REFRESH_TOKEN_NAME);
167
+ } catch {}
168
+ };
169
+
112
170
  export class AssistantCloudAnonymousAuthStrategy implements AssistantCloudAuthStrategy {
113
171
  public readonly strategy = "anon";
114
172
 
@@ -119,15 +177,7 @@ export class AssistantCloudAnonymousAuthStrategy implements AssistantCloudAuthSt
119
177
  this.baseUrl = baseUrl;
120
178
  this.jwtStrategy = new AssistantCloudJWTAuthStrategy(async () => {
121
179
  const currentTime = Date.now();
122
- const storedRefreshTokenJson = localStorage.getItem(
123
- AUI_REFRESH_TOKEN_NAME,
124
- );
125
- const storedRefreshToken = storedRefreshTokenJson
126
- ? (JSON.parse(storedRefreshTokenJson) as {
127
- token: string;
128
- expires_at: string;
129
- })
130
- : undefined;
180
+ const storedRefreshToken = readRefreshToken();
131
181
 
132
182
  if (storedRefreshToken) {
133
183
  const refreshExpiry = new Date(storedRefreshToken.expires_at).getTime();
@@ -145,15 +195,12 @@ export class AssistantCloudAnonymousAuthStrategy implements AssistantCloudAuthSt
145
195
  const data = await response.json();
146
196
  const { access_token, refresh_token } = data;
147
197
  if (refresh_token) {
148
- localStorage.setItem(
149
- AUI_REFRESH_TOKEN_NAME,
150
- JSON.stringify(refresh_token),
151
- );
198
+ writeRefreshToken(refresh_token);
152
199
  }
153
200
  return access_token;
154
201
  }
155
202
  } else {
156
- localStorage.removeItem(AUI_REFRESH_TOKEN_NAME);
203
+ removeRefreshToken();
157
204
  }
158
205
  }
159
206
 
@@ -169,10 +216,7 @@ export class AssistantCloudAnonymousAuthStrategy implements AssistantCloudAuthSt
169
216
 
170
217
  if (!access_token || !refresh_token) return null;
171
218
 
172
- localStorage.setItem(
173
- AUI_REFRESH_TOKEN_NAME,
174
- JSON.stringify(refresh_token),
175
- );
219
+ writeRefreshToken(refresh_token);
176
220
  return access_token;
177
221
  });
178
222
  }
@@ -0,0 +1,40 @@
1
+ import type { AssistantCloudAPI } from "./AssistantCloudAPI";
2
+ import {
3
+ decodeCloudMessage,
4
+ type CloudMessage,
5
+ } from "./AssistantCloudThreadMessages";
6
+ import { readCloudArray, readCloudRecord } from "./cloudResponse";
7
+
8
+ type AssistantCloudProjectThreadMessageListQuery = {
9
+ format?: string;
10
+ limit?: number;
11
+ after?: string;
12
+ };
13
+
14
+ type AssistantCloudProjectThreadMessageListResponse = {
15
+ messages: CloudMessage[];
16
+ };
17
+
18
+ export class AssistantCloudProjectThreadMessages {
19
+ constructor(private cloud: AssistantCloudAPI) {}
20
+
21
+ public async list(
22
+ threadId: string,
23
+ query?: AssistantCloudProjectThreadMessageListQuery,
24
+ ): Promise<AssistantCloudProjectThreadMessageListResponse> {
25
+ const response = readCloudRecord(
26
+ await this.cloud.makeRequest(
27
+ `/projects/threads/${encodeURIComponent(threadId)}/messages`,
28
+ { query },
29
+ ),
30
+ "project thread message list response",
31
+ );
32
+ const messages = readCloudArray(response.messages, "messages");
33
+
34
+ return {
35
+ messages: messages.map((message, index) =>
36
+ decodeCloudMessage(message, `messages[${index}]`),
37
+ ),
38
+ };
39
+ }
40
+ }
@@ -0,0 +1,47 @@
1
+ import type { AssistantCloudAPI } from "./AssistantCloudAPI";
2
+ import { AssistantCloudProjectThreadMessages } from "./AssistantCloudProjectThreadMessages";
3
+ import { decodeCloudThread, type CloudThread } from "./AssistantCloudThreads";
4
+ import { readCloudArray, readCloudRecord } from "./cloudResponse";
5
+
6
+ type AssistantCloudProjectThreadsListQuery = {
7
+ is_archived?: boolean;
8
+ limit?: number;
9
+ after?: string;
10
+ };
11
+
12
+ type AssistantCloudProjectThreadsListResponse = {
13
+ threads: CloudThread[];
14
+ };
15
+
16
+ export class AssistantCloudProjectThreads {
17
+ public readonly messages: AssistantCloudProjectThreadMessages;
18
+
19
+ constructor(private cloud: AssistantCloudAPI) {
20
+ this.messages = new AssistantCloudProjectThreadMessages(cloud);
21
+ }
22
+
23
+ public async list(
24
+ query?: AssistantCloudProjectThreadsListQuery,
25
+ ): Promise<AssistantCloudProjectThreadsListResponse> {
26
+ const response = readCloudRecord(
27
+ await this.cloud.makeRequest("/projects/threads", {
28
+ query: {
29
+ ...query,
30
+ // The shared query serializer drops `false`; the wire accepts the
31
+ // string form, so the archive filter survives explicitly.
32
+ ...(query?.is_archived !== undefined
33
+ ? { is_archived: String(query.is_archived) }
34
+ : {}),
35
+ },
36
+ }),
37
+ "project thread list response",
38
+ );
39
+ const threads = readCloudArray(response.threads, "threads");
40
+
41
+ return {
42
+ threads: threads.map((thread, index) =>
43
+ decodeCloudThread(thread, `threads[${index}]`),
44
+ ),
45
+ };
46
+ }
47
+ }
@@ -0,0 +1,10 @@
1
+ import type { AssistantCloudAPI } from "./AssistantCloudAPI";
2
+ import { AssistantCloudProjectThreads } from "./AssistantCloudProjectThreads";
3
+
4
+ export class AssistantCloudProjects {
5
+ public readonly threads: AssistantCloudProjectThreads;
6
+
7
+ constructor(cloud: AssistantCloudAPI) {
8
+ this.threads = new AssistantCloudProjectThreads(cloud);
9
+ }
10
+ }
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { AssistantCloudAPI } from "./AssistantCloudAPI";
3
+ import { AssistantCloudThreadMessages } from "./AssistantCloudThreadMessages";
4
+
5
+ const createCloudThreadMessages = () => {
6
+ const makeRequest = vi.fn();
7
+ const api = { makeRequest } as unknown as AssistantCloudAPI;
8
+ return {
9
+ messages: new AssistantCloudThreadMessages(api),
10
+ makeRequest,
11
+ };
12
+ };
13
+
14
+ describe("AssistantCloudThreadMessages responses", () => {
15
+ it("decodes canonical message responses without changing content", async () => {
16
+ const { messages, makeRequest } = createCloudThreadMessages();
17
+ makeRequest.mockResolvedValue({
18
+ messages: [
19
+ {
20
+ id: "message-1",
21
+ parent_id: null,
22
+ height: 0,
23
+ created_at: "2026-07-16T13:00:00.000Z",
24
+ updated_at: "2026-07-16T13:05:00.987Z",
25
+ format: "aui/v0",
26
+ content: { created_at: "leave-this-string-untouched" },
27
+ },
28
+ ],
29
+ });
30
+
31
+ const result = await messages.list("thread-1");
32
+ const message = result.messages[0]!;
33
+
34
+ expect(message.created_at).toBeInstanceOf(Date);
35
+ expect(message.updated_at).toBeInstanceOf(Date);
36
+ expect(message.updated_at.toISOString()).toBe("2026-07-16T13:05:00.987Z");
37
+ expect(message.content).toEqual({
38
+ created_at: "leave-this-string-untouched",
39
+ });
40
+ });
41
+ });
@@ -1,5 +1,14 @@
1
1
  import type { ReadonlyJSONObject } from "assistant-stream/utils";
2
2
  import type { AssistantCloudAPI } from "./AssistantCloudAPI";
3
+ import {
4
+ readCloudArray,
5
+ readCloudInteger,
6
+ readCloudJSONObject,
7
+ readCloudNullableString,
8
+ readCloudRecord,
9
+ readCloudString,
10
+ readCloudTimestamp,
11
+ } from "./cloudResponse";
3
12
 
4
13
  export type CloudMessage = {
5
14
  id: string;
@@ -33,6 +42,22 @@ type AssistantCloudThreadMessageUpdateBody = {
33
42
  content: ReadonlyJSONObject;
34
43
  };
35
44
 
45
+ export const decodeCloudMessage = (
46
+ value: unknown,
47
+ field: string,
48
+ ): CloudMessage => {
49
+ const message = readCloudRecord(value, field);
50
+ return {
51
+ id: readCloudString(message.id, `${field}.id`),
52
+ parent_id: readCloudNullableString(message.parent_id, `${field}.parent_id`),
53
+ height: readCloudInteger(message.height, `${field}.height`),
54
+ created_at: readCloudTimestamp(message.created_at, `${field}.created_at`),
55
+ updated_at: readCloudTimestamp(message.updated_at, `${field}.updated_at`),
56
+ format: readCloudString(message.format, `${field}.format`),
57
+ content: readCloudJSONObject(message.content, `${field}.content`),
58
+ };
59
+ };
60
+
36
61
  export class AssistantCloudThreadMessages {
37
62
  constructor(private cloud: AssistantCloudAPI) {}
38
63
 
@@ -40,10 +65,20 @@ export class AssistantCloudThreadMessages {
40
65
  threadId: string,
41
66
  query?: AssistantCloudThreadMessageListQuery,
42
67
  ): Promise<AssistantCloudThreadMessageListResponse> {
43
- return this.cloud.makeRequest(
44
- `/threads/${encodeURIComponent(threadId)}/messages`,
45
- { query },
68
+ const response = readCloudRecord(
69
+ await this.cloud.makeRequest(
70
+ `/threads/${encodeURIComponent(threadId)}/messages`,
71
+ { query },
72
+ ),
73
+ "thread message list response",
46
74
  );
75
+ const messages = readCloudArray(response.messages, "messages");
76
+
77
+ return {
78
+ messages: messages.map((message, index) =>
79
+ decodeCloudMessage(message, `messages[${index}]`),
80
+ ),
81
+ };
47
82
  }
48
83
 
49
84
  public async create(
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { AssistantCloudAPI } from "./AssistantCloudAPI";
3
+ import { AssistantCloudThreads } from "./AssistantCloudThreads";
4
+
5
+ const createCloudThreads = () => {
6
+ const makeRequest = vi.fn();
7
+ const api = { makeRequest } as unknown as AssistantCloudAPI;
8
+ return { threads: new AssistantCloudThreads(api), makeRequest };
9
+ };
10
+
11
+ const threadResponse = {
12
+ title: null,
13
+ last_message_at: "2026-07-16T12:30:00.000Z",
14
+ metadata: { created_at: "leave-this-string-untouched" },
15
+ external_id: null,
16
+ id: "thread-1",
17
+ project_id: "project-1",
18
+ created_at: "2026-07-16T12:00:00.000Z",
19
+ updated_at: "2026-07-16T12:15:00.123Z",
20
+ workspace_id: "workspace-1",
21
+ is_archived: false,
22
+ };
23
+
24
+ describe("AssistantCloudThreads responses", () => {
25
+ it("decodes canonical thread list responses", async () => {
26
+ const { threads, makeRequest } = createCloudThreads();
27
+ makeRequest.mockResolvedValue({ threads: [threadResponse] });
28
+
29
+ const result = await threads.list();
30
+ const thread = result.threads[0]!;
31
+
32
+ expect(thread.created_at).toBeInstanceOf(Date);
33
+ expect(thread.updated_at).toBeInstanceOf(Date);
34
+ expect(thread.last_message_at).toBeInstanceOf(Date);
35
+ expect(thread.updated_at.toISOString()).toBe("2026-07-16T12:15:00.123Z");
36
+ expect(thread.title).toBe("");
37
+ expect(thread.metadata).toEqual({
38
+ created_at: "leave-this-string-untouched",
39
+ });
40
+ });
41
+
42
+ it("unwraps and decodes a single thread response", async () => {
43
+ const { threads, makeRequest } = createCloudThreads();
44
+ makeRequest.mockResolvedValue({ thread: threadResponse });
45
+
46
+ const result = await threads.get("thread-1");
47
+
48
+ expect(result.created_at).toBeInstanceOf(Date);
49
+ expect(result.created_at.toISOString()).toBe("2026-07-16T12:00:00.000Z");
50
+ });
51
+
52
+ it("rejects a bare thread that is missing the response envelope", async () => {
53
+ const { threads, makeRequest } = createCloudThreads();
54
+ makeRequest.mockResolvedValue(threadResponse);
55
+
56
+ await expect(threads.get("thread-1")).rejects.toThrow(
57
+ 'Invalid Assistant Cloud response for "thread": expected an object',
58
+ );
59
+ });
60
+
61
+ it("rejects genuinely invalid response timestamps with field context", async () => {
62
+ const { threads, makeRequest } = createCloudThreads();
63
+ makeRequest.mockResolvedValue({
64
+ thread: {
65
+ ...threadResponse,
66
+ updated_at: "not-a-timestamp",
67
+ },
68
+ });
69
+
70
+ await expect(threads.get("thread-1")).rejects.toThrow(
71
+ 'Invalid Assistant Cloud response for "thread.updated_at": expected a canonical ISO timestamp',
72
+ );
73
+ });
74
+ });
@@ -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,5 +1,7 @@
1
1
  export type { CloudMessage } from "./AssistantCloudThreadMessages";
2
2
  export type { AssistantCloudTelemetryConfig } from "./AssistantCloudAPI";
3
+ export { CloudAPIError } from "./AssistantCloudAPI";
4
+ export { CloudResponseError } from "./cloudResponse";
3
5
  export type { AssistantCloudRunReport } from "./AssistantCloudRuns";
4
6
  export { AssistantCloud } from "./AssistantCloud";
5
7
  export { CloudMessagePersistence } from "./CloudMessagePersistence";