assistant-cloud 0.1.43 → 0.2.0

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 (81) hide show
  1. package/README.md +40 -0
  2. package/dist/AssistantCloud.d.ts +4 -0
  3. package/dist/AssistantCloud.d.ts.map +1 -1
  4. package/dist/AssistantCloud.js +11 -5
  5. package/dist/AssistantCloud.js.map +1 -1
  6. package/dist/AssistantCloudAPI.d.ts +17 -1
  7. package/dist/AssistantCloudAPI.d.ts.map +1 -1
  8. package/dist/AssistantCloudAPI.js +20 -9
  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 +11 -1
  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 +115 -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 +11 -0
  23. package/dist/AssistantCloudRuns.d.ts.map +1 -1
  24. package/dist/AssistantCloudRuns.js.map +1 -1
  25. package/dist/AssistantCloudScores.d.ts +27 -0
  26. package/dist/AssistantCloudScores.d.ts.map +1 -0
  27. package/dist/AssistantCloudScores.js +30 -0
  28. package/dist/AssistantCloudScores.js.map +1 -0
  29. package/dist/AssistantCloudThreadMessages.d.ts +11 -1
  30. package/dist/AssistantCloudThreadMessages.d.ts.map +1 -1
  31. package/dist/AssistantCloudThreadMessages.js +12 -1
  32. package/dist/AssistantCloudThreadMessages.js.map +1 -1
  33. package/dist/AssistantCloudThreads.d.ts +8 -0
  34. package/dist/AssistantCloudThreads.d.ts.map +1 -1
  35. package/dist/AssistantCloudThreads.js +9 -1
  36. package/dist/AssistantCloudThreads.js.map +1 -1
  37. package/dist/CloudMessagePersistence.d.ts +4 -0
  38. package/dist/CloudMessagePersistence.d.ts.map +1 -1
  39. package/dist/CloudMessagePersistence.js +38 -11
  40. package/dist/CloudMessagePersistence.js.map +1 -1
  41. package/dist/cloudResponse.d.ts +4 -1
  42. package/dist/cloudResponse.d.ts.map +1 -1
  43. package/dist/cloudResponse.js +14 -1
  44. package/dist/cloudResponse.js.map +1 -1
  45. package/dist/index.d.ts +7 -3
  46. package/dist/index.js +5 -2
  47. package/dist/runTelemetry.d.ts +70 -7
  48. package/dist/runTelemetry.d.ts.map +1 -1
  49. package/dist/runTelemetry.js +144 -1
  50. package/dist/runTelemetry.js.map +1 -1
  51. package/dist/telemetry/index.d.ts +32 -0
  52. package/dist/telemetry/index.d.ts.map +1 -0
  53. package/dist/telemetry/index.js +62 -0
  54. package/dist/telemetry/index.js.map +1 -0
  55. package/package.json +28 -4
  56. package/src/AssistantCloud.ts +20 -8
  57. package/src/AssistantCloudAPI.ts +42 -10
  58. package/src/AssistantCloudAuthStrategy.ts +13 -0
  59. package/src/AssistantCloudEvents.test.ts +124 -0
  60. package/src/AssistantCloudEvents.ts +185 -0
  61. package/src/AssistantCloudFiles.test.ts +59 -0
  62. package/src/AssistantCloudFiles.ts +39 -0
  63. package/src/AssistantCloudRuns.ts +12 -1
  64. package/src/AssistantCloudScores.test.ts +113 -0
  65. package/src/AssistantCloudScores.ts +61 -0
  66. package/src/AssistantCloudThreadMessages.test.ts +51 -0
  67. package/src/AssistantCloudThreadMessages.ts +33 -0
  68. package/src/AssistantCloudThreads.test.ts +21 -0
  69. package/src/AssistantCloudThreads.ts +21 -0
  70. package/src/CloudMessagePersistence.ts +48 -16
  71. package/src/cloudResponse.test.ts +34 -1
  72. package/src/cloudResponse.ts +30 -0
  73. package/src/index.ts +24 -1
  74. package/src/runTelemetry.test.ts +325 -0
  75. package/src/runTelemetry.ts +257 -6
  76. package/src/telemetry/index.test.ts +252 -0
  77. package/src/telemetry/index.ts +98 -0
  78. package/src/tests/AssistantCloud.test.ts +22 -0
  79. package/src/tests/AssistantCloudAPI.test.ts +44 -4
  80. package/src/tests/AssistantCloudAuthStrategy.test.ts +71 -2
  81. package/src/tests/CloudMessagePersistence.test.ts +115 -0
