assistant-cloud 0.1.43 → 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 (120) hide show
  1. package/README.md +66 -0
  2. package/dist/AssistantCloud.d.ts +6 -1
  3. package/dist/AssistantCloud.d.ts.map +1 -1
  4. package/dist/AssistantCloud.js +13 -5
  5. package/dist/AssistantCloud.js.map +1 -1
  6. package/dist/AssistantCloudAPI.d.ts +28 -3
  7. package/dist/AssistantCloudAPI.d.ts.map +1 -1
  8. package/dist/AssistantCloudAPI.js +37 -10
  9. package/dist/AssistantCloudAPI.js.map +1 -1
  10. package/dist/AssistantCloudAuthStrategy.d.ts +4 -1
  11. package/dist/AssistantCloudAuthStrategy.d.ts.map +1 -1
  12. package/dist/AssistantCloudAuthStrategy.js +15 -3
  13. package/dist/AssistantCloudAuthStrategy.js.map +1 -1
  14. package/dist/AssistantCloudEvents.d.ts +32 -0
  15. package/dist/AssistantCloudEvents.d.ts.map +1 -0
  16. package/dist/AssistantCloudEvents.js +119 -0
  17. package/dist/AssistantCloudEvents.js.map +1 -0
  18. package/dist/AssistantCloudFiles.d.ts +12 -1
  19. package/dist/AssistantCloudFiles.d.ts.map +1 -1
  20. package/dist/AssistantCloudFiles.js +13 -1
  21. package/dist/AssistantCloudFiles.js.map +1 -1
  22. package/dist/AssistantCloudRuns.d.ts +12 -0
  23. package/dist/AssistantCloudRuns.d.ts.map +1 -1
  24. package/dist/AssistantCloudRuns.js +2 -1
  25. package/dist/AssistantCloudRuns.js.map +1 -1
  26. package/dist/AssistantCloudScores.d.ts +27 -0
  27. package/dist/AssistantCloudScores.d.ts.map +1 -0
  28. package/dist/AssistantCloudScores.js +30 -0
  29. package/dist/AssistantCloudScores.js.map +1 -0
  30. package/dist/AssistantCloudThreadMessages.d.ts +11 -1
  31. package/dist/AssistantCloudThreadMessages.d.ts.map +1 -1
  32. package/dist/AssistantCloudThreadMessages.js +12 -1
  33. package/dist/AssistantCloudThreadMessages.js.map +1 -1
  34. package/dist/AssistantCloudThreads.d.ts +8 -0
  35. package/dist/AssistantCloudThreads.d.ts.map +1 -1
  36. package/dist/AssistantCloudThreads.js +9 -1
  37. package/dist/AssistantCloudThreads.js.map +1 -1
  38. package/dist/CloudEngagementReporter.d.ts +74 -0
  39. package/dist/CloudEngagementReporter.d.ts.map +1 -0
  40. package/dist/CloudEngagementReporter.js +132 -0
  41. package/dist/CloudEngagementReporter.js.map +1 -0
  42. package/dist/CloudMessagePersistence.d.ts +12 -9
  43. package/dist/CloudMessagePersistence.d.ts.map +1 -1
  44. package/dist/CloudMessagePersistence.js +54 -20
  45. package/dist/CloudMessagePersistence.js.map +1 -1
  46. package/dist/CloudRunReporter.d.ts +20 -0
  47. package/dist/CloudRunReporter.d.ts.map +1 -0
  48. package/dist/CloudRunReporter.js +38 -0
  49. package/dist/CloudRunReporter.js.map +1 -0
  50. package/dist/FormattedCloudPersistence.d.ts +3 -8
  51. package/dist/FormattedCloudPersistence.d.ts.map +1 -1
  52. package/dist/FormattedCloudPersistence.js +3 -8
  53. package/dist/FormattedCloudPersistence.js.map +1 -1
  54. package/dist/ai-sdk/index.d.ts +33 -0
  55. package/dist/ai-sdk/index.d.ts.map +1 -0
  56. package/dist/ai-sdk/index.js +147 -0
  57. package/dist/ai-sdk/index.js.map +1 -0
  58. package/dist/cloudResponse.d.ts +4 -1
  59. package/dist/cloudResponse.d.ts.map +1 -1
  60. package/dist/cloudResponse.js +14 -1
  61. package/dist/cloudResponse.js.map +1 -1
  62. package/dist/generateThreadTitle.js +1 -0
  63. package/dist/generateThreadTitle.js.map +1 -1
  64. package/dist/index.d.ts +10 -4
  65. package/dist/index.js +7 -2
  66. package/dist/instrumentMcpSampling.d.ts.map +1 -1
  67. package/dist/instrumentMcpSampling.js +12 -2
  68. package/dist/instrumentMcpSampling.js.map +1 -1
  69. package/dist/runTelemetry.d.ts +86 -7
  70. package/dist/runTelemetry.d.ts.map +1 -1
  71. package/dist/runTelemetry.js +144 -1
  72. package/dist/runTelemetry.js.map +1 -1
  73. package/dist/telemetry/index.d.ts +32 -0
  74. package/dist/telemetry/index.d.ts.map +1 -0
  75. package/dist/telemetry/index.js +62 -0
  76. package/dist/telemetry/index.js.map +1 -0
  77. package/dist/version.d.ts +5 -0
  78. package/dist/version.d.ts.map +1 -0
  79. package/dist/version.js +6 -0
  80. package/dist/version.js.map +1 -0
  81. package/package.json +43 -6
  82. package/src/AssistantCloud.ts +23 -8
  83. package/src/AssistantCloudAPI.ts +72 -10
  84. package/src/AssistantCloudAuthStrategy.ts +17 -2
  85. package/src/AssistantCloudEvents.test.ts +149 -0
  86. package/src/AssistantCloudEvents.ts +190 -0
  87. package/src/AssistantCloudFiles.test.ts +59 -0
  88. package/src/AssistantCloudFiles.ts +39 -0
  89. package/src/AssistantCloudRuns.ts +13 -1
  90. package/src/AssistantCloudScores.test.ts +113 -0
  91. package/src/AssistantCloudScores.ts +61 -0
  92. package/src/AssistantCloudThreadMessages.test.ts +51 -0
  93. package/src/AssistantCloudThreadMessages.ts +33 -0
  94. package/src/AssistantCloudThreads.test.ts +21 -0
  95. package/src/AssistantCloudThreads.ts +21 -0
  96. package/src/CloudEngagementReporter.ts +231 -0
  97. package/src/CloudMessagePersistence.ts +70 -27
  98. package/src/CloudRunReporter.ts +38 -0
  99. package/src/FormattedCloudPersistence.ts +3 -8
  100. package/src/ai-sdk/index.test.ts +258 -0
  101. package/src/ai-sdk/index.ts +222 -0
  102. package/src/cloudResponse.test.ts +34 -1
  103. package/src/cloudResponse.ts +30 -0
  104. package/src/generateThreadTitle.test.ts +32 -0
  105. package/src/generateThreadTitle.ts +1 -0
  106. package/src/index.ts +35 -2
  107. package/src/instrumentMcpSampling.test.ts +102 -0
  108. package/src/instrumentMcpSampling.ts +16 -2
  109. package/src/runTelemetry.test.ts +325 -0
  110. package/src/runTelemetry.ts +274 -6
  111. package/src/telemetry/index.test.ts +252 -0
  112. package/src/telemetry/index.ts +98 -0
  113. package/src/tests/AssistantCloud.test.ts +55 -1
  114. package/src/tests/AssistantCloudAPI.test.ts +95 -4
  115. package/src/tests/AssistantCloudAuthStrategy.test.ts +93 -2
  116. package/src/tests/AssistantCloudProjects.test.ts +1 -5
  117. package/src/tests/CloudEngagementReporter.test.ts +184 -0
  118. package/src/tests/CloudMessagePersistence.test.ts +350 -0
  119. package/src/tests/CloudRunReporter.test.ts +100 -0
  120. package/src/version.ts +4 -0
