assistant-cloud 0.1.42 → 0.1.43

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.
@@ -1 +1 @@
1
- {"version":3,"file":"AssistantCloudAuthStrategy.d.ts","names":[],"sources":["../src/AssistantCloudAuthStrategy.ts"],"mappings":";KAMY;WACD;EACT,kBAAkB,QAAQ;EAC1B,gBAAgB,SAAS;;cAmFd,yCAAyC;;WACpC;UAER;UACA;UACA;EAGI,YAAA,yBAAyB;EAIxB,kBAAkB,QAAQ;UA0BzB;EAUP,gBAAgB,SAAS;;cAcrB,4CAA4C;;WACvC;EAMJ,YAAA,gBAAgB,gBAAgB;EAM/B,kBAAkB,QAAQ;EAQhC;;cA0EI,+CAA+C;WAC1C;UAER;UACA;EAEI,YAAA;EAoEC,kBAAkB,QAAQ;EAIhC,gBAAgB,SAAS"}
1
+ {"version":3,"file":"AssistantCloudAuthStrategy.d.ts","names":[],"sources":["../src/AssistantCloudAuthStrategy.ts"],"mappings":";KAkCY;WACD;EACT,kBAAkB,QAAQ;EAC1B,gBAAgB,SAAS;;cAmFd,yCAAyC;;WACpC;UAER;UACA;UACA;EAGI,YAAA,yBAAyB;EAIxB,kBAAkB,QAAQ;UA0BzB;EAUP,gBAAgB,SAAS;;cAcrB,4CAA4C;;WACvC;EAMJ,YAAA,gBAAgB,gBAAgB;EAM/B,kBAAkB,QAAQ;EAQhC;;cAwHI,+CAA+C;WAC1C;UAER;UACA;EAEI,YAAA;EAwFC,kBAAkB,QAAQ;EAIhC,gBAAgB,SAAS"}
@@ -1,5 +1,22 @@
1
1
  import { CloudResponseError, readCloudRecord, readCloudString } from "./cloudResponse.js";
2
2
  //#region src/AssistantCloudAuthStrategy.ts