@@ -0,0 +1,252 @@
1
+ import {
2
+ ROOT_CONTEXT,
3
+ context,
4
+ trace,
5
+ type Context,
6
+ type ContextManager,
7
+ } from "@opentelemetry/api";
8
+ import type { ReadableSpan, SpanExporter } from "@opentelemetry/sdk-trace-base";
9
+ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
10
+ import {
11
+ assistantCloudTraceExportOptions,
12
+ assistantCloudTraceMetadata,
13
+ createAssistantCloudSpanProcessor,
14
+ isAssistantCloudSpan,
15
+ withAssistantCloudTraceMetadata,
16
+ } from "./index";
17
+
18
+ const TRACE_ID = "0123456789abcdef0123456789abcdef";
19
+ const SPAN_ID = "0123456789abcdef";
20
+
21
+ class TestContextManager implements ContextManager {
22
+ private activeContext: Context = ROOT_CONTEXT;
23
+
24
+ active(): Context {
25
+ return this.activeContext;
26
+ }
27
+
28
+ with<A extends unknown[], F extends (...args: A) => ReturnType<F>>(
29
+ nextContext: Context,
30
+ fn: F,
31
+ thisArg?: ThisParameterType<F>,
32
+ ...args: A
33
+ ): ReturnType<F> {
34
+ const previousContext = this.activeContext;
35
+ this.activeContext = nextContext;
36
+ try {
37
+ return fn.call(thisArg, ...args);
38
+ } finally {
39
+ this.activeContext = previousContext;
40
+ }
41
+ }
42
+
43
+ bind<T>(_context: Context, target: T): T {
44
+ return target;
45
+ }
46
+
47
+ enable(): this {
48
+ return this;
49
+ }
50
+
51
+ disable(): this {
52
+ this.activeContext = ROOT_CONTEXT;
53
+ return this;
54
+ }
55
+ }
56
+
57
+ function span(
58
+ name: string,
59
+ attributes: Record<string, unknown> = {},
60
+ ): ReadableSpan {
61
+ return {
62
+ name,
63
+ attributes,
64
+ spanContext: () => ({
65
+ traceId: TRACE_ID,
66
+ spanId: SPAN_ID,
67
+ traceFlags: 1,
68
+ }),
69
+ resource: { asyncAttributesPending: false },
70
+ } as ReadableSpan;
71
+ }
72
+
73
+ function recordingExporter() {
74
+ const spans: ReadableSpan[] = [];
75
+ const exporter = {
76
+ export: vi.fn(
77
+ (
78
+ nextSpans: ReadableSpan[],
79
+ resultCallback: Parameters<SpanExporter["export"]>[1],
80
+ ) => {
81
+ spans.push(...nextSpans);
82
+ resultCallback({ code: 0 });
83
+ },
84
+ ),
85
+ shutdown: vi.fn().mockResolvedValue(undefined),
86
+ } satisfies SpanExporter;
87
+
88
+ return { exporter, spans };
89
+ }
90
+
91
+ beforeAll(() => {
92
+ context.disable();
93
+ context.setGlobalContextManager(new TestContextManager());
94
+ });
95
+
96
+ afterAll(() => {
97
+ context.disable();
98
+ });
99
+
100
+ describe("assistantCloudTraceExportOptions", () => {
101
+ it("uses the default receiver and authorization header", () => {
102
+ expect(assistantCloudTraceExportOptions({ apiKey: "key" })).toEqual({
103
+ url: "https://backend.assistant-api.com/v1/traces",
104
+ headers: { Authorization: "Bearer key" },
105
+ });
106
+ });
107
+
108
+ it("uses a custom receiver without a trailing slash", () => {
109
+ expect(
110
+ assistantCloudTraceExportOptions({
111
+ apiKey: "key",
112
+ baseUrl: "https://cloud.example.com",
113
+ }),
114
+ ).toEqual({
115
+ url: "https://cloud.example.com/v1/traces",
116
+ headers: { Authorization: "Bearer key" },
117
+ });
118
+ });
119
+
120
+ it("removes trailing slashes and preserves extra headers", () => {
121
+ expect(
122
+ assistantCloudTraceExportOptions({
123
+ apiKey: "key",
124
+ baseUrl: "https://cloud.example.com/",
125
+ headers: { "X-Workspace": "workspace_1" },
126
+ }),
127
+ ).toEqual({
128
+ url: "https://cloud.example.com/v1/traces",
129
+ headers: {
130
+ Authorization: "Bearer key",
131
+ "X-Workspace": "workspace_1",
132
+ },
133
+ });
134
+ });
135
+
136
+ it("rejects an empty API key", () => {
137
+ expect(() => assistantCloudTraceExportOptions({ apiKey: "" })).toThrow(
138
+ "An Assistant Cloud API key is required",
139
+ );
140
+ });
141
+ });
142
+
143
+ describe("isAssistantCloudSpan", () => {
144
+ it("accepts AI span name prefixes", () => {
145
+ expect(isAssistantCloudSpan(span("gen_ai.client"))).toBe(true);
146
+ expect(isAssistantCloudSpan(span("ai.streamText"))).toBe(true);
147
+ expect(isAssistantCloudSpan(span("llm.generate"))).toBe(true);
148
+ });
149
+
150
+ it("accepts the GenAI operation attribute", () => {
151
+ expect(
152
+ isAssistantCloudSpan(
153
+ span("generate", { "gen_ai.operation.name": "ai.generateText" }),
154
+ ),
155
+ ).toBe(true);
156
+ });
157
+
158
+ it("rejects ordinary spans", () => {
159
+ expect(isAssistantCloudSpan(span("GET /api/chat"))).toBe(false);
160
+ });
161
+ });
162
+
163
+ describe("createAssistantCloudSpanProcessor", () => {
164
+ it("exports matching spans and drops other spans", async () => {
165
+ const { exporter, spans } = recordingExporter();
166
+ const processor = createAssistantCloudSpanProcessor(exporter);
167
+
168
+ processor.onEnd(span("gen_ai.client"));
169
+ processor.onEnd(span("GET /api/chat"));
170
+ await processor.forceFlush();
171
+
172
+ expect(spans.map((candidate) => candidate.name)).toEqual(["gen_ai.client"]);
173
+ });
174
+
175
+ it("uses a custom filter when one is provided", async () => {
176
+ const { exporter, spans } = recordingExporter();
177
+ const processor = createAssistantCloudSpanProcessor(exporter, {
178
+ filter: (candidate) => candidate.name === "custom",
179
+ });
180
+
181
+ processor.onEnd(span("gen_ai.client"));
182
+ processor.onEnd(span("custom"));
183
+ await processor.forceFlush();
184
+
185
+ expect(spans.map((candidate) => candidate.name)).toEqual(["custom"]);
186
+ });
187
+
188
+ it("flushes and shuts down the wrapped processor", async () => {
189
+ const { exporter } = recordingExporter();
190
+ const processor = createAssistantCloudSpanProcessor(exporter);
191
+
192
+ await processor.forceFlush();
193
+ await processor.shutdown();
194
+
195
+ expect(exporter.shutdown).toHaveBeenCalledTimes(1);
196
+ });
197
+ });
198
+
199
+ describe("assistantCloudTraceMetadata", () => {
200
+ it("returns the active trace ID", () => {
201
+ const result = context.with(
202
+ trace.setSpanContext(context.active(), {
203
+ traceId: TRACE_ID,
204
+ spanId: SPAN_ID,
205
+ traceFlags: 1,
206
+ }),
207
+ assistantCloudTraceMetadata,
208
+ );
209
+
210
+ expect(result).toEqual({ traceId: TRACE_ID });
211
+ });
212
+
213
+ it("returns an empty object without an active span", () => {
214
+ expect(assistantCloudTraceMetadata()).toEqual({});
215
+ });
216
+ });
217
+
218
+ describe("withAssistantCloudTraceMetadata", () => {
219
+ it("merges the active trace ID into start metadata", () => {
220
+ const messageMetadata = withAssistantCloudTraceMetadata(({ part }) => ({
221
+ type: part.type,
222
+ }));
223
+
224
+ const result = context.with(
225
+ trace.setSpanContext(context.active(), {
226
+ traceId: TRACE_ID,
227
+ spanId: SPAN_ID,
228
+ traceFlags: 1,
229
+ }),
230
+ () => messageMetadata({ part: { type: "start" } }),
231
+ );
232
+
233
+ expect(result).toEqual({ type: "start", traceId: TRACE_ID });
234
+ });
235
+
236
+ it("passes through start metadata when no span is active", () => {
237
+ const value = { started: true };
238
+ const messageMetadata = withAssistantCloudTraceMetadata(() => value);
239
+
240
+ expect(messageMetadata({ part: { type: "start" } })).toBe(value);
241
+ expect(withAssistantCloudTraceMetadata()({ part: { type: "start" } })).toBe(
242
+ undefined,
243
+ );
244
+ });
245
+
246
+ it("passes through metadata for other parts", () => {
247
+ const value = { usage: { totalTokens: 42 } };
248
+ const messageMetadata = withAssistantCloudTraceMetadata(() => value);
249
+
250
+ expect(messageMetadata({ part: { type: "finish" } })).toBe(value);
251
+ });
252
+ });
@@ -0,0 +1,98 @@
1
+ import { trace } from "@opentelemetry/api";
2
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
3
+ import {
4
+ BatchSpanProcessor,
5
+ type ReadableSpan,
6
+ type SpanExporter,
7
+ type SpanProcessor,
8
+ } from "@opentelemetry/sdk-trace-base";
9
+
10
+ const DEFAULT_BACKEND_BASE_URL = "https://backend.assistant-api.com";
11
+ const AI_SPAN_PREFIXES = ["gen_ai.", "ai.", "llm."];
12
+
13
+ export type AssistantCloudTraceExportOptions = {
14
+ apiKey: string;
15
+ baseUrl?: string;
16
+ headers?: Record<string, string>;
17
+ };
18
+
19
+ export type AssistantCloudSpanProcessorOptions = {
20
+ filter?: (span: ReadableSpan) => boolean;
21
+ };
22
+
23
+ export function assistantCloudTraceExportOptions({
24
+ apiKey,
25
+ baseUrl = DEFAULT_BACKEND_BASE_URL,
26
+ headers,
27
+ }: AssistantCloudTraceExportOptions): {
28
+ url: string;
29
+ headers: Record<string, string>;
30
+ } {
31
+ if (!apiKey) throw new Error("An Assistant Cloud API key is required");
32
+
33
+ return {
34
+ url: `${trimTrailingSlashes(baseUrl)}/v1/traces`,
35
+ headers: {
36
+ Authorization: `Bearer ${apiKey}`,
37
+ ...headers,
38
+ },
39
+ };
40
+ }
41
+
42
+ function trimTrailingSlashes(url: string): string {
43
+ let end = url.length;
44
+ while (end > 0 && url[end - 1] === "/") end--;
45
+ return url.slice(0, end);
46
+ }
47
+
48
+ export function createAssistantCloudTraceExporter(
49
+ options: AssistantCloudTraceExportOptions,
50
+ ): SpanExporter {
51
+ return new OTLPTraceExporter(assistantCloudTraceExportOptions(options));
52
+ }
53
+
54
+ export function isAssistantCloudSpan(span: ReadableSpan): boolean {
55
+ return (
56
+ typeof span.attributes["gen_ai.operation.name"] === "string" ||
57
+ AI_SPAN_PREFIXES.some((prefix) => span.name.startsWith(prefix))
58
+ );
59
+ }
60
+
61
+ export function createAssistantCloudSpanProcessor(
62
+ exporter: SpanExporter,
63
+ options: AssistantCloudSpanProcessorOptions = {},
64
+ ): SpanProcessor {
65
+ const processor = new BatchSpanProcessor(exporter);
66
+ const filter = options.filter ?? isAssistantCloudSpan;
67
+
68
+ return {
69
+ onStart: (span, parentContext) => processor.onStart(span, parentContext),
70
+ onEnd: (span) => {
71
+ if (filter(span)) processor.onEnd(span);
72
+ },
73
+ forceFlush: () => processor.forceFlush(),
74
+ shutdown: () => processor.shutdown(),
75
+ };
76
+ }
77
+
78
+ export function assistantCloudTraceMetadata(): { traceId?: string } {
79
+ const spanContext = trace.getActiveSpan()?.spanContext();
80
+ return spanContext && trace.isSpanContextValid(spanContext)
81
+ ? { traceId: spanContext.traceId }
82
+ : {};
83
+ }
84
+
85
+ export function withAssistantCloudTraceMetadata<
86
+ Part extends { type: string } = { type: string },
87
+ >(
88
+ messageMetadata?: (options: {
89
+ part: Part;
90
+ }) => Record<string, unknown> | undefined,
91
+ ): (options: { part: Part }) => Record<string, unknown> | undefined {
92
+ return (options) => {
93
+ const metadata = messageMetadata?.(options);
94
+ if (options.part.type !== "start") return metadata;
95
+ const trace = assistantCloudTraceMetadata();
96
+ return trace.traceId === undefined ? metadata : { ...metadata, ...trace };
97
+ };
98
+ }
@@ -23,6 +23,13 @@ describe("AssistantCloud telemetry config", () => {
23
23
  expect(createCloud({ enabled: false }).telemetry.enabled).toBe(false);
24
24
  });
