assistant-cloud 0.1.35 → 0.1.37
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.
- package/dist/AssistantCloud.d.ts +2 -0
- package/dist/AssistantCloud.d.ts.map +1 -1
- package/dist/AssistantCloud.js +4 -1
- package/dist/AssistantCloud.js.map +1 -1
- package/dist/AssistantCloudAPI.d.ts.map +1 -1
- package/dist/AssistantCloudAPI.js +8 -4
- package/dist/AssistantCloudAPI.js.map +1 -1
- package/dist/AssistantCloudAuthStrategy.d.ts +2 -0
- package/dist/AssistantCloudAuthStrategy.d.ts.map +1 -1
- package/dist/AssistantCloudAuthStrategy.js +15 -5
- package/dist/AssistantCloudAuthStrategy.js.map +1 -1
- package/dist/AssistantCloudAuthTokens.d.ts.map +1 -1
- package/dist/AssistantCloudAuthTokens.js.map +1 -1
- package/dist/AssistantCloudFiles.d.ts.map +1 -1
- package/dist/AssistantCloudFiles.js.map +1 -1
- package/dist/AssistantCloudProjectThreadMessages.d.ts +19 -0
- package/dist/AssistantCloudProjectThreadMessages.d.ts.map +1 -0
- package/dist/AssistantCloudProjectThreadMessages.js +16 -0
- package/dist/AssistantCloudProjectThreadMessages.js.map +1 -0
- package/dist/AssistantCloudProjectThreads.d.ts +21 -0
- package/dist/AssistantCloudProjectThreads.d.ts.map +1 -0
- package/dist/AssistantCloudProjectThreads.js +22 -0
- package/dist/AssistantCloudProjectThreads.js.map +1 -0
- package/dist/AssistantCloudProjects.d.ts +10 -0
- package/dist/AssistantCloudProjects.d.ts.map +1 -0
- package/dist/AssistantCloudProjects.js +12 -0
- package/dist/AssistantCloudProjects.js.map +1 -0
- package/dist/AssistantCloudRuns.d.ts.map +1 -1
- package/dist/AssistantCloudRuns.js.map +1 -1
- package/dist/AssistantCloudThreadMessages.d.ts +2 -1
- package/dist/AssistantCloudThreadMessages.d.ts.map +1 -1
- package/dist/AssistantCloudThreadMessages.js +15 -2
- package/dist/AssistantCloudThreadMessages.js.map +1 -1
- package/dist/AssistantCloudThreads.d.ts +3 -2
- package/dist/AssistantCloudThreads.d.ts.map +1 -1
- package/dist/AssistantCloudThreads.js +21 -4
- package/dist/AssistantCloudThreads.js.map +1 -1
- package/dist/CloudMessagePersistence.d.ts +2 -1
- package/dist/CloudMessagePersistence.d.ts.map +1 -1
- package/dist/CloudMessagePersistence.js +11 -6
- package/dist/CloudMessagePersistence.js.map +1 -1
- package/dist/FormattedCloudPersistence.d.ts.map +1 -1
- package/dist/cloudResponse.d.ts +16 -0
- package/dist/cloudResponse.d.ts.map +1 -0
- package/dist/cloudResponse.js +43 -0
- package/dist/cloudResponse.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/instrumentMcpSampling.d.ts.map +1 -1
- package/dist/tests/setup.d.ts +1 -1
- package/dist/tests/setup.js +2 -4
- package/dist/tests/setup.js.map +1 -1
- package/package.json +3 -3
- package/src/AssistantCloud.ts +3 -0
- package/src/AssistantCloudAPI.ts +15 -10
- package/src/AssistantCloudAuthStrategy.ts +21 -6
- package/src/AssistantCloudAuthTokens.ts +5 -1
- package/src/AssistantCloudFiles.ts +5 -1
- package/src/AssistantCloudProjectThreadMessages.ts +44 -0
- package/src/AssistantCloudProjectThreads.ts +50 -0
- package/src/AssistantCloudProjects.ts +10 -0
- package/src/AssistantCloudRuns.ts +5 -1
- package/src/AssistantCloudThreadMessages.test.ts +41 -0
- package/src/AssistantCloudThreadMessages.ts +43 -4
- package/src/AssistantCloudThreads.test.ts +74 -0
- package/src/AssistantCloudThreads.ts +55 -4
- package/src/CloudMessagePersistence.ts +18 -5
- package/src/cloudResponse.test.ts +28 -0
- package/src/cloudResponse.ts +72 -0
- package/src/index.ts +1 -0
- package/src/tests/AssistantCloudAPI.test.ts +5 -19
- package/src/tests/AssistantCloudAuthStrategy.test.ts +50 -22
- package/src/tests/AssistantCloudProjects.test.ts +121 -0
- package/src/tests/CloudMessagePersistence.test.ts +40 -0
- package/src/tests/setup.ts +2 -9
- package/src/tests/AssistantCloudFiles.test.ts +0 -562
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { AssistantCloudAPI } from "./AssistantCloudAPI";
|
|
3
|
+
import { AssistantCloudThreads } from "./AssistantCloudThreads";
|
|
4
|
+
|
|
5
|
+
const createCloudThreads = () => {
|
|
6
|
+
const makeRequest = vi.fn();
|
|
7
|
+
const api = { makeRequest } as unknown as AssistantCloudAPI;
|
|
8
|
+
return { threads: new AssistantCloudThreads(api), makeRequest };
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const threadResponse = {
|
|
12
|
+
title: null,
|
|
13
|
+
last_message_at: "2026-07-16T12:30:00.000Z",
|
|
14
|
+
metadata: { created_at: "leave-this-string-untouched" },
|
|
15
|
+
external_id: null,
|
|
16
|
+
id: "thread-1",
|
|
17
|
+
project_id: "project-1",
|
|
18
|
+
created_at: "2026-07-16T12:00:00.000Z",
|
|
19
|
+
updated_at: "2026-07-16T12:15:00.123Z",
|
|
20
|
+
workspace_id: "workspace-1",
|
|
21
|
+
is_archived: false,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe("AssistantCloudThreads responses", () => {
|
|
25
|
+
it("decodes canonical thread list responses", async () => {
|
|
26
|
+
const { threads, makeRequest } = createCloudThreads();
|
|
27
|
+
makeRequest.mockResolvedValue({ threads: [threadResponse] });
|
|
28
|
+
|
|
29
|
+
const result = await threads.list();
|
|
30
|
+
const thread = result.threads[0]!;
|
|
31
|
+
|
|
32
|
+
expect(thread.created_at).toBeInstanceOf(Date);
|
|
33
|
+
expect(thread.updated_at).toBeInstanceOf(Date);
|
|
34
|
+
expect(thread.last_message_at).toBeInstanceOf(Date);
|
|
35
|
+
expect(thread.updated_at.toISOString()).toBe("2026-07-16T12:15:00.123Z");
|
|
36
|
+
expect(thread.title).toBe("");
|
|
37
|
+
expect(thread.metadata).toEqual({
|
|
38
|
+
created_at: "leave-this-string-untouched",
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("unwraps and decodes a single thread response", async () => {
|
|
43
|
+
const { threads, makeRequest } = createCloudThreads();
|
|
44
|
+
makeRequest.mockResolvedValue({ thread: threadResponse });
|
|
45
|
+
|
|
46
|
+
const result = await threads.get("thread-1");
|
|
47
|
+
|
|
48
|
+
expect(result.created_at).toBeInstanceOf(Date);
|
|
49
|
+
expect(result.created_at.toISOString()).toBe("2026-07-16T12:00:00.000Z");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("rejects a bare thread that is missing the response envelope", async () => {
|
|
53
|
+
const { threads, makeRequest } = createCloudThreads();
|
|
54
|
+
makeRequest.mockResolvedValue(threadResponse);
|
|
55
|
+
|
|
56
|
+
await expect(threads.get("thread-1")).rejects.toThrow(
|
|
57
|
+
'Invalid Assistant Cloud response for "thread": expected an object',
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("rejects genuinely invalid response timestamps with field context", async () => {
|
|
62
|
+
const { threads, makeRequest } = createCloudThreads();
|
|
63
|
+
makeRequest.mockResolvedValue({
|
|
64
|
+
thread: {
|
|
65
|
+
...threadResponse,
|
|
66
|
+
updated_at: "not-a-timestamp",
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await expect(threads.get("thread-1")).rejects.toThrow(
|
|
71
|
+
'Invalid Assistant Cloud response for "thread.updated_at": expected a canonical ISO timestamp',
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import type { AssistantCloudAPI } from "./AssistantCloudAPI";
|
|
2
2
|
import { AssistantCloudThreadMessages } from "./AssistantCloudThreadMessages";
|
|
3
|
+
import {
|
|
4
|
+
readCloudArray,
|
|
5
|
+
readCloudBoolean,
|
|
6
|
+
readCloudNullableString,
|
|
7
|
+
readCloudRecord,
|
|
8
|
+
readCloudString,
|
|
9
|
+
readCloudTimestamp,
|
|
10
|
+
} from "./cloudResponse";
|
|
3
11
|
|
|
4
12
|
type AssistantCloudThreadsListQuery = {
|
|
5
13
|
is_archived?: boolean;
|
|
@@ -7,7 +15,7 @@ type AssistantCloudThreadsListQuery = {
|
|
|
7
15
|
after?: string;
|
|
8
16
|
};
|
|
9
17
|
|
|
10
|
-
type CloudThread = {
|
|
18
|
+
export type CloudThread = {
|
|
11
19
|
title: string;
|
|
12
20
|
last_message_at: Date;
|
|
13
21
|
metadata: unknown;
|
|
@@ -42,21 +50,64 @@ type AssistantCloudThreadsUpdateBody = {
|
|
|
42
50
|
is_archived?: boolean | undefined;
|
|
43
51
|
};
|
|
44
52
|
|
|
53
|
+
export const decodeCloudThread = (
|
|
54
|
+
value: unknown,
|
|
55
|
+
field: string,
|
|
56
|
+
): CloudThread => {
|
|
57
|
+
const thread = readCloudRecord(value, field);
|
|
58
|
+
return {
|
|
59
|
+
title: readCloudNullableString(thread.title, `${field}.title`) ?? "",
|
|
60
|
+
last_message_at: readCloudTimestamp(
|
|
61
|
+
thread.last_message_at,
|
|
62
|
+
`${field}.last_message_at`,
|
|
63
|
+
),
|
|
64
|
+
metadata: thread.metadata,
|
|
65
|
+
external_id: readCloudNullableString(
|
|
66
|
+
thread.external_id,
|
|
67
|
+
`${field}.external_id`,
|
|
68
|
+
),
|
|
69
|
+
id: readCloudString(thread.id, `${field}.id`),
|
|
70
|
+
project_id: readCloudString(thread.project_id, `${field}.project_id`),
|
|
71
|
+
created_at: readCloudTimestamp(thread.created_at, `${field}.created_at`),
|
|
72
|
+
updated_at: readCloudTimestamp(thread.updated_at, `${field}.updated_at`),
|
|
73
|
+
workspace_id: readCloudString(thread.workspace_id, `${field}.workspace_id`),
|
|
74
|
+
is_archived: readCloudBoolean(thread.is_archived, `${field}.is_archived`),
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
|
|
45
78
|
export class AssistantCloudThreads {
|
|
46
79
|
public readonly messages: AssistantCloudThreadMessages;
|
|
47
80
|
|
|
48
|
-
|
|
81
|
+
private cloud: AssistantCloudAPI;
|
|
82
|
+
|
|
83
|
+
constructor(cloud: AssistantCloudAPI) {
|
|
84
|
+
this.cloud = cloud;
|
|
49
85
|
this.messages = new AssistantCloudThreadMessages(cloud);
|
|
50
86
|
}
|
|
51
87
|
|
|
52
88
|
public async list(
|
|
53
89
|
query?: AssistantCloudThreadsListQuery,
|
|
54
90
|
): Promise<AssistantCloudThreadsListResponse> {
|
|
55
|
-
|
|
91
|
+
const response = readCloudRecord(
|
|
92
|
+
await this.cloud.makeRequest("/threads", { query }),
|
|
93
|
+
"thread list response",
|
|
94
|
+
);
|
|
95
|
+
const threads = readCloudArray(response.threads, "threads");
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
threads: threads.map((thread, index) =>
|
|
99
|
+
decodeCloudThread(thread, `threads[${index}]`),
|
|
100
|
+
),
|
|
101
|
+
};
|
|
56
102
|
}
|
|
57
103
|
|
|
58
104
|
public async get(threadId: string): Promise<CloudThread> {
|
|
59
|
-
|
|
105
|
+
const response = readCloudRecord(
|
|
106
|
+
await this.cloud.makeRequest(`/threads/${encodeURIComponent(threadId)}`),
|
|
107
|
+
"thread response",
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
return decodeCloudThread(response.thread, "thread");
|
|
60
111
|
}
|
|
61
112
|
|
|
62
113
|
public async create(
|
|
@@ -14,8 +14,13 @@ import type { AssistantCloud } from "./AssistantCloud";
|
|
|
14
14
|
*/
|
|
15
15
|
export class CloudMessagePersistence {
|
|
16
16
|
private idMapping: Record<string, string | Promise<string>> = {};
|
|
17
|
+
private getCloud: () => AssistantCloud;
|
|
17
18
|
|
|
18
|
-
constructor(
|
|
19
|
+
constructor(cloud: AssistantCloud);
|
|
20
|
+
constructor(getCloud: () => AssistantCloud);
|
|
21
|
+
constructor(cloud: AssistantCloud | (() => AssistantCloud)) {
|
|
22
|
+
this.getCloud = typeof cloud === "function" ? cloud : () => cloud;
|
|
23
|
+
}
|
|
19
24
|
|
|
20
25
|
/**
|
|
21
26
|
* Persist a message to the cloud.
|
|
@@ -33,12 +38,13 @@ export class CloudMessagePersistence {
|
|
|
33
38
|
format: string,
|
|
34
39
|
content: ReadonlyJSONObject,
|
|
35
40
|
): Promise<void> {
|
|
41
|
+
const cloud = this.getCloud();
|
|
36
42
|
// Resolve parent's remote ID if it exists (may be a promise if concurrent)
|
|
37
43
|
const resolvedParentId = parentId
|
|
38
44
|
? ((await this.idMapping[parentId]) ?? parentId)
|
|
39
45
|
: null;
|
|
40
46
|
|
|
41
|
-
const task =
|
|
47
|
+
const task = cloud.threads.messages
|
|
42
48
|
.create(threadId, {
|
|
43
49
|
parent_id: resolvedParentId,
|
|
44
50
|
format,
|
|
@@ -70,9 +76,15 @@ export class CloudMessagePersistence {
|
|
|
70
76
|
_format: string,
|
|
71
77
|
content: ReadonlyJSONObject,
|
|
72
78
|
): Promise<void> {
|
|
79
|
+
const cloud = this.getCloud();
|
|
73
80
|
const remoteId = await this.getRemoteId(messageId);
|
|
74
|
-
if (!remoteId)
|
|
75
|
-
|
|
81
|
+
if (!remoteId) {
|
|
82
|
+
console.warn(
|
|
83
|
+
`Skipping update for message ${messageId}: no remote id is mapped.`,
|
|
84
|
+
);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
await cloud.threads.messages.update(threadId, remoteId, { content });
|
|
76
88
|
}
|
|
77
89
|
|
|
78
90
|
/**
|
|
@@ -103,7 +115,8 @@ export class CloudMessagePersistence {
|
|
|
103
115
|
* @returns Array of cloud messages
|
|
104
116
|
*/
|
|
105
117
|
async load(threadId: string, format?: string) {
|
|
106
|
-
const
|
|
118
|
+
const cloud = this.getCloud();
|
|
119
|
+
const { messages } = await cloud.threads.messages.list(
|
|
107
120
|
threadId,
|
|
108
121
|
format ? { format } : undefined,
|
|
109
122
|
);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { CloudResponseError, readCloudTimestamp } from "./cloudResponse";
|
|
3
|
+
|
|
4
|
+
describe("readCloudTimestamp", () => {
|
|
5
|
+
it("decodes a canonical Cloud timestamp", () => {
|
|
6
|
+
expect(
|
|
7
|
+
readCloudTimestamp(
|
|
8
|
+
"2026-07-16T12:15:00.123Z",
|
|
9
|
+
"thread.updated_at",
|
|
10
|
+
).toISOString(),
|
|
11
|
+
).toBe("2026-07-16T12:15:00.123Z");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it.each([
|
|
15
|
+
"2026-02-30T12:15:00.000Z",
|
|
16
|
+
"2026-13-01T12:15:00Z",
|
|
17
|
+
"2026-07-16 12:15:00.123456",
|
|
18
|
+
"2026-07-16T12:15:00.123+00:00",
|
|
19
|
+
new Date("2026-07-16T12:15:00.123Z"),
|
|
20
|
+
"not-a-timestamp",
|
|
21
|
+
])("rejects invalid timestamp %s", (value) => {
|
|
22
|
+
const decode = () => readCloudTimestamp(value, "thread.updated_at");
|
|
23
|
+
expect(decode).toThrow(CloudResponseError);
|
|
24
|
+
expect(decode).toThrow(
|
|
25
|
+
'Invalid Assistant Cloud response for "thread.updated_at": expected a canonical ISO timestamp',
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ReadonlyJSONObject } from "assistant-stream/utils";
|
|
2
|
+
|
|
3
|
+
export class CloudResponseError extends Error {
|
|
4
|
+
constructor(message: string) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "CloudResponseError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const invalidCloudResponse = (field: string, expected: string) =>
|
|
11
|
+
new CloudResponseError(
|
|
12
|
+
`Invalid Assistant Cloud response for "${field}": expected ${expected}`,
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
export const readCloudRecord = (
|
|
16
|
+
value: unknown,
|
|
17
|
+
field: string,
|
|
18
|
+
): Record<string, unknown> => {
|
|
19
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
20
|
+
throw invalidCloudResponse(field, "an object");
|
|
21
|
+
}
|
|
22
|
+
return value as Record<string, unknown>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const readCloudArray = (value: unknown, field: string): unknown[] => {
|
|
26
|
+
if (!Array.isArray(value)) throw invalidCloudResponse(field, "an array");
|
|
27
|
+
return value;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const readCloudString = (value: unknown, field: string): string => {
|
|
31
|
+
if (typeof value !== "string") throw invalidCloudResponse(field, "a string");
|
|
32
|
+
return value;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const readCloudNullableString = (
|
|
36
|
+
value: unknown,
|
|
37
|
+
field: string,
|
|
38
|
+
): string | null => {
|
|
39
|
+
if (value === null) return null;
|
|
40
|
+
return readCloudString(value, field);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const readCloudBoolean = (value: unknown, field: string): boolean => {
|
|
44
|
+
if (typeof value !== "boolean") {
|
|
45
|
+
throw invalidCloudResponse(field, "a boolean");
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const readCloudInteger = (value: unknown, field: string): number => {
|
|
51
|
+
if (!Number.isInteger(value)) throw invalidCloudResponse(field, "an integer");
|
|
52
|
+
return value as number;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const readCloudTimestamp = (value: unknown, field: string): Date => {
|
|
56
|
+
if (typeof value !== "string") {
|
|
57
|
+
throw invalidCloudResponse(field, "a canonical ISO timestamp");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const date = new Date(value);
|
|
61
|
+
// The round trip pins the backend DTO's Date#toISOString() wire contract.
|
|
62
|
+
if (Number.isNaN(date.getTime()) || date.toISOString() !== value) {
|
|
63
|
+
throw invalidCloudResponse(field, "a canonical ISO timestamp");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return date;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const readCloudJSONObject = (
|
|
70
|
+
value: unknown,
|
|
71
|
+
field: string,
|
|
72
|
+
): ReadonlyJSONObject => readCloudRecord(value, field) as ReadonlyJSONObject;
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export type { CloudMessage } from "./AssistantCloudThreadMessages";
|
|
2
2
|
export type { AssistantCloudTelemetryConfig } from "./AssistantCloudAPI";
|
|
3
3
|
export { CloudAPIError } from "./AssistantCloudAPI";
|
|
4
|
+
export { CloudResponseError } from "./cloudResponse";
|
|
4
5
|
export type { AssistantCloudRunReport } from "./AssistantCloudRuns";
|
|
5
6
|
export { AssistantCloud } from "./AssistantCloud";
|
|
6
7
|
export { CloudMessagePersistence } from "./CloudMessagePersistence";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { AssistantCloudAPI, CloudAPIError } from "../AssistantCloudAPI";
|
|
3
3
|
|
|
4
4
|
describe("AssistantCloudAPI", () => {
|
|
@@ -6,6 +6,10 @@ describe("AssistantCloudAPI", () => {
|
|
|
6
6
|
vi.restoreAllMocks();
|
|
7
7
|
});
|
|
8
8
|
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
vi.unstubAllGlobals();
|
|
11
|
+
});
|
|
12
|
+
|
|
9
13
|
it("serializes query params, merges auth headers, and sends JSON body", async () => {
|
|
10
14
|
const fetchMock = vi.fn().mockResolvedValue({
|
|
11
15
|
ok: true,
|
|
@@ -219,22 +223,4 @@ describe("AssistantCloudAPI", () => {
|
|
|
219
223
|
await expect(api.makeRequest("/threads/t-1")).resolves.toBeUndefined();
|
|
220
224
|
expect(text).not.toHaveBeenCalled();
|
|
221
225
|
});
|
|
222
|
-
|
|
223
|
-
it("makeRequest returns undefined from a whitespace-only success body", async () => {
|
|
224
|
-
const fetchMock = vi.fn().mockResolvedValue({
|
|
225
|
-
ok: true,
|
|
226
|
-
status: 200,
|
|
227
|
-
headers: new Headers(),
|
|
228
|
-
text: vi.fn().mockResolvedValue(" "),
|
|
229
|
-
});
|
|
230
|
-
vi.stubGlobal("fetch", fetchMock);
|
|
231
|
-
|
|
232
|
-
const api = new AssistantCloudAPI({
|
|
233
|
-
apiKey: "test-key",
|
|
234
|
-
userId: "u-1",
|
|
235
|
-
workspaceId: "w-1",
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
await expect(api.makeRequest("/threads/t-1")).resolves.toBeUndefined();
|
|
239
|
-
});
|
|
240
226
|
});
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
AssistantCloudAnonymousAuthStrategy,
|
|
4
|
+
AssistantCloudJWTAuthStrategy,
|
|
5
|
+
} from "../AssistantCloudAuthStrategy";
|
|
3
6
|
|
|
4
7
|
const baseUrl = "https://test.example.com";
|
|
5
8
|
const accessToken = `${Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url")}.${Buffer.from(JSON.stringify({ exp: 4102444800 })).toString("base64url")}.sig`;
|
|
@@ -72,6 +75,25 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
|
|
|
72
75
|
);
|
|
73
76
|
});
|
|
74
77
|
|
|
78
|
+
it("deduplicates concurrent anonymous token requests", async () => {
|
|
79
|
+
delete (globalThis as { localStorage?: Storage }).localStorage;
|
|
80
|
+
const fetchMock = mockAnonymousTokenFetch();
|
|
81
|
+
const strategy = new AssistantCloudAnonymousAuthStrategy(baseUrl);
|
|
82
|
+
|
|
83
|
+
await expect(
|
|
84
|
+
Promise.all([
|
|
85
|
+
strategy.getAuthHeaders(),
|
|
86
|
+
strategy.getAuthHeaders(),
|
|
87
|
+
strategy.getAuthHeaders(),
|
|
88
|
+
]),
|
|
89
|
+
).resolves.toEqual([
|
|
90
|
+
{ Authorization: `Bearer ${accessToken}` },
|
|
91
|
+
{ Authorization: `Bearer ${accessToken}` },
|
|
92
|
+
{ Authorization: `Bearer ${accessToken}` },
|
|
93
|
+
]);
|
|
94
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
95
|
+
});
|
|
96
|
+
|
|
75
97
|
it("returns an anonymous access token without localStorage", async () => {
|
|
76
98
|
delete (globalThis as { localStorage?: Storage }).localStorage;
|
|
77
99
|
mockAnonymousTokenFetch();
|
|
@@ -102,7 +124,9 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
|
|
|
102
124
|
it("returns an anonymous access token when storage methods throw", async () => {
|
|
103
125
|
const getItem = vi
|
|
104
126
|
.fn<(key: string) => string | null>()
|
|
105
|
-
.mockReturnValueOnce(
|
|
127
|
+
.mockReturnValueOnce(
|
|
128
|
+
JSON.stringify({ token: "expired", expires_at: "2022-01-01" }),
|
|
129
|
+
)
|
|
106
130
|
.mockImplementation(() => {
|
|
107
131
|
throw new DOMException("blocked", "SecurityError");
|
|
108
132
|
});
|
|
@@ -115,26 +139,12 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
|
|
|
115
139
|
installLocalStorage({ getItem, setItem, removeItem } as Storage);
|
|
116
140
|
mockAnonymousTokenFetch();
|
|
117
141
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
},
|
|
125
|
-
{ now: () => fixedTime },
|
|
126
|
-
) as unknown as DateConstructor;
|
|
127
|
-
|
|
128
|
-
try {
|
|
129
|
-
await expect(
|
|
130
|
-
new AssistantCloudAnonymousAuthStrategy(baseUrl).getAuthHeaders(),
|
|
131
|
-
).resolves.toEqual({ Authorization: `Bearer ${accessToken}` });
|
|
132
|
-
await expect(
|
|
133
|
-
new AssistantCloudAnonymousAuthStrategy(baseUrl).getAuthHeaders(),
|
|
134
|
-
).resolves.toEqual({ Authorization: `Bearer ${accessToken}` });
|
|
135
|
-
} finally {
|
|
136
|
-
globalThis.Date = originalDate;
|
|
137
|
-
}
|
|
142
|
+
await expect(
|
|
143
|
+
new AssistantCloudAnonymousAuthStrategy(baseUrl).getAuthHeaders(),
|
|
144
|
+
).resolves.toEqual({ Authorization: `Bearer ${accessToken}` });
|
|
145
|
+
await expect(
|
|
146
|
+
new AssistantCloudAnonymousAuthStrategy(baseUrl).getAuthHeaders(),
|
|
147
|
+
).resolves.toEqual({ Authorization: `Bearer ${accessToken}` });
|
|
138
148
|
expect(getItem).toHaveBeenCalledTimes(2);
|
|
139
149
|
expect(setItem).toHaveBeenCalledTimes(2);
|
|
140
150
|
expect(removeItem).toHaveBeenCalledTimes(1);
|
|
@@ -161,3 +171,21 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
|
|
|
161
171
|
expect(values.get("aui:refresh_token")).toBe(JSON.stringify(refreshToken));
|
|
162
172
|
});
|
|
163
173
|
});
|
|
174
|
+
|
|
175
|
+
describe("AssistantCloudJWTAuthStrategy", () => {
|
|
176
|
+
it("retries token acquisition after a failed request", async () => {
|
|
177
|
+
const authToken = vi
|
|
178
|
+
.fn<() => Promise<string | null>>()
|
|
179
|
+
.mockRejectedValueOnce(new Error("authentication unavailable"))
|
|
180
|
+
.mockResolvedValueOnce(accessToken);
|
|
181
|
+
const strategy = new AssistantCloudJWTAuthStrategy(authToken);
|
|
182
|
+
|
|
183
|
+
await expect(strategy.getAuthHeaders()).rejects.toThrow(
|
|
184
|
+
"authentication unavailable",
|
|
185
|
+
);
|
|
186
|
+
await expect(strategy.getAuthHeaders()).resolves.toEqual({
|
|
187
|
+
Authorization: `Bearer ${accessToken}`,
|
|
188
|
+
});
|
|
189
|
+
expect(authToken).toHaveBeenCalledTimes(2);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { AssistantCloudProjects } from "../AssistantCloudProjects";
|
|
3
|
+
import type { AssistantCloudAPI } from "../AssistantCloudAPI";
|
|
4
|
+
|
|
5
|
+
vi.mock("../AssistantCloudAPI");
|
|
6
|
+
|
|
7
|
+
describe("AssistantCloudProjects", () => {
|
|
8
|
+
let projects: AssistantCloudProjects;
|
|
9
|
+
let mockApi: AssistantCloudAPI;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
mockApi = {
|
|
13
|
+
makeRequest: vi.fn().mockResolvedValue({}),
|
|
14
|
+
makeRawRequest: vi.fn(),
|
|
15
|
+
_auth: { getAuthHeaders: vi.fn() },
|
|
16
|
+
_baseUrl: "https://backend.assistant-api.com",
|
|
17
|
+
} as unknown as AssistantCloudAPI;
|
|
18
|
+
|
|
19
|
+
projects = new AssistantCloudProjects(mockApi);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
vi.clearAllMocks();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("lists project threads with the query", async () => {
|
|
27
|
+
vi.mocked(mockApi.makeRequest).mockResolvedValue({ threads: [] });
|
|
28
|
+
|
|
29
|
+
await projects.threads.list({ limit: 10, after: "t1" });
|
|
30
|
+
|
|
31
|
+
expect(mockApi.makeRequest).toHaveBeenCalledWith("/projects/threads", {
|
|
32
|
+
query: { limit: 10, after: "t1" },
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("sends both archive filter values as strings", async () => {
|
|
37
|
+
vi.mocked(mockApi.makeRequest).mockResolvedValue({ threads: [] });
|
|
38
|
+
|
|
39
|
+
await projects.threads.list({ is_archived: false });
|
|
40
|
+
expect(mockApi.makeRequest).toHaveBeenLastCalledWith("/projects/threads", {
|
|
41
|
+
query: { is_archived: "false" },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
await projects.threads.list({ is_archived: true });
|
|
45
|
+
expect(mockApi.makeRequest).toHaveBeenLastCalledWith("/projects/threads", {
|
|
46
|
+
query: { is_archived: "true" },
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("lists project thread messages with the query", async () => {
|
|
51
|
+
vi.mocked(mockApi.makeRequest).mockResolvedValue({ messages: [] });
|
|
52
|
+
|
|
53
|
+
await projects.threads.messages.list("thread_123", {
|
|
54
|
+
format: "ai-sdk/v5",
|
|
55
|
+
limit: 100,
|
|
56
|
+
after: "msg_9",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(mockApi.makeRequest).toHaveBeenCalledWith(
|
|
60
|
+
"/projects/threads/thread_123/messages",
|
|
61
|
+
{ query: { format: "ai-sdk/v5", limit: 100, after: "msg_9" } },
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("url-encodes the thread id in the messages path", async () => {
|
|
66
|
+
vi.mocked(mockApi.makeRequest).mockResolvedValue({ messages: [] });
|
|
67
|
+
|
|
68
|
+
await projects.threads.messages.list("thread/with space");
|
|
69
|
+
|
|
70
|
+
expect(mockApi.makeRequest).toHaveBeenCalledWith(
|
|
71
|
+
"/projects/threads/thread%2Fwith%20space/messages",
|
|
72
|
+
{ query: undefined },
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("decodes project thread list responses", async () => {
|
|
77
|
+
vi.mocked(mockApi.makeRequest).mockResolvedValue({
|
|
78
|
+
threads: [
|
|
79
|
+
{
|
|
80
|
+
title: "Project thread",
|
|
81
|
+
last_message_at: "2026-07-16T12:30:00.000Z",
|
|
82
|
+
metadata: {},
|
|
83
|
+
external_id: null,
|
|
84
|
+
id: "thread_1",
|
|
85
|
+
project_id: "project_1",
|
|
86
|
+
created_at: "2026-07-16T12:00:00.000Z",
|
|
87
|
+
updated_at: "2026-07-16T12:15:00.000Z",
|
|
88
|
+
workspace_id: "workspace_1",
|
|
89
|
+
is_archived: false,
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const result = await projects.threads.list();
|
|
95
|
+
const thread = result.threads[0]!;
|
|
96
|
+
|
|
97
|
+
expect(thread.last_message_at instanceof Date).toBe(true);
|
|
98
|
+
expect(thread.is_archived).toBeTypeOf("boolean");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("decodes project thread message list responses", async () => {
|
|
102
|
+
vi.mocked(mockApi.makeRequest).mockResolvedValue({
|
|
103
|
+
messages: [
|
|
104
|
+
{
|
|
105
|
+
id: "message_1",
|
|
106
|
+
parent_id: null,
|
|
107
|
+
height: 0,
|
|
108
|
+
created_at: "2026-07-16T13:00:00.000Z",
|
|
109
|
+
updated_at: "2026-07-16T13:05:00.000Z",
|
|
110
|
+
format: "aui/v0",
|
|
111
|
+
content: {},
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const result = await projects.threads.messages.list("thread_1");
|
|
117
|
+
const message = result.messages[0]!;
|
|
118
|
+
|
|
119
|
+
expect(message.created_at instanceof Date).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -37,6 +37,33 @@ describe("CloudMessagePersistence", () => {
|
|
|
37
37
|
expect(await persistence.getRemoteId("local-1")).toBe("remote-1");
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
+
it("uses the current client without losing ID mappings", async () => {
|
|
41
|
+
const firstCloud = createMockCloud();
|
|
42
|
+
const secondCloud = createMockCloud();
|
|
43
|
+
let currentCloud = firstCloud;
|
|
44
|
+
persistence = new CloudMessagePersistence(() => currentCloud);
|
|
45
|
+
vi.mocked(firstCloud.threads.messages.create).mockResolvedValue({
|
|
46
|
+
message_id: "remote-parent",
|
|
47
|
+
});
|
|
48
|
+
vi.mocked(secondCloud.threads.messages.create).mockResolvedValue({
|
|
49
|
+
message_id: "remote-child",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
await persistence.append("thread-1", "parent", null, "aui/v0", {
|
|
53
|
+
text: "parent",
|
|
54
|
+
});
|
|
55
|
+
currentCloud = secondCloud;
|
|
56
|
+
await persistence.append("thread-1", "child", "parent", "aui/v0", {
|
|
57
|
+
text: "child",
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(secondCloud.threads.messages.create).toHaveBeenCalledWith(
|
|
61
|
+
"thread-1",
|
|
62
|
+
expect.objectContaining({ parent_id: "remote-parent" }),
|
|
63
|
+
);
|
|
64
|
+
expect(await persistence.getRemoteId("child")).toBe("remote-child");
|
|
65
|
+
});
|
|
66
|
+
|
|
40
67
|
it("resolves parent ID from a concurrent append", async () => {
|
|
41
68
|
// Parent creation is delayed — the promise won't resolve immediately
|
|
42
69
|
let resolveParent!: (v: { message_id: string }) => void;
|
|
@@ -121,6 +148,19 @@ describe("CloudMessagePersistence", () => {
|
|
|
121
148
|
);
|
|
122
149
|
});
|
|
123
150
|
|
|
151
|
+
it("warns and skips update when no remote id is mapped", async () => {
|
|
152
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
153
|
+
|
|
154
|
+
await persistence.update("thread-1", "unmapped-1", "aui/v0", {
|
|
155
|
+
text: "x",
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
expect(cloud.threads.messages.update).not.toHaveBeenCalled();
|
|
159
|
+
expect(warn).toHaveBeenCalledWith(
|
|
160
|
+
"Skipping update for message unmapped-1: no remote id is mapped.",
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
124
164
|
it("cleans up ID mapping on append failure", async () => {
|
|
125
165
|
vi.mocked(cloud.threads.messages.create).mockRejectedValue(
|
|
126
166
|
new Error("network error"),
|
package/src/tests/setup.ts
CHANGED
|
@@ -1,11 +1,4 @@
|
|
|
1
|
-
// This file contains setup code for tests
|
|
2
1
|
import { vi } from "vitest";
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
const OriginalDate = globalThis.Date;
|
|
7
|
-
const fixedDate = new OriginalDate("2023-01-01");
|
|
8
|
-
globalThis.Date = vi.fn(() => fixedDate) as any;
|
|
9
|
-
globalThis.Date.now = vi.fn(() => fixedDate.getTime());
|
|
10
|
-
|
|
11
|
-
// Add any other globalThis setup needed for tests
|
|
3
|
+
vi.useFakeTimers({ toFake: ["Date"] });
|
|
4
|
+
vi.setSystemTime(new Date("2023-01-01T00:00:00.000Z"));
|