assistant-cloud 0.1.41 → 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.
Files changed (40) hide show
  1. package/README.md +3 -3
  2. package/dist/AssistantCloud.js +2 -2
  3. package/dist/AssistantCloud.js.map +1 -1
  4. package/dist/AssistantCloudAPI.d.ts.map +1 -1
  5. package/dist/AssistantCloudAPI.js +5 -5
  6. package/dist/AssistantCloudAPI.js.map +1 -1
  7. package/dist/AssistantCloudAuthStrategy.d.ts.map +1 -1
  8. package/dist/AssistantCloudAuthStrategy.js +68 -17
  9. package/dist/AssistantCloudAuthStrategy.js.map +1 -1
  10. package/dist/AssistantCloudRuns.d.ts +3 -13
  11. package/dist/AssistantCloudRuns.d.ts.map +1 -1
  12. package/dist/AssistantCloudRuns.js.map +1 -1
  13. package/dist/CloudMessagePersistence.d.ts.map +1 -1
  14. package/dist/CloudMessagePersistence.js +19 -11
  15. package/dist/CloudMessagePersistence.js.map +1 -1
  16. package/dist/generateThreadTitle.d.ts +15 -0
  17. package/dist/generateThreadTitle.d.ts.map +1 -0
  18. package/dist/generateThreadTitle.js +25 -0
  19. package/dist/generateThreadTitle.js.map +1 -0
  20. package/dist/index.d.ts +3 -1
  21. package/dist/index.js +3 -1
  22. package/dist/runTelemetry.d.ts +61 -0
  23. package/dist/runTelemetry.d.ts.map +1 -0
  24. package/dist/runTelemetry.js +82 -0
  25. package/dist/runTelemetry.js.map +1 -0
  26. package/package.json +5 -5
  27. package/src/AssistantCloud.ts +1 -1
  28. package/src/AssistantCloudAPI.ts +9 -8
  29. package/src/AssistantCloudAuthStrategy.ts +140 -46
  30. package/src/AssistantCloudRuns.ts +3 -14
  31. package/src/CloudMessagePersistence.ts +23 -19
  32. package/src/generateThreadTitle.test.ts +71 -0
  33. package/src/generateThreadTitle.ts +38 -0
  34. package/src/index.ts +10 -0
  35. package/src/runTelemetry.test.ts +171 -0
  36. package/src/runTelemetry.ts +144 -0
  37. package/src/tests/AssistantCloud.test.ts +39 -0
  38. package/src/tests/AssistantCloudAPI.test.ts +25 -0
  39. package/src/tests/AssistantCloudAuthStrategy.test.ts +284 -10
  40. package/src/tests/CloudMessagePersistence.test.ts +93 -0
@@ -0,0 +1,82 @@
1
+ //#region src/runTelemetry.ts
2
+ const MAX_TELEMETRY_TEXT_LENGTH = 5e4;
3
+ const BASE64_PATTERN = /^[A-Za-z0-9+/]{100,}={0,2}$/;
4
+ /**
5
+ * Clamps a string to the size the runs endpoint accepts for a single span
6
+ * field.
7
+ */
8
+ function truncateRunTelemetryText(value) {
9
+ if (value.length <= MAX_TELEMETRY_TEXT_LENGTH) return value;
10
+ return value.slice(0, MAX_TELEMETRY_TEXT_LENGTH);
11
+ }
12
+ function safeStringify(value) {
13
+ if (value == null) return void 0;
14
+ try {
15
+ return truncateRunTelemetryText(JSON.stringify(value));
16
+ } catch {
17
+ return;
18
+ }
19
+ }
20
+ function summarizeMcpResult(value) {
21
+ if (value == null) return void 0;
22
+ try {
23
+ const parsed = typeof value === "string" ? JSON.parse(value) : value;
24
+ if (Array.isArray(parsed)) {
25
+ const summarized = parsed.map((item) => {
26
+ if (item && typeof item === "object" && item.type) {
27
+ if ((item.type === "image" || item.type === "audio") && typeof item.data === "string" && BASE64_PATTERN.test(item.data.slice(0, 200))) {
28
+ const sizeKB = (item.data.length * 3 / 4 / 1024).toFixed(1);
29
+ return {
30
+ ...item,
31
+ data: `[${item.type}: ${sizeKB}KB]`
32
+ };
33
+ }
34
+ }
35
+ return item;
36
+ });
37
+ return truncateRunTelemetryText(JSON.stringify(summarized));
38
+ }
39
+ } catch {}
40
+ return safeStringify(value);
41
+ }
42
+ /**
43
+ * Serializes one tool call into the shape the runs endpoint accepts. An `mcp`
44
+ * source has its result summarized, because MCP content blocks carry inline
45
+ * base64 image and audio payloads that would otherwise dominate the report.
46
+ */
47
+ function createRunTelemetryToolCall(init) {
48
+ const { toolName, toolCallId, args, argsText, result, toolSource } = init;
49
+ const call = {
50
+ tool_name: toolName,
51
+ tool_call_id: toolCallId
52
+ };
53
+ const toolArgs = argsText != null ? truncateRunTelemetryText(argsText) : safeStringify(args);
54
+ if (toolArgs !== void 0) call.tool_args = toolArgs;
55
+ const toolResult = toolSource === "mcp" ? summarizeMcpResult(result) : safeStringify(result);
56
+ if (toolResult !== void 0) call.tool_result = toolResult;
57
+ if (toolSource) call.tool_source = toolSource;
58
+ return call;
59
+ }
60
+ /**
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,
64
+ * so callers can tell an empty usage object from a zeroed one.
65
+ */
66
+ function normalizeRunTelemetryUsage(usage) {
67
+ const inputTokens = usage.inputTokens ?? usage.promptTokens;
68
+ const outputTokens = usage.outputTokens ?? usage.completionTokens;
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;
72
+ return {
73
+ ...inputTokens != null ? { inputTokens } : void 0,
74
+ ...outputTokens != null ? { outputTokens } : void 0,
75
+ ...reasoningTokens != null ? { reasoningTokens } : void 0,
76
+ ...cachedInputTokens != null ? { cachedInputTokens } : void 0
77
+ };
78
+ }
79
+ //#endregion
80
+ export { createRunTelemetryToolCall, normalizeRunTelemetryUsage, truncateRunTelemetryText };
81
+
82
+ //# sourceMappingURL=runTelemetry.js.map
@@ -0,0 +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 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.41",
3
+ "version": "0.1.43",
4
4
  "description": "Cloud integration for assistant-ui",
