assistant-cloud 0.1.40 → 0.1.42

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 (57) 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 +35 -12
  9. package/dist/AssistantCloudAuthStrategy.js.map +1 -1
  10. package/dist/AssistantCloudAuthTokens.d.ts.map +1 -1
  11. package/dist/AssistantCloudAuthTokens.js +3 -1
  12. package/dist/AssistantCloudAuthTokens.js.map +1 -1
  13. package/dist/AssistantCloudFiles.d.ts.map +1 -1
  14. package/dist/AssistantCloudFiles.js +16 -4
  15. package/dist/AssistantCloudFiles.js.map +1 -1
  16. package/dist/AssistantCloudRuns.d.ts +3 -13
  17. package/dist/AssistantCloudRuns.d.ts.map +1 -1
  18. package/dist/AssistantCloudRuns.js +10 -2
  19. package/dist/AssistantCloudRuns.js.map +1 -1
  20. package/dist/AssistantCloudThreadMessages.d.ts.map +1 -1
  21. package/dist/AssistantCloudThreadMessages.js +3 -2
  22. package/dist/AssistantCloudThreadMessages.js.map +1 -1
  23. package/dist/AssistantCloudThreads.d.ts.map +1 -1
  24. package/dist/AssistantCloudThreads.js +3 -2
  25. package/dist/AssistantCloudThreads.js.map +1 -1
  26. package/dist/generateThreadTitle.d.ts +15 -0
  27. package/dist/generateThreadTitle.d.ts.map +1 -0
  28. package/dist/generateThreadTitle.js +25 -0
  29. package/dist/generateThreadTitle.js.map +1 -0
  30. package/dist/index.d.ts +3 -1
  31. package/dist/index.js +3 -1
  32. package/dist/runTelemetry.d.ts +54 -0
  33. package/dist/runTelemetry.d.ts.map +1 -0
  34. package/dist/runTelemetry.js +79 -0
  35. package/dist/runTelemetry.js.map +1 -0
  36. package/package.json +5 -5
  37. package/src/AssistantCloud.ts +1 -1
  38. package/src/AssistantCloudAPI.ts +9 -8
  39. package/src/AssistantCloudAuthStrategy.ts +49 -12
  40. package/src/AssistantCloudAuthTokens.test.ts +30 -0
  41. package/src/AssistantCloudAuthTokens.ts +7 -1
  42. package/src/AssistantCloudFiles.test.ts +71 -0
  43. package/src/AssistantCloudFiles.ts +56 -10
  44. package/src/AssistantCloudRuns.ts +37 -15
  45. package/src/AssistantCloudThreadMessages.test.ts +20 -0
  46. package/src/AssistantCloudThreadMessages.ts +10 -3
  47. package/src/AssistantCloudThreads.test.ts +17 -0
  48. package/src/AssistantCloudThreads.ts +6 -1
  49. package/src/generateThreadTitle.test.ts +71 -0
  50. package/src/generateThreadTitle.ts +38 -0
  51. package/src/index.ts +10 -0
  52. package/src/runTelemetry.test.ts +130 -0
  53. package/src/runTelemetry.ts +140 -0
  54. package/src/tests/AssistantCloud.test.ts +39 -0
  55. package/src/tests/AssistantCloudAPI.test.ts +25 -0
  56. package/src/tests/AssistantCloudAuthStrategy.test.ts +278 -5
  57. package/src/tests/AssistantCloudRuns.test.ts +115 -0
@@ -12,6 +12,26 @@ const createCloudThreadMessages = () => {
12
12
  };
13
13
 
