assistant-cloud 0.2.0 → 0.2.1

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 (80) hide show
  1. package/README.md +26 -0
  2. package/dist/AssistantCloud.d.ts +2 -1
  3. package/dist/AssistantCloud.d.ts.map +1 -1
  4. package/dist/AssistantCloud.js +2 -0
  5. package/dist/AssistantCloud.js.map +1 -1
  6. package/dist/AssistantCloudAPI.d.ts +11 -2
  7. package/dist/AssistantCloudAPI.d.ts.map +1 -1
  8. package/dist/AssistantCloudAPI.js +17 -1
  9. package/dist/AssistantCloudAPI.js.map +1 -1
  10. package/dist/AssistantCloudAuthStrategy.d.ts.map +1 -1
  11. package/dist/AssistantCloudAuthStrategy.js +4 -2
  12. package/dist/AssistantCloudAuthStrategy.js.map +1 -1
  13. package/dist/AssistantCloudEvents.d.ts.map +1 -1
  14. package/dist/AssistantCloudEvents.js +4 -0
  15. package/dist/AssistantCloudEvents.js.map +1 -1
  16. package/dist/AssistantCloudRuns.d.ts +1 -0
  17. package/dist/AssistantCloudRuns.d.ts.map +1 -1
  18. package/dist/AssistantCloudRuns.js +2 -1
  19. package/dist/AssistantCloudRuns.js.map +1 -1
  20. package/dist/CloudEngagementReporter.d.ts +74 -0
  21. package/dist/CloudEngagementReporter.d.ts.map +1 -0
  22. package/dist/CloudEngagementReporter.js +132 -0
  23. package/dist/CloudEngagementReporter.js.map +1 -0
  24. package/dist/CloudMessagePersistence.d.ts +8 -9
  25. package/dist/CloudMessagePersistence.d.ts.map +1 -1
  26. package/dist/CloudMessagePersistence.js +18 -11
  27. package/dist/CloudMessagePersistence.js.map +1 -1
  28. package/dist/CloudRunReporter.d.ts +20 -0
  29. package/dist/CloudRunReporter.d.ts.map +1 -0
  30. package/dist/CloudRunReporter.js +38 -0
  31. package/dist/CloudRunReporter.js.map +1 -0
  32. package/dist/FormattedCloudPersistence.d.ts +3 -8
  33. package/dist/FormattedCloudPersistence.d.ts.map +1 -1
  34. package/dist/FormattedCloudPersistence.js +3 -8
  35. package/dist/FormattedCloudPersistence.js.map +1 -1
  36. package/dist/ai-sdk/index.d.ts +33 -0
  37. package/dist/ai-sdk/index.d.ts.map +1 -0
  38. package/dist/ai-sdk/index.js +147 -0
  39. package/dist/ai-sdk/index.js.map +1 -0
  40. package/dist/generateThreadTitle.js +1 -0
  41. package/dist/generateThreadTitle.js.map +1 -1
  42. package/dist/index.d.ts +5 -3
  43. package/dist/index.js +4 -2
  44. package/dist/instrumentMcpSampling.d.ts.map +1 -1
  45. package/dist/instrumentMcpSampling.js +12 -2
  46. package/dist/instrumentMcpSampling.js.map +1 -1
  47. package/dist/runTelemetry.d.ts +17 -1
  48. package/dist/runTelemetry.d.ts.map +1 -1
  49. package/dist/runTelemetry.js.map +1 -1
  50. package/dist/version.d.ts +5 -0
  51. package/dist/version.d.ts.map +1 -0
  52. package/dist/version.js +6 -0
  53. package/dist/version.js.map +1 -0
  54. package/package.json +19 -6
  55. package/src/AssistantCloud.ts +3 -0
  56. package/src/AssistantCloudAPI.ts +31 -1
  57. package/src/AssistantCloudAuthStrategy.ts +4 -2
  58. package/src/AssistantCloudEvents.test.ts +27 -2
  59. package/src/AssistantCloudEvents.ts +5 -0
  60. package/src/AssistantCloudRuns.ts +1 -0
  61. package/src/CloudEngagementReporter.ts +231 -0
  62. package/src/CloudMessagePersistence.ts +24 -13
  63. package/src/CloudRunReporter.ts +38 -0
  64. package/src/FormattedCloudPersistence.ts +3 -8
  65. package/src/ai-sdk/index.test.ts +258 -0
  66. package/src/ai-sdk/index.ts +222 -0
  67. package/src/generateThreadTitle.test.ts +32 -0
  68. package/src/generateThreadTitle.ts +1 -0
  69. package/src/index.ts +11 -1
  70. package/src/instrumentMcpSampling.test.ts +102 -0
  71. package/src/instrumentMcpSampling.ts +16 -2
  72. package/src/runTelemetry.ts +17 -0
  73. package/src/tests/AssistantCloud.test.ts +33 -1
  74. package/src/tests/AssistantCloudAPI.test.ts +51 -0
  75. package/src/tests/AssistantCloudAuthStrategy.test.ts +22 -0
  76. package/src/tests/AssistantCloudProjects.test.ts +1 -5
  77. package/src/tests/CloudEngagementReporter.test.ts +184 -0
  78. package/src/tests/CloudMessagePersistence.test.ts +235 -0
  79. package/src/tests/CloudRunReporter.test.ts +100 -0
  80. package/src/version.ts +4 -0