3
+ const AUTH_TOKEN_REQUEST_TIMEOUT_MS = 3e4;
4
+ const withAuthTokenDeadline = async (operation, run) => {
5
+ const controller = new AbortController();
6
+ let timedOut = false;
7
+ const timeout = setTimeout(() => {
8
+ timedOut = true;
9
+ controller.abort();
10
+ }, AUTH_TOKEN_REQUEST_TIMEOUT_MS);
11
+ try {
12
+ return await run(controller.signal);
13
+ } catch (error) {
14
+ if (timedOut) throw new Error(`Assistant Cloud ${operation} timed out after ${AUTH_TOKEN_REQUEST_TIMEOUT_MS}ms`, { cause: error });
15
+ throw error;
16
+ } finally {
17
+ clearTimeout(timeout);
18
+ }
19
+ };
3
20
  const getJwtExpiry = (jwt) => {
4
21
  try {
5
22
  const bodyPart = jwt.split(".")[1];
@@ -149,36 +166,70 @@ const removeRefreshToken = (baseUrl) => {
149
166
  storage.removeItem(getRefreshTokenName(baseUrl));
150
167
  } catch {}
151
168
  };
169
+ const anonymousAuthTokenRequests = /* @__PURE__ */ new WeakMap();
170
+ const getWebLockManager = () => {
171
+ if (!("navigator" in globalThis)) return null;
172
+ return globalThis.navigator?.locks ?? null;
173
+ };
174
+ const getAnonymousAuthLockName = (baseUrl) => `assistant-cloud:anonymous-auth:${baseUrl}`;
175
+ const getSharedAnonymousAuthToken = (baseUrl, requestToken) => {
176
+ const storage = getLocalStorage();
177
+ if (!storage) return requestToken();
178
+ let storageRequests = anonymousAuthTokenRequests.get(storage);
179
+ if (!storageRequests) {
180
+ storageRequests = /* @__PURE__ */ new Map();
181
+ anonymousAuthTokenRequests.set(storage, storageRequests);
182
+ }
183
+ const activeRequest = storageRequests.get(baseUrl);
184
+ if (activeRequest) return activeRequest;
185
+ const locks = getWebLockManager();
186
+ const sharedRequest = (locks ? locks.request(getAnonymousAuthLockName(baseUrl), requestToken) : requestToken()).finally(() => {
187
+ if (storageRequests.get(baseUrl) === sharedRequest) storageRequests.delete(baseUrl);
188
+ });
189
+ storageRequests.set(baseUrl, sharedRequest);
190
+ return sharedRequest;
191
+ };
152
192
  var AssistantCloudAnonymousAuthStrategy = class {
153
193
  strategy = "anon";
154
194
  baseUrl;
155
195
  jwtStrategy;
156
196
  constructor(baseUrl) {
157
197
  this.baseUrl = baseUrl;
158
- this.jwtStrategy = new AssistantCloudJWTAuthStrategy(async () => {
198
+ const requestAuthToken = async () => {
159
199
  const currentTime = Date.now();
160
200
  const storedRefreshToken = readRefreshToken(this.baseUrl);
161
201
  if (storedRefreshToken) {
162
202
  if (new Date(storedRefreshToken.expires_at).getTime() - currentTime > 3e4) {
163
- const response = await fetch(`${this.baseUrl}/v1/auth/tokens/refresh`, {
164
- method: "POST",
165
- headers: { "Content-Type": "application/json" },
166
- body: JSON.stringify({ refresh_token: storedRefreshToken.token })
203
+ const refreshedAccessToken = await withAuthTokenDeadline("refresh token request", async (signal) => {
204
+ const response = await fetch(`${this.baseUrl}/v1/auth/tokens/refresh`, {
205
+ method: "POST",
206
+ headers: { "Content-Type": "application/json" },
207
+ body: JSON.stringify({ refresh_token: storedRefreshToken.token }),
208
+ signal
209
+ });
210
+ if (response.ok) {
211
+ const { data, accessToken } = await readAuthTokenResponse(response, "refresh auth token response");
212
+ if (data.refresh_token != null) writeRefreshToken(this.baseUrl, readRefreshTokenResponse(data.refresh_token, "refresh auth token response.refresh_token"));
213
+ return accessToken;
214
+ }
215
+ if (response.status === 429 || response.status >= 500) throw new Error(`Assistant Cloud token refresh failed with status ${response.status}`);
216
+ return null;
167
217
  });
168
- if (response.ok) {
169
- const { data, accessToken } = await readAuthTokenResponse(response, "refresh auth token response");
170
- if (data.refresh_token != null) writeRefreshToken(this.baseUrl, readRefreshTokenResponse(data.refresh_token, "refresh auth token response.refresh_token"));
171
- return accessToken;
172
- }
173
- if (response.status === 429 || response.status >= 500) throw new Error(`Assistant Cloud token refresh failed with status ${response.status}`);
218
+ if (refreshedAccessToken !== null) return refreshedAccessToken;
174
219
  } else removeRefreshToken(this.baseUrl);
175
220
  }
176
- const response = await fetch(`${this.baseUrl}/v1/auth/tokens/anonymous`, { method: "POST" });
177
- if (!response.ok) return null;
178
- const { data, accessToken } = await readAuthTokenResponse(response, "anonymous auth token response");
179
- writeRefreshToken(this.baseUrl, readRefreshTokenResponse(data.refresh_token, "anonymous auth token response.refresh_token"));
180
- return accessToken;
181
- });
221
+ return withAuthTokenDeadline("anonymous token request", async (signal) => {
222
+ const response = await fetch(`${this.baseUrl}/v1/auth/tokens/anonymous`, {
223
+ method: "POST",
224
+ signal
225
+ });
226
+ if (!response.ok) return null;
227
+ const { data, accessToken } = await readAuthTokenResponse(response, "anonymous auth token response");
228
+ writeRefreshToken(this.baseUrl, readRefreshTokenResponse(data.refresh_token, "anonymous auth token response.refresh_token"));
229
+ return accessToken;
230
+ });
231
+ };
232
+ this.jwtStrategy = new AssistantCloudJWTAuthStrategy(() => getSharedAnonymousAuthToken(this.baseUrl, requestAuthToken));
182
233
  }
183
234
  async getAuthHeaders() {
184
235
  return this.jwtStrategy.getAuthHeaders();
@@ -1 +1 @@
1
- {"version":3,"file":"AssistantCloudAuthStrategy.js","names":["#authTokenCallback","#apiKey","#userId","#workspaceId"],"sources":["../src/AssistantCloudAuthStrategy.ts"],"sourcesContent":["import {\n CloudResponseError,\n readCloudRecord,\n readCloudString,\n} from \"./cloudResponse\";\n\nexport type AssistantCloudAuthStrategy = {\n readonly strategy: \"anon\" | \"jwt\" | \"api-key\";\n getAuthHeaders(): Promise<Record<string, string> | false>;\n readAuthHeaders(headers: Headers): void;\n};\n\nconst getJwtExpiry = (jwt: string): number => {\n try {\n const parts = jwt.split(\".\");\n const bodyPart = parts[1];\n if (!bodyPart) {\n throw new Error(\"Invalid JWT format\");\n }\n\n // Convert from Base64Url to Base64 and add padding if necessary\n let base64 = bodyPart.replace(/-/g, \"+\").replace(/_/g, \"/\");\n while (base64.length % 4 !== 0) {\n base64 += \"=\";\n }\n\n // Decode the Base64 string and parse the payload\n const payload = atob(base64);\n const payloadObj = JSON.parse(payload);\n const exp = payloadObj.exp;\n\n if (!exp || typeof exp !== \"number\") {\n throw new Error('JWT does not contain a valid \"exp\" field');\n }\n\n // Convert expiration time to milliseconds\n return exp * 1000;\n } catch (error) {\n throw new Error(`Unable to determine the token expiry: ${error}`);\n }\n};\n\ntype RefreshToken = {\n token: string;\n expires_at: string;\n};\n\nconst readNonEmptyCloudString = (value: unknown, field: string): string => {\n const result = readCloudString(value, field);\n if (result.length === 0) {\n throw new CloudResponseError(\n `Invalid Assistant Cloud response for \"${field}\": expected a non-empty string`,\n );\n }\n return result;\n};\n\nconst readRefreshTokenResponse = (\n value: unknown,\n field: string,\n): RefreshToken => {\n const refreshToken = readCloudRecord(value, field);\n return {\n token: readNonEmptyCloudString(refreshToken.token, `${field}.token`),\n expires_at: readNonEmptyCloudString(\n refreshToken.expires_at,\n `${field}.expires_at`,\n ),\n };\n};\n\nconst readAuthTokenResponse = async (\n response: Response,\n field: string,\n): Promise<{ data: Record<string, unknown>; accessToken: string }> => {\n let value: unknown;\n try {\n value = await response.json();\n } catch {\n throw new CloudResponseError(\n `Invalid Assistant Cloud response for \"${field}\": expected valid JSON`,\n );\n }\n\n const data = readCloudRecord(value, field);\n const accessToken = readNonEmptyCloudString(\n data.access_token,\n `${field}.access_token`,\n );\n return { data, accessToken };\n};\n\nexport class AssistantCloudJWTAuthStrategy implements AssistantCloudAuthStrategy {\n public readonly strategy = \"jwt\";\n\n private cachedToken: string | null = null;\n private tokenExpiry: number | null = null;\n private tokenRequest: Promise<Record<string, string> | false> | null = null;\n #authTokenCallback: () => Promise<string | null>;\n\n constructor(authTokenCallback: () => Promise<string | null>) {\n this.#authTokenCallback = authTokenCallback;\n }\n\n public async getAuthHeaders(): Promise<Record<string, string> | false> {\n const currentTime = Date.now();\n\n // Use cached token if it's valid for at least 30 more seconds\n if (\n this.cachedToken &&\n this.tokenExpiry &&\n this.tokenExpiry - currentTime > 30 * 1000\n ) {\n return { Authorization: `Bearer ${this.cachedToken}` };\n }\n\n if (!this.tokenRequest) {\n this.tokenRequest = this.fetchAuthHeaders();\n }\n\n const tokenRequest = this.tokenRequest;\n try {\n return await tokenRequest;\n } finally {\n if (this.tokenRequest === tokenRequest) {\n this.tokenRequest = null;\n }\n }\n }\n\n private async fetchAuthHeaders(): Promise<Record<string, string> | false> {\n const token = await this.#authTokenCallback();\n if (!token) return false;\n\n this.cachedToken = token;\n this.tokenExpiry = getJwtExpiry(token);\n\n return { Authorization: `Bearer ${token}` };\n }\n\n public readAuthHeaders(headers: Headers) {\n const authHeader = headers.get(\"Authorization\");\n if (!authHeader) return;\n\n const [scheme, token] = authHeader.split(\" \");\n if (scheme !== \"Bearer\" || !token) {\n throw new Error(\"Invalid auth header received\");\n }\n\n this.cachedToken = token;\n this.tokenExpiry = getJwtExpiry(token);\n }\n}\n\nexport class AssistantCloudAPIKeyAuthStrategy implements AssistantCloudAuthStrategy {\n public readonly strategy = \"api-key\";\n\n #apiKey: string;\n #userId: string;\n #workspaceId: string;\n\n constructor(apiKey: string, userId: string, workspaceId: string) {\n this.#apiKey = apiKey;\n this.#userId = userId;\n this.#workspaceId = workspaceId;\n }\n\n public async getAuthHeaders(): Promise<Record<string, string>> {\n return {\n Authorization: `Bearer ${this.#apiKey}`,\n \"Aui-User-Id\": this.#userId,\n \"Aui-Workspace-Id\": this.#workspaceId,\n };\n }\n\n public readAuthHeaders() {\n // No operation needed for API key auth\n }\n}\n\nconst LEGACY_AUI_REFRESH_TOKEN_NAME = \"aui:refresh_token\";\n\nconst getRefreshTokenName = (baseUrl: string): string =>\n `${LEGACY_AUI_REFRESH_TOKEN_NAME}:${baseUrl}`;\n\nconst removeLegacyRefreshToken = (storage: Storage): void => {\n try {\n storage.removeItem(LEGACY_AUI_REFRESH_TOKEN_NAME);\n } catch {}\n};\n\nconst getLocalStorage = (): Storage | null => {\n if (!(\"localStorage\" in globalThis)) return null;\n try {\n return (globalThis as { localStorage: Storage }).localStorage;\n } catch {\n return null;\n }\n};\n\nconst readRefreshToken = (baseUrl: string): RefreshToken | undefined => {\n const storage = getLocalStorage();\n if (!storage) return undefined;\n try {\n const name = getRefreshTokenName(baseUrl);\n const value = storage.getItem(name);\n if (value) {\n removeLegacyRefreshToken(storage);\n return JSON.parse(value) as RefreshToken;\n }\n\n const legacyValue = storage.getItem(LEGACY_AUI_REFRESH_TOKEN_NAME);\n if (!legacyValue) return undefined;\n\n let refreshToken: RefreshToken;\n try {\n refreshToken = JSON.parse(legacyValue) as RefreshToken;\n } catch {\n removeLegacyRefreshToken(storage);\n return undefined;\n }\n\n storage.setItem(name, legacyValue);\n removeLegacyRefreshToken(storage);\n return refreshToken;\n } catch {\n return undefined;\n }\n};\n\nconst writeRefreshToken = (\n baseUrl: string,\n refreshToken: RefreshToken,\n): void => {\n const storage = getLocalStorage();\n if (!storage) return;\n try {\n storage.setItem(getRefreshTokenName(baseUrl), JSON.stringify(refreshToken));\n } catch {}\n};\n\nconst removeRefreshToken = (baseUrl: string): void => {\n const storage = getLocalStorage();\n if (!storage) return;\n try {\n storage.removeItem(getRefreshTokenName(baseUrl));\n } catch {}\n};\n\nexport class AssistantCloudAnonymousAuthStrategy implements AssistantCloudAuthStrategy {\n public readonly strategy = \"anon\";\n\n private baseUrl: string;\n private jwtStrategy: AssistantCloudJWTAuthStrategy;\n\n constructor(baseUrl: string) {\n this.baseUrl = baseUrl;\n this.jwtStrategy = new AssistantCloudJWTAuthStrategy(async () => {\n const currentTime = Date.now();\n const storedRefreshToken = readRefreshToken(this.baseUrl);\n\n if (storedRefreshToken) {\n const refreshExpiry = new Date(storedRefreshToken.expires_at).getTime();\n if (refreshExpiry - currentTime > 30 * 1000) {\n const response = await fetch(\n `${this.baseUrl}/v1/auth/tokens/refresh`,\n {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ refresh_token: storedRefreshToken.token }),\n },\n );\n\n if (response.ok) {\n const { data, accessToken } = await readAuthTokenResponse(\n response,\n \"refresh auth token response\",\n );\n if (data.refresh_token != null) {\n writeRefreshToken(\n this.baseUrl,\n readRefreshTokenResponse(\n data.refresh_token,\n \"refresh auth token response.refresh_token\",\n ),\n );\n }\n return accessToken;\n }\n\n if (response.status === 429 || response.status >= 500) {\n throw new Error(\n `Assistant Cloud token refresh failed with status ${response.status}`,\n );\n }\n } else {\n removeRefreshToken(this.baseUrl);\n }\n }\n\n // No valid refresh token; request a new anonymous token\n const response = await fetch(`${this.baseUrl}/v1/auth/tokens/anonymous`, {\n method: \"POST\",\n });\n\n if (!response.ok) return null;\n\n const { data, accessToken } = await readAuthTokenResponse(\n response,\n \"anonymous auth token response\",\n );\n\n writeRefreshToken(\n this.baseUrl,\n readRefreshTokenResponse(\n data.refresh_token,\n \"anonymous auth token response.refresh_token\",\n ),\n );\n return accessToken;\n });\n }\n\n public async getAuthHeaders(): Promise<Record<string, string> | false> {\n return this.jwtStrategy.getAuthHeaders();\n }\n\n public readAuthHeaders(headers: Headers): void {\n this.jwtStrategy.readAuthHeaders(headers);\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"AssistantCloudAuthStrategy.js","names":[],"sources":["../src/AssistantCloudAuthStrategy.ts"],"sourcesContent":["import {\n CloudResponseError,\n readCloudRecord,\n readCloudString,\n} from \"./cloudResponse\";\n\nconst AUTH_TOKEN_REQUEST_TIMEOUT_MS = 30_000;\n\nconst withAuthTokenDeadline = async <T>(\n operation: string,\n run: (signal: AbortSignal) => Promise<T>,\n): Promise<T> => {\n const controller = new AbortController();\n let timedOut = false;\n const timeout = setTimeout(() => {\n timedOut = true;\n controller.abort();\n }, AUTH_TOKEN_REQUEST_TIMEOUT_MS);\n\n try {\n return await run(controller.signal);\n } catch (error) {\n if (timedOut) {\n throw new Error(\n `Assistant Cloud ${operation} timed out after ${AUTH_TOKEN_REQUEST_TIMEOUT_MS}ms`,\n { cause: error },\n );\n }\n throw error;\n } finally {\n clearTimeout(timeout);\n }\n};\n\nexport type AssistantCloudAuthStrategy = {\n readonly strategy: \"anon\" | \"jwt\" | \"api-key\";\n getAuthHeaders(): Promise<Record<string, string> | false>;\n readAuthHeaders(headers: Headers): void;\n};\n\nconst getJwtExpiry = (jwt: string): number => {\n try {\n const parts = jwt.split(\".\");\n const bodyPart = parts[1];\n if (!bodyPart) {\n throw new Error(\"Invalid JWT format\");\n }\n\n // Convert from Base64Url to Base64 and add padding if necessary\n let base64 = bodyPart.replace(/-/g, \"+\").replace(/_/g, \"/\");\n while (base64.length % 4 !== 0) {\n base64 += \"=\";\n }\n\n // Decode the Base64 string and parse the payload\n const payload = atob(base64);\n const payloadObj = JSON.parse(payload);\n const exp = payloadObj.exp;\n\n if (!exp || typeof exp !== \"number\") {\n throw new Error('JWT does not contain a valid \"exp\" field');\n }\n\n // Convert expiration time to milliseconds\n return exp * 1000;\n } catch (error) {\n throw new Error(`Unable to determine the token expiry: ${error}`);\n }\n};\n\ntype RefreshToken = {\n token: string;\n expires_at: string;\n};\n\nconst readNonEmptyCloudString = (value: unknown, field: string): string => {\n const result = readCloudString(value, field);\n if (result.length === 0) {\n throw new CloudResponseError(\n `Invalid Assistant Cloud response for \"${field}\": expected a non-empty string`,\n );\n }\n return result;\n};\n\nconst readRefreshTokenResponse = (\n value: unknown,\n field: string,\n): RefreshToken => {\n const refreshToken = readCloudRecord(value, field);\n return {\n token: readNonEmptyCloudString(refreshToken.token, `${field}.token`),\n expires_at: readNonEmptyCloudString(\n refreshToken.expires_at,\n `${field}.expires_at`,\n ),\n };\n};\n\nconst readAuthTokenResponse = async (\n response: Response,\n field: string,\n): Promise<{ data: Record<string, unknown>; accessToken: string }> => {\n let value: unknown;\n try {\n value = await response.json();\n } catch {\n throw new CloudResponseError(\n `Invalid Assistant Cloud response for \"${field}\": expected valid JSON`,\n );\n }\n\n const data = readCloudRecord(value, field);\n const accessToken = readNonEmptyCloudString(\n data.access_token,\n `${field}.access_token`,\n );\n return { data, accessToken };\n};\n\nexport class AssistantCloudJWTAuthStrategy implements AssistantCloudAuthStrategy {\n public readonly strategy = \"jwt\";\n\n private cachedToken: string | null = null;\n private tokenExpiry: number | null = null;\n private tokenRequest: Promise<Record<string, string> | false> | null = null;\n #authTokenCallback: () => Promise<string | null>;\n\n constructor(authTokenCallback: () => Promise<string | null>) {\n this.#authTokenCallback = authTokenCallback;\n }\n\n public async getAuthHeaders(): Promise<Record<string, string> | false> {\n const currentTime = Date.now();\n\n // Use cached token if it's valid for at least 30 more seconds\n if (\n this.cachedToken &&\n this.tokenExpiry &&\n this.tokenExpiry - currentTime > 30 * 1000\n ) {\n return { Authorization: `Bearer ${this.cachedToken}` };\n }\n\n if (!this.tokenRequest) {\n this.tokenRequest = this.fetchAuthHeaders();\n }\n\n const tokenRequest = this.tokenRequest;\n try {\n return await tokenRequest;\n } finally {\n if (this.tokenRequest === tokenRequest) {\n this.tokenRequest = null;\n }\n }\n }\n\n private async fetchAuthHeaders(): Promise<Record<string, string> | false> {\n const token = await this.#authTokenCallback();\n if (!token) return false;\n\n this.cachedToken = token;\n this.tokenExpiry = getJwtExpiry(token);\n\n return { Authorization: `Bearer ${token}` };\n }\n\n public readAuthHeaders(headers: Headers) {\n const authHeader = headers.get(\"Authorization\");\n if (!authHeader) return;\n\n const [scheme, token] = authHeader.split(\" \");\n if (scheme !== \"Bearer\" || !token) {\n throw new Error(\"Invalid auth header received\");\n }\n\n this.cachedToken = token;\n this.tokenExpiry = getJwtExpiry(token);\n }\n}\n\nexport class AssistantCloudAPIKeyAuthStrategy implements AssistantCloudAuthStrategy {\n public readonly strategy = \"api-key\";\n\n #apiKey: string;\n #userId: string;\n #workspaceId: string;\n\n constructor(apiKey: string, userId: string, workspaceId: string) {\n this.#apiKey = apiKey;\n this.#userId = userId;\n this.#workspaceId = workspaceId;\n }\n\n public async getAuthHeaders(): Promise<Record<string, string>> {\n return {\n Authorization: `Bearer ${this.#apiKey}`,\n \"Aui-User-Id\": this.#userId,\n \"Aui-Workspace-Id\": this.#workspaceId,\n };\n }\n\n public readAuthHeaders() {\n // No operation needed for API key auth\n }\n}\n\nconst LEGACY_AUI_REFRESH_TOKEN_NAME = \"aui:refresh_token\";\n\nconst getRefreshTokenName = (baseUrl: string): string =>\n `${LEGACY_AUI_REFRESH_TOKEN_NAME}:${baseUrl}`;\n\nconst removeLegacyRefreshToken = (storage: Storage): void => {\n try {\n storage.removeItem(LEGACY_AUI_REFRESH_TOKEN_NAME);\n } catch {}\n};\n\nconst getLocalStorage = (): Storage | null => {\n if (!(\"localStorage\" in globalThis)) return null;\n try {\n return (globalThis as { localStorage: Storage }).localStorage;\n } catch {\n return null;\n }\n};\n\nconst readRefreshToken = (baseUrl: string): RefreshToken | undefined => {\n const storage = getLocalStorage();\n if (!storage) return undefined;\n try {\n const name = getRefreshTokenName(baseUrl);\n const value = storage.getItem(name);\n if (value) {\n removeLegacyRefreshToken(storage);\n return JSON.parse(value) as RefreshToken;\n }\n\n const legacyValue = storage.getItem(LEGACY_AUI_REFRESH_TOKEN_NAME);\n if (!legacyValue) return undefined;\n\n let refreshToken: RefreshToken;\n try {\n refreshToken = JSON.parse(legacyValue) as RefreshToken;\n } catch {\n removeLegacyRefreshToken(storage);\n return undefined;\n }\n\n storage.setItem(name, legacyValue);\n removeLegacyRefreshToken(storage);\n return refreshToken;\n } catch {\n return undefined;\n }\n};\n\nconst writeRefreshToken = (\n baseUrl: string,\n refreshToken: RefreshToken,\n): void => {\n const storage = getLocalStorage();\n if (!storage) return;\n try {\n storage.setItem(getRefreshTokenName(baseUrl), JSON.stringify(refreshToken));\n } catch {}\n};\n\nconst removeRefreshToken = (baseUrl: string): void => {\n const storage = getLocalStorage();\n if (!storage) return;\n try {\n storage.removeItem(getRefreshTokenName(baseUrl));\n } catch {}\n};\n\n// In-flight sharing follows refresh-token storage scope to isolate server requests.\nconst anonymousAuthTokenRequests = new WeakMap<\n Storage,\n Map<string, Promise<string | null>>\n>();\n\nconst getWebLockManager = (): LockManager | null => {\n if (!(\"navigator\" in globalThis)) return null;\n return (\n (globalThis as { navigator?: { locks?: LockManager } }).navigator?.locks ??\n null\n );\n};\n\nconst getAnonymousAuthLockName = (baseUrl: string): string =>\n `assistant-cloud:anonymous-auth:${baseUrl}`;\n\nconst getSharedAnonymousAuthToken = (\n baseUrl: string,\n requestToken: () => Promise<string | null>,\n): Promise<string | null> => {\n const storage = getLocalStorage();\n if (!storage) return requestToken();\n\n let storageRequests = anonymousAuthTokenRequests.get(storage);\n if (!storageRequests) {\n storageRequests = new Map();\n anonymousAuthTokenRequests.set(storage, storageRequests);\n }\n\n const activeRequest = storageRequests.get(baseUrl);\n if (activeRequest) return activeRequest;\n\n const locks = getWebLockManager();\n const request = locks\n ? locks.request(getAnonymousAuthLockName(baseUrl), requestToken)\n : requestToken();\n const sharedRequest = request.finally(() => {\n if (storageRequests.get(baseUrl) === sharedRequest) {\n storageRequests.delete(baseUrl);\n }\n });\n storageRequests.set(baseUrl, sharedRequest);\n return sharedRequest;\n};\n\nexport class AssistantCloudAnonymousAuthStrategy implements AssistantCloudAuthStrategy {\n public readonly strategy = \"anon\";\n\n private baseUrl: string;\n private jwtStrategy: AssistantCloudJWTAuthStrategy;\n\n constructor(baseUrl: string) {\n this.baseUrl = baseUrl;\n const requestAuthToken = async (): Promise<string | null> => {\n const currentTime = Date.now();\n const storedRefreshToken = readRefreshToken(this.baseUrl);\n\n if (storedRefreshToken) {\n const refreshExpiry = new Date(storedRefreshToken.expires_at).getTime();\n if (refreshExpiry - currentTime > 30 * 1000) {\n const refreshedAccessToken = await withAuthTokenDeadline(\n \"refresh token request\",\n async (signal) => {\n const response = await fetch(\n `${this.baseUrl}/v1/auth/tokens/refresh`,\n {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n refresh_token: storedRefreshToken.token,\n }),\n signal,\n },\n );\n\n if (response.ok) {\n const { data, accessToken } = await readAuthTokenResponse(\n response,\n \"refresh auth token response\",\n );\n if (data.refresh_token != null) {\n writeRefreshToken(\n this.baseUrl,\n readRefreshTokenResponse(\n data.refresh_token,\n \"refresh auth token response.refresh_token\",\n ),\n );\n }\n return accessToken;\n }\n\n if (response.status === 429 || response.status >= 500) {\n throw new Error(\n `Assistant Cloud token refresh failed with status ${response.status}`,\n );\n }\n\n return null;\n },\n );\n if (refreshedAccessToken !== null) return refreshedAccessToken;\n } else {\n removeRefreshToken(this.baseUrl);\n }\n }\n\n // No valid refresh token; request a new anonymous token\n return withAuthTokenDeadline(\n \"anonymous token request\",\n async (signal) => {\n const response = await fetch(\n `${this.baseUrl}/v1/auth/tokens/anonymous`,\n { method: \"POST\", signal },\n );\n\n if (!response.ok) return null;\n\n const { data, accessToken } = await readAuthTokenResponse(\n response,\n \"anonymous auth token response\",\n );\n\n writeRefreshToken(\n this.baseUrl,\n readRefreshTokenResponse(\n data.refresh_token,\n \"anonymous auth token response.refresh_token\",\n ),\n );\n return accessToken;\n },\n );\n };\n this.jwtStrategy = new AssistantCloudJWTAuthStrategy(() =>\n getSharedAnonymousAuthToken(this.baseUrl, requestAuthToken),\n );\n }\n\n public async getAuthHeaders(): Promise<Record<string, string> | false> {\n return this.jwtStrategy.getAuthHeaders();\n }\n\n public readAuthHeaders(headers: Headers): void {\n this.jwtStrategy.readAuthHeaders(headers);\n }\n}\n"],"mappings":";;AAMA,MAAM,gCAAgC;AAEtC,MAAM,wBAAwB,OAC5B,WACA,QACe;CACf,MAAM,aAAa,IAAI,gBAAgB;CACvC,IAAI,WAAW;CACf,MAAM,UAAU,iBAAiB;EAC/B,WAAW;EACX,WAAW,MAAM;CACnB,GAAG,6BAA6B;CAEhC,IAAI;EACF,OAAO,MAAM,IAAI,WAAW,MAAM;CACpC,SAAS,OAAO;EACd,IAAI,UACF,MAAM,IAAI,MACR,mBAAmB,UAAU,mBAAmB,8BAA8B,KAC9E,EAAE,OAAO,MAAM,CACjB;EAEF,MAAM;CACR,UAAU;EACR,aAAa,OAAO;CACtB;AACF;AAQA,MAAM,gBAAgB,QAAwB;CAC5C,IAAI;EAEF,MAAM,WADQ,IAAI,MAAM,GACH,CAAC,CAAC;EACvB,IAAI,CAAC,UACH,MAAM,IAAI,MAAM,oBAAoB;EAItC,IAAI,SAAS,SAAS,QAAQ,MAAM,GAAG,CAAC,CAAC,QAAQ,MAAM,GAAG;EAC1D,OAAO,OAAO,SAAS,MAAM,GAC3B,UAAU;EAIZ,MAAM,UAAU,KAAK,MAAM;EAE3B,MAAM,MADa,KAAK,MAAM,OACT,CAAC,CAAC;EAEvB,IAAI,CAAC,OAAO,OAAO,QAAQ,UACzB,MAAM,IAAI,MAAM,4CAA0C;EAI5D,OAAO,MAAM;CACf,SAAS,OAAO;EACd,MAAM,IAAI,MAAM,yCAAyC,OAAO;CAClE;AACF;AAOA,MAAM,2BAA2B,OAAgB,UAA0B;CACzE,MAAM,SAAS,gBAAgB,OAAO,KAAK;CAC3C,IAAI,OAAO,WAAW,GACpB,MAAM,IAAI,mBACR,yCAAyC,MAAM,+BACjD;CAEF,OAAO;AACT;AAEA,MAAM,4BACJ,OACA,UACiB;CACjB,MAAM,eAAe,gBAAgB,OAAO,KAAK;CACjD,OAAO;EACL,OAAO,wBAAwB,aAAa,OAAO,GAAG,MAAM,OAAO;EACnE,YAAY,wBACV,aAAa,YACb,GAAG,MAAM,YACX;CACF;AACF;AAEA,MAAM,wBAAwB,OAC5B,UACA,UACoE;CACpE,IAAI;CACJ,IAAI;EACF,QAAQ,MAAM,SAAS,KAAK;CAC9B,QAAQ;EACN,MAAM,IAAI,mBACR,yCAAyC,MAAM,uBACjD;CACF;CAEA,MAAM,OAAO,gBAAgB,OAAO,KAAK;CAKzC,OAAO;EAAE;EAAM,aAJK,wBAClB,KAAK,cACL,GAAG,MAAM,cAEc;CAAE;AAC7B;AAEA,IAAa,gCAAb,MAAiF;CAC/E,WAA2B;CAE3B,cAAqC;CACrC,cAAqC;CACrC,eAAuE;CACvE;CAEA,YAAY,mBAAiD;EAC3D,KAAK,qBAAqB;CAC5B;CAEA,MAAa,iBAA0D;EACrE,MAAM,cAAc,KAAK,IAAI;EAG7B,IACE,KAAK,eACL,KAAK,eACL,KAAK,cAAc,cAAc,KAEjC,OAAO,EAAE,eAAe,UAAU,KAAK,cAAc;EAGvD,IAAI,CAAC,KAAK,cACR,KAAK,eAAe,KAAK,iBAAiB;EAG5C,MAAM,eAAe,KAAK;EAC1B,IAAI;GACF,OAAO,MAAM;EACf,UAAU;GACR,IAAI,KAAK,iBAAiB,cACxB,KAAK,eAAe;EAExB;CACF;CAEA,MAAc,mBAA4D;EACxE,MAAM,QAAQ,MAAM,KAAK,mBAAmB;EAC5C,IAAI,CAAC,OAAO,OAAO;EAEnB,KAAK,cAAc;EACnB,KAAK,cAAc,aAAa,KAAK;EAErC,OAAO,EAAE,eAAe,UAAU,QAAQ;CAC5C;CAEA,gBAAuB,SAAkB;EACvC,MAAM,aAAa,QAAQ,IAAI,eAAe;EAC9C,IAAI,CAAC,YAAY;EAEjB,MAAM,CAAC,QAAQ,SAAS,WAAW,MAAM,GAAG;EAC5C,IAAI,WAAW,YAAY,CAAC,OAC1B,MAAM,IAAI,MAAM,8BAA8B;EAGhD,KAAK,cAAc;EACnB,KAAK,cAAc,aAAa,KAAK;CACvC;AACF;AAEA,IAAa,mCAAb,MAAoF;CAClF,WAA2B;CAE3B;CACA;CACA;CAEA,YAAY,QAAgB,QAAgB,aAAqB;EAC/D,KAAK,UAAU;EACf,KAAK,UAAU;EACf,KAAK,eAAe;CACtB;CAEA,MAAa,iBAAkD;EAC7D,OAAO;GACL,eAAe,UAAU,KAAK;GAC9B,eAAe,KAAK;GACpB,oBAAoB,KAAK;EAC3B;CACF;CAEA,kBAAyB,CAEzB;AACF;AAEA,MAAM,gCAAgC;AAEtC,MAAM,uBAAuB,YAC3B,GAAG,8BAA8B,GAAG;AAEtC,MAAM,4BAA4B,YAA2B;CAC3D,IAAI;EACF,QAAQ,WAAW,6BAA6B;CAClD,QAAQ,CAAC;AACX;AAEA,MAAM,wBAAwC;CAC5C,IAAI,EAAE,kBAAkB,aAAa,OAAO;CAC5C,IAAI;EACF,OAAQ,WAAyC;CACnD,QAAQ;EACN,OAAO;CACT;AACF;AAEA,MAAM,oBAAoB,YAA8C;CACtE,MAAM,UAAU,gBAAgB;CAChC,IAAI,CAAC,SAAS,OAAO,KAAA;CACrB,IAAI;EACF,MAAM,OAAO,oBAAoB,OAAO;EACxC,MAAM,QAAQ,QAAQ,QAAQ,IAAI;EAClC,IAAI,OAAO;GACT,yBAAyB,OAAO;GAChC,OAAO,KAAK,MAAM,KAAK;EACzB;EAEA,MAAM,cAAc,QAAQ,QAAQ,6BAA6B;EACjE,IAAI,CAAC,aAAa,OAAO,KAAA;EAEzB,IAAI;EACJ,IAAI;GACF,eAAe,KAAK,MAAM,WAAW;EACvC,QAAQ;GACN,yBAAyB,OAAO;GAChC;EACF;EAEA,QAAQ,QAAQ,MAAM,WAAW;EACjC,yBAAyB,OAAO;EAChC,OAAO;CACT,QAAQ;EACN;CACF;AACF;AAEA,MAAM,qBACJ,SACA,iBACS;CACT,MAAM,UAAU,gBAAgB;CAChC,IAAI,CAAC,SAAS;CACd,IAAI;EACF,QAAQ,QAAQ,oBAAoB,OAAO,GAAG,KAAK,UAAU,YAAY,CAAC;CAC5E,QAAQ,CAAC;AACX;AAEA,MAAM,sBAAsB,YAA0B;CACpD,MAAM,UAAU,gBAAgB;CAChC,IAAI,CAAC,SAAS;CACd,IAAI;EACF,QAAQ,WAAW,oBAAoB,OAAO,CAAC;CACjD,QAAQ,CAAC;AACX;AAGA,MAAM,6CAA6B,IAAI,QAGrC;AAEF,MAAM,0BAA8C;CAClD,IAAI,EAAE,eAAe,aAAa,OAAO;CACzC,OACG,WAAuD,WAAW,SACnE;AAEJ;AAEA,MAAM,4BAA4B,YAChC,kCAAkC;AAEpC,MAAM,+BACJ,SACA,iBAC2B;CAC3B,MAAM,UAAU,gBAAgB;CAChC,IAAI,CAAC,SAAS,OAAO,aAAa;CAElC,IAAI,kBAAkB,2BAA2B,IAAI,OAAO;CAC5D,IAAI,CAAC,iBAAiB;EACpB,kCAAkB,IAAI,IAAI;EAC1B,2BAA2B,IAAI,SAAS,eAAe;CACzD;CAEA,MAAM,gBAAgB,gBAAgB,IAAI,OAAO;CACjD,IAAI,eAAe,OAAO;CAE1B,MAAM,QAAQ,kBAAkB;CAIhC,MAAM,iBAHU,QACZ,MAAM,QAAQ,yBAAyB,OAAO,GAAG,YAAY,IAC7D,aAAa,EAAA,CACa,cAAc;EAC1C,IAAI,gBAAgB,IAAI,OAAO,MAAM,eACnC,gBAAgB,OAAO,OAAO;CAElC,CAAC;CACD,gBAAgB,IAAI,SAAS,aAAa;CAC1C,OAAO;AACT;AAEA,IAAa,sCAAb,MAAuF;CACrF,WAA2B;CAE3B;CACA;CAEA,YAAY,SAAiB;EAC3B,KAAK,UAAU;EACf,MAAM,mBAAmB,YAAoC;GAC3D,MAAM,cAAc,KAAK,IAAI;GAC7B,MAAM,qBAAqB,iBAAiB,KAAK,OAAO;GAExD,IAAI,oBAAoB;IAEtB,IADsB,IAAI,KAAK,mBAAmB,UAAU,CAAC,CAAC,QAC9C,IAAI,cAAc,KAAW;KAC3C,MAAM,uBAAuB,MAAM,sBACjC,yBACA,OAAO,WAAW;MAChB,MAAM,WAAW,MAAM,MACrB,GAAG,KAAK,QAAQ,0BAChB;OACE,QAAQ;OACR,SAAS,EAAE,gBAAgB,mBAAmB;OAC9C,MAAM,KAAK,UAAU,EACnB,eAAe,mBAAmB,MACpC,CAAC;OACD;MACF,CACF;MAEA,IAAI,SAAS,IAAI;OACf,MAAM,EAAE,MAAM,gBAAgB,MAAM,sBAClC,UACA,6BACF;OACA,IAAI,KAAK,iBAAiB,MACxB,kBACE,KAAK,SACL,yBACE,KAAK,eACL,2CACF,CACF;OAEF,OAAO;MACT;MAEA,IAAI,SAAS,WAAW,OAAO,SAAS,UAAU,KAChD,MAAM,IAAI,MACR,oDAAoD,SAAS,QAC/D;MAGF,OAAO;KACT,CACF;KACA,IAAI,yBAAyB,MAAM,OAAO;IAC5C,OACE,mBAAmB,KAAK,OAAO;GAEnC;GAGA,OAAO,sBACL,2BACA,OAAO,WAAW;IAChB,MAAM,WAAW,MAAM,MACrB,GAAG,KAAK,QAAQ,4BAChB;KAAE,QAAQ;KAAQ;IAAO,CAC3B;IAEA,IAAI,CAAC,SAAS,IAAI,OAAO;IAEzB,MAAM,EAAE,MAAM,gBAAgB,MAAM,sBAClC,UACA,+BACF;IAEA,kBACE,KAAK,SACL,yBACE,KAAK,eACL,6CACF,CACF;IACA,OAAO;GACT,CACF;EACF;EACA,KAAK,cAAc,IAAI,oCACrB,4BAA4B,KAAK,SAAS,gBAAgB,CAC5D;CACF;CAEA,MAAa,iBAA0D;EACrE,OAAO,KAAK,YAAY,eAAe;CACzC;CAEA,gBAAuB,SAAwB;EAC7C,KAAK,YAAY,gBAAgB,OAAO;CAC1C;AACF"}
@@ -1 +1 @@
1
- {"version":3,"file":"CloudMessagePersistence.d.ts","names":[],"sources":["../src/CloudMessagePersistence.ts"],"mappings":";;;;;;;;;;;;;;;cAca;UACH;UACA;EAEI,YAAA,OAAO;EACP,YAAA,gBAAgB;;;;;;;;;;EActB,OACJ,kBACA,mBACA,yBACA,gBACA,SAAS,qBACR;;;;EAiCG,OACJ,kBACA,mBACA,iBACA,SAAS,qBACR;;;;EAeH,YAAY;;;;;EAQN,YAAY,oBAAoB;;;;;;;;;;;EAgBhC,KAAK,kBAAkB,kBAAe,QAhBN;;;;EAgCtC"}
1
+ {"version":3,"file":"CloudMessagePersistence.d.ts","names":[],"sources":["../src/CloudMessagePersistence.ts"],"mappings":";;;;;;;;;;;;;;;cAca;UACH;UACA;EAEI,YAAA,OAAO;EACP,YAAA,gBAAgB;;;;;;;;;;EActB,OACJ,kBACA,mBACA,yBACA,gBACA,SAAS,qBACR;;;;EAqCG,OACJ,kBACA,mBACA,iBACA,SAAS,qBACR;;;;EAeH,YAAY;;;;;EAQN,YAAY,oBAAoB;;;;;;;;;;;EAgBhC,KAAK,kBAAkB,kBAAe,QAhBN;;;;EAgCtC"}
@@ -27,20 +27,28 @@ var CloudMessagePersistence = class {
27
27
  */
28
28
  async append(threadId, messageId, parentId, format, content) {
29
29
  const cloud = this.getCloud();
30
- const resolvedParentId = parentId ? await this.idMapping[parentId] ?? parentId : null;
31
- const task = cloud.threads.messages.create(threadId, {
32
- parent_id: resolvedParentId,
33
- format,
34
- content
35
- }).then(({ message_id }) => {
36
- this.idMapping[messageId] = message_id;
30
+ const existing = this.idMapping[messageId];
31
+ if (existing instanceof Promise) {
32
+ await existing;
33
+ return;
34
+ }
35
+ const task = (async () => {
36
+ const resolvedParentId = parentId ? await this.idMapping[parentId] ?? parentId : null;
37
+ const { message_id } = await cloud.threads.messages.create(threadId, {
38
+ parent_id: resolvedParentId,
39
+ format,
40
+ content
41
+ });
37
42
  return message_id;
38
- }).catch((err) => {
43
+ })();
44
+ this.idMapping[messageId] = task;
45
+ try {
46
+ const remoteId = await task;
47
+ if (this.idMapping[messageId] === task) this.idMapping[messageId] = remoteId;
48
+ } catch (err) {
39
49
  if (this.idMapping[messageId] === task) delete this.idMapping[messageId];
40
50
  throw err;
41
- });
42
- this.idMapping[messageId] = task;
43
- return task.then(() => {});
51
+ }
44
52
  }
45
53
  /**
46
54
  * Update an already-persisted message in the cloud.
@@ -1 +1 @@
1
- {"version":3,"file":"CloudMessagePersistence.js","names":[],"sources":["../src/CloudMessagePersistence.ts"],"sourcesContent":["import type { ReadonlyJSONObject } from \"assistant-stream/utils\";\nimport type { AssistantCloud } from \"./AssistantCloud\";\n\n/**\n * Shared persistence logic for cloud message storage.\n *\n * Handles ID mapping (local → remote) and parent_id chaining for both:\n * - AssistantCloudThreadHistoryAdapter (assistant-ui runtime)\n * - useCloudChat (standalone AI SDK hook)\n *\n * The promise-based ID resolution handles concurrent appends — if message B's\n * parent is message A, and A is still being created, we await A's promise\n * to get its remote ID before creating B.\n */\nexport class CloudMessagePersistence {\n private idMapping: Record<string, string | Promise<string>> = {};\n private getCloud: () => AssistantCloud;\n\n constructor(cloud: AssistantCloud);\n constructor(getCloud: () => AssistantCloud);\n constructor(cloud: AssistantCloud | (() => AssistantCloud)) {\n this.getCloud = typeof cloud === \"function\" ? cloud : () => cloud;\n }\n\n /**\n * Persist a message to the cloud.\n *\n * @param threadId - Remote thread ID\n * @param messageId - Local message ID (used for tracking)\n * @param parentId - Local parent message ID (or null for first message)\n * @param format - Message format (e.g., \"aui/v0\", \"ai-sdk/v6\")\n * @param content - Message content (format-specific)\n */\n async append(\n threadId: string,\n messageId: string,\n parentId: string | null,\n format: string,\n content: ReadonlyJSONObject,\n ): Promise<void> {\n const cloud = this.getCloud();\n // Resolve parent's remote ID if it exists (may be a promise if concurrent)\n const resolvedParentId = parentId\n ? ((await this.idMapping[parentId]) ?? parentId)\n : null;\n\n const task = cloud.threads.messages\n .create(threadId, {\n parent_id: resolvedParentId,\n format,\n content,\n })\n .then(({ message_id }) => {\n this.idMapping[messageId] = message_id;\n return message_id;\n })\n .catch((err) => {\n // Only delete if we're still the active task (avoids clobbering a retry)\n if (this.idMapping[messageId] === task) {\n delete this.idMapping[messageId];\n }\n throw err;\n });\n\n // Store the promise immediately so concurrent appends can await it\n this.idMapping[messageId] = task;\n return task.then(() => {});\n }\n\n /**\n * Update an already-persisted message in the cloud.\n */\n async update(\n threadId: string,\n messageId: string,\n _format: string,\n content: ReadonlyJSONObject,\n ): Promise<void> {\n const cloud = this.getCloud();\n const remoteId = await this.getRemoteId(messageId);\n if (!remoteId) {\n console.warn(\n `Skipping update for message ${messageId}: no remote id is mapped.`,\n );\n return;\n }\n await cloud.threads.messages.update(threadId, remoteId, { content });\n }\n\n /**\n * Check if a message has been persisted (or is currently being persisted).\n */\n isPersisted(messageId: string): boolean {\n return messageId in this.idMapping;\n }\n\n /**\n * Get the remote ID for a local message ID (resolved).\n * Returns undefined if not persisted.\n */\n async getRemoteId(messageId: string): Promise<string | undefined> {\n const entry = this.idMapping[messageId];\n if (!entry) return undefined;\n return entry;\n }\n\n /**\n * Load messages from the cloud and populate the ID mapping.\n *\n * The ID mapping is populated so that `isPersisted()` returns true for\n * loaded messages, preventing re-persistence of already-stored messages.\n *\n * @param threadId - Remote thread ID\n * @param format - Optional format filter\n * @returns Array of cloud messages\n */\n async load(threadId: string, format?: string) {\n const cloud = this.getCloud();\n const { messages } = await cloud.threads.messages.list(\n threadId,\n format ? { format } : undefined,\n );\n // Populate ID mapping so isPersisted() recognizes loaded messages\n for (const m of messages) {\n this.idMapping[m.id] = m.id;\n }\n return messages;\n }\n\n /**\n * Reset the ID mapping (call when switching threads).\n */\n reset() {\n this.idMapping = {};\n }\n}\n"],"mappings":";;;;;;;;;;;;AAcA,IAAa,0BAAb,MAAqC;CACnC,YAA8D,CAAC;CAC/D;CAIA,YAAY,OAAgD;EAC1D,KAAK,WAAW,OAAO,UAAU,aAAa,cAAc;CAC9D;;;;;;;;;;CAWA,MAAM,OACJ,UACA,WACA,UACA,QACA,SACe;EACf,MAAM,QAAQ,KAAK,SAAS;EAE5B,MAAM,mBAAmB,WACnB,MAAM,KAAK,UAAU,aAAc,WACrC;EAEJ,MAAM,OAAO,MAAM,QAAQ,SACxB,OAAO,UAAU;GAChB,WAAW;GACX;GACA;EACF,CAAC,CAAC,CACD,MAAM,EAAE,iBAAiB;GACxB,KAAK,UAAU,aAAa;GAC5B,OAAO;EACT,CAAC,CAAC,CACD,OAAO,QAAQ;GAEd,IAAI,KAAK,UAAU,eAAe,MAChC,OAAO,KAAK,UAAU;GAExB,MAAM;EACR,CAAC;EAGH,KAAK,UAAU,aAAa;EAC5B,OAAO,KAAK,WAAW,CAAC,CAAC;CAC3B;;;;CAKA,MAAM,OACJ,UACA,WACA,SACA,SACe;EACf,MAAM,QAAQ,KAAK,SAAS;EAC5B,MAAM,WAAW,MAAM,KAAK,YAAY,SAAS;EACjD,IAAI,CAAC,UAAU;GACb,QAAQ,KACN,+BAA+B,UAAU,0BAC3C;GACA;EACF;EACA,MAAM,MAAM,QAAQ,SAAS,OAAO,UAAU,UAAU,EAAE,QAAQ,CAAC;CACrE;;;;CAKA,YAAY,WAA4B;EACtC,OAAO,aAAa,KAAK;CAC3B;;;;;CAMA,MAAM,YAAY,WAAgD;EAChE,MAAM,QAAQ,KAAK,UAAU;EAC7B,IAAI,CAAC,OAAO,OAAO,KAAA;EACnB,OAAO;CACT;;;;;;;;;;;CAYA,MAAM,KAAK,UAAkB,QAAiB;EAE5C,MAAM,EAAE,aAAa,MADP,KAAK,SACY,CAAC,CAAC,QAAQ,SAAS,KAChD,UACA,SAAS,EAAE,OAAO,IAAI,KAAA,CACxB;EAEA,KAAK,MAAM,KAAK,UACd,KAAK,UAAU,EAAE,MAAM,EAAE;EAE3B,OAAO;CACT;;;;CAKA,QAAQ;EACN,KAAK,YAAY,CAAC;CACpB;AACF"}
1
+ {"version":3,"file":"CloudMessagePersistence.js","names":[],"sources":["../src/CloudMessagePersistence.ts"],"sourcesContent":["import type { ReadonlyJSONObject } from \"assistant-stream/utils\";\nimport type { AssistantCloud } from \"./AssistantCloud\";\n\n/**\n * Shared persistence logic for cloud message storage.\n *\n * Handles ID mapping (local → remote) and parent_id chaining for both:\n * - AssistantCloudThreadHistoryAdapter (assistant-ui runtime)\n * - useCloudChat (standalone AI SDK hook)\n *\n * The promise-based ID resolution handles concurrent appends — if message B's\n * parent is message A, and A is still being created, we await A's promise\n * to get its remote ID before creating B.\n */\nexport class CloudMessagePersistence {\n private idMapping: Record<string, string | Promise<string>> = {};\n private getCloud: () => AssistantCloud;\n\n constructor(cloud: AssistantCloud);\n constructor(getCloud: () => AssistantCloud);\n constructor(cloud: AssistantCloud | (() => AssistantCloud)) {\n this.getCloud = typeof cloud === \"function\" ? cloud : () => cloud;\n }\n\n /**\n * Persist a message to the cloud.\n *\n * @param threadId - Remote thread ID\n * @param messageId - Local message ID (used for tracking)\n * @param parentId - Local parent message ID (or null for first message)\n * @param format - Message format (e.g., \"aui/v0\", \"ai-sdk/v6\")\n * @param content - Message content (format-specific)\n */\n async append(\n threadId: string,\n messageId: string,\n parentId: string | null,\n format: string,\n content: ReadonlyJSONObject,\n ): Promise<void> {\n const cloud = this.getCloud();\n const existing = this.idMapping[messageId];\n if (existing instanceof Promise) {\n await existing;\n return;\n }\n\n const task = (async () => {\n const resolvedParentId = parentId\n ? ((await this.idMapping[parentId]) ?? parentId)\n : null;\n const { message_id } = await cloud.threads.messages.create(threadId, {\n parent_id: resolvedParentId,\n format,\n content,\n });\n return message_id;\n })();\n\n this.idMapping[messageId] = task;\n try {\n const remoteId = await task;\n if (this.idMapping[messageId] === task) {\n this.idMapping[messageId] = remoteId;\n }\n } catch (err) {\n if (this.idMapping[messageId] === task) {\n delete this.idMapping[messageId];\n }\n throw err;\n }\n }\n\n /**\n * Update an already-persisted message in the cloud.\n */\n async update(\n threadId: string,\n messageId: string,\n _format: string,\n content: ReadonlyJSONObject,\n ): Promise<void> {\n const cloud = this.getCloud();\n const remoteId = await this.getRemoteId(messageId);\n if (!remoteId) {\n console.warn(\n `Skipping update for message ${messageId}: no remote id is mapped.`,\n );\n return;\n }\n await cloud.threads.messages.update(threadId, remoteId, { content });\n }\n\n /**\n * Check if a message has been persisted (or is currently being persisted).\n */\n isPersisted(messageId: string): boolean {\n return messageId in this.idMapping;\n }\n\n /**\n * Get the remote ID for a local message ID (resolved).\n * Returns undefined if not persisted.\n */\n async getRemoteId(messageId: string): Promise<string | undefined> {\n const entry = this.idMapping[messageId];\n if (!entry) return undefined;\n return entry;\n }\n\n /**\n * Load messages from the cloud and populate the ID mapping.\n *\n * The ID mapping is populated so that `isPersisted()` returns true for\n * loaded messages, preventing re-persistence of already-stored messages.\n *\n * @param threadId - Remote thread ID\n * @param format - Optional format filter\n * @returns Array of cloud messages\n */\n async load(threadId: string, format?: string) {\n const cloud = this.getCloud();\n const { messages } = await cloud.threads.messages.list(\n threadId,\n format ? { format } : undefined,\n );\n // Populate ID mapping so isPersisted() recognizes loaded messages\n for (const m of messages) {\n this.idMapping[m.id] = m.id;\n }\n return messages;\n }\n\n /**\n * Reset the ID mapping (call when switching threads).\n */\n reset() {\n this.idMapping = {};\n }\n}\n"],"mappings":";;;;;;;;;;;;AAcA,IAAa,0BAAb,MAAqC;CACnC,YAA8D,CAAC;CAC/D;CAIA,YAAY,OAAgD;EAC1D,KAAK,WAAW,OAAO,UAAU,aAAa,cAAc;CAC9D;;;;;;;;;;CAWA,MAAM,OACJ,UACA,WACA,UACA,QACA,SACe;EACf,MAAM,QAAQ,KAAK,SAAS;EAC5B,MAAM,WAAW,KAAK,UAAU;EAChC,IAAI,oBAAoB,SAAS;GAC/B,MAAM;GACN;EACF;EAEA,MAAM,QAAQ,YAAY;GACxB,MAAM,mBAAmB,WACnB,MAAM,KAAK,UAAU,aAAc,WACrC;GACJ,MAAM,EAAE,eAAe,MAAM,MAAM,QAAQ,SAAS,OAAO,UAAU;IACnE,WAAW;IACX;IACA;GACF,CAAC;GACD,OAAO;EACT,EAAA,CAAG;EAEH,KAAK,UAAU,aAAa;EAC5B,IAAI;GACF,MAAM,WAAW,MAAM;GACvB,IAAI,KAAK,UAAU,eAAe,MAChC,KAAK,UAAU,aAAa;EAEhC,SAAS,KAAK;GACZ,IAAI,KAAK,UAAU,eAAe,MAChC,OAAO,KAAK,UAAU;GAExB,MAAM;EACR;CACF;;;;CAKA,MAAM,OACJ,UACA,WACA,SACA,SACe;EACf,MAAM,QAAQ,KAAK,SAAS;EAC5B,MAAM,WAAW,MAAM,KAAK,YAAY,SAAS;EACjD,IAAI,CAAC,UAAU;GACb,QAAQ,KACN,+BAA+B,UAAU,0BAC3C;GACA;EACF;EACA,MAAM,MAAM,QAAQ,SAAS,OAAO,UAAU,UAAU,EAAE,QAAQ,CAAC;CACrE;;;;CAKA,YAAY,WAA4B;EACtC,OAAO,aAAa,KAAK;CAC3B;;;;;CAMA,MAAM,YAAY,WAAgD;EAChE,MAAM,QAAQ,KAAK,UAAU;EAC7B,IAAI,CAAC,OAAO,OAAO,KAAA;EACnB,OAAO;CACT;;;;;;;;;;;CAYA,MAAM,KAAK,UAAkB,QAAiB;EAE5C,MAAM,EAAE,aAAa,MADP,KAAK,SACY,CAAC,CAAC,QAAQ,SAAS,KAChD,UACA,SAAS,EAAE,OAAO,IAAI,KAAA,CACxB;EAEA,KAAK,MAAM,KAAK,UACd,KAAK,UAAU,EAAE,MAAM,EAAE;EAE3B,OAAO;CACT;;;;CAKA,QAAQ;EACN,KAAK,YAAY,CAAC;CACpB;AACF"}
@@ -42,10 +42,17 @@ type RunTelemetryUsage = {
42
42
  type RunTelemetryUsageInit = RunTelemetryUsage & {
43
43
  promptTokens?: number;
44
44
  completionTokens?: number;
45
+ inputTokenDetails?: {
46
+ cacheReadTokens?: number;
47
+ };
48
+ outputTokenDetails?: {
49
+ reasoningTokens?: number;
50
+ };
45
51
  };
46
52
  /**
47
- * Resolves the token counts a provider reports under either the current or the
48
- * legacy prompt/completion names. Returns undefined when no count is present,
53
+ * Resolves the token counts a provider reports under any of the names the AI
54
+ * SDK has used: the current top-level ones, the legacy prompt/completion pair,
55
+ * and the v7 token detail objects. Returns undefined when no count is present,
49
56
  * so callers can tell an empty usage object from a zeroed one.
50
57
  */
51
58
  declare function normalizeRunTelemetryUsage(usage: RunTelemetryUsageInit): RunTelemetryUsage | undefined;
@@ -1 +1 @@
1
- {"version":3,"file":"runTelemetry.d.ts","names":[],"sources":["../src/runTelemetry.ts"],"mappings":";;KAMY;EACV;EACA;EACA;EACA;EACA;EACA;EACA;EACA,iBAAiB;;;;;;iBAOH,yBAAyB;KAwC7B;EACV;EACA;EACA;;;;;EAKA;EACA;EACA;;;;;;;iBAQc,2BACd,MAAM,2BACL;KAgBS;EACV;EACA;EACA;EACA;;KAGU,wBAAwB;EAClC;EACA;;;;;;;iBAQc,2BACd,OAAO,wBACN"}
1
+ {"version":3,"file":"runTelemetry.d.ts","names":[],"sources":["../src/runTelemetry.ts"],"mappings":";;KAMY;EACV;EACA;EACA;EACA;EACA;EACA;EACA;EACA,iBAAiB;;;;;;iBAOH,yBAAyB;KAwC7B;EACV;EACA;EACA;;;;;EAKA;EACA;EACA;;;;;;;iBAQc,2BACd,MAAM,2BACL;KAgBS;EACV;EACA;EACA;EACA;;KAGU,wBAAwB;EAClC;EACA;EACA;IAAsB;;EACtB;IAAuB;;;;;;;;;iBAST,2BACd,OAAO,wBACN"}
@@ -58,19 +58,22 @@ function createRunTelemetryToolCall(init) {
58
58
  return call;
59
59
  }
60
60
  /**
61
- * Resolves the token counts a provider reports under either the current or the
62
- * legacy prompt/completion names. Returns undefined when no count is present,
61
+ * Resolves the token counts a provider reports under any of the names the AI
62
+ * SDK has used: the current top-level ones, the legacy prompt/completion pair,
63
+ * and the v7 token detail objects. Returns undefined when no count is present,
63
64
  * so callers can tell an empty usage object from a zeroed one.
64
65
  */
65
66
  function normalizeRunTelemetryUsage(usage) {
66
67
  const inputTokens = usage.inputTokens ?? usage.promptTokens;
67
68
  const outputTokens = usage.outputTokens ?? usage.completionTokens;
68
- if (inputTokens == null && outputTokens == null && usage.reasoningTokens == null && usage.cachedInputTokens == null) return;
69
+ const reasoningTokens = usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens;
70
+ const cachedInputTokens = usage.cachedInputTokens ?? usage.inputTokenDetails?.cacheReadTokens;
71
+ if (inputTokens == null && outputTokens == null && reasoningTokens == null && cachedInputTokens == null) return;
69
72
  return {
70
73
  ...inputTokens != null ? { inputTokens } : void 0,
71
74
  ...outputTokens != null ? { outputTokens } : void 0,
72
- ...usage.reasoningTokens != null ? { reasoningTokens: usage.reasoningTokens } : void 0,
73
- ...usage.cachedInputTokens != null ? { cachedInputTokens: usage.cachedInputTokens } : void 0
75
+ ...reasoningTokens != null ? { reasoningTokens } : void 0,
76
+ ...cachedInputTokens != null ? { cachedInputTokens } : void 0
74
77
  };
75
78
  }
76
79
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"runTelemetry.js","names":[],"sources":["../src/runTelemetry.ts"],"sourcesContent":["import type { SamplingCallData } from \"./instrumentMcpSampling\";\n\nconst MAX_TELEMETRY_TEXT_LENGTH = 50_000;\n\nconst BASE64_PATTERN = /^[A-Za-z0-9+/]{100,}={0,2}$/;\n\nexport type AssistantCloudRunReportToolCall = {\n tool_name: string;\n tool_call_id: string;\n tool_args?: string;\n tool_result?: string;\n tool_source?: \"mcp\" | \"frontend\" | \"backend\";\n start_ms?: number;\n end_ms?: number;\n sampling_calls?: SamplingCallData[];\n};\n\n/**\n * Clamps a string to the size the runs endpoint accepts for a single span\n * field.\n */\nexport function truncateRunTelemetryText(value: string): string {\n if (value.length <= MAX_TELEMETRY_TEXT_LENGTH) return value;\n return value.slice(0, MAX_TELEMETRY_TEXT_LENGTH);\n}\n\nfunction safeStringify(value: unknown): string | undefined {\n if (value == null) return undefined;\n try {\n return truncateRunTelemetryText(JSON.stringify(value));\n } catch {\n return undefined;\n }\n}\n\nfunction summarizeMcpResult(value: unknown): string | undefined {\n if (value == null) return undefined;\n try {\n const parsed = typeof value === \"string\" ? JSON.parse(value) : value;\n if (Array.isArray(parsed)) {\n const summarized = parsed.map((item) => {\n if (item && typeof item === \"object\" && item.type) {\n if (\n (item.type === \"image\" || item.type === \"audio\") &&\n typeof item.data === \"string\" &&\n BASE64_PATTERN.test(item.data.slice(0, 200))\n ) {\n const sizeKB = ((item.data.length * 3) / 4 / 1024).toFixed(1);\n return { ...item, data: `[${item.type}: ${sizeKB}KB]` };\n }\n }\n return item;\n });\n return truncateRunTelemetryText(JSON.stringify(summarized));\n }\n } catch {\n // not JSON array, fall through\n }\n return safeStringify(value);\n}\n\nexport type RunTelemetryToolCallInit = {\n toolName: string;\n toolCallId: string;\n args?: unknown;\n /**\n * Pre-serialized arguments, used in place of serializing `args`. Values over\n * the span size are clamped before they are included in the report.\n */\n argsText?: string | undefined;\n result?: unknown;\n toolSource?: \"mcp\" | \"frontend\" | \"backend\" | undefined;\n};\n\n/**\n * Serializes one tool call into the shape the runs endpoint accepts. An `mcp`\n * source has its result summarized, because MCP content blocks carry inline\n * base64 image and audio payloads that would otherwise dominate the report.\n */\nexport function createRunTelemetryToolCall(\n init: RunTelemetryToolCallInit,\n): AssistantCloudRunReportToolCall {\n const { toolName, toolCallId, args, argsText, result, toolSource } = init;\n const call: AssistantCloudRunReportToolCall = {\n tool_name: toolName,\n tool_call_id: toolCallId,\n };\n const toolArgs =\n argsText != null ? truncateRunTelemetryText(argsText) : safeStringify(args);\n if (toolArgs !== undefined) call.tool_args = toolArgs;\n const toolResult =\n toolSource === \"mcp\" ? summarizeMcpResult(result) : safeStringify(result);\n if (toolResult !== undefined) call.tool_result = toolResult;\n if (toolSource) call.tool_source = toolSource;\n return call;\n}\n\nexport type RunTelemetryUsage = {\n inputTokens?: number;\n outputTokens?: number;\n reasoningTokens?: number;\n cachedInputTokens?: number;\n};\n\nexport type RunTelemetryUsageInit = RunTelemetryUsage & {\n promptTokens?: number;\n completionTokens?: number;\n};\n\n/**\n * Resolves the token counts a provider reports under either the current or the\n * legacy prompt/completion names. Returns undefined when no count is present,\n * so callers can tell an empty usage object from a zeroed one.\n */\nexport function normalizeRunTelemetryUsage(\n usage: RunTelemetryUsageInit,\n): RunTelemetryUsage | undefined {\n const inputTokens = usage.inputTokens ?? usage.promptTokens;\n const outputTokens = usage.outputTokens ?? usage.completionTokens;\n\n if (\n inputTokens == null &&\n outputTokens == null &&\n usage.reasoningTokens == null &&\n usage.cachedInputTokens == null\n ) {\n return undefined;\n }\n\n return {\n ...(inputTokens != null ? { inputTokens } : undefined),\n ...(outputTokens != null ? { outputTokens } : undefined),\n ...(usage.reasoningTokens != null\n ? { reasoningTokens: usage.reasoningTokens }\n : undefined),\n ...(usage.cachedInputTokens != null\n ? { cachedInputTokens: usage.cachedInputTokens }\n : undefined),\n };\n}\n"],"mappings":";AAEA,MAAM,4BAA4B;AAElC,MAAM,iBAAiB;;;;;AAiBvB,SAAgB,yBAAyB,OAAuB;CAC9D,IAAI,MAAM,UAAU,2BAA2B,OAAO;CACtD,OAAO,MAAM,MAAM,GAAG,yBAAyB;AACjD;AAEA,SAAS,cAAc,OAAoC;CACzD,IAAI,SAAS,MAAM,OAAO,KAAA;CAC1B,IAAI;EACF,OAAO,yBAAyB,KAAK,UAAU,KAAK,CAAC;CACvD,QAAQ;EACN;CACF;AACF;AAEA,SAAS,mBAAmB,OAAoC;CAC9D,IAAI,SAAS,MAAM,OAAO,KAAA;CAC1B,IAAI;EACF,MAAM,SAAS,OAAO,UAAU,WAAW,KAAK,MAAM,KAAK,IAAI;EAC/D,IAAI,MAAM,QAAQ,MAAM,GAAG;GACzB,MAAM,aAAa,OAAO,KAAK,SAAS;IACtC,IAAI,QAAQ,OAAO,SAAS,YAAY,KAAK,MAExC;UAAA,KAAK,SAAS,WAAW,KAAK,SAAS,YACxC,OAAO,KAAK,SAAS,YACrB,eAAe,KAAK,KAAK,KAAK,MAAM,GAAG,GAAG,CAAC,GAC3C;MACA,MAAM,UAAW,KAAK,KAAK,SAAS,IAAK,IAAI,KAAA,CAAM,QAAQ,CAAC;MAC5D,OAAO;OAAE,GAAG;OAAM,MAAM,IAAI,KAAK,KAAK,IAAI,OAAO;MAAK;KACxD;;IAEF,OAAO;GACT,CAAC;GACD,OAAO,yBAAyB,KAAK,UAAU,UAAU,CAAC;EAC5D;CACF,QAAQ,CAER;CACA,OAAO,cAAc,KAAK;AAC5B;;;;;;AAoBA,SAAgB,2BACd,MACiC;CACjC,MAAM,EAAE,UAAU,YAAY,MAAM,UAAU,QAAQ,eAAe;CACrE,MAAM,OAAwC;EAC5C,WAAW;EACX,cAAc;CAChB;CACA,MAAM,WACJ,YAAY,OAAO,yBAAyB,QAAQ,IAAI,cAAc,IAAI;CAC5E,IAAI,aAAa,KAAA,GAAW,KAAK,YAAY;CAC7C,MAAM,aACJ,eAAe,QAAQ,mBAAmB,MAAM,IAAI,cAAc,MAAM;CAC1E,IAAI,eAAe,KAAA,GAAW,KAAK,cAAc;CACjD,IAAI,YAAY,KAAK,cAAc;CACnC,OAAO;AACT;;;;;;AAmBA,SAAgB,2BACd,OAC+B;CAC/B,MAAM,cAAc,MAAM,eAAe,MAAM;CAC/C,MAAM,eAAe,MAAM,gBAAgB,MAAM;CAEjD,IACE,eAAe,QACf,gBAAgB,QAChB,MAAM,mBAAmB,QACzB,MAAM,qBAAqB,MAE3B;CAGF,OAAO;EACL,GAAI,eAAe,OAAO,EAAE,YAAY,IAAI,KAAA;EAC5C,GAAI,gBAAgB,OAAO,EAAE,aAAa,IAAI,KAAA;EAC9C,GAAI,MAAM,mBAAmB,OACzB,EAAE,iBAAiB,MAAM,gBAAgB,IACzC,KAAA;EACJ,GAAI,MAAM,qBAAqB,OAC3B,EAAE,mBAAmB,MAAM,kBAAkB,IAC7C,KAAA;CACN;AACF"}
1
+ {"version":3,"file":"runTelemetry.js","names":[],"sources":["../src/runTelemetry.ts"],"sourcesContent":["import type { SamplingCallData } from \"./instrumentMcpSampling\";\n\nconst MAX_TELEMETRY_TEXT_LENGTH = 50_000;\n\nconst BASE64_PATTERN = /^[A-Za-z0-9+/]{100,}={0,2}$/;\n\nexport type AssistantCloudRunReportToolCall = {\n tool_name: string;\n tool_call_id: string;\n tool_args?: string;\n tool_result?: string;\n tool_source?: \"mcp\" | \"frontend\" | \"backend\";\n start_ms?: number;\n end_ms?: number;\n sampling_calls?: SamplingCallData[];\n};\n\n/**\n * Clamps a string to the size the runs endpoint accepts for a single span\n * field.\n */\nexport function truncateRunTelemetryText(value: string): string {\n if (value.length <= MAX_TELEMETRY_TEXT_LENGTH) return value;\n return value.slice(0, MAX_TELEMETRY_TEXT_LENGTH);\n}\n\nfunction safeStringify(value: unknown): string | undefined {\n if (value == null) return undefined;\n try {\n return truncateRunTelemetryText(JSON.stringify(value));\n } catch {\n return undefined;\n }\n}\n\nfunction summarizeMcpResult(value: unknown): string | undefined {\n if (value == null) return undefined;\n try {\n const parsed = typeof value === \"string\" ? JSON.parse(value) : value;\n if (Array.isArray(parsed)) {\n const summarized = parsed.map((item) => {\n if (item && typeof item === \"object\" && item.type) {\n if (\n (item.type === \"image\" || item.type === \"audio\") &&\n typeof item.data === \"string\" &&\n BASE64_PATTERN.test(item.data.slice(0, 200))\n ) {\n const sizeKB = ((item.data.length * 3) / 4 / 1024).toFixed(1);\n return { ...item, data: `[${item.type}: ${sizeKB}KB]` };\n }\n }\n return item;\n });\n return truncateRunTelemetryText(JSON.stringify(summarized));\n }\n } catch {\n // not JSON array, fall through\n }\n return safeStringify(value);\n}\n\nexport type RunTelemetryToolCallInit = {\n toolName: string;\n toolCallId: string;\n args?: unknown;\n /**\n * Pre-serialized arguments, used in place of serializing `args`. Values over\n * the span size are clamped before they are included in the report.\n */\n argsText?: string | undefined;\n result?: unknown;\n toolSource?: \"mcp\" | \"frontend\" | \"backend\" | undefined;\n};\n\n/**\n * Serializes one tool call into the shape the runs endpoint accepts. An `mcp`\n * source has its result summarized, because MCP content blocks carry inline\n * base64 image and audio payloads that would otherwise dominate the report.\n */\nexport function createRunTelemetryToolCall(\n init: RunTelemetryToolCallInit,\n): AssistantCloudRunReportToolCall {\n const { toolName, toolCallId, args, argsText, result, toolSource } = init;\n const call: AssistantCloudRunReportToolCall = {\n tool_name: toolName,\n tool_call_id: toolCallId,\n };\n const toolArgs =\n argsText != null ? truncateRunTelemetryText(argsText) : safeStringify(args);\n if (toolArgs !== undefined) call.tool_args = toolArgs;\n const toolResult =\n toolSource === \"mcp\" ? summarizeMcpResult(result) : safeStringify(result);\n if (toolResult !== undefined) call.tool_result = toolResult;\n if (toolSource) call.tool_source = toolSource;\n return call;\n}\n\nexport type RunTelemetryUsage = {\n inputTokens?: number;\n outputTokens?: number;\n reasoningTokens?: number;\n cachedInputTokens?: number;\n};\n\nexport type RunTelemetryUsageInit = RunTelemetryUsage & {\n promptTokens?: number;\n completionTokens?: number;\n inputTokenDetails?: { cacheReadTokens?: number };\n outputTokenDetails?: { reasoningTokens?: number };\n};\n\n/**\n * Resolves the token counts a provider reports under any of the names the AI\n * SDK has used: the current top-level ones, the legacy prompt/completion pair,\n * and the v7 token detail objects. Returns undefined when no count is present,\n * so callers can tell an empty usage object from a zeroed one.\n */\nexport function normalizeRunTelemetryUsage(\n usage: RunTelemetryUsageInit,\n): RunTelemetryUsage | undefined {\n const inputTokens = usage.inputTokens ?? usage.promptTokens;\n const outputTokens = usage.outputTokens ?? usage.completionTokens;\n // AI SDK v7 moved these under token detail objects; v6 kept them top-level.\n const reasoningTokens =\n usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens;\n const cachedInputTokens =\n usage.cachedInputTokens ?? usage.inputTokenDetails?.cacheReadTokens;\n\n if (\n inputTokens == null &&\n outputTokens == null &&\n reasoningTokens == null &&\n cachedInputTokens == null\n ) {\n return undefined;\n }\n\n return {\n ...(inputTokens != null ? { inputTokens } : undefined),\n ...(outputTokens != null ? { outputTokens } : undefined),\n ...(reasoningTokens != null ? { reasoningTokens } : undefined),\n ...(cachedInputTokens != null ? { cachedInputTokens } : undefined),\n };\n}\n"],"mappings":";AAEA,MAAM,4BAA4B;AAElC,MAAM,iBAAiB;;;;;AAiBvB,SAAgB,yBAAyB,OAAuB;CAC9D,IAAI,MAAM,UAAU,2BAA2B,OAAO;CACtD,OAAO,MAAM,MAAM,GAAG,yBAAyB;AACjD;AAEA,SAAS,cAAc,OAAoC;CACzD,IAAI,SAAS,MAAM,OAAO,KAAA;CAC1B,IAAI;EACF,OAAO,yBAAyB,KAAK,UAAU,KAAK,CAAC;CACvD,QAAQ;EACN;CACF;AACF;AAEA,SAAS,mBAAmB,OAAoC;CAC9D,IAAI,SAAS,MAAM,OAAO,KAAA;CAC1B,IAAI;EACF,MAAM,SAAS,OAAO,UAAU,WAAW,KAAK,MAAM,KAAK,IAAI;EAC/D,IAAI,MAAM,QAAQ,MAAM,GAAG;GACzB,MAAM,aAAa,OAAO,KAAK,SAAS;IACtC,IAAI,QAAQ,OAAO,SAAS,YAAY,KAAK,MAExC;UAAA,KAAK,SAAS,WAAW,KAAK,SAAS,YACxC,OAAO,KAAK,SAAS,YACrB,eAAe,KAAK,KAAK,KAAK,MAAM,GAAG,GAAG,CAAC,GAC3C;MACA,MAAM,UAAW,KAAK,KAAK,SAAS,IAAK,IAAI,KAAA,CAAM,QAAQ,CAAC;MAC5D,OAAO;OAAE,GAAG;OAAM,MAAM,IAAI,KAAK,KAAK,IAAI,OAAO;MAAK;KACxD;;IAEF,OAAO;GACT,CAAC;GACD,OAAO,yBAAyB,KAAK,UAAU,UAAU,CAAC;EAC5D;CACF,QAAQ,CAER;CACA,OAAO,cAAc,KAAK;AAC5B;;;;;;AAoBA,SAAgB,2BACd,MACiC;CACjC,MAAM,EAAE,UAAU,YAAY,MAAM,UAAU,QAAQ,eAAe;CACrE,MAAM,OAAwC;EAC5C,WAAW;EACX,cAAc;CAChB;CACA,MAAM,WACJ,YAAY,OAAO,yBAAyB,QAAQ,IAAI,cAAc,IAAI;CAC5E,IAAI,aAAa,KAAA,GAAW,KAAK,YAAY;CAC7C,MAAM,aACJ,eAAe,QAAQ,mBAAmB,MAAM,IAAI,cAAc,MAAM;CAC1E,IAAI,eAAe,KAAA,GAAW,KAAK,cAAc;CACjD,IAAI,YAAY,KAAK,cAAc;CACnC,OAAO;AACT;;;;;;;AAsBA,SAAgB,2BACd,OAC+B;CAC/B,MAAM,cAAc,MAAM,eAAe,MAAM;CAC/C,MAAM,eAAe,MAAM,gBAAgB,MAAM;CAEjD,MAAM,kBACJ,MAAM,mBAAmB,MAAM,oBAAoB;CACrD,MAAM,oBACJ,MAAM,qBAAqB,MAAM,mBAAmB;CAEtD,IACE,eAAe,QACf,gBAAgB,QAChB,mBAAmB,QACnB,qBAAqB,MAErB;CAGF,OAAO;EACL,GAAI,eAAe,OAAO,EAAE,YAAY,IAAI,KAAA;EAC5C,GAAI,gBAAgB,OAAO,EAAE,aAAa,IAAI,KAAA;EAC9C,GAAI,mBAAmB,OAAO,EAAE,gBAAgB,IAAI,KAAA;EACpD,GAAI,qBAAqB,OAAO,EAAE,kBAAkB,IAAI,KAAA;CAC1D;AACF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "assistant-cloud",
3
- "version": "0.1.42",
3
+ "version": "0.1.43",
4
4
  "description": "Cloud integration for assistant-ui",
5
5
  "keywords": [
6
6
  "assistant",
@@ -27,11 +27,11 @@
27
27
  ],
28
28
  "sideEffects": false,
29
29
  "dependencies": {
30
- "assistant-stream": "^0.3.40"
30
+ "assistant-stream": "^0.3.41"
31
31
  },
32
32
  "devDependencies": {
33
- "@assistant-ui/x-buildutils": "0.0.24",
34
- "@types/node": "^26.2.0",
33
+ "@assistant-ui/x-buildutils": "0.0.25",
34
+ "@types/node": "^26.4.0",
35
35
  "vitest": "^4.1.11"
36
36
  },
37
37
  "publishConfig": {
@@ -4,6 +4,34 @@ import {
4
4
  readCloudString,
5
5
  } from "./cloudResponse";
6
6
 
7
+ const AUTH_TOKEN_REQUEST_TIMEOUT_MS = 30_000;
8
+
9
+ const withAuthTokenDeadline = async <T>(
10
+ operation: string,
11
+ run: (signal: AbortSignal) => Promise<T>,
12
+ ): Promise<T> => {
13
+ const controller = new AbortController();
14
+ let timedOut = false;
15
+ const timeout = setTimeout(() => {
16
+ timedOut = true;
17
+ controller.abort();
18
+ }, AUTH_TOKEN_REQUEST_TIMEOUT_MS);
19
+
20
+ try {
21
+ return await run(controller.signal);
22
+ } catch (error) {
23
+ if (timedOut) {
24
+ throw new Error(
25
+ `Assistant Cloud ${operation} timed out after ${AUTH_TOKEN_REQUEST_TIMEOUT_MS}ms`,
26
+ { cause: error },
27
+ );
28
+ }
29
+ throw error;
30
+ } finally {
31
+ clearTimeout(timeout);
32
+ }
33
+ };
34
+
7
35
  export type AssistantCloudAuthStrategy = {
8
36
  readonly strategy: "anon" | "jwt" | "api-key";
9
37
  getAuthHeaders(): Promise<Record<string, string> | false>;
@@ -247,6 +275,52 @@ const removeRefreshToken = (baseUrl: string): void => {
247
275
  } catch {}
248
276
  };
249
277
 
278
+ // In-flight sharing follows refresh-token storage scope to isolate server requests.
279
+ const anonymousAuthTokenRequests = new WeakMap<
280
+ Storage,
281
+ Map<string, Promise<string | null>>
282
+ >();
283
+
284
+ const getWebLockManager = (): LockManager | null => {
285
+ if (!("navigator" in globalThis)) return null;
286
+ return (
287
+ (globalThis as { navigator?: { locks?: LockManager } }).navigator?.locks ??
288
+ null
289
+ );
290
+ };
291
+
292
+ const getAnonymousAuthLockName = (baseUrl: string): string =>
293
+ `assistant-cloud:anonymous-auth:${baseUrl}`;
294
+
295
+ const getSharedAnonymousAuthToken = (
296
+ baseUrl: string,
297
+ requestToken: () => Promise<string | null>,
298
+ ): Promise<string | null> => {
299
+ const storage = getLocalStorage();
300
+ if (!storage) return requestToken();
301
+
302
+ let storageRequests = anonymousAuthTokenRequests.get(storage);
303
+ if (!storageRequests) {
304
+ storageRequests = new Map();
305
+ anonymousAuthTokenRequests.set(storage, storageRequests);
306
+ }
307
+
308
+ const activeRequest = storageRequests.get(baseUrl);
309
+ if (activeRequest) return activeRequest;
310
+
311
+ const locks = getWebLockManager();
312
+ const request = locks
313
+ ? locks.request(getAnonymousAuthLockName(baseUrl), requestToken)
314
+ : requestToken();
315
+ const sharedRequest = request.finally(() => {
316
+ if (storageRequests.get(baseUrl) === sharedRequest) {
317
+ storageRequests.delete(baseUrl);
318
+ }
319
+ });
320
+ storageRequests.set(baseUrl, sharedRequest);
321
+ return sharedRequest;
322
+ };
323
+
250
324
  export class AssistantCloudAnonymousAuthStrategy implements AssistantCloudAuthStrategy {
251
325
  public readonly strategy = "anon";
252
326
 
@@ -255,70 +329,90 @@ export class AssistantCloudAnonymousAuthStrategy implements AssistantCloudAuthSt
255
329
 
256
330
  constructor(baseUrl: string) {
257
331
  this.baseUrl = baseUrl;
258
- this.jwtStrategy = new AssistantCloudJWTAuthStrategy(async () => {
332
+ const requestAuthToken = async (): Promise<string | null> => {
259
333
  const currentTime = Date.now();
260
334
  const storedRefreshToken = readRefreshToken(this.baseUrl);
261
335
 
262
336
  if (storedRefreshToken) {
263
337
  const refreshExpiry = new Date(storedRefreshToken.expires_at).getTime();
264
338
  if (refreshExpiry - currentTime > 30 * 1000) {
265
- const response = await fetch(
266
- `${this.baseUrl}/v1/auth/tokens/refresh`,
267
- {
268
- method: "POST",
269
- headers: { "Content-Type": "application/json" },
270
- body: JSON.stringify({ refresh_token: storedRefreshToken.token }),
339
+ const refreshedAccessToken = await withAuthTokenDeadline(
340
+ "refresh token request",
341
+ async (signal) => {
342
+ const response = await fetch(
343
+ `${this.baseUrl}/v1/auth/tokens/refresh`,
344
+ {
345
+ method: "POST",
346
+ headers: { "Content-Type": "application/json" },
347
+ body: JSON.stringify({
348
+ refresh_token: storedRefreshToken.token,
349
+ }),
350
+ signal,
351
+ },
352
+ );
353
+
354
+ if (response.ok) {
355
+ const { data, accessToken } = await readAuthTokenResponse(
356
+ response,
357
+ "refresh auth token response",
358
+ );
359
+ if (data.refresh_token != null) {
360
+ writeRefreshToken(
361
+ this.baseUrl,
362
+ readRefreshTokenResponse(
363
+ data.refresh_token,
364
+ "refresh auth token response.refresh_token",
365
+ ),
366
+ );
367
+ }
368
+ return accessToken;
369
+ }
370
+
371
+ if (response.status === 429 || response.status >= 500) {
372
+ throw new Error(
373
+ `Assistant Cloud token refresh failed with status ${response.status}`,
374
+ );
375
+ }
376
+
377
+ return null;
271
378
  },
272
379
  );
273
-
274
- if (response.ok) {
275
- const { data, accessToken } = await readAuthTokenResponse(
276
- response,
277
- "refresh auth token response",
278
- );
279
- if (data.refresh_token != null) {
280
- writeRefreshToken(
281
- this.baseUrl,
282
- readRefreshTokenResponse(
283
- data.refresh_token,
284
- "refresh auth token response.refresh_token",
285
- ),
286
- );
287
- }
288
- return accessToken;
289
- }
290
-
291
- if (response.status === 429 || response.status >= 500) {
292
- throw new Error(
293
- `Assistant Cloud token refresh failed with status ${response.status}`,
294
- );
295
- }
380
+ if (refreshedAccessToken !== null) return refreshedAccessToken;
296
381
  } else {
297
382
  removeRefreshToken(this.baseUrl);
298
383
  }
299
384
  }
300
385
 
301
386
  // No valid refresh token; request a new anonymous token
302
- const response = await fetch(`${this.baseUrl}/v1/auth/tokens/anonymous`, {
303
- method: "POST",
304
- });
387
+ return withAuthTokenDeadline(
388
+ "anonymous token request",
389
+ async (signal) => {
390
+ const response = await fetch(
391
+ `${this.baseUrl}/v1/auth/tokens/anonymous`,
392
+ { method: "POST", signal },
393
+ );
305
394
 
306
- if (!response.ok) return null;
395
+ if (!response.ok) return null;
307
396
 
308
- const { data, accessToken } = await readAuthTokenResponse(
309
- response,
310
- "anonymous auth token response",
311
- );
397
+ const { data, accessToken } = await readAuthTokenResponse(
398
+ response,
399
+ "anonymous auth token response",
400
+ );
312
401
 
313
- writeRefreshToken(
314
- this.baseUrl,
315
- readRefreshTokenResponse(
316
- data.refresh_token,
317
- "anonymous auth token response.refresh_token",
318
- ),
402
+ writeRefreshToken(
403
+ this.baseUrl,
404
+ readRefreshTokenResponse(
405
+ data.refresh_token,
406
+ "anonymous auth token response.refresh_token",
407
+ ),
408
+ );
409
+ return accessToken;
410
+ },
319
411
  );
320
- return accessToken;
321
- });
412
+ };
413
+ this.jwtStrategy = new AssistantCloudJWTAuthStrategy(() =>
414
+ getSharedAnonymousAuthToken(this.baseUrl, requestAuthToken),
415
+ );
322
416
  }
323
417
 
324
418
  public async getAuthHeaders(): Promise<Record<string, string> | false> {
@@ -39,32 +39,36 @@ export class CloudMessagePersistence {
39
39
  content: ReadonlyJSONObject,
40
40
  ): Promise<void> {
41
41
  const cloud = this.getCloud();
42
- // Resolve parent's remote ID if it exists (may be a promise if concurrent)
43
- const resolvedParentId = parentId
44
- ? ((await this.idMapping[parentId]) ?? parentId)
45
- : null;
42
+ const existing = this.idMapping[messageId];
43
+ if (existing instanceof Promise) {
44
+ await existing;
45
+ return;
46
+ }
46
47
 
47
- const task = cloud.threads.messages
48
- .create(threadId, {
48
+ const task = (async () => {
49
+ const resolvedParentId = parentId
50
+ ? ((await this.idMapping[parentId]) ?? parentId)
51
+ : null;
52
+ const { message_id } = await cloud.threads.messages.create(threadId, {
49
53
  parent_id: resolvedParentId,
50
54
  format,
51
55
  content,
52
- })
53
- .then(({ message_id }) => {
54
- this.idMapping[messageId] = message_id;
55
- return message_id;
56
- })
57
- .catch((err) => {
58
- // Only delete if we're still the active task (avoids clobbering a retry)
59
- if (this.idMapping[messageId] === task) {
60
- delete this.idMapping[messageId];
61
- }
62
- throw err;
63
56
  });
57
+ return message_id;
58
+ })();
64
59
 
65
- // Store the promise immediately so concurrent appends can await it
66
60
  this.idMapping[messageId] = task;
67
- return task.then(() => {});
61
+ try {
62
+ const remoteId = await task;
63
+ if (this.idMapping[messageId] === task) {
64
+ this.idMapping[messageId] = remoteId;
65
+ }
66
+ } catch (err) {
67
+ if (this.idMapping[messageId] === task) {
68
+ delete this.idMapping[messageId];
69
+ }
70
+ throw err;
71
+ }
68
72
  }
69
73
 
70
74
  /**
@@ -124,7 +124,48 @@ describe("normalizeRunTelemetryUsage", () => {
124
124
  ).toEqual({ inputTokens: 0, cachedInputTokens: 5 });
125
125
  });
126
126
 
127
+ it("reads the AI SDK v7 token detail objects", () => {
128
+ expect(
129
+ normalizeRunTelemetryUsage({
130
+ inputTokens: 12,
131
+ outputTokens: 7,
132
+ inputTokenDetails: { cacheReadTokens: 5 },
133
+ outputTokenDetails: { reasoningTokens: 3 },
134
+ }),
135
+ ).toEqual({
136
+ inputTokens: 12,
137
+ outputTokens: 7,
138
+ reasoningTokens: 3,
139
+ cachedInputTokens: 5,
140
+ });
141
+ });
142
+
143
+ it("prefers the top-level detail counts over the nested ones", () => {
144
+ expect(
145
+ normalizeRunTelemetryUsage({
146
+ reasoningTokens: 3,
147
+ cachedInputTokens: 5,
148
+ inputTokenDetails: { cacheReadTokens: 90 },
149
+ outputTokenDetails: { reasoningTokens: 90 },
150
+ }),
151
+ ).toEqual({ reasoningTokens: 3, cachedInputTokens: 5 });
152
+ });
153
+
154
+ it("returns a usage object when only the nested counts are present", () => {
155
+ expect(
156
+ normalizeRunTelemetryUsage({
157
+ inputTokenDetails: { cacheReadTokens: 5 },
158
+ }),
159
+ ).toEqual({ cachedInputTokens: 5 });
160
+ });
161
+
127
162
  it("returns undefined when no count is present", () => {
128
163
  expect(normalizeRunTelemetryUsage({})).toBeUndefined();
164
+ expect(
165
+ normalizeRunTelemetryUsage({
166
+ inputTokenDetails: {},
167
+ outputTokenDetails: {},
168
+ }),
169
+ ).toBeUndefined();
129
170
  });
130
171
  });
@@ -105,11 +105,14 @@ export type RunTelemetryUsage = {
105
105
  export type RunTelemetryUsageInit = RunTelemetryUsage & {
106
106
  promptTokens?: number;
107
107
  completionTokens?: number;
108
+ inputTokenDetails?: { cacheReadTokens?: number };
109
+ outputTokenDetails?: { reasoningTokens?: number };
108
110
  };
109
111
 
110
112
  /**
111
- * Resolves the token counts a provider reports under either the current or the
112
- * legacy prompt/completion names. Returns undefined when no count is present,
113
+ * Resolves the token counts a provider reports under any of the names the AI
114
+ * SDK has used: the current top-level ones, the legacy prompt/completion pair,
115
+ * and the v7 token detail objects. Returns undefined when no count is present,
113
116
  * so callers can tell an empty usage object from a zeroed one.
114
117
  */
115
118
  export function normalizeRunTelemetryUsage(
@@ -117,12 +120,17 @@ export function normalizeRunTelemetryUsage(
117
120
  ): RunTelemetryUsage | undefined {
118
121
  const inputTokens = usage.inputTokens ?? usage.promptTokens;
119
122
  const outputTokens = usage.outputTokens ?? usage.completionTokens;
123
+ // AI SDK v7 moved these under token detail objects; v6 kept them top-level.
124
+ const reasoningTokens =
125
+ usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens;
126
+ const cachedInputTokens =
127
+ usage.cachedInputTokens ?? usage.inputTokenDetails?.cacheReadTokens;
120
128
 
121
129
  if (
122
130
  inputTokens == null &&
123
131
  outputTokens == null &&
124
- usage.reasoningTokens == null &&
125
- usage.cachedInputTokens == null
132
+ reasoningTokens == null &&
133
+ cachedInputTokens == null
126
134
  ) {
127
135
  return undefined;
128
136
  }
@@ -130,11 +138,7 @@ export function normalizeRunTelemetryUsage(
130
138
  return {
131
139
  ...(inputTokens != null ? { inputTokens } : undefined),
132
140
  ...(outputTokens != null ? { outputTokens } : undefined),
133
- ...(usage.reasoningTokens != null
134
- ? { reasoningTokens: usage.reasoningTokens }
135
- : undefined),
136
- ...(usage.cachedInputTokens != null
137
- ? { cachedInputTokens: usage.cachedInputTokens }
138
- : undefined),
141
+ ...(reasoningTokens != null ? { reasoningTokens } : undefined),
142
+ ...(cachedInputTokens != null ? { cachedInputTokens } : undefined),
139
143
  };
140
144
  }
@@ -43,6 +43,7 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
43
43
  });
44
44
 
45
45
  afterEach(() => {
46
+ vi.useRealTimers();
46
47
  vi.unstubAllGlobals();
47
48
  if (originalLocalStorageDescriptor) {
48
49
  Object.defineProperty(
@@ -83,7 +84,7 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
83
84
  expect(values.get(refreshTokenKey)).toBe(JSON.stringify(nextRefreshToken));
84
85
  expect(fetchMock).toHaveBeenCalledWith(
85
86
  `${baseUrl}/v1/auth/tokens/anonymous`,
86
- { method: "POST" },
87
+ { method: "POST", signal: expect.any(AbortSignal) },
87
88
  );
88
89
  });
89
90
 
@@ -106,6 +107,276 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
106
107
  expect(fetchMock).toHaveBeenCalledTimes(1);
107
108
  });
108
109
 
110
+ it("deduplicates anonymous token requests across strategy instances", async () => {
111
+ const values = new Map<string, string>();
112
+ installLocalStorage({
113
+ getItem: (key) => values.get(key) ?? null,
114
+ setItem: (key, value) => {
115
+ values.set(key, value);
116
+ },
117
+ removeItem: (key) => {
118
+ values.delete(key);
119
+ },
120
+ } as Storage);
121
+ const fetchMock = mockAnonymousTokenFetch();
122
+ const first = new AssistantCloudAnonymousAuthStrategy(baseUrl);
123
+ const second = new AssistantCloudAnonymousAuthStrategy(baseUrl);
124
+
125
+ await expect(
126
+ Promise.all([first.getAuthHeaders(), second.getAuthHeaders()]),
127
+ ).resolves.toEqual([
128
+ { Authorization: `Bearer ${accessToken}` },
129
+ { Authorization: `Bearer ${accessToken}` },
130
+ ]);
131
+ expect(fetchMock).toHaveBeenCalledTimes(1);
132
+ expect(values.get(refreshTokenKey)).toBe(JSON.stringify(refreshToken));
133
+ });
134
+
135
+ it("coordinates anonymous token requests across realms", async () => {
136
+ const values = new Map<string, string>();
137
+ installLocalStorage({
138
+ getItem: (key) => values.get(key) ?? null,
139
+ setItem: (key, value) => {
140
+ values.set(key, value);
141
+ },
142
+ removeItem: (key) => {
143
+ values.delete(key);
144
+ },
145
+ } as Storage);
146
+ let lockTail: Promise<unknown> = Promise.resolve();
147
+ const lockRequest = vi.fn(
148
+ (_name: string, callback: () => Promise<string | null>) => {
149
+ const request = lockTail.then(callback);
150
+ lockTail = request.then(
151
+ () => undefined,
152
+ () => undefined,
153
+ );
154
+ return request;
155
+ },
156
+ );
157
+ vi.stubGlobal("navigator", { locks: { request: lockRequest } });
158
+ const rotatedRefreshToken = { token: "r2", expires_at: "2099-01-01" };
159
+ const response = (refreshTokenValue: typeof refreshToken) => ({
160
+ ok: true,
161
+ json: vi.fn().mockResolvedValue({
162
+ access_token: accessToken,
163
+ refresh_token: refreshTokenValue,
164
+ }),
165
+ });
166
+ const fetchMock = vi
167
+ .fn()
168
+ .mockResolvedValueOnce(response(refreshToken))
169
+ .mockResolvedValueOnce(response(rotatedRefreshToken));
170
+ vi.stubGlobal("fetch", fetchMock);
171
+
172
+ const first = new AssistantCloudAnonymousAuthStrategy(baseUrl);
173
+ vi.resetModules();
174
+ const { AssistantCloudAnonymousAuthStrategy: Second } =
175
+ await import("../AssistantCloudAuthStrategy");
176
+
177
+ await expect(
178
+ Promise.all([
179
+ first.getAuthHeaders(),
180
+ new Second(baseUrl).getAuthHeaders(),
181
+ ]),
182
+ ).resolves.toEqual([
183
+ { Authorization: `Bearer ${accessToken}` },
184
+ { Authorization: `Bearer ${accessToken}` },
185
+ ]);
186
+ expect(fetchMock).toHaveBeenNthCalledWith(
187
+ 1,
188
+ `${baseUrl}/v1/auth/tokens/anonymous`,
189
+ { method: "POST", signal: expect.any(AbortSignal) },
190
+ );
191
+ expect(fetchMock).toHaveBeenNthCalledWith(
192
+ 2,
193
+ `${baseUrl}/v1/auth/tokens/refresh`,
194
+ {
195
+ method: "POST",
196
+ headers: { "Content-Type": "application/json" },
197
+ body: JSON.stringify({ refresh_token: refreshToken.token }),
198
+ signal: expect.any(AbortSignal),
199
+ },
200
+ );
201
+ expect(lockRequest).toHaveBeenCalledTimes(2);
202
+ expect(values.get(refreshTokenKey)).toBe(
203
+ JSON.stringify(rotatedRefreshToken),
204
+ );
205
+ });
206
+
207
+ it("retries shared anonymous token requests after a failure", async () => {
208
+ const values = new Map<string, string>();
209
+ installLocalStorage({
210
+ getItem: (key) => values.get(key) ?? null,
211
+ setItem: (key, value) => {
212
+ values.set(key, value);
213
+ },
214
+ removeItem: (key) => {
215
+ values.delete(key);
216
+ },
217
+ } as Storage);
218
+ const failure = new Error("authentication unavailable");
219
+ const fetchMock = vi
220
+ .fn()
221
+ .mockRejectedValueOnce(failure)
222
+ .mockResolvedValueOnce({
223
+ ok: true,
224
+ json: vi.fn().mockResolvedValue({
225
+ access_token: accessToken,
226
+ refresh_token: refreshToken,
227
+ }),
228
+ });
229
+ vi.stubGlobal("fetch", fetchMock);
230
+ const first = new AssistantCloudAnonymousAuthStrategy(baseUrl);
231
+ const second = new AssistantCloudAnonymousAuthStrategy(baseUrl);
232
+
233
+ await Promise.all([
234
+ expect(first.getAuthHeaders()).rejects.toBe(failure),
235
+ expect(second.getAuthHeaders()).rejects.toBe(failure),
236
+ ]);
237
+ expect(fetchMock).toHaveBeenCalledTimes(1);
238
+
239
+ await expect(
240
+ new AssistantCloudAnonymousAuthStrategy(baseUrl).getAuthHeaders(),
241
+ ).resolves.toEqual({ Authorization: `Bearer ${accessToken}` });
242
+ expect(fetchMock).toHaveBeenCalledTimes(2);
243
+ });
244
+
245
+ it("aborts timed out shared anonymous token requests before retrying", async () => {
246
+ vi.useFakeTimers();
247
+ const values = new Map<string, string>();
248
+ installLocalStorage({
249
+ getItem: (key) => values.get(key) ?? null,
250
+ setItem: (key, value) => {
251
+ values.set(key, value);
252
+ },
253
+ removeItem: (key) => {
254
+ values.delete(key);
255
+ },
256
+ } as Storage);
257
+ let requestSignal: AbortSignal | null | undefined;
258
+ const fetchMock = vi
259
+ .fn()
260
+ .mockImplementationOnce((_input: RequestInfo | URL, init?: RequestInit) =>
261
+ Promise.resolve({
262
+ ok: true,
263
+ json: () =>
264
+ new Promise<never>((_resolve, reject) => {
265
+ requestSignal = init?.signal;
266
+ requestSignal?.addEventListener(
267
+ "abort",
268
+ () => reject(requestSignal?.reason),
269
+ { once: true },
270
+ );
271
+ }),
272
+ } as Response),
273
+ )
274
+ .mockResolvedValueOnce({
275
+ ok: true,
276
+ json: vi.fn().mockResolvedValue({
277
+ access_token: accessToken,
278
+ refresh_token: refreshToken,
279
+ }),
280
+ });
281
+ vi.stubGlobal("fetch", fetchMock);
282
+ const first = new AssistantCloudAnonymousAuthStrategy(baseUrl);
283
+ const second = new AssistantCloudAnonymousAuthStrategy(baseUrl);
284
+
285
+ const firstRequest = expect(first.getAuthHeaders()).rejects.toThrow(
286
+ "Assistant Cloud anonymous token request timed out after 30000ms",
287
+ );
288
+ const secondRequest = expect(second.getAuthHeaders()).rejects.toThrow(
289
+ "Assistant Cloud anonymous token request timed out after 30000ms",
290
+ );
291
+ await vi.advanceTimersByTimeAsync(0);
292
+ expect(fetchMock).toHaveBeenCalledTimes(1);
293
+ expect(requestSignal).toBeInstanceOf(AbortSignal);
294
+
295
+ await vi.advanceTimersByTimeAsync(30_000);
296
+ await Promise.all([firstRequest, secondRequest]);
297
+ expect(requestSignal?.aborted).toBe(true);
298
+ expect(values.has(refreshTokenKey)).toBe(false);
299
+
300
+ await expect(
301
+ new AssistantCloudAnonymousAuthStrategy(baseUrl).getAuthHeaders(),
302
+ ).resolves.toEqual({ Authorization: `Bearer ${accessToken}` });
303
+ expect(fetchMock).toHaveBeenCalledTimes(2);
304
+ });
305
+
306
+ it("aborts timed out refresh requests without replacing the identity", async () => {
307
+ vi.useFakeTimers();
308
+ const values = new Map([[refreshTokenKey, JSON.stringify(refreshToken)]]);
309
+ installLocalStorage({
310
+ getItem: (key) => values.get(key) ?? null,
311
+ setItem: (key, value) => {
312
+ values.set(key, value);
313
+ },
314
+ removeItem: (key) => {
315
+ values.delete(key);
316
+ },
317
+ } as Storage);
318
+ let requestSignal: AbortSignal | null | undefined;
319
+ const rotatedRefreshToken = { token: "r2", expires_at: "2099-02-01" };
320
+ const fetchMock = vi
321
+ .fn()
322
+ .mockImplementationOnce(
323
+ (_input: RequestInfo | URL, init?: RequestInit) =>
324
+ new Promise<Response>((_resolve, reject) => {
325
+ requestSignal = init?.signal;
326
+ requestSignal?.addEventListener(
327
+ "abort",
328
+ () => reject(requestSignal?.reason),
329
+ { once: true },
330
+ );
331
+ }),
332
+ )
333
+ .mockResolvedValueOnce({
334
+ ok: true,
335
+ json: vi.fn().mockResolvedValue({
336
+ access_token: accessToken,
337
+ refresh_token: rotatedRefreshToken,
338
+ }),
339
+ });
340
+ vi.stubGlobal("fetch", fetchMock);
341
+ const strategy = new AssistantCloudAnonymousAuthStrategy(baseUrl);
342
+
343
+ const request = expect(strategy.getAuthHeaders()).rejects.toThrow(
344
+ "Assistant Cloud refresh token request timed out after 30000ms",
345
+ );
346
+ expect(requestSignal).toBeInstanceOf(AbortSignal);
347
+
348
+ await vi.advanceTimersByTimeAsync(30_000);
349
+ await request;
350
+ expect(requestSignal?.aborted).toBe(true);
351
+ expect(values.get(refreshTokenKey)).toBe(JSON.stringify(refreshToken));
352
+
353
+ await expect(strategy.getAuthHeaders()).resolves.toEqual({
354
+ Authorization: `Bearer ${accessToken}`,
355
+ });
356
+ expect(fetchMock).toHaveBeenCalledTimes(2);
357
+ expect(values.get(refreshTokenKey)).toBe(
358
+ JSON.stringify(rotatedRefreshToken),
359
+ );
360
+ });
361
+
362
+ it("keeps anonymous token requests independent without localStorage", async () => {
363
+ delete (globalThis as { localStorage?: Storage }).localStorage;
364
+ const lockRequest = vi.fn();
365
+ vi.stubGlobal("navigator", { locks: { request: lockRequest } });
366
+ const fetchMock = mockAnonymousTokenFetch();
367
+ const first = new AssistantCloudAnonymousAuthStrategy(baseUrl);
368
+ const second = new AssistantCloudAnonymousAuthStrategy(baseUrl);
369
+
370
+ await expect(
371
+ Promise.all([first.getAuthHeaders(), second.getAuthHeaders()]),
372
+ ).resolves.toEqual([
373
+ { Authorization: `Bearer ${accessToken}` },
374
+ { Authorization: `Bearer ${accessToken}` },
375
+ ]);
376
+ expect(fetchMock).toHaveBeenCalledTimes(2);
377
+ expect(lockRequest).not.toHaveBeenCalled();
378
+ });
379
+
109
380
  it("scopes anonymous refresh tokens by backend", async () => {
110
381
  const secondBaseUrl = "https://other.example.com";
111
382
  const values = new Map<string, string>();
@@ -140,20 +411,20 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
140
411
  });
141
412
  vi.stubGlobal("fetch", fetchMock);
142
413
 
143
- await new AssistantCloudAnonymousAuthStrategy(baseUrl).getAuthHeaders();
144
- await new AssistantCloudAnonymousAuthStrategy(
145
- secondBaseUrl,
146
- ).getAuthHeaders();
414
+ await Promise.all([
415
+ new AssistantCloudAnonymousAuthStrategy(baseUrl).getAuthHeaders(),
416
+ new AssistantCloudAnonymousAuthStrategy(secondBaseUrl).getAuthHeaders(),
417
+ ]);
147
418
 
148
419
  expect(fetchMock).toHaveBeenNthCalledWith(
149
420
  1,
150
421
  `${baseUrl}/v1/auth/tokens/anonymous`,
151
- { method: "POST" },
422
+ { method: "POST", signal: expect.any(AbortSignal) },
152
423
  );
153
424
  expect(fetchMock).toHaveBeenNthCalledWith(
154
425
  2,
155
426
  `${secondBaseUrl}/v1/auth/tokens/anonymous`,
156
- { method: "POST" },
427
+ { method: "POST", signal: expect.any(AbortSignal) },
157
428
  );
158
429
  expect(values.get(refreshTokenKey)).toBe(JSON.stringify(refreshToken));
159
430
  expect(values.get(`aui:refresh_token:${secondBaseUrl}`)).toBe(
@@ -206,12 +477,13 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
206
477
  method: "POST",
207
478
  headers: { "Content-Type": "application/json" },
208
479
  body: JSON.stringify({ refresh_token: refreshToken.token }),
480
+ signal: expect.any(AbortSignal),
209
481
  },
210
482
  );
211
483
  expect(fetchMock).toHaveBeenNthCalledWith(
212
484
  2,
213
485
  `${secondBaseUrl}/v1/auth/tokens/anonymous`,
214
- { method: "POST" },
486
+ { method: "POST", signal: expect.any(AbortSignal) },
215
487
  );
216
488
  expect(values.get(refreshTokenKey)).toBe(JSON.stringify(refreshToken));
217
489
  expect(values.get(`aui:refresh_token:${secondBaseUrl}`)).toBe(
@@ -270,12 +542,13 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
270
542
  method: "POST",
271
543
  headers: { "Content-Type": "application/json" },
272
544
  body: JSON.stringify({ refresh_token: scopedRefreshToken.token }),
545
+ signal: expect.any(AbortSignal),
273
546
  },
274
547
  );
275
548
  expect(fetchMock).toHaveBeenNthCalledWith(
276
549
  2,
277
550
  `${secondBaseUrl}/v1/auth/tokens/anonymous`,
278
- { method: "POST" },
551
+ { method: "POST", signal: expect.any(AbortSignal) },
279
552
  );
280
553
  expect(values.has("aui:refresh_token")).toBe(false);
281
554
  expect(values.get(`aui:refresh_token:${secondBaseUrl}`)).toBe(
@@ -470,6 +743,7 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
470
743
  method: "POST",
471
744
  headers: { "Content-Type": "application/json" },
472
745
  body: JSON.stringify({ refresh_token: refreshToken.token }),
746
+ signal: expect.any(AbortSignal),
473
747
  },
474
748
  );
475
749
  expect(setItem).not.toHaveBeenCalled();
@@ -538,7 +812,7 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
538
812
  expect(fetchMock).toHaveBeenNthCalledWith(
539
813
  2,
540
814
  `${baseUrl}/v1/auth/tokens/anonymous`,
541
- { method: "POST" },
815
+ { method: "POST", signal: expect.any(AbortSignal) },
542
816
  );
543
817
  expect(values.get(refreshTokenKey)).toBe(
544
818
  JSON.stringify(replacementRefreshToken),
@@ -108,6 +108,99 @@ describe("CloudMessagePersistence", () => {
108
108
  });
109
109
  });
110
110
 
111
+ it("deduplicates concurrent child appends while the parent is pending", async () => {
112
+ let resolveParent!: (value: { message_id: string }) => void;
113
+ vi.mocked(cloud.threads.messages.create)
114
+ .mockImplementationOnce(
115
+ () =>
116
+ new Promise((resolve) => {
117
+ resolveParent = resolve;
118
+ }),
119
+ )
120
+ .mockResolvedValue({ message_id: "remote-child" });
121
+
122
+ const parent = persistence.append("thread-1", "parent", null, "aui/v0", {
123
+ text: "parent",
124
+ });
125
+ const firstChild = persistence.append(
126
+ "thread-1",
127
+ "child",
128
+ "parent",
129
+ "aui/v0",
130
+ { text: "child" },
131
+ );
132
+ const secondChild = persistence.append(
133
+ "thread-1",
134
+ "child",
135
+ "parent",
136
+ "aui/v0",
137
+ { text: "child" },
138
+ );
139
+
140
+ expect(persistence.isPersisted("child")).toBe(true);
141
+ resolveParent({ message_id: "remote-parent" });
142
+ await Promise.all([parent, firstChild, secondChild]);
143
+
144
+ expect(cloud.threads.messages.create).toHaveBeenCalledTimes(2);
145
+ });
146
+
147
+ it("resolves remote IDs throughout a concurrent message chain", async () => {
148
+ let resolveParent!: (value: { message_id: string }) => void;
149
+ vi.mocked(cloud.threads.messages.create)
150
+ .mockImplementationOnce(
151
+ () =>
152
+ new Promise((resolve) => {
153
+ resolveParent = resolve;
154
+ }),
155
+ )
156
+ .mockResolvedValueOnce({ message_id: "remote-child" })
157
+ .mockResolvedValueOnce({ message_id: "remote-grandchild" });
158
+
159
+ const parent = persistence.append("thread-1", "parent", null, "aui/v0", {
160
+ text: "parent",
161
+ });
162
+ const child = persistence.append("thread-1", "child", "parent", "aui/v0", {
163
+ text: "child",
164
+ });
165
+ const grandchild = persistence.append(
166
+ "thread-1",
167
+ "grandchild",
168
+ "child",
169
+ "aui/v0",
170
+ { text: "grandchild" },
171
+ );
172
+
173
+ resolveParent({ message_id: "remote-parent" });
174
+ await Promise.all([parent, child, grandchild]);
175
+
176
+ expect(cloud.threads.messages.create).toHaveBeenCalledWith("thread-1", {
177
+ parent_id: "remote-parent",
178
+ format: "aui/v0",
179
+ content: { text: "child" },
180
+ });
181
+ expect(cloud.threads.messages.create).toHaveBeenCalledWith("thread-1", {
182
+ parent_id: "remote-child",
183
+ format: "aui/v0",
184
+ content: { text: "grandchild" },
185
+ });
186
+ });
187
+
188
+ it("re-appends a message after its mapping has settled", async () => {
189
+ vi.mocked(cloud.threads.messages.create)
190
+ .mockResolvedValueOnce({ message_id: "remote-1" })
191
+ .mockResolvedValueOnce({ message_id: "remote-2" });
192
+
193
+ await persistence.append("thread-1", "local-1", null, "aui/v0", {
194
+ text: "first",
195
+ });
196
+ await persistence.append("thread-1", "local-1", null, "aui/v0", {
197
+ text: "second",
198
+ });
199
+
200
+ expect(cloud.threads.messages.create).toHaveBeenCalledTimes(2);
201
+ expect(await persistence.getRemoteId("local-1")).toBe("remote-2");
202
+ });
203
+
111
204
  it("loaded messages are marked as persisted and not re-created", async () => {
112
205
  vi.mocked(cloud.threads.messages.list).mockResolvedValue({
113
206
  messages: [