25
25
 
26
+ it("can disable engagement events without disabling run reports", () => {
27
+ expect(createCloud({ events: false }).telemetry).toEqual({
28
+ enabled: true,
29
+ events: false,
30
+ });
31
+ });
32
+
26
33
  it("stays enabled when the config object carries an undefined enabled", () => {
27
34
  const beforeReport: NonNullable<
28
35
  AssistantCloudTelemetryConfig["beforeReport"]
@@ -36,4 +43,19 @@ describe("AssistantCloud telemetry config", () => {
36
43
  expect(telemetry.enabled).toBe(true);
37
44
  expect(telemetry.beforeReport).toBe(beforeReport);
38
45
  });
46
+
47
+ it("preserves configured run report dimensions", () => {
48
+ expect(
49
+ createCloud({
50
+ release: "web-2026.09.08",
51
+ environment: "production",
52
+ tags: ["region:sg", "tier:paid"],
53
+ }).telemetry,
54
+ ).toEqual({
55
+ enabled: true,
56
+ release: "web-2026.09.08",
57
+ environment: "production",
58
+ tags: ["region:sg", "tier:paid"],
59
+ });
60
+ });
39
61
  });
@@ -143,7 +143,7 @@ describe("AssistantCloudAPI", () => {
143
143
  );
144
144
  });