@@ -0,0 +1,222 @@
1
+ import type { UIMessage } from "ai";
2
+ import type { MessageFormatAdapter } from "../FormattedCloudPersistence";
3
+ import type { SamplingCallData } from "../instrumentMcpSampling";
4
+ import {
5
+ type AssistantCloudRunReportToolCall,
6
+ createRunTelemetryToolCall,
7
+ extractRunTelemetryModelId,
8
+ normalizeRunTelemetryUsage,
9
+ type RunMessageTelemetry,
10
+ type RunReportStepInit,
11
+ type RunTelemetryUsage,
12
+ type RunTelemetryUsageInit,
13
+ truncateRunTelemetryText,
14
+ } from "../runTelemetry";
15
+
16
+ export type AISDKStorageFormat = Omit<UIMessage, "id">;
17
+
18
+ /** The stored form of an AI SDK message: the message without its id. */
19
+ export const aiSDKV6FormatAdapter: MessageFormatAdapter<
20
+ UIMessage,
21
+ AISDKStorageFormat
22
+ > = {
23
+ format: "ai-sdk/v6",
24
+ encode: ({ message: { id: _id, ...message } }) => message,
25
+ decode: (stored) => ({
26
+ parentId: stored.parent_id,
27
+ message: { id: stored.id, ...stored.content } as UIMessage,
28
+ }),
29
+ getId: (message) => message.id,
30
+ };
31
+
32
+ /**
33
+ * A message as an AI SDK integration holds it, or as the cloud stored it under
34
+ * the ai-sdk/v6 format, which drops the id. Parts are typed by their `type`
35
+ * alone, so this shape and the telemetry read from it need nothing from `ai`.
36
+ */
37
+ export type AISDKMessageLike = {
38
+ id?: string | undefined;
39
+ role: string;
40
+ parts: readonly { type: string; [key: string]: unknown }[];
41
+ metadata?: unknown;
42
+ };
43
+
44
+ type Part = Record<string, unknown> & { type: string };
45
+
46
+ function isRecord(value: unknown): value is Record<string, unknown> {
47
+ return value !== null && typeof value === "object";
48
+ }
49
+
50
+ function isPart(value: unknown): value is Part {
51
+ return isRecord(value) && typeof value.type === "string";
52
+ }
53
+
54
+ /**
55
+ * The AI SDK's own tool part rules, kept here so the entry loads without the
56
+ * `ai` runtime: a static tool part is `tool-<name>`, a dynamic one is
57
+ * `dynamic-tool` with its name in `toolName`.
58
+ */
59
+ function toolCallOf(part: Part): AssistantCloudRunReportToolCall | undefined {
60
+ if (typeof part.toolCallId !== "string") return undefined;
61
+ const isStatic = part.type.startsWith("tool-");
62
+ if (!isStatic && part.type !== "dynamic-tool") return undefined;
63
+ const toolName = isStatic
64
+ ? part.type.slice("tool-".length)
65
+ : typeof part.toolName === "string"
66
+ ? part.toolName
67
+ : undefined;
68
+ if (!toolName) return undefined;
69
+ return createRunTelemetryToolCall({
70
+ toolName,
71
+ toolCallId: part.toolCallId,
72
+ args: part.input ?? part.args,
73
+ result: part.output ?? part.result,
74
+ toolSource: isStatic ? "frontend" : "mcp",
75
+ });
76
+ }
77
+
78
+ function attachSamplingCalls(
79
+ toolCalls: readonly AssistantCloudRunReportToolCall[],
80
+ metadata: Record<string, unknown> | undefined,
81
+ ): void {
82
+ const samplingCalls = isRecord(metadata?.samplingCalls)
83
+ ? (metadata.samplingCalls as Record<string, SamplingCallData[]>)
84
+ : undefined;
85
+ if (!samplingCalls) return;
86
+ for (const toolCall of toolCalls) {
87
+ const calls = samplingCalls[toolCall.tool_call_id];
88
+ if (Array.isArray(calls) && calls.length > 0) {
89
+ toolCall.sampling_calls = calls;
90
+ }
91
+ }
92
+ }
93
+
94
+ function stepUsages(
95
+ metadata: Record<string, unknown> | undefined,
96
+ ): (RunTelemetryUsageInit | undefined)[] {
97
+ const steps = metadata?.steps;
98
+ if (!Array.isArray(steps)) return [];
99
+ return steps.map((step) =>
100
+ isRecord(step) && isRecord(step.usage)
101
+ ? (step.usage as RunTelemetryUsageInit)
102
+ : undefined,
103
+ );
104
+ }
105
+
106
+ function sumUsage(usages: readonly RunTelemetryUsage[]): RunTelemetryUsage {
107
+ const total: RunTelemetryUsage = {};
108
+ for (const usage of usages) {
109
+ if (usage.inputTokens != null) {
110
+ total.inputTokens = (total.inputTokens ?? 0) + usage.inputTokens;
111
+ }
112
+ if (usage.outputTokens != null) {
113
+ total.outputTokens = (total.outputTokens ?? 0) + usage.outputTokens;
114
+ }
115
+ if (usage.reasoningTokens != null) {
116
+ total.reasoningTokens =
117
+ (total.reasoningTokens ?? 0) + usage.reasoningTokens;
118
+ }
119
+ if (usage.cachedInputTokens != null) {
120
+ total.cachedInputTokens =
121
+ (total.cachedInputTokens ?? 0) + usage.cachedInputTokens;
122
+ }
123
+ }
124
+ return total;
125
+ }
126
+
127
+ /**
128
+ * The usage a message reports: `metadata.usage` when the integration copied
129
+ * the run total there, else the sum over `metadata.steps[].usage`.
130
+ */
131
+ function messageUsage(
132
+ metadata: Record<string, unknown> | undefined,
133
+ ): RunTelemetryUsage | undefined {
134
+ const total = isRecord(metadata?.usage)
135
+ ? normalizeRunTelemetryUsage(metadata.usage as RunTelemetryUsageInit)
136
+ : undefined;
137
+ if (total) return total;
138
+ const perStep = stepUsages(metadata).flatMap((usage) => {
139
+ const normalized = usage ? normalizeRunTelemetryUsage(usage) : undefined;
140
+ return normalized ? [normalized] : [];
141
+ });
142
+ return perStep.length > 0 ? sumUsage(perStep) : undefined;
143
+ }
144
+
145
+ /**
146
+ * Reads the run report fields out of the assistant messages of one run. A run
147
+ * the AI SDK streamed as one message is one element; a run the cloud stored as
148
+ * several assistant rows is aggregated, step by step, in order. Returns null
149
+ * when no assistant message is present. Status reads completed when the run
150
+ * produced text or tool calls, as a live finish with reason `tool-calls` does;
151
+ * an integration that observed the finish event overrides it.
152
+ */
153
+ export function extractAISDKRunTelemetry(
154
+ messages: readonly AISDKMessageLike[],
155
+ ): RunMessageTelemetry | null {
156
+ const textParts: string[] = [];
157
+ const toolCalls: AssistantCloudRunReportToolCall[] = [];
158
+ const steps: RunReportStepInit[] = [];
159
+ const usages: RunTelemetryUsage[] = [];
160
+ let assistant: AISDKMessageLike | undefined;
161
+
162
+ for (const message of messages) {
163
+ if (message.role !== "assistant") continue;
164
+ assistant = message;
165
+ const metadata = isRecord(message.metadata) ? message.metadata : undefined;
166
+ const usagePerStep = stepUsages(metadata);
167
+ const messageToolCalls: AssistantCloudRunReportToolCall[] = [];
168
+ let step: RunReportStepInit | undefined;
169
+ let stepIndex = -1;
170
+
171
+ for (const part of message.parts) {
172
+ if (!isPart(part)) continue;
173
+ if (part.type === "step-start") {
174
+ stepIndex += 1;
175
+ const usage = usagePerStep[stepIndex];
176
+ step = usage ? { usage } : {};
177
+ steps.push(step);
178
+ continue;
179
+ }
180
+ if (part.type === "text" && typeof part.text === "string" && part.text) {
181
+ textParts.push(part.text);
182
+ continue;
183
+ }
184
+ const toolCall = toolCallOf(part);
185
+ if (!toolCall) continue;
186
+ toolCalls.push(toolCall);
187
+ messageToolCalls.push(toolCall);
188
+ if (step) {
189
+ step.toolCalls = [...(step.toolCalls ?? []), toolCall];
190
+ step.finishReason = "tool-calls";
191
+ }
192
+ }
193
+
194
+ attachSamplingCalls(messageToolCalls, metadata);
195
+ const usage = messageUsage(metadata);
196
+ if (usage) usages.push(usage);
197
+ }
198
+
199
+ if (!assistant) return null;
200
+
201
+ const metadata = isRecord(assistant.metadata)
202
+ ? assistant.metadata
203
+ : undefined;
204
+
205
+ const usage = usages.length > 0 ? sumUsage(usages) : undefined;
206
+ const modelId = extractRunTelemetryModelId(metadata);
207
+ const completed = textParts.length > 0 || toolCalls.length > 0;
208
+ return {
209
+ ...(assistant.id !== undefined
210
+ ? { assistantMessageId: assistant.id }
211
+ : undefined),
212
+ status: completed ? "completed" : "incomplete",
213
+ ...(toolCalls.length > 0 ? { toolCalls } : undefined),
214
+ ...(steps.length > 0 ? { steps, totalSteps: steps.length } : undefined),
215
+ ...(textParts.length > 0
216
+ ? { outputText: truncateRunTelemetryText(textParts.join("")) }
217
+ : undefined),
218
+ ...(usage ? { usage } : undefined),
219
+ ...(modelId ? { modelId } : undefined),
220
+ ...(metadata ? { metadata } : undefined),
221
+ };
222
+ }
@@ -68,4 +68,36 @@ describe("generateThreadTitle", () => {
68
68
 
69
69
  expect(update).not.toHaveBeenCalled();
70
70
  });