14
14
  describe("AssistantCloudThreadMessages responses", () => {
15
+ it("validates created message IDs", async () => {
16
+ const { messages, makeRequest } = createCloudThreadMessages();
17
+ const body = {
18
+ parent_id: null,
19
+ format: "aui/v0",
20
+ content: {},
21
+ };
22
+ makeRequest.mockResolvedValueOnce({ message_id: "message-1" });
23
+
24
+ await expect(messages.create("thread-1", body)).resolves.toEqual({
25
+ message_id: "message-1",
26
+ });
27
+
28
+ makeRequest.mockResolvedValueOnce({});
29
+
30
+ await expect(messages.create("thread-1", body)).rejects.toThrow(
31
+ 'Invalid Assistant Cloud response for "message_id": expected a string',
32
+ );
33
+ });
34
+
15
35
  it("decodes canonical message responses without changing content", async () => {
16
36
  const { messages, makeRequest } = createCloudThreadMessages();
17
37
  makeRequest.mockResolvedValue({
@@ -89,10 +89,17 @@ export class AssistantCloudThreadMessages {
89
89
  threadId: string,
90
90
  body: AssistantCloudThreadMessageCreateBody,
91
91
  ): Promise<AssistantCloudMessageCreateResponse> {
92
- return this.cloud.makeRequest(
93
- `/threads/${encodeURIComponent(threadId)}/messages`,
94
- { method: "POST", body },
92
+ const response = readCloudRecord(
93
+ await this.cloud.makeRequest(
94
+ `/threads/${encodeURIComponent(threadId)}/messages`,
95
+ { method: "POST", body },
96
+ ),
97
+ "thread message create response",
95
98
  );
99
+
100
+ return {
101
+ message_id: readCloudString(response.message_id, "message_id"),
102
+ };
96
103
  }
97
104
 
98
105
  public async update(
@@ -22,6 +22,23 @@ const threadResponse = {
22
22
  };
23
23
 
24
24
  describe("AssistantCloudThreads responses", () => {
25
+ it("validates created thread IDs", async () => {
26
+ const { threads, makeRequest } = createCloudThreads();
27
+ makeRequest.mockResolvedValueOnce({ thread_id: "thread-1" });
28
+
29
+ await expect(
30
+ threads.create({ last_message_at: new Date() }),
31
+ ).resolves.toEqual({ thread_id: "thread-1" });
32
+
33
+ makeRequest.mockResolvedValueOnce({});
34
+
35
+ await expect(
36
+ threads.create({ last_message_at: new Date() }),
37
+ ).rejects.toThrow(
38
+ 'Invalid Assistant Cloud response for "thread_id": expected a string',
39
+ );
40
+ });
41
+
25
42
  it("forwards both archive filter values", async () => {
26
43
  const { threads, makeRequest } = createCloudThreads();
27
44
  makeRequest.mockResolvedValue({ threads: [] });
@@ -117,7 +117,12 @@ export class AssistantCloudThreads {
117
117
  public async create(
118
118
  body: AssistantCloudThreadsCreateBody,
119
119
  ): Promise<AssistantCloudThreadsCreateResponse> {
120
- return this.cloud.makeRequest("/threads", { method: "POST", body });
120
+ const response = readCloudRecord(
121
+ await this.cloud.makeRequest("/threads", { method: "POST", body }),
122
+ "thread create response",
123
+ );
124
+
125
+ return { thread_id: readCloudString(response.thread_id, "thread_id") };
121
126
  }
122
127
 
123
128
  public async update(
@@ -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 {
@@ -0,0 +1,130 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ createRunTelemetryToolCall,
4
+ normalizeRunTelemetryUsage,
5
+ truncateRunTelemetryText,
6
+ } from "./runTelemetry";
7
+
8
+ const MAX = 50_000;
9
+
10
+ describe("truncateRunTelemetryText", () => {
11
+ it("passes text at or under the cap through unchanged", () => {
12
+ expect(truncateRunTelemetryText("hello")).toBe("hello");
13
+ const exact = "a".repeat(MAX);
14
+ expect(truncateRunTelemetryText(exact)).toBe(exact);
15
+ });
16
+
17
+ it("clamps text over the cap", () => {
18
+ expect(truncateRunTelemetryText("a".repeat(MAX + 1))).toHaveLength(MAX);
19
+ });
20
+ });
21
+
22
+ describe("createRunTelemetryToolCall", () => {
23
+ it("serializes args and omits tool_source when the caller gives none", () => {
24
+ expect(
25
+ createRunTelemetryToolCall({
26
+ toolName: "calculator",
27
+ toolCallId: "call-1",
28
+ args: { a: 1 },
29
+ result: { sum: 1 },
30
+ }),
31
+ ).toEqual({
32
+ tool_name: "calculator",
33
+ tool_call_id: "call-1",
34
+ tool_args: '{"a":1}',
35
+ tool_result: '{"sum":1}',
36
+ });
37
+ });
38
+
39
+ it("clamps serialized args and results", () => {
40
+ const call = createRunTelemetryToolCall({
41
+ toolName: "t",
42
+ toolCallId: "call-1",
43
+ args: { blob: "a".repeat(MAX) },
44
+ result: { blob: "a".repeat(MAX) },
45
+ });
46
+ expect(call.tool_args).toHaveLength(MAX);
47
+ expect(call.tool_result).toHaveLength(MAX);
48
+ });
49
+
50
+ it("clamps pre-serialized argsText to the cap", () => {
51
+ const argsText = "a".repeat(MAX + 10);
52
+ const call = createRunTelemetryToolCall({
53
+ toolName: "t",
54
+ toolCallId: "call-1",
55
+ argsText,
56
+ args: { ignored: true },
57
+ });
58
+ expect(call.tool_args).toBe(argsText.slice(0, MAX));
59
+ });
60
+
61
+ it("omits fields whose value cannot be serialized", () => {
62
+ const circular: Record<string, unknown> = {};
63
+ circular.self = circular;
64
+ expect(
65
+ createRunTelemetryToolCall({
66
+ toolName: "t",
67
+ toolCallId: "call-1",
68
+ args: circular,
69
+ result: undefined,
70
+ }),
71
+ ).toEqual({ tool_name: "t", tool_call_id: "call-1" });
72
+ });
73
+
74
+ it("summarizes base64 image and audio blocks in an mcp result", () => {
75
+ const call = createRunTelemetryToolCall({
76
+ toolName: "t",
77
+ toolCallId: "call-1",
78
+ toolSource: "mcp",
79
+ result: [
80
+ { type: "text", text: "keep me" },
81
+ { type: "image", data: "A".repeat(4096) },
82
+ ],
83
+ });
84
+ expect(call.tool_source).toBe("mcp");
85
+ expect(call.tool_result).toContain("keep me");
86
+ expect(call.tool_result).toContain("[image: 3.0KB]");
87
+ expect(call.tool_result).not.toContain("A".repeat(200));
88
+ });
89
+
90
+ it("leaves a non-mcp result unsummarized", () => {
91
+ const result = [{ type: "image", data: "A".repeat(4096) }];
92
+ const call = createRunTelemetryToolCall({
93
+ toolName: "t",
94
+ toolCallId: "call-1",
95
+ toolSource: "frontend",
96
+ result,
97
+ });
98
+ expect(call.tool_source).toBe("frontend");
99
+ expect(call.tool_result).toBe(JSON.stringify(result));
100
+ });
101
+ });
102
+
103
+ describe("normalizeRunTelemetryUsage", () => {
104
+ it("prefers the current names over the legacy ones", () => {
105
+ expect(
106
+ normalizeRunTelemetryUsage({
107
+ inputTokens: 1,
108
+ outputTokens: 2,
109
+ promptTokens: 90,
110
+ completionTokens: 90,
111
+ }),
112
+ ).toEqual({ inputTokens: 1, outputTokens: 2 });
113
+ });
114
+
115
+ it("falls back to the legacy prompt and completion names", () => {
116
+ expect(
117
+ normalizeRunTelemetryUsage({ promptTokens: 3, completionTokens: 4 }),
118
+ ).toEqual({ inputTokens: 3, outputTokens: 4 });
119
+ });
120
+
121
+ it("keeps a zero count and omits an absent one", () => {
122
+ expect(
123
+ normalizeRunTelemetryUsage({ inputTokens: 0, cachedInputTokens: 5 }),
124
+ ).toEqual({ inputTokens: 0, cachedInputTokens: 5 });
125
+ });
126
+
127
+ it("returns undefined when no count is present", () => {
128
+ expect(normalizeRunTelemetryUsage({})).toBeUndefined();
129
+ });
130
+ });
@@ -0,0 +1,140 @@
1
+ import type { SamplingCallData } from "./instrumentMcpSampling";
2
+
3
+ const MAX_TELEMETRY_TEXT_LENGTH = 50_000;
4
+
5
+ const BASE64_PATTERN = /^[A-Za-z0-9+/]{100,}={0,2}$/;
6
+
7
+ export type AssistantCloudRunReportToolCall = {
8
+ tool_name: string;
9
+ tool_call_id: string;
10
+ tool_args?: string;
11
+ tool_result?: string;
12
+ tool_source?: "mcp" | "frontend" | "backend";
13
+ start_ms?: number;
14
+ end_ms?: number;
15
+ sampling_calls?: SamplingCallData[];
16
+ };
17
+
18
+ /**
19
+ * Clamps a string to the size the runs endpoint accepts for a single span
20
+ * field.
21
+ */
22
+ export function truncateRunTelemetryText(value: string): string {
23
+ if (value.length <= MAX_TELEMETRY_TEXT_LENGTH) return value;
24
+ return value.slice(0, MAX_TELEMETRY_TEXT_LENGTH);
25
+ }
26
+
27
+ function safeStringify(value: unknown): string | undefined {
28
+ if (value == null) return undefined;
29
+ try {
30
+ return truncateRunTelemetryText(JSON.stringify(value));
31
+ } catch {
32
+ return undefined;
33
+ }
34
+ }
35
+
36
+ function summarizeMcpResult(value: unknown): string | undefined {
37
+ if (value == null) return undefined;
38
+ try {
39
+ const parsed = typeof value === "string" ? JSON.parse(value) : value;
40
+ if (Array.isArray(parsed)) {
41
+ const summarized = parsed.map((item) => {
42
+ if (item && typeof item === "object" && item.type) {
43
+ if (
44
+ (item.type === "image" || item.type === "audio") &&
45
+ typeof item.data === "string" &&
46
+ BASE64_PATTERN.test(item.data.slice(0, 200))
47
+ ) {
48
+ const sizeKB = ((item.data.length * 3) / 4 / 1024).toFixed(1);
49
+ return { ...item, data: `[${item.type}: ${sizeKB}KB]` };
50
+ }
51
+ }
52
+ return item;
53
+ });
54
+ return truncateRunTelemetryText(JSON.stringify(summarized));
55
+ }
56
+ } catch {
57
+ // not JSON array, fall through
58
+ }
59
+ return safeStringify(value);
60
+ }
61
+
62
+ export type RunTelemetryToolCallInit = {
63
+ toolName: string;
64
+ toolCallId: string;
65
+ args?: unknown;
66
+ /**
67
+ * Pre-serialized arguments, used in place of serializing `args`. Values over
68
+ * the span size are clamped before they are included in the report.
69
+ */
70
+ argsText?: string | undefined;
71
+ result?: unknown;
72
+ toolSource?: "mcp" | "frontend" | "backend" | undefined;
73
+ };
74
+
75
+ /**
76
+ * Serializes one tool call into the shape the runs endpoint accepts. An `mcp`
77
+ * source has its result summarized, because MCP content blocks carry inline
78
+ * base64 image and audio payloads that would otherwise dominate the report.
79
+ */
80
+ export function createRunTelemetryToolCall(
81
+ init: RunTelemetryToolCallInit,
82
+ ): AssistantCloudRunReportToolCall {
83
+ const { toolName, toolCallId, args, argsText, result, toolSource } = init;
84
+ const call: AssistantCloudRunReportToolCall = {
85
+ tool_name: toolName,
86
+ tool_call_id: toolCallId,
87
+ };
88
+ const toolArgs =
89
+ argsText != null ? truncateRunTelemetryText(argsText) : safeStringify(args);
90
+ if (toolArgs !== undefined) call.tool_args = toolArgs;
91
+ const toolResult =
92
+ toolSource === "mcp" ? summarizeMcpResult(result) : safeStringify(result);
93
+ if (toolResult !== undefined) call.tool_result = toolResult;
94
+ if (toolSource) call.tool_source = toolSource;
95
+ return call;
96
+ }
97
+
98
+ export type RunTelemetryUsage = {
99
+ inputTokens?: number;
100
+ outputTokens?: number;
101
+ reasoningTokens?: number;
102
+ cachedInputTokens?: number;
103
+ };
104
+
105
+ export type RunTelemetryUsageInit = RunTelemetryUsage & {
106
+ promptTokens?: number;
107
+ completionTokens?: number;
108
+ };
109
+
110
+ /**
111
+ * Resolves the token counts a provider reports under either the current or the
112
+ * legacy prompt/completion names. Returns undefined when no count is present,
113
+ * so callers can tell an empty usage object from a zeroed one.
114
+ */
115
+ export function normalizeRunTelemetryUsage(
116
+ usage: RunTelemetryUsageInit,
117
+ ): RunTelemetryUsage | undefined {
118
+ const inputTokens = usage.inputTokens ?? usage.promptTokens;
119
+ const outputTokens = usage.outputTokens ?? usage.completionTokens;
120
+
121
+ if (
122
+ inputTokens == null &&
123
+ outputTokens == null &&
124
+ usage.reasoningTokens == null &&
125
+ usage.cachedInputTokens == null
126
+ ) {
127
+ return undefined;
128
+ }
129
+
130
+ return {
131
+ ...(inputTokens != null ? { inputTokens } : undefined),
132
+ ...(outputTokens != null ? { outputTokens } : undefined),
133
+ ...(usage.reasoningTokens != null
134
+ ? { reasoningTokens: usage.reasoningTokens }
135
+ : undefined),
136
+ ...(usage.cachedInputTokens != null
137
+ ? { cachedInputTokens: usage.cachedInputTokens }
138
+ : undefined),
139
+ };
140
+ }
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { AssistantCloud } from "../AssistantCloud";
3
+ import type { AssistantCloudTelemetryConfig } from "../AssistantCloudAPI";
4
+
5
+ const createCloud = (
6
+ telemetry?: ConstructorParameters<typeof AssistantCloud>[0]["telemetry"],
7
+ ) =>
8
+ new AssistantCloud({
9
+ apiKey: "test-key",
10
+ userId: "user-id",
11
+ workspaceId: "workspace-id",
12
+ ...(telemetry !== undefined ? { telemetry } : {}),
13
+ });
14
+
15
+ describe("AssistantCloud telemetry config", () => {
16
+ it("defaults to enabled", () => {
17
+ expect(createCloud().telemetry.enabled).toBe(true);
18
+ expect(createCloud(true).telemetry.enabled).toBe(true);
19
+ });
20
+
21
+ it("disables when configured off", () => {
22
+ expect(createCloud(false).telemetry.enabled).toBe(false);
23
+ expect(createCloud({ enabled: false }).telemetry.enabled).toBe(false);
24
+ });
25
+
26
+ it("stays enabled when the config object carries an undefined enabled", () => {
27
+ const beforeReport: NonNullable<
28
+ AssistantCloudTelemetryConfig["beforeReport"]
29
+ > = (report) => report;
30
+ // JS consumers (and TS apps without exactOptionalPropertyTypes) can pass
31
+ // an explicitly-undefined enabled, e.g. { enabled: cfg.enabled }.
32
+ const telemetry = createCloud({
33
+ enabled: undefined,
34
+ beforeReport,
35
+ } as unknown as AssistantCloudTelemetryConfig).telemetry;
36
+ expect(telemetry.enabled).toBe(true);
37
+ expect(telemetry.beforeReport).toBe(beforeReport);
38
+ });
39
+ });
@@ -169,6 +169,31 @@ describe("AssistantCloudAPI", () => {
169
169
  expect(error.status).toBe(400);
170
170
  });
171
171
 
172
+ it("falls back to the response text when the JSON error body has no message", async () => {
173
+ const fetchMock = vi.fn().mockResolvedValue({
174
+ ok: false,
175
+ status: 429,
176
+ headers: new Headers(),
177
+ text: vi
178
+ .fn()
179
+ .mockResolvedValue(JSON.stringify({ error: "rate limited" })),
180
+ });
181
+ vi.stubGlobal("fetch", fetchMock);
182
+
183
+ const api = new AssistantCloudAPI({
184
+ apiKey: "test-key",
185
+ userId: "u-1",
186
+ workspaceId: "w-1",
187
+ });
188
+
189
+ const error = await api.makeRawRequest("/threads").catch((e) => e);
190
+ expect(error).toBeInstanceOf(CloudAPIError);
191
+ expect(error.message).toBe(
192
+ 'Request failed with status 429, {"error":"rate limited"}',
193
+ );
194
+ expect(error.status).toBe(429);
195
+ });
196
+
172
197
  it("throws generic error with status for non-JSON error responses", async () => {
173
198
  const fetchMock = vi.fn().mockResolvedValue({
174
199
  ok: false,