5
5
  "keywords": [
6
6
  "assistant",
@@ -27,12 +27,12 @@
27
27
  ],
28
28
  "sideEffects": false,
29
29
  "dependencies": {
30
- "assistant-stream": "^0.3.38"
30
+ "assistant-stream": "^0.3.41"
31
31
  },
32
32
  "devDependencies": {
33
- "@types/node": "^26.2.0",
34
- "vitest": "^4.1.10",
35
- "@assistant-ui/x-buildutils": "0.0.23"
33
+ "@assistant-ui/x-buildutils": "0.0.25",
34
+ "@types/node": "^26.4.0",
35
+ "vitest": "^4.1.11"
36
36
  },
37
37
  "publishConfig": {
38
38
  "access": "public",
@@ -33,6 +33,6 @@ export class AssistantCloud {
33
33
  ? { enabled: false }
34
34
  : t === true || t === undefined
35
35
  ? { enabled: true }
36
- : { enabled: t.enabled !== false, ...t };
36
+ : { ...t, enabled: t.enabled !== false };
37
37
  }
38
38
  }
@@ -139,16 +139,17 @@ export class AssistantCloudAPI {
139
139
 
140
140
  if (!response.ok) {
141
141
  const text = await response.text();
142
+ let message: string | undefined;
142
143
  try {
143
144
  const body = JSON.parse(text);
144
- throw new CloudAPIError(body.message, response.status);
145
- } catch (error) {
146
- if (error instanceof CloudAPIError) throw error;
147
- throw new CloudAPIError(
148
- `Request failed with status ${response.status}, ${text}`,
149
- response.status,
150
- );
151
- }
145
+ if (typeof body?.message === "string" && body.message.length > 0) {
146
+ message = body.message;
147
+ }
148
+ } catch {}
149
+ throw new CloudAPIError(
150
+ message ?? `Request failed with status ${response.status}, ${text}`,
151
+ response.status,
152
+ );
152
153
  }
153
154
 
154
155
  return response;