@@ -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);
@@ -23,6 +27,13 @@ describe("AssistantCloud telemetry config", () => {
23
27
  expect(createCloud({ enabled: false }).telemetry.enabled).toBe(false);
24
28
  });
25
29
 
30
+ it("can disable engagement events without disabling run reports", () => {
31
+ expect(createCloud({ events: false }).telemetry).toEqual({
32
+ enabled: true,
33
+ events: false,
34
+ });
35
+ });
36
+
26
37
  it("stays enabled when the config object carries an undefined enabled", () => {
27
38
  const beforeReport: NonNullable<
28
39
  AssistantCloudTelemetryConfig["beforeReport"]
@@ -36,4 +47,47 @@ describe("AssistantCloud telemetry config", () => {
36
47
  expect(telemetry.enabled).toBe(true);
37
48
  expect(telemetry.beforeReport).toBe(beforeReport);
38
49
  });
50
+
51
+ it("preserves configured run report dimensions", () => {
52
+ expect(
53
+ createCloud({
54
+ release: "web-2026.09.08",
55
+ environment: "production",
56
+ tags: ["region:sg", "tier:paid"],
57
+ }).telemetry,
58
+ ).toEqual({
59
+ enabled: true,
60
+ release: "web-2026.09.08",
61
+ environment: "production",
62
+ tags: ["region:sg", "tier:paid"],
63
+ });
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
+ });
39
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,
@@ -143,7 +194,7 @@ describe("AssistantCloudAPI", () => {
143
194
  );
144
195
  });
145
196
 
146
- it("throws APIError with parsed message for JSON error responses", async () => {
197
+ it("preserves the current error shape for JSON error responses without a code", async () => {
147
198
  const fetchMock = vi.fn().mockResolvedValue({
148
199
  ok: false,
149
200
  status: 400,
@@ -167,16 +218,54 @@ describe("AssistantCloudAPI", () => {
167
218
  expect(error.name).toBe("CloudAPIError");
168
219
  expect(error.message).toBe("invalid request payload");
169
220
  expect(error.status).toBe(400);
221
+ expect(error.code).toBeUndefined();
222
+ expect(error.details).toBeUndefined();
223
+ });
224
+
225
+ it("exposes the plan-limit error code and details", async () => {
226
+ const fetchMock = vi.fn().mockResolvedValue({
227
+ ok: false,
228
+ status: 402,
229
+ headers: new Headers(),
230
+ text: vi.fn().mockResolvedValue(
231
+ JSON.stringify({
232
+ error: "plan_limit_reached",
233
+ plan: "free",
234
+ period_end: "2026-10-01T00:00:00.000Z",
235
+ cap: 100,
236
+ }),
237
+ ),
238
+ });
239
+ vi.stubGlobal("fetch", fetchMock);
240
+
241
+ const api = new AssistantCloudAPI({
242
+ apiKey: "test-key",
243
+ userId: "u-1",
244
+ workspaceId: "w-1",
245
+ });
246
+
247
+ const error = await api.makeRawRequest("/threads").catch((e) => e);
248
+ expect(error).toBeInstanceOf(CloudAPIError);
249
+ expect(error.message).toBe(
250
+ 'Request failed with status 402, {"error":"plan_limit_reached","plan":"free","period_end":"2026-10-01T00:00:00.000Z","cap":100}',
251
+ );
252
+ expect(error.status).toBe(402);
253
+ expect(error.code).toBe("plan_limit_reached");
254
+ expect(error.details).toEqual({
255
+ plan: "free",
256
+ period_end: "2026-10-01T00:00:00.000Z",
257
+ cap: 100,
258
+ });
170
259
  });
171
260
 
172
- it("falls back to the response text when the JSON error body has no message", async () => {
261
+ it("exposes rate-limit error codes with empty details", async () => {
173
262
  const fetchMock = vi.fn().mockResolvedValue({
174
263
  ok: false,
175
264
  status: 429,
176
265
  headers: new Headers(),
177
266
  text: vi
178
267
  .fn()
179
- .mockResolvedValue(JSON.stringify({ error: "rate limited" })),
268
+ .mockResolvedValue(JSON.stringify({ error: "rate_limited" })),
180
269
  });
181
270
  vi.stubGlobal("fetch", fetchMock);
182
271
 
@@ -189,9 +278,11 @@ describe("AssistantCloudAPI", () => {
189
278
  const error = await api.makeRawRequest("/threads").catch((e) => e);
190
279
  expect(error).toBeInstanceOf(CloudAPIError);
191
280
  expect(error.message).toBe(
192
- 'Request failed with status 429, {"error":"rate limited"}',
281
+ 'Request failed with status 429, {"error":"rate_limited"}',
193
282
  );
194
283
  expect(error.status).toBe(429);
284
+ expect(error.code).toBe("rate_limited");
285
+ expect(error.details).toEqual({});
195
286
  });
196
287
 
197
288
  it("throws generic error with status for non-JSON error responses", async () => {
@@ -1,7 +1,9 @@
1
+ /// <reference types="node" />
1
2
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
3
  import {
3
4
  AssistantCloudAnonymousAuthStrategy,
4
5
  AssistantCloudJWTAuthStrategy,
6
+ readAnonymousRefreshToken,
5
7
  } from "../AssistantCloudAuthStrategy";
6
8
  import { CloudResponseError } from "../cloudResponse";
7
9
 
@@ -56,6 +58,73 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
56
58
  }
57
59
  });
58
60
 
61
+ it("reads the stored anonymous refresh token", () => {
62
+ const values = new Map([[refreshTokenKey, JSON.stringify(refreshToken)]]);
63
+ installLocalStorage({
64
+ getItem: (key) => values.get(key) ?? null,
65
+ setItem: (key, value) => {
66
+ values.set(key, value);
67
+ },
68
+ removeItem: (key) => {
69
+ values.delete(key);
70
+ },
71
+ } as Storage);
72
+
73
+ expect(readAnonymousRefreshToken(baseUrl)).toBe(refreshToken.token);
74
+ });
75
+
76
+ it("reads the token stored for a base url given with a trailing slash", () => {
77
+ // The two-step cast below erases the contextual Storage typing the plain
78
+ // `as Storage` sites get, so this parameter needs its own annotation.
79
+ installLocalStorage({
80
+ getItem: (key: string) =>
81
+ key === refreshTokenKey ? JSON.stringify(refreshToken) : null,
82
+ setItem: vi.fn(),
83
+ removeItem: vi.fn(),
84
+ } as unknown as Storage);
85
+
86
+ expect(readAnonymousRefreshToken(`${baseUrl}/`)).toBe(refreshToken.token);
87
+ });
88
+
89
+ it("returns null when no anonymous refresh token is stored", () => {
90
+ installLocalStorage({
91
+ getItem: () => null,
92
+ setItem: vi.fn(),
93
+ removeItem: vi.fn(),
94
+ } as unknown as Storage);
95
+
96
+ expect(readAnonymousRefreshToken(baseUrl)).toBeNull();
97
+ });
98
+
99
+ it.each([30_000, 0, -1])(
100
+ "returns null when the stored anonymous refresh token expires in %i ms",
101
+ (expiresIn) => {
102
+ vi.useFakeTimers();
103
+ const now = Date.UTC(2026, 8, 4, 12, 0, 0);
104
+ vi.setSystemTime(now);
105
+ const values = new Map([
106
+ [
107
+ refreshTokenKey,
108
+ JSON.stringify({
109
+ token: refreshToken.token,
110
+ expires_at: new Date(now + expiresIn).toISOString(),
111
+ }),
112
+ ],
113
+ ]);
114
+ installLocalStorage({
115
+ getItem: (key) => values.get(key) ?? null,
116
+ setItem: (key, value) => {
117
+ values.set(key, value);
118
+ },
119
+ removeItem: (key) => {
120
+ values.delete(key);
121
+ },
122
+ } as Storage);
123
+
124
+ expect(readAnonymousRefreshToken(baseUrl)).toBeNull();
125
+ },
126
+ );
127
+
59
128
  it.each([
60
129
  "2099-01-01",
61
130
  "2099-01-01T00:00:00Z",
@@ -269,7 +338,7 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
269
338
  { once: true },
270
339
  );
271
340
  }),
272
- } as Response),
341
+ } as unknown as Response),
273
342
  )
274
343
  .mockResolvedValueOnce({
275
344
  ok: true,
@@ -598,7 +667,7 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
598
667
  const removeItem = vi.fn(() => {
599
668
  throw new DOMException("blocked", "SecurityError");
600
669
  });
601
- installLocalStorage({ getItem, setItem, removeItem } as Storage);
670
+ installLocalStorage({ getItem, setItem, removeItem } as unknown as Storage);
602
671
  mockAnonymousTokenFetch();
603
672
 
604
673
  await expect(
@@ -881,4 +950,26 @@ describe("AssistantCloudJWTAuthStrategy", () => {
881
950
  });
882
951
  expect(authToken).toHaveBeenCalledTimes(2);
883
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
+ });
884
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
 
@@ -0,0 +1,184 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { AssistantCloud } from "../AssistantCloud";
3
+ import {
4
+ CloudEngagementReporter,
5
+ type EngagementIdResolver,
6
+ } from "../CloudEngagementReporter";
7
+
8
+ const createCloud = () => {
9
+ const track = vi.fn();
10
+ const cloud = { events: { track } } as unknown as AssistantCloud;
11
+ return { cloud, track };
12
+ };
13
+
14
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
15
+
16
+ describe("CloudEngagementReporter", () => {
17
+ it("resolves ids through the resolver and awaits the thread only for a send", async () => {
18
+ const { cloud, track } = createCloud();
19
+ const resolveIds = vi.fn<EngagementIdResolver>(
20
+ async (threadId, messageId) => ({
21
+ thread_id: `remote-${threadId}`,
22
+ ...(messageId ? { message_id: `remote-${messageId}` } : undefined),
23
+ }),
24
+ );
25
+ const reporter = new CloudEngagementReporter(cloud, resolveIds);
26
+
27
+ reporter.messageSent("t1", { messageId: "m1", chars: 12, attachments: 1 });
28
+ reporter.messageCopied("t1", "m2");
29
+ await flush();
30
+
31
+ expect(resolveIds).toHaveBeenNthCalledWith(1, "t1", "m1", {
32
+ awaitThread: true,
33
+ });
34
+ expect(resolveIds).toHaveBeenNthCalledWith(2, "t1", "m2", {
35
+ awaitThread: false,
36
+ });
37
+ expect(track).toHaveBeenCalledWith({
38
+ kind: "message_sent",
39
+ thread_id: "remote-t1",
40
+ message_id: "remote-m1",
41
+ props: { chars: 12, attachments: 1 },
42
+ });
43
+ expect(track).toHaveBeenCalledWith({
44
+ kind: "message_copied",
45
+ thread_id: "remote-t1",
46
+ message_id: "remote-m2",
47
+ });
48
+ });
49
+
50
+ it("reports tool approval decisions with their resolved message IDs", async () => {
51
+ const { cloud, track } = createCloud();
52
+ const reporter = new CloudEngagementReporter(
53
+ cloud,
54
+ (threadId, messageId) => ({
55
+ thread_id: `remote-${threadId}`,
56
+ ...(messageId !== undefined
57
+ ? { message_id: `remote-${messageId}` }
58
+ : {}),
59
+ }),
60
+ );
61
+
62
+ reporter.toolApproved("t1", "m1", "tool-1", "send_email");
63
+ reporter.toolRejected("t1", "m2", "tool-2", "delete_account");
64
+ await flush();
65
+
66
+ expect(track).toHaveBeenNthCalledWith(1, {
67
+ kind: "tool_approved",
68
+ thread_id: "remote-t1",
69
+ message_id: "remote-m1",
70
+ props: { toolCallId: "tool-1", toolName: "send_email" },
71
+ });
72
+ expect(track).toHaveBeenNthCalledWith(2, {
73
+ kind: "tool_rejected",
74
+ thread_id: "remote-t1",
75
+ message_id: "remote-m2",
76
+ props: { toolCallId: "tool-2", toolName: "delete_account" },
77
+ });
78
+ });
79
+
80
+ it("measures a stop against the run start and reports it once per run", async () => {
81
+ const { cloud, track } = createCloud();
82
+ const reporter = new CloudEngagementReporter(cloud);
83
+
84
+ vi.setSystemTime(new Date("2023-01-01T00:00:00.000Z"));
85
+ reporter.runStopped("t1");
86
+ reporter.runStarted("t1");
87
+ vi.setSystemTime(new Date("2023-01-01T00:00:01.500Z"));
88
+ reporter.runStopped("t1");
89
+ reporter.runStopped("t1");
90
+ await flush();
91
+
92
+ expect(track).toHaveBeenCalledOnce();
93
+ expect(track).toHaveBeenCalledWith({
94
+ kind: "run_stopped",
95
+ thread_id: "t1",
96
+ value: 1500,
97
+ });
98
+ });
99
+
100
+ it("carries the time since the previous run ended on the next send", async () => {
101
+ const { cloud, track } = createCloud();
102
+ const reporter = new CloudEngagementReporter(cloud);
103
+
104
+ vi.setSystemTime(new Date("2023-01-01T00:00:00.000Z"));
105
+ reporter.runStarted("t1");
106
+ reporter.runEnded("t1");
107
+ vi.setSystemTime(new Date("2023-01-01T00:00:04.000Z"));
108
+ reporter.messageSent("t1", { chars: 3, attachments: 0 });
109
+ await flush();
110
+
111
+ expect(track).toHaveBeenCalledWith(
112
+ expect.objectContaining({ kind: "message_sent", value: 4000 }),
113
+ );
114
+ });
115
+
116
+ it("shows one error per run and one suggestion list per thread", async () => {
117
+ const { cloud, track } = createCloud();
118
+ const reporter = new CloudEngagementReporter(cloud);
119
+
120
+ reporter.runStarted("t1");
121
+ reporter.errorShown("t1", { messageId: "m1", reason: "error" });
122
+ reporter.errorShown("t1", { messageId: "m1", reason: "error" });
123
+ reporter.runStarted("t1");
124
+ reporter.errorShown("t1", { reason: "timeout" });
125
+ reporter.suggestionsShown("t1", 3);
126
+ reporter.suggestionsShown("t1", 3);
127
+ await flush();
128
+
129
+ const kinds = track.mock.calls.map(([event]) => event.kind);
130
+ expect(kinds).toEqual(["error_shown", "error_shown", "suggestions_shown"]);
131
+ expect(track).toHaveBeenCalledWith({
132
+ kind: "suggestions_shown",
133
+ thread_id: "t1",
134
+ value: 3,
135
+ });
136
+ });
137
+
138
+ it("drops a thread switch the cloud cannot attribute and survives a failing resolver", async () => {
139
+ const { cloud, track } = createCloud();
140
+ const reporter = new CloudEngagementReporter(cloud, (threadId) =>
141
+ threadId === "known" ? { thread_id: "remote" } : {},
142
+ );
143
+ reporter.threadSwitched("unknown");
144
+ reporter.threadSwitched("known");
145
+ const failing = new CloudEngagementReporter(cloud, () => {
146
+ throw new Error("no ids");
147
+ });
148
+ failing.messageCopied("t1");
149
+ await flush();
150
+
151
+ expect(track).toHaveBeenCalledOnce();
152
+ expect(track).toHaveBeenCalledWith({
153
+ kind: "thread_switched",
154
+ thread_id: "remote",
155
+ });
156
+ });
157
+
158
+ it("keeps the state of the most recently touched threads only", async () => {
159
+ const { cloud, track } = createCloud();
160
+ const reporter = new CloudEngagementReporter(cloud);
161
+ for (let index = 0; index < 300; index++) {
162
+ reporter.suggestionsShown(`t${index}`, 1);
163
+ }
164
+ reporter.suggestionsShown("t0", 1);
165
+ reporter.suggestionsShown("t299", 1);
166
+ await flush();
167
+
168
+ expect(track).toHaveBeenCalledTimes(301);
169
+ });
170
+
171
+ it("keeps a started run until it stops, however many threads ran since", async () => {
172
+ const { cloud, track } = createCloud();
173
+ const reporter = new CloudEngagementReporter(cloud);
174
+ for (let index = 0; index < 300; index++) {
175
+ reporter.runStarted(`t${index}`);
176
+ }
177
+ reporter.runStopped("t0");
178
+ await flush();
179
+
180
+ expect(track).toHaveBeenCalledWith(
181
+ expect.objectContaining({ kind: "run_stopped", thread_id: "t0" }),
182
+ );
183
+ });
184
+ });