71
+
72
+ it("returns null without updating for a whitespace-only title", async () => {
73
+ const { cloud, update } = createCloud(
74
+ titleStream({ type: "text-delta", textDelta: " \n\t" }),
75
+ );
76
+
77
+ await expect(
78
+ generateThreadTitle(cloud, {
79
+ threadId: "thread-1",
80
+ messages: [],
81
+ }),
82
+ ).resolves.toBeNull();
83
+
84
+ expect(update).not.toHaveBeenCalled();
85
+ });
86
+
87
+ it("trims surrounding whitespace from a generated title", async () => {
88
+ const { cloud, update } = createCloud(
89
+ titleStream({ type: "text-delta", textDelta: " Weather chat \n" }),
90
+ );
91
+
92
+ await expect(
93
+ generateThreadTitle(cloud, {
94
+ threadId: "thread-1",
95
+ messages: [],
96
+ }),
97
+ ).resolves.toBe("Weather chat");
98
+
99
+ expect(update).toHaveBeenCalledExactlyOnceWith("thread-1", {
100
+ title: "Weather chat",
101
+ });
102
+ });
71
103
  });
@@ -30,6 +30,7 @@ export async function generateThreadTitle(
30
30
  reader.releaseLock();
31
31
  }
32
32
 
33
+ title = title.trim();
33
34
  if (title) {
34
35
  await cloud.threads.update(options.threadId, { title });
35
36
  }
