assistant-cloud 0.1.42 → 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 +79 -18
  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 +53 -18
  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 +79 -9
  48. package/dist/runTelemetry.d.ts.map +1 -1
  49. package/dist/runTelemetry.js +152 -6
  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 +153 -46
  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 +65 -29
  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 +366 -0
  75. package/src/runTelemetry.ts +271 -16
  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 +354 -11
  81. package/src/tests/CloudMessagePersistence.test.ts +208 -0
@@ -0,0 +1,124 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import {
5
+ AssistantCloudEvents,
6
+ type AssistantCloudEvent,
7
+ } from "./AssistantCloudEvents";
8
+ import type { AssistantCloudAPI } from "./AssistantCloudAPI";
9
+
10
+ const event = (index: number): AssistantCloudEvent => ({
11
+ kind: "message_sent",
12
+ thread_id: `thread-${index}`,
13
+ });
14
+
15
+ const createEvents = (enabled = true) => {
16
+ const makeRequest = vi.fn().mockResolvedValue({ accepted: 1 });
17
+ const events = new AssistantCloudEvents(
18
+ { makeRequest } as unknown as AssistantCloudAPI,
19
+ () => enabled,
20
+ );
21
+ return { events, makeRequest };
22
+ };
23
+
24
+ afterEach(() => {
25
+ vi.useRealTimers();
26
+ vi.restoreAllMocks();
27
+ Object.defineProperty(document, "visibilityState", {
28
+ configurable: true,
29
+ value: "visible",
30
+ });
31
+ });
32
+
33
+ describe("AssistantCloudEvents", () => {
34
+ it("flushes twenty events in one keepalive request", async () => {
35
+ const { events, makeRequest } = createEvents();
36
+
37
+ for (let index = 0; index < 20; index++) events.track(event(index));
38
+
39
+ await vi.waitFor(() => expect(makeRequest).toHaveBeenCalledOnce());
40
+ expect(makeRequest).toHaveBeenCalledWith("/events", {
41
+ method: "POST",
42
+ body: { events: Array.from({ length: 20 }, (_, index) => event(index)) },
43
+ keepalive: true,
44
+ });
45
+ });
46
+
47
+ it("keeps its page listeners only while events are buffered", async () => {
48
+ const add = vi.spyOn(window, "addEventListener");
49
+ const remove = vi.spyOn(window, "removeEventListener");
50
+ const { events, makeRequest } = createEvents();
51
+
52
+ for (let index = 0; index < 20; index++) events.track(event(index));
53
+ expect(add).toHaveBeenCalledWith("pagehide", expect.any(Function));
54
+
55
+ await vi.waitFor(() => expect(makeRequest).toHaveBeenCalledOnce());
56
+ await vi.waitFor(() =>
57
+ expect(remove).toHaveBeenCalledWith("pagehide", expect.any(Function)),
58
+ );
59
+ });
60
+
61
+ it("dispose removes the listeners and flushes what is buffered", async () => {
62
+ const remove = vi.spyOn(document, "removeEventListener");
63
+ const { events, makeRequest } = createEvents();
64
+
65
+ events.track(event(1));
66
+ events.dispose();
67
+
68
+ expect(remove).toHaveBeenCalledWith(
69
+ "visibilitychange",
70
+ expect.any(Function),
71
+ );
72
+ await vi.waitFor(() => expect(makeRequest).toHaveBeenCalledOnce());
73
+ });
74
+
75
+ it("flushes buffered events after two seconds", async () => {
76
+ vi.useFakeTimers();
77
+ const { events, makeRequest } = createEvents();
78
+
79
+ events.track(event(1));
80
+ await vi.advanceTimersByTimeAsync(1_999);
81
+ expect(makeRequest).not.toHaveBeenCalled();
82
+
83
+ await vi.advanceTimersByTimeAsync(1);
84
+ expect(makeRequest).toHaveBeenCalledOnce();
85
+ });
86
+
87
+ it("flushes when the document becomes hidden", async () => {
88
+ const { events, makeRequest } = createEvents();
89
+ events.track(event(1));
90
+
91
+ Object.defineProperty(document, "visibilityState", {
92
+ configurable: true,
93
+ value: "hidden",
94
+ });
95
+ document.dispatchEvent(new Event("visibilitychange"));
96
+
97
+ await vi.waitFor(() => expect(makeRequest).toHaveBeenCalledOnce());
98
+ });
99
+
100
+ it("does not send events when telemetry is disabled", async () => {
101
+ vi.useFakeTimers();
102
+ const { events, makeRequest } = createEvents(false);
103
+ events.track(event(1));
104
+
105
+ await vi.advanceTimersByTimeAsync(2_000);
106
+ expect(makeRequest).not.toHaveBeenCalled();
107
+ });
108
+
109
+ it("keeps every request below the server batch limit", async () => {
110
+ const { events, makeRequest } = createEvents();
111
+
112
+ for (let index = 0; index < 51; index++) events.track(event(index));
113
+
114
+ const sent = () =>
115
+ makeRequest.mock.calls.reduce(
116
+ (count, [, options]) => count + options.body.events.length,
117
+ 0,
118
+ );
119
+ await vi.waitFor(() => expect(sent()).toBe(51));
120
+ for (const [, options] of makeRequest.mock.calls) {
121
+ expect(options.body.events.length).toBeLessThanOrEqual(50);
122
+ }
123
+ });
124
+ });
@@ -0,0 +1,185 @@
1
+ import type { AssistantCloudAPI } from "./AssistantCloudAPI";
2
+
3
+ export type AssistantCloudEventKind =
4
+ | "message_sent"
5
+ | "message_edited"
6
+ | "run_stopped"
7
+ | "message_regenerated"
8
+ | "message_copied"
9
+ | "branch_switched"
10
+ | "suggestions_shown"
11
+ | "suggestion_clicked"
12
+ | "attachment_added"
13
+ | "attachment_failed"
14
+ | "thread_switched"
15
+ | "tool_approved"
16
+ | "tool_rejected"
17
+ | "speech_started"
18
+ | "voice_started"
19
+ | "error_shown";
20
+
21
+ export type AssistantCloudEvent = {
22
+ kind: AssistantCloudEventKind;
23
+ thread_id?: string | undefined;
24
+ message_id?: string | undefined;
25
+ run_id?: string | undefined;
26
+ value?: number | undefined;
27
+ props?: Readonly<Record<string, string | number | boolean>> | undefined;
28
+ };
29
+
30
+ const FLUSH_SIZE = 20;
31
+ const MAX_BATCH_SIZE = 50;
32
+ const FLUSH_DELAY_MS = 2_000;
33
+
34
+ export class AssistantCloudEvents {
35
+ private buffer: AssistantCloudEvent[] = [];
36
+ private timer: ReturnType<typeof setTimeout> | undefined;
37
+ private flushing: Promise<void> | undefined;
38
+
39
+ private readonly cloud: AssistantCloudAPI;
40
+ private readonly isEnabled: () => boolean;
41
+
42
+ private listening = false;
43
+
44
+ constructor(cloud: AssistantCloudAPI, isEnabled: () => boolean) {
45
+ this.cloud = cloud;
46
+ this.isEnabled = isEnabled;
47
+ }
48
+
49
+ public track(event: AssistantCloudEvent): void {
50
+ if (!this.isEnabled()) return;
51
+
52
+ this.listen();
53
+ this.buffer.push(normalizeEvent(event));
54
+ if (this.buffer.length >= FLUSH_SIZE) {
55
+ void this.flush();
56
+ return;
57
+ }
58
+
59
+ this.scheduleFlush();
60
+ }
61
+
62
+ private listen(): void {
63
+ if (
64
+ this.listening ||
65
+ typeof window === "undefined" ||
66
+ typeof document === "undefined"
67
+ ) {
68
+ return;
69
+ }
70
+ this.listening = true;
71
+ window.addEventListener("pagehide", this.flush);
72
+ document.addEventListener("visibilitychange", this.onVisibilityChange);
73
+ }
74
+
75
+ private unlisten(): void {
76
+ if (!this.listening) return;
77
+ this.listening = false;
78
+ window.removeEventListener("pagehide", this.flush);
79
+ document.removeEventListener("visibilitychange", this.onVisibilityChange);
80
+ }
81
+
82
+ public dispose(): void {
83
+ this.unlisten();
84
+ this.clearFlushTimer();
85
+ void this.flush();
86
+ }
87
+
88
+ private onVisibilityChange = () => {
89
+ if (document.visibilityState === "hidden") {
90
+ void this.flush();
91
+ }
92
+ };
93
+
94
+ private flush = async (): Promise<void> => {
95
+ if (!this.isEnabled()) {
96
+ this.buffer = [];
97
+ this.clearFlushTimer();
98
+ this.unlisten();
99
+ return;
100
+ }
101
+ if (this.flushing) return this.flushing;
102
+
103
+ this.clearFlushTimer();
104
+ const task = this.flushPending();
105
+ this.flushing = task;
106
+ void task.then(() => {
107
+ if (this.flushing !== task) return;
108
+ this.flushing = undefined;
109
+ if (this.buffer.length === 0) {
110
+ this.clearFlushTimer();
111
+ this.unlisten();
112
+ } else {
113
+ void this.flush();
114
+ }
115
+ });
116
+ return task;
117
+ };
118
+
119
+ private async flushPending(): Promise<void> {
120
+ while (this.buffer.length > 0) {
121
+ const events = this.buffer.splice(0, MAX_BATCH_SIZE);
122
+ try {
123
+ await this.cloud.makeRequest("/events", {
124
+ method: "POST",
125
+ body: { events },
126
+ keepalive: true,
127
+ });
128
+ } catch {}
129
+ }
130
+ }
131
+
132
+ private scheduleFlush() {
133
+ if (this.timer !== undefined) return;
134
+ this.timer = setTimeout(() => {
135
+ this.timer = undefined;
136
+ void this.flush();
137
+ }, FLUSH_DELAY_MS);
138
+ }
139
+
140
+ private clearFlushTimer() {
141
+ if (this.timer === undefined) return;
142
+ clearTimeout(this.timer);
143
+ this.timer = undefined;
144
+ }
145
+ }
146
+
147
+ const normalizeEvent = (event: AssistantCloudEvent): AssistantCloudEvent => {
148
+ const props = normalizeProps(event.props);
149
+ return {
150
+ kind: event.kind,
151
+ ...(normalizeId(event.thread_id) ? { thread_id: event.thread_id } : {}),
152
+ ...(normalizeId(event.message_id) ? { message_id: event.message_id } : {}),
153
+ ...(normalizeId(event.run_id) ? { run_id: event.run_id } : {}),
154
+ ...(isNonNegativeInteger(event.value) ? { value: event.value } : {}),
155
+ ...(props ? { props } : {}),
156
+ };
157
+ };
158
+
159
+ const normalizeId = (value: string | undefined): string | undefined =>
160
+ value && value.length <= 48 ? value : undefined;
161
+
162
+ const isNonNegativeInteger = (value: number | undefined): value is number =>
163
+ value !== undefined && Number.isInteger(value) && value >= 0;
164
+
165
+ const normalizeProps = (
166
+ props: AssistantCloudEvent["props"],
167
+ ): AssistantCloudEvent["props"] => {
168
+ if (!props) return undefined;
169
+ const entries = Object.entries(props);
170
+ if (
171
+ entries.some(
172
+ ([, value]) =>
173
+ (typeof value === "string" && value.length > 256) ||
174
+ (typeof value !== "string" &&
175
+ typeof value !== "number" &&
176
+ typeof value !== "boolean"),
177
+ )
178
+ ) {
179
+ return undefined;
180
+ }
181
+ const value = Object.fromEntries(entries);
182
+ return new TextEncoder().encode(JSON.stringify(value)).byteLength <= 1024
183
+ ? value
184
+ : undefined;
185
+ };
@@ -40,6 +40,27 @@ describe("AssistantCloudFiles responses", () => {
40
40
  });