@@ -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> {
@@ -1,5 +1,5 @@
1
1
  import type { AssistantCloudAPI } from "./AssistantCloudAPI";
2
- import type { SamplingCallData } from "./instrumentMcpSampling";
2
+ import type { AssistantCloudRunReportToolCall } from "./runTelemetry";
3
3
  import { AssistantStream, PlainTextDecoder } from "assistant-stream";
4
4
  import {
5
5
  CloudResponseError,
@@ -13,17 +13,6 @@ type AssistantCloudRunsStreamBody = {
13
13
  messages: readonly unknown[]; // TODO type
14
14
  };
15
15
 
16
- type ReportToolCall = {
17
- tool_name: string;
18
- tool_call_id: string;
19
- tool_args?: string;
20
- tool_result?: string;
21
- tool_source?: "mcp" | "frontend" | "backend";
22
- start_ms?: number;
23
- end_ms?: number;
24
- sampling_calls?: SamplingCallData[];
25
- };
26
-
27
16
  // NOTE: Keep this payload shape aligned with the strict runtime validator in
28
17
  // assistant-cloud: apps/aui-cloud-api/src/endpoints/runs/create.ts
29
18
  // (createRunSchema). New telemetry fields must be added in both repos together.
@@ -31,13 +20,13 @@ export type AssistantCloudRunReport = {
31
20
  thread_id: string;
32
21
  status: "completed" | "incomplete" | "error";
33
22
  total_steps?: number;
34
- tool_calls?: ReportToolCall[];
23
+ tool_calls?: AssistantCloudRunReportToolCall[];
35
24
  steps?: {
36
25
  input_tokens?: number;
37
26
  output_tokens?: number;
38
27
  reasoning_tokens?: number;
39
28
  cached_input_tokens?: number;
40
- tool_calls?: ReportToolCall[];
29
+ tool_calls?: AssistantCloudRunReportToolCall[];
41
30
  start_ms?: number;
42
31
  end_ms?: number;
43
32
  }[];
@@ -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
  /**
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { AssistantCloud } from "./AssistantCloud";
3
+ import { generateThreadTitle } from "./generateThreadTitle";
4
+
5
+ const titleStream = (...chunks: { type: string; textDelta?: string }[]) =>
6
+ new ReadableStream({
7
+ start(controller) {
8
+ for (const chunk of chunks) controller.enqueue(chunk);
9
+ controller.close();
10
+ },
11
+ });
12
+
13
+ const createCloud = (stream: ReadableStream<unknown>) => {
14
+ const update = vi.fn().mockResolvedValue(undefined);
15
+ const run = vi.fn().mockResolvedValue(stream);
16
+ const cloud = {
17
+ threads: { update },
18
+ runs: { stream: run },
19
+ } as unknown as AssistantCloud;
20
+ return { cloud, update, run };
21
+ };
22
+
23
+ describe("generateThreadTitle", () => {
24
+ it("accumulates text deltas and updates the thread title", async () => {
25
+ const { cloud, run, update } = createCloud(
26
+ titleStream(
27
+ { type: "text-delta", textDelta: "Weather " },
28
+ { type: "text-delta", textDelta: "chat" },
29
+ ),
30
+ );
31
+
32
+ await expect(
33
+ generateThreadTitle(cloud, {
34
+ threadId: "thread-1",
35
+ messages: [
36
+ {
37
+ role: "user",
38
+ content: [{ type: "text", text: "What is the weather today?" }],
39
+ },
40
+ ],
41
+ }),
42
+ ).resolves.toBe("Weather chat");
43
+
44
+ expect(run).toHaveBeenCalledExactlyOnceWith({
45
+ thread_id: "thread-1",
46
+ assistant_id: "system/thread_title",
47
+ messages: [
48
+ {
49
+ role: "user",
50
+ content: [{ type: "text", text: "What is the weather today?" }],
51
+ },
52
+ ],
53
+ });
54
+ expect(update).toHaveBeenCalledExactlyOnceWith("thread-1", {
55
+ title: "Weather chat",
56
+ });
57
+ });
58
+
59
+ it("returns null without updating when the stream has no text", async () => {
60
+ const { cloud, update } = createCloud(titleStream());
61
+
62
+ await expect(
63
+ generateThreadTitle(cloud, {
64
+ threadId: "thread-1",
65
+ messages: [],
66
+ }),
67
+ ).resolves.toBeNull();
68
+
69
+ expect(update).not.toHaveBeenCalled();
70
+ });
71
+ });
@@ -0,0 +1,38 @@
1
+ import type { AssistantCloud } from "./AssistantCloud";
2
+
3
+ export async function generateThreadTitle(
4
+ cloud: AssistantCloud,
5
+ options: {
6
+ threadId: string;
7
+ messages: readonly {
8
+ role: string;
9
+ content: readonly { type: "text"; text: string }[];
10
+ }[];
11
+ },
12
+ ): Promise<string | null> {
13
+ const stream = await cloud.runs.stream({
14
+ thread_id: options.threadId,
15
+ assistant_id: "system/thread_title",
16
+ messages: options.messages,
17
+ });
18
+
19
+ let title = "";
20
+ const reader = stream.getReader();
21
+ try {
22
+ while (true) {
23
+ const { done, value: chunk } = await reader.read();
24
+ if (done) break;
25
+ if (chunk.type === "text-delta") {
26
+ title += chunk.textDelta;
27
+ }
28
+ }
29
+ } finally {
30
+ reader.releaseLock();
31
+ }
32
+
33
+ if (title) {
34
+ await cloud.threads.update(options.threadId, { title });
35
+ }
36
+
37
+ return title || null;
38
+ }
package/src/index.ts CHANGED
@@ -2,7 +2,17 @@ export type { CloudMessage } from "./AssistantCloudThreadMessages";
2
2
  export type { AssistantCloudTelemetryConfig } from "./AssistantCloudAPI";
3
3
  export { CloudAPIError } from "./AssistantCloudAPI";
4
4
  export { CloudResponseError } from "./cloudResponse";
5
+ export { generateThreadTitle } from "./generateThreadTitle";
5
6
  export type { AssistantCloudRunReport } from "./AssistantCloudRuns";
7
+ export {
8
+ createRunTelemetryToolCall,
9
+ normalizeRunTelemetryUsage,
10
+ truncateRunTelemetryText,
11
+ type AssistantCloudRunReportToolCall,
12
+ type RunTelemetryToolCallInit,
13
+ type RunTelemetryUsage,
14
+ type RunTelemetryUsageInit,
15
+ } from "./runTelemetry";
6
16
  export { AssistantCloud } from "./AssistantCloud";
7
17
  export { CloudMessagePersistence } from "./CloudMessagePersistence";
8
18
  export {