package/src/index.ts CHANGED
@@ -3,7 +3,10 @@ export type {
3
3
  AssistantCloudThreadMessageFeedbackBody,
4
4
  AssistantCloudThreadMessageFeedbackResponse,
5
5
  } from "./AssistantCloudThreadMessages";
6
- export type { AssistantCloudTelemetryConfig } from "./AssistantCloudAPI";
6
+ export type {
7
+ AssistantCloudTelemetryConfig,
8
+ SdkIdentity,
9
+ } from "./AssistantCloudAPI";
7
10
  export {
8
11
  AssistantCloudEvents,
9
12
  type AssistantCloudEvent,
@@ -28,6 +31,7 @@ export {
28
31
  normalizeRunTelemetryUsage,
29
32
  truncateRunTelemetryText,
30
33
  type AssistantCloudRunReportToolCall,
34
+ type RunMessageTelemetry,
31
35
  type RunReportInit,
32
36
  type RunReportOutcome,
33
37
  type RunReportStepInit,
@@ -36,6 +40,12 @@ export {
36
40
  type RunTelemetryUsageInit,
37
41
  } from "./runTelemetry";
38
42
  export { AssistantCloud } from "./AssistantCloud";
43
+ export { CloudRunReporter, type CloudRunReportInit } from "./CloudRunReporter";
44
+ export {
45
+ CloudEngagementReporter,
46
+ type EngagementEventIds,
47
+ type EngagementIdResolver,
48
+ } from "./CloudEngagementReporter";
39
49
  export { readAnonymousRefreshToken } from "./AssistantCloudAuthStrategy";
40
50
  export { CloudMessagePersistence } from "./CloudMessagePersistence";
41
51
  export {
@@ -0,0 +1,102 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import {
3
+ wrapSamplingHandler,
4
+ createSamplingCollector,
5
+ type McpSamplingRequest,
6
+ } from "./instrumentMcpSampling";
7
+
8
+ const request: McpSamplingRequest = {
9
+ method: "sampling/createMessage",
10
+ params: { messages: [], maxTokens: 10 },
11
+ };
12
+
13
+ describe("wrapSamplingHandler", () => {
14
+ it("returns the response and records sampling metrics", async () => {
15
+ const collector = createSamplingCollector();
16
+ const response = {
17
+ model: "test-model",
18
+ content: { type: "text", text: "hello" },
19
+ usage: { inputTokens: 2, outputTokens: 3 },
20
+ };
21
+ const handler = vi.fn(async () => response);
22
+ await expect(
23
+ wrapSamplingHandler(handler, collector.collect)(request),
24
+ ).resolves.toBe(response);
25
+ expect(handler).toHaveBeenCalledWith(request);
26
+ expect(collector.getCalls()).toEqual([
27
+ expect.objectContaining({
28
+ model_id: "test-model",
29
+ input_tokens: 2,
30
+ output_tokens: 3,
31
+ duration_ms: expect.any(Number),
32
+ }),
33
+ ]);
34
+ });
35
+
36
+ it.each([false, true])(
37
+ "preserves successful responses when the observer fails asynchronously=%s",
38
+ async (asynchronous) => {
39
+ const error = new Error("metrics unavailable");
40
+ const observer = asynchronous
41
+ ? async () => {
42
+ throw error;
43
+ }
44
+ : () => {
45
+ throw error;
46
+ };
47
+ const response = { content: "successful response" };
48
+ const log = vi.spyOn(console, "error").mockImplementation(() => {});
49
+ try {
50
+ await expect(
51
+ wrapSamplingHandler(async () => response, observer)(request),
52
+ ).resolves.toBe(response);
53
+ expect(log).toHaveBeenCalledWith(
54
+ "[assistant-cloud] onSamplingCall callback threw an error",
55
+ error,
56
+ );
57
+ } finally {
58
+ log.mockRestore();
59
+ }
60
+ },
61
+ );
62
+
63
+ it("does not await an asynchronous observer", async () => {
64
+ const response = { content: "successful response" };
65
+ await expect(
66
+ wrapSamplingHandler(
67
+ async () => response,
68
+ () => new Promise<void>(() => {}),
69
+ )(request),
70
+ ).resolves.toBe(response);
71
+ });
72
+
73
+ it("propagates model errors without calling the observer", async () => {
74
+ const error = new Error("model failed");
75
+ const observer = vi.fn();
76
+ await expect(
77
+ wrapSamplingHandler(async () => {
78
+ throw error;
79
+ }, observer)(request),
80
+ ).rejects.toBe(error);
81
+ expect(observer).not.toHaveBeenCalled();
82
+ });
83
+
84
+ it("preserves successful responses even if error reporting throws", async () => {
85
+ const response = { content: "successful response" };
86
+ const log = vi.spyOn(console, "error").mockImplementation(() => {
87
+ throw new Error("logging failed");
88
+ });
89
+ try {
90
+ await expect(
91
+ wrapSamplingHandler(
92
+ async () => response,
93
+ () => {
94
+ throw new Error("metrics failed");
95
+ },
96
+ )(request),
97
+ ).resolves.toBe(response);
98
+ } finally {
99
+ log.mockRestore();
100
+ }
101
+ });
102
+ });
@@ -43,6 +43,15 @@ export type McpSamplingResponse = {
43
43
  [key: string]: unknown;
44
44
  };
45
45
 
46
+ const reportSamplingError = (error: unknown): void => {
47
+ try {
48
+ console.error(
49
+ "[assistant-cloud] onSamplingCall callback threw an error",
50
+ error,
51
+ );
52
+ } catch {}
53
+ };
54
+
46
55
  /**
47
56
  * Wraps an MCP sampling handler to intercept and measure sampling calls.
48
57
  *
@@ -80,7 +89,7 @@ export function wrapSamplingHandler(
80
89
  const reasoningTokens = response.usage?.reasoningTokens;
81
90
  const cachedInputTokens = response.usage?.cachedInputTokens;
82
91
 
83
- onSamplingCall({
92
+ const data: SamplingCallData = {
84
93
  ...(modelId ? { model_id: modelId } : undefined),
85
94
  ...(inputTokens != null ? { input_tokens: inputTokens } : undefined),
86
95
  ...(outputTokens != null ? { output_tokens: outputTokens } : undefined),
@@ -91,7 +100,12 @@ export function wrapSamplingHandler(
91
100
  ? { cached_input_tokens: cachedInputTokens }
92
101
  : undefined),
93
102
  duration_ms: durationMs,
94
- });
103
+ };
104
+ try {
105
+ void Promise.resolve(onSamplingCall(data)).catch(reportSamplingError);
106
+ } catch (error) {
107
+ reportSamplingError(error);
108
+ }
95
109
 
96
110
  return response;
97
111
  };
@@ -225,6 +225,23 @@ export type RunReportStepInit = {
225
225
  finishReason?: string | undefined;
226
226
  };
227
227
 
228
+ /**
229
+ * The run report fields read from the messages of one run, in whichever
230
+ * format they were stored: the status the messages imply, the tool calls, the
231
+ * steps, the text, the usage and the model.
232
+ */
233
+ export type RunMessageTelemetry = {
234
+ assistantMessageId?: string;
235
+ status: "completed" | "incomplete";
236
+ toolCalls?: AssistantCloudRunReportToolCall[];
237
+ steps?: RunReportStepInit[];
238
+ totalSteps?: number;
239
+ outputText?: string;
240
+ usage?: RunTelemetryUsage;
241
+ modelId?: string;
242
+ metadata?: Record<string, unknown>;
243
+ };
244
+
228
245
  export type RunReportInit = {
229
246
  threadId: string;
230
247
  status: AssistantCloudRunReport["status"];
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { AssistantCloud } from "../AssistantCloud";
3
3
  import type { AssistantCloudTelemetryConfig } from "../AssistantCloudAPI";
4
4
 
@@ -13,6 +13,10 @@ const createCloud = (
13
13
  });
14
14
 
15
15
  describe("AssistantCloud telemetry config", () => {
16
+ afterEach(() => {
17
+ vi.unstubAllGlobals();
18
+ });
19
+
16
20
  it("defaults to enabled", () => {
17
21
  expect(createCloud().telemetry.enabled).toBe(true);
18
22
  expect(createCloud(true).telemetry.enabled).toBe(true);
@@ -58,4 +62,32 @@ describe("AssistantCloud telemetry config", () => {
58
62
  tags: ["region:sg", "tier:paid"],
59
63
  });
60
64
  });
65
+
66
+ it("forwards registered SDK identities to requests and stream options", async () => {
67
+ const fetchMock = vi.fn().mockResolvedValue({
68
+ ok: true,
69
+ headers: new Headers(),
70
+ text: vi.fn().mockResolvedValue(JSON.stringify({ threads: [] })),
71
+ });
72
+ vi.stubGlobal("fetch", fetchMock);
73
+
74
+ const cloud = createCloud();
75
+ cloud.registerSdk({ name: "@assistant-ui/core", version: "0.3.18" });
76
+
77
+ await cloud.threads.list();
78
+
79
+ const [, init] = fetchMock.mock.calls[0]!;
80
+ expect(init.headers).toMatchObject({
81
+ "Aui-Sdk": expect.stringMatching(
82
+ /^assistant-cloud\/.* @assistant-ui\/core\/0\.3\.18$/,
83
+ ),
84
+ });
85
+ await expect(
86
+ cloud.runs.__internal_getAssistantOptions("assistant-id").headers(),
87
+ ).resolves.toMatchObject({
88
+ "Aui-Sdk": expect.stringMatching(
89
+ /^assistant-cloud\/.* @assistant-ui\/core\/0\.3\.18$/,
90
+ ),
91
+ });
92
+ });
61
93
  });
@@ -45,6 +45,7 @@ describe("AssistantCloudAPI", () => {
45
45
  Authorization: "Bearer test-key",
46
46
  "Aui-User-Id": "u-1",
47
47
  "Aui-Workspace-Id": "w-1",
48
+ "Aui-Sdk": expect.stringMatching(/^assistant-cloud\//),
48
49
  "Content-Type": "application/json",
49
50
  "X-Test": "1",
50
51
  });
@@ -53,6 +54,56 @@ describe("AssistantCloudAPI", () => {
53
54
  expect(init.body).toBe(JSON.stringify({ hello: "world" }));
54
55
  });
55
56
 
57
+ it("ignores identities that cannot travel in a header", () => {
58
+ const api = new AssistantCloudAPI({
59
+ apiKey: "test-key",
60
+ userId: "u-1",
61
+ workspaceId: "w-1",
62
+ });
63
+ api.registerSdk({ name: "bad name", version: "1.0.0" });
64
+ api.registerSdk({ name: "@scope/pkg", version: "1.0.0 ok" });
65
+ api.registerSdk({ name: "@scope/pkg\ttab", version: "1.0.0" });
66
+ api.registerSdk({ name: "@scope/ok", version: " 1.0.0 " });
67
+
68
+ expect(api.sdkHeader().split(" ")).toEqual([
69
+ expect.stringMatching(/^assistant-cloud\//),
70
+ "@scope/ok/1.0.0",
71
+ ]);
72
+ });
73
+
74
+ it("sends each registered SDK identity once in registration order", async () => {
75
+ const fetchMock = vi.fn().mockResolvedValue({
76
+ ok: true,
77
+ headers: new Headers(),
78
+ json: vi.fn().mockResolvedValue({}),
79
+ });
80
+ vi.stubGlobal("fetch", fetchMock);
81
+
82
+ const api = new AssistantCloudAPI({
83
+ apiKey: "test-key",
84
+ userId: "u-1",
85
+ workspaceId: "w-1",
86
+ });
87
+
88
+ api.registerSdk({ name: " @assistant-ui/core ", version: " 0.3.18 " });
89
+ api.registerSdk({ name: "@assistant-ui/core", version: "0.3.18" });
90
+ api.registerSdk({ name: "@assistant-ui/ai-sdk", version: "0.0.5" });
91
+ api.registerSdk({ name: " ", version: "0.0.5" });
92
+ api.registerSdk({ name: "@assistant-ui/react-langgraph", version: " " });
93
+
94
+ await api.makeRawRequest("/threads", {
95
+ headers: { "Aui-Sdk": "overridden" },
96
+ });
97
+
98
+ const [, init] = fetchMock.mock.calls[0]!;
99
+ expect(init.headers).toMatchObject({ "Aui-Sdk": api.sdkHeader() });
100
+ expect(api.sdkHeader().split(" ")).toEqual([
101
+ expect.stringMatching(/^assistant-cloud\//),
102
+ "@assistant-ui/core/0.3.18",
103
+ "@assistant-ui/ai-sdk/0.0.5",
104
+ ]);
105
+ });
106
+
56
107
  it("uses custom baseUrl when provided with apiKey config", async () => {
57
108
  const fetchMock = vi.fn().mockResolvedValue({
58
109
  ok: true,
@@ -950,4 +950,26 @@ describe("AssistantCloudJWTAuthStrategy", () => {
950
950
  });
951
951
  expect(authToken).toHaveBeenCalledTimes(2);
952
952
  });
953
+
954
+ it("does not cache a malformed rotated token", async () => {
955
+ const authToken = vi
956
+ .fn<() => Promise<string | null>>()
957
+ .mockResolvedValue(accessToken);
958
+ const strategy = new AssistantCloudJWTAuthStrategy(authToken);
959
+
960
+ await expect(strategy.getAuthHeaders()).resolves.toEqual({
961
+ Authorization: `Bearer ${accessToken}`,
962
+ });
963
+
964
+ expect(() =>
965
+ strategy.readAuthHeaders(
966
+ new Headers({ Authorization: "Bearer malformed" }),
967
+ ),
968
+ ).toThrow("Unable to determine the token expiry");
969
+
970
+ await expect(strategy.getAuthHeaders()).resolves.toEqual({
971
+ Authorization: `Bearer ${accessToken}`,
972
+ });
973
+ expect(authToken).toHaveBeenCalledTimes(1);
974
+ });
953
975
  });
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
2
  import { AssistantCloudProjects } from "../AssistantCloudProjects";
3
3
  import type { AssistantCloudAPI } from "../AssistantCloudAPI";
4
4
 
@@ -19,10 +19,6 @@ describe("AssistantCloudProjects", () => {
19
19
  projects = new AssistantCloudProjects(mockApi);
20
20
  });
21
21
 
22
- afterEach(() => {
23
- vi.clearAllMocks();
24
- });
25
-
26
22
  it("lists project threads with the query", async () => {
27
23
  vi.mocked(mockApi.makeRequest).mockResolvedValue({ threads: [] });
28
24