41
41
 
42
42
  it("decodes presigned upload responses", async () => {
43
+ const { files, makeRequest } = createCloudFiles();
44
+ makeRequest.mockResolvedValue({
45
+ success: true,
46
+ signedUrl: "https://uploads.example.com/file",
47
+ expiresAt: "2026-08-16T12:00:00.000Z",
48
+ publicUrl: "https://cdn.example.com/file",
49
+ key: "attachments/file",
50
+ });
51
+
52
+ await expect(
53
+ files.generatePresignedUploadUrl({ filename: "notes.txt" }),
54
+ ).resolves.toEqual({
55
+ success: true,
56
+ signedUrl: "https://uploads.example.com/file",
57
+ expiresAt: "2026-08-16T12:00:00.000Z",
58
+ publicUrl: "https://cdn.example.com/file",
59
+ key: "attachments/file",
60
+ });
61
+ });
62
+
63
+ it("accepts presigned upload responses from older clouds without a key", async () => {
43
64
  const { files, makeRequest } = createCloudFiles();
44
65
  makeRequest.mockResolvedValue({
45
66
  success: true,
@@ -68,4 +89,42 @@ describe("AssistantCloudFiles responses", () => {
68
89
  'Invalid Assistant Cloud response for "presigned upload response.success": expected a boolean',
69
90
  );
70
91
  });
92
+
93
+ it("decodes presigned download responses", async () => {
94
+ const { files, makeRequest } = createCloudFiles();
95
+ makeRequest.mockResolvedValue({
96
+ signedUrl: "https://downloads.example.com/file",
97
+ expiresAt: "2026-08-16T12:00:00.000Z",
98
+ key: "attachments/file",
99
+ });
100
+
101
+ await expect(
102
+ files.generatePresignedDownloadUrl({ key: "attachments/file" }),
103
+ ).resolves.toEqual({
104
+ signedUrl: "https://downloads.example.com/file",
105
+ expiresAt: "2026-08-16T12:00:00.000Z",
106
+ key: "attachments/file",
107
+ });
108
+ expect(makeRequest).toHaveBeenCalledWith(
109
+ "/files/attachments/generate-presigned-download-url",
110
+ { method: "POST", body: { key: "attachments/file" } },
111
+ );
112
+ });
113
+
114
+ it("rejects malformed presigned download responses", async () => {
115
+ const { files, makeRequest } = createCloudFiles();
116
+ makeRequest.mockResolvedValue({
117
+ signedUrl: "https://downloads.example.com/file",
118
+ expiresAt: "2026-08-16T12:00:00.000Z",
119
+ key: 42,
120
+ });
121
+
122
+ await expect(
123
+ files.generatePresignedDownloadUrl({
124
+ url: "https://cdn.example.com/file",
125
+ }),
126
+ ).rejects.toThrow(
127
+ 'Invalid Assistant Cloud response for "presigned download response.key": expected a string',
128
+ );
129
+ });
71
130
  });
@@ -26,6 +26,13 @@ type GeneratePresignedUploadUrlResponse = {
26
26
  signedUrl: string;
27
27
  expiresAt: string;
28
28
  publicUrl: string;
29
+ key?: string;
30
+ };
31
+
32
+ export type GeneratePresignedDownloadUrlResponse = {
33
+ signedUrl: string;
34
+ expiresAt: string;
35
+ key: string;
29
36
  };
30
37
 
31
38
  export class AssistantCloudFiles {
@@ -93,6 +100,38 @@ export class AssistantCloudFiles {
93
100
  response.publicUrl,
94
101
  "presigned upload response.publicUrl",
95
102
  ),
103
+ ...("key" in response
104
+ ? {
105
+ key: readCloudString(response.key, "presigned upload response.key"),
106
+ }
107
+ : {}),
108
+ };
109
+ }
110
+
111
+ public async generatePresignedDownloadUrl(
112
+ body: { key: string } | { url: string },
113
+ ): Promise<GeneratePresignedDownloadUrlResponse> {
114
+ const response = readCloudRecord(
115
+ await this.cloud.makeRequest(
116
+ "/files/attachments/generate-presigned-download-url",
117
+ {
118
+ method: "POST",
119
+ body,
120
+ },
121
+ ),
122
+ "presigned download response",
123
+ );
124
+
125
+ return {
126
+ signedUrl: readCloudString(
127
+ response.signedUrl,
128
+ "presigned download response.signedUrl",
129
+ ),
130
+ expiresAt: readCloudString(
131
+ response.expiresAt,
132
+ "presigned download response.expiresAt",
133
+ ),
134
+ key: readCloudString(response.key, "presigned download response.key"),
96
135
  };
97
136
  }