145
145
 
146
- it("throws APIError with parsed message for JSON error responses", async () => {
146
+ it("preserves the current error shape for JSON error responses without a code", async () => {
147
147
  const fetchMock = vi.fn().mockResolvedValue({
148
148
  ok: false,
149
149
  status: 400,
@@ -167,16 +167,54 @@ describe("AssistantCloudAPI", () => {
167
167
  expect(error.name).toBe("CloudAPIError");
168
168
  expect(error.message).toBe("invalid request payload");
169
169
  expect(error.status).toBe(400);
170
+ expect(error.code).toBeUndefined();
171
+ expect(error.details).toBeUndefined();
170
172
  });
171
173
 
172
- it("falls back to the response text when the JSON error body has no message", async () => {
174
+ it("exposes the plan-limit error code and details", async () => {
175
+ const fetchMock = vi.fn().mockResolvedValue({
176
+ ok: false,
177
+ status: 402,
178
+ headers: new Headers(),
179
+ text: vi.fn().mockResolvedValue(
180
+ JSON.stringify({
181
+ error: "plan_limit_reached",
182
+ plan: "free",
183
+ period_end: "2026-10-01T00:00:00.000Z",
184
+ cap: 100,
185
+ }),
186
+ ),
187
+ });
188
+ vi.stubGlobal("fetch", fetchMock);
189
+
190
+ const api = new AssistantCloudAPI({
191
+ apiKey: "test-key",
192
+ userId: "u-1",
193
+ workspaceId: "w-1",
194
+ });
195
+
196
+ const error = await api.makeRawRequest("/threads").catch((e) => e);
197
+ expect(error).toBeInstanceOf(CloudAPIError);
198
+ expect(error.message).toBe(
199
+ 'Request failed with status 402, {"error":"plan_limit_reached","plan":"free","period_end":"2026-10-01T00:00:00.000Z","cap":100}',
200
+ );
201
+ expect(error.status).toBe(402);
202
+ expect(error.code).toBe("plan_limit_reached");
203
+ expect(error.details).toEqual({
204
+ plan: "free",
205
+ period_end: "2026-10-01T00:00:00.000Z",
206
+ cap: 100,
207
+ });
208
+ });
209
+
210
+ it("exposes rate-limit error codes with empty details", async () => {
173
211
  const fetchMock = vi.fn().mockResolvedValue({
174
212
  ok: false,
175
213
  status: 429,
176
214
  headers: new Headers(),
177
215
  text: vi
178
216
  .fn()
179
- .mockResolvedValue(JSON.stringify({ error: "rate limited" })),
217
+ .mockResolvedValue(JSON.stringify({ error: "rate_limited" })),
180
218
  });
181
219
  vi.stubGlobal("fetch", fetchMock);
182
220
 
@@ -189,9 +227,11 @@ describe("AssistantCloudAPI", () => {
189
227
  const error = await api.makeRawRequest("/threads").catch((e) => e);
190
228
  expect(error).toBeInstanceOf(CloudAPIError);
191
229
  expect(error.message).toBe(
192
- 'Request failed with status 429, {"error":"rate limited"}',
230
+ 'Request failed with status 429, {"error":"rate_limited"}',
193
231
  );
194
232
  expect(error.status).toBe(429);
233
+ expect(error.code).toBe("rate_limited");
234
+ expect(error.details).toEqual({});
195
235
  });
196
236
 
197
237
  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(
@@ -14,6 +14,18 @@ function createMockCloud() {
14
14
  } as unknown as AssistantCloud;
15
15
  }
16
16
 
17
+ function createCloudMessages(count: number) {
18
+ return Array.from({ length: count }, (_, index) => ({
19
+ id: `message-${index + 1}`,
20
+ parent_id: null,
21
+ height: index,
22
+ created_at: "2025-01-01T00:00:00Z" as unknown as Date,
23
+ updated_at: "2025-01-01T00:00:00Z" as unknown as Date,
24
+ format: "aui/v0",
25
+ content: {},
26
+ }));
27
+ }
28
+
17
29
  describe("CloudMessagePersistence", () => {
18
30
  let cloud: AssistantCloud;
19
31
  let persistence: CloudMessagePersistence;
@@ -35,8 +47,29 @@ describe("CloudMessagePersistence", () => {
35
47
 
36
48
  expect(persistence.isPersisted("local-1")).toBe(true);
37
49
  expect(await persistence.getRemoteId("local-1")).toBe("remote-1");
50
+ expect(persistence.getResolvedRemoteId("local-1")).toBe("remote-1");
38
51
  });
39
52
 
53
+ it.each(["__proto__", "constructor", "toString"])(
54
+ "supports prototype-named local ID %s",
55
+ async (messageId) => {
56
+ vi.mocked(cloud.threads.messages.create).mockResolvedValue({
57
+ message_id: `remote-${messageId}`,
58
+ });
59
+
60
+ expect(persistence.isPersisted(messageId)).toBe(false);
61
+
62
+ await persistence.append("thread-1", messageId, null, "aui/v0", {
63
+ text: "hello",
64
+ });
65
+
66
+ expect(persistence.isPersisted(messageId)).toBe(true);
67
+ expect(await persistence.getRemoteId(messageId)).toBe(
68
+ `remote-${messageId}`,
69
+ );
70
+ },
71
+ );
72
+
40
73
  it("uses the current client without losing ID mappings", async () => {
41
74
  const firstCloud = createMockCloud();
42
75
  const secondCloud = createMockCloud();
@@ -221,6 +254,88 @@ describe("CloudMessagePersistence", () => {
221
254
  expect(persistence.isPersisted("msg-1")).toBe(true);
222
255
  });
223
256
 
257
+ it("maps prototype-named loaded messages", async () => {
258
+ vi.mocked(cloud.threads.messages.list).mockResolvedValue({
259
+ messages: [
260
+ {
261
+ id: "__proto__",
262
+ parent_id: null,
263
+ height: 0,
264
+ created_at: "2025-01-01T00:00:00Z" as unknown as Date,
265
+ updated_at: "2025-01-01T00:00:00Z" as unknown as Date,
266
+ format: "aui/v0",
267
+ content: { text: "loaded" },
268
+ },
269
+ ],
270
+ });
271
+
272
+ await persistence.load("thread-1");
273
+
274
+ expect(persistence.isPersisted("__proto__")).toBe(true);
275
+ expect(await persistence.getRemoteId("__proto__")).toBe("__proto__");
276
+ });
277
+
278
+ it("follows the message cursor across pages in server order", async () => {
279
+ const rows = createCloudMessages(450);
280
+ vi.mocked(cloud.threads.messages.list).mockImplementation(
281
+ async (_threadId, query) => {
282
+ const start = query?.after
283
+ ? rows.findIndex((row) => row.id === query.after) + 1
284
+ : 0;
285
+ return { messages: rows.slice(start, start + 200) };
286
+ },
287
+ );
288
+
289
+ const messages = await persistence.load("thread-1", "aui/v0");
290
+
291
+ expect(messages.map((message) => message.id)).toEqual(
292
+ rows.map((row) => row.id),
293
+ );
294
+ expect(cloud.threads.messages.list).toHaveBeenCalledTimes(3);
295
+ expect(cloud.threads.messages.list).toHaveBeenNthCalledWith(1, "thread-1", {
296
+ format: "aui/v0",
297
+ limit: 200,
298
+ });
299
+ expect(cloud.threads.messages.list).toHaveBeenNthCalledWith(3, "thread-1", {
300
+ format: "aui/v0",
301
+ limit: 200,
302
+ after: "message-400",
303
+ });
304
+ });
305
+
306
+ it("stops without duplicates when a later cursor stops resolving", async () => {
307
+ const rows = createCloudMessages(450);
308
+ vi.mocked(cloud.threads.messages.list).mockImplementation(
309
+ async (_threadId, query) => {
310
+ // The third request replays page one, the way the endpoint answers a
311
+ // cursor whose message no longer resolves.
312
+ const start =
313
+ query?.after && query.after !== "message-400"
314
+ ? rows.findIndex((row) => row.id === query.after) + 1
315
+ : 0;
316
+ return { messages: rows.slice(start, start + 200) };
317
+ },
318
+ );
319
+
320
+ const messages = await persistence.load("thread-1", "aui/v0");
321
+
322
+ expect(messages.map((message) => message.id)).toEqual(
323
+ rows.slice(0, 400).map((row) => row.id),
324
+ );
325
+ expect(cloud.threads.messages.list).toHaveBeenCalledTimes(3);
326
+ });
327
+
328
+ it("stops when a page does not advance the cursor", async () => {
329
+ vi.mocked(cloud.threads.messages.list).mockResolvedValue({
330
+ messages: createCloudMessages(200),
331
+ });
332
+
333
+ const messages = await persistence.load("thread-1", "aui/v0");
334
+
335
+ expect(messages).toHaveLength(200);
336
+ expect(cloud.threads.messages.list).toHaveBeenCalledTimes(2);
337
+ });
338
+
224
339
  it("updates an already-persisted message", async () => {
225
340
  vi.mocked(cloud.threads.messages.create).mockResolvedValue({
226
341
  message_id: "remote-1",