98
137
  }
@@ -14,11 +14,21 @@ type AssistantCloudRunsStreamBody = {
14
14
  };
15
15
 
16
16
  // NOTE: Keep this payload shape aligned with the strict runtime validator in
17
- // assistant-cloud: apps/aui-cloud-api/src/endpoints/runs/create.ts
17
+ // assistant-cloud: apps/api/src/endpoints/runs/create.ts
18
18
  // (createRunSchema). New telemetry fields must be added in both repos together.
19
19
  export type AssistantCloudRunReport = {
20
20
  thread_id: string;
21
21
  status: "completed" | "incomplete" | "error";
22
+ outcome_type?: "aborted" | "disconnected" | "length" | "content_filter";
23
+ message_id?: string;
24
+ first_token_ms?: number;
25
+ release?: string;
26
+ environment?: string;
27
+ tags?: string[];
28
+ provider?: string;
29
+ trace_id?: string;
30
+ error_code?: string;
31
+ error?: string;
22
32
  total_steps?: number;
23
33
  tool_calls?: AssistantCloudRunReportToolCall[];
24
34
  steps?: {
@@ -29,6 +39,7 @@ export type AssistantCloudRunReport = {
29
39
  tool_calls?: AssistantCloudRunReportToolCall[];
30
40
  start_ms?: number;
31
41
  end_ms?: number;
42
+ finish_reason?: string;
32
43
  }[];
33
44
  input_tokens?: number;
34
45
  output_tokens?: number;
@@ -0,0 +1,113 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { AssistantCloudAPI } from "./AssistantCloudAPI";
3
+ import { AssistantCloudScores } from "./AssistantCloudScores";
4
+
5
+ const createCloudScores = () => {
6
+ const makeRequest = vi.fn();
7
+ const api = { makeRequest } as unknown as AssistantCloudAPI;
8
+ return { scores: new AssistantCloudScores(api), makeRequest };
9
+ };
10
+
11
+ describe("AssistantCloudScores", () => {
12
+ it("decodes the number the cloud stores for a boolean score", async () => {
13
+ const { scores, makeRequest } = createCloudScores();
14
+ makeRequest.mockResolvedValue({
15
+ score_id: "score_2",
16
+ name: "resolved",
17
+ data_type: "boolean",
18
+ value: 1,
19
+ string_value: null,
20
+ });
21
+
22
+ await expect(
23
+ scores.create({
24
+ name: "resolved",
25
+ data_type: "boolean",
26
+ value: true,
27
+ thread_id: "thread_1",
28
+ }),
29
+ ).resolves.toMatchObject({ data_type: "boolean", value: 1 });
30
+ });
31
+
32
+ it("posts the score body as given and decodes the response", async () => {
33
+ const { scores, makeRequest } = createCloudScores();
34
+ makeRequest.mockResolvedValue({
35
+ score_id: "score_1",
36
+ name: "helpfulness",
37
+ data_type: "numeric",
38
+ value: 0.8,
39
+ string_value: null,
40
+ });
41
+
42
+ await expect(
43
+ scores.create({
44
+ name: "helpfulness",
45
+ data_type: "numeric",
46
+ value: 0.8,
47
+ thread_id: "thread_1",
48
+ message_id: "msg_1",
49
+ }),
50
+ ).resolves.toEqual({
51
+ score_id: "score_1",
52
+ name: "helpfulness",
53
+ data_type: "numeric",
54
+ value: 0.8,
55
+ string_value: null,
56
+ });
57
+ expect(makeRequest).toHaveBeenCalledWith("/scores", {
58
+ method: "POST",
59
+ body: {
60
+ name: "helpfulness",
61
+ data_type: "numeric",
62
+ value: 0.8,
63
+ thread_id: "thread_1",
64
+ message_id: "msg_1",
65
+ },
66
+ });
67
+ });
68
+
69
+ it("decodes a categorical score", async () => {
70
+ const { scores, makeRequest } = createCloudScores();
71
+ makeRequest.mockResolvedValue({
72
+ score_id: "score_2",
73
+ name: "tone",
74
+ data_type: "categorical",
75
+ value: null,
76
+ string_value: "friendly",
77
+ });
78
+
79
+ await expect(
80
+ scores.create({
81
+ name: "tone",
82
+ data_type: "categorical",
83
+ string_value: "friendly",
84
+ run_id: "run_1",
85
+ }),
86
+ ).resolves.toMatchObject({
87
+ data_type: "categorical",
88
+ string_value: "friendly",
89
+ });
90
+ });
91
+
92
+ it("rejects a malformed score response", async () => {
93
+ const { scores, makeRequest } = createCloudScores();
94
+ makeRequest.mockResolvedValue({
95
+ score_id: 42,
96
+ name: "helpfulness",
97
+ data_type: "numeric",
98
+ value: 1,
99
+ string_value: null,
100
+ });
101
+
102
+ await expect(
103
+ scores.create({
104
+ name: "helpfulness",
105
+ data_type: "numeric",
106
+ value: 1,
107
+ run_id: "run_1",
108
+ }),
109
+ ).rejects.toThrow(
110
+ 'Invalid Assistant Cloud response for "score response.score_id": expected a string',
111
+ );
112
+ });
113
+ });
@@ -0,0 +1,61 @@
1
+ import type { AssistantCloudAPI } from "./AssistantCloudAPI";
2
+ import {
3
+ readCloudEnum,
4
+ readCloudNullableNumber,
5
+ readCloudNullableString,
6
+ readCloudRecord,
7
+ readCloudString,
8
+ } from "./cloudResponse";
9
+
10
+ const SCORE_DATA_TYPES = ["numeric", "categorical", "boolean"] as const;
11
+
12
+ export type AssistantCloudScoreBody = {
13
+ name: string;
14
+ data_type: "numeric" | "categorical" | "boolean";
15
+ value?: number | boolean;
16
+ string_value?: string;
17
+ comment?: string;
18
+ thread_id?: string;
19
+ message_id?: string;
20
+ run_id?: string;
21
+ };
22
+
23
+ export type AssistantCloudScoreResponse = {
24
+ score_id: string;
25
+ name: string;
26
+ data_type: "numeric" | "categorical" | "boolean";
27
+ value: number | null;
28
+ string_value: string | null;
29
+ };
30
+
31
+ export class AssistantCloudScores {
32
+ private cloud: AssistantCloudAPI;
33
+
34
+ constructor(cloud: AssistantCloudAPI) {
35
+ this.cloud = cloud;
36
+ }
37
+
38
+ public async create(
39
+ body: AssistantCloudScoreBody,
40
+ ): Promise<AssistantCloudScoreResponse> {
41
+ const response = readCloudRecord(
42
+ await this.cloud.makeRequest("/scores", { method: "POST", body }),
43
+ "score response",
44
+ );
45
+
46
+ return {
47
+ score_id: readCloudString(response.score_id, "score response.score_id"),
48
+ name: readCloudString(response.name, "score response.name"),
49
+ data_type: readCloudEnum(
50
+ response.data_type,
51
+ "score response.data_type",
52
+ SCORE_DATA_TYPES,
53
+ ),
54
+ value: readCloudNullableNumber(response.value, "score response.value"),
55
+ string_value: readCloudNullableString(
56
+ response.string_value,
57
+ "score response.string_value",
58
+ ),
59
+ };
60
+ }
61
+ }
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
2
  import type { AssistantCloudAPI } from "./AssistantCloudAPI";
3
3
  import { AssistantCloudThreadMessages } from "./AssistantCloudThreadMessages";
4
+ import { CloudResponseError } from "./cloudResponse";
4
5
 
5
6
  const createCloudThreadMessages = () => {
6
7
  const makeRequest = vi.fn();
@@ -32,6 +33,41 @@ describe("AssistantCloudThreadMessages responses", () => {
32
33
  );
33
34
  });
34
35
 
36
+ it("submits and validates message feedback", async () => {
37
+ const { messages, makeRequest } = createCloudThreadMessages();
38
+ const body = { type: "positive" as const };
39
+ makeRequest.mockResolvedValueOnce({
40
+ feedback_id: "feedback-1",
41
+ type: "positive",
42
+ });
43
+
44
+ await expect(
45
+ messages.feedback("thread/1", "message/1", body),
46
+ ).resolves.toEqual({
47
+ feedback_id: "feedback-1",
48
+ type: "positive",
49
+ });
50
+ expect(makeRequest).toHaveBeenCalledWith(
51
+ "/threads/thread%2F1/messages/message%2F1/feedback",
52
+ { method: "POST", body },
53
+ );
54
+
55
+ makeRequest.mockResolvedValueOnce({ type: "positive" });
56
+
57
+ await expect(
58
+ messages.feedback("thread-1", "message-1", body),
59
+ ).rejects.toBeInstanceOf(CloudResponseError);
60
+
61
+ makeRequest.mockResolvedValueOnce({
62
+ feedback_id: "feedback-1",
63
+ type: "neutral",
64
+ });
65
+
66
+ await expect(
67
+ messages.feedback("thread-1", "message-1", body),
68
+ ).rejects.toThrow('expected one of "positive", "negative"');
69
+ });
70
+
35
71
  it("decodes canonical message responses without changing content", async () => {
36
72
  const { messages, makeRequest } = createCloudThreadMessages();
37
73
  makeRequest.mockResolvedValue({
@@ -58,4 +94,19 @@ describe("AssistantCloudThreadMessages responses", () => {
58
94
  created_at: "leave-this-string-untouched",
59
95
  });
60
96
  });
97
+
98
+ it("forwards the pagination query to the list endpoint", async () => {
99
+ const { messages, makeRequest } = createCloudThreadMessages();
100
+ makeRequest.mockResolvedValueOnce({ messages: [] });
101
+
102
+ await messages.list("thread/1", {
103
+ format: "aui/v0",
104
+ limit: 200,
105
+ after: "message-200",
106
+ });
107
+
108
+ expect(makeRequest).toHaveBeenCalledWith("/threads/thread%2F1/messages", {
109
+ query: { format: "aui/v0", limit: 200, after: "message-200" },
110
+ });
111
+ });
61
112
  });