assistant-cloud 0.2.0 → 0.2.2
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/README.md +48 -6
- package/dist/AssistantCloud.d.ts +3 -3
- package/dist/AssistantCloud.d.ts.map +1 -1
- package/dist/AssistantCloud.js +2 -0
- package/dist/AssistantCloud.js.map +1 -1
- package/dist/AssistantCloudAPI.d.ts +14 -6
- package/dist/AssistantCloudAPI.d.ts.map +1 -1
- package/dist/AssistantCloudAPI.js +17 -1
- package/dist/AssistantCloudAPI.js.map +1 -1
- package/dist/AssistantCloudAuthStrategy.d.ts +6 -7
- package/dist/AssistantCloudAuthStrategy.d.ts.map +1 -1
- package/dist/AssistantCloudAuthStrategy.js +4 -2
- package/dist/AssistantCloudAuthStrategy.js.map +1 -1
- package/dist/AssistantCloudAuthTokens.d.ts +1 -2
- package/dist/AssistantCloudAuthTokens.d.ts.map +1 -1
- package/dist/AssistantCloudEvents.d.ts +3 -4
- package/dist/AssistantCloudEvents.d.ts.map +1 -1
- package/dist/AssistantCloudEvents.js +5 -1
- package/dist/AssistantCloudEvents.js.map +1 -1
- package/dist/AssistantCloudFiles.d.ts +2 -3
- package/dist/AssistantCloudFiles.d.ts.map +1 -1
- package/dist/AssistantCloudProjectThreadMessages.d.ts +1 -2
- package/dist/AssistantCloudProjectThreadMessages.d.ts.map +1 -1
- package/dist/AssistantCloudProjectThreads.d.ts +1 -2
- package/dist/AssistantCloudProjectThreads.d.ts.map +1 -1
- package/dist/AssistantCloudProjects.d.ts +1 -2
- package/dist/AssistantCloudProjects.d.ts.map +1 -1
- package/dist/AssistantCloudRuns.d.ts +3 -3
- package/dist/AssistantCloudRuns.d.ts.map +1 -1
- package/dist/AssistantCloudRuns.js +2 -1
- package/dist/AssistantCloudRuns.js.map +1 -1
- package/dist/AssistantCloudScores.d.ts +3 -4
- package/dist/AssistantCloudScores.d.ts.map +1 -1
- package/dist/AssistantCloudThreadMessages.d.ts +7 -6
- package/dist/AssistantCloudThreadMessages.d.ts.map +1 -1
- package/dist/AssistantCloudThreadMessages.js +7 -2
- package/dist/AssistantCloudThreadMessages.js.map +1 -1
- package/dist/AssistantCloudThreads.d.ts +3 -4
- package/dist/AssistantCloudThreads.d.ts.map +1 -1
- package/dist/CloudEngagementReporter.d.ts +73 -0
- package/dist/CloudEngagementReporter.d.ts.map +1 -0
- package/dist/CloudEngagementReporter.js +132 -0
- package/dist/CloudEngagementReporter.js.map +1 -0
- package/dist/CloudMessagePersistence.d.ts +9 -11
- package/dist/CloudMessagePersistence.d.ts.map +1 -1
- package/dist/CloudMessagePersistence.js +18 -11
- package/dist/CloudMessagePersistence.js.map +1 -1
- package/dist/CloudRunReporter.d.ts +19 -0
- package/dist/CloudRunReporter.d.ts.map +1 -0
- package/dist/CloudRunReporter.js +47 -0
- package/dist/CloudRunReporter.js.map +1 -0
- package/dist/FormattedCloudPersistence.d.ts +5 -11
- package/dist/FormattedCloudPersistence.d.ts.map +1 -1
- package/dist/FormattedCloudPersistence.js +3 -8
- package/dist/FormattedCloudPersistence.js.map +1 -1
- package/dist/ai-sdk/index.d.ts +32 -0
- package/dist/ai-sdk/index.d.ts.map +1 -0
- package/dist/ai-sdk/index.js +147 -0
- package/dist/ai-sdk/index.js.map +1 -0
- package/dist/cloudResponse.d.ts +12 -13
- package/dist/cloudResponse.d.ts.map +1 -1
- package/dist/generateThreadTitle.d.ts +1 -2
- package/dist/generateThreadTitle.d.ts.map +1 -1
- package/dist/generateThreadTitle.js +1 -0
- package/dist/generateThreadTitle.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.js +4 -2
- package/dist/instrumentMcpSampling.d.ts +6 -7
- package/dist/instrumentMcpSampling.d.ts.map +1 -1
- package/dist/instrumentMcpSampling.js +12 -2
- package/dist/instrumentMcpSampling.js.map +1 -1
- package/dist/runTelemetry.d.ts +30 -15
- package/dist/runTelemetry.d.ts.map +1 -1
- package/dist/runTelemetry.js.map +1 -1
- package/dist/telemetry/index.d.ts +8 -9
- package/dist/telemetry/index.d.ts.map +1 -1
- package/dist/version.d.ts +4 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +6 -0
- package/dist/version.js.map +1 -0
- package/package.json +24 -10
- package/src/AssistantCloud.ts +3 -0
- package/src/AssistantCloudAPI.ts +31 -1
- package/src/AssistantCloudAuthStrategy.ts +4 -2
- package/src/AssistantCloudEvents.test.ts +68 -2
- package/src/AssistantCloudEvents.ts +6 -0
- package/src/AssistantCloudRuns.ts +1 -0
- package/src/AssistantCloudThreadMessages.test.ts +53 -2
- package/src/AssistantCloudThreadMessages.ts +13 -1
- package/src/CloudEngagementReporter.ts +231 -0
- package/src/CloudMessagePersistence.ts +24 -13
- package/src/CloudRunReporter.ts +50 -0
- package/src/FormattedCloudPersistence.ts +3 -8
- package/src/ai-sdk/index.test.ts +258 -0
- package/src/ai-sdk/index.ts +222 -0
- package/src/generateThreadTitle.test.ts +32 -0
- package/src/generateThreadTitle.ts +1 -0
- package/src/index.ts +11 -1
- package/src/instrumentMcpSampling.test.ts +102 -0
- package/src/instrumentMcpSampling.ts +16 -2
- package/src/runTelemetry.ts +17 -0
- package/src/tests/AssistantCloud.test.ts +33 -1
- package/src/tests/AssistantCloudAPI.test.ts +51 -0
- package/src/tests/AssistantCloudAuthStrategy.test.ts +22 -0
- package/src/tests/AssistantCloudProjects.test.ts +1 -5
- package/src/tests/CloudEngagementReporter.test.ts +184 -0
- package/src/tests/CloudMessagePersistence.test.ts +235 -0
- package/src/tests/CloudRunReporter.test.ts +135 -0
- package/src/version.ts +4 -0
package/src/runTelemetry.ts
CHANGED
|
@@ -225,6 +225,23 @@ export type RunReportStepInit = {
|
|
|
225
225
|
finishReason?: string | undefined;
|
|
226
226
|
};
|
|
227
227
|
|
|
228
|
+
/**
|
|
229
|
+
* The run report fields read from the messages of one run, in whichever
|
|
230
|
+
* format they were stored: the status the messages imply, the tool calls, the
|
|
231
|
+
* steps, the text, the usage and the model.
|
|
232
|
+
*/
|
|
233
|
+
export type RunMessageTelemetry = {
|
|
234
|
+
assistantMessageId?: string;
|
|
235
|
+
status: "completed" | "incomplete";
|
|
236
|
+
toolCalls?: AssistantCloudRunReportToolCall[];
|
|
237
|
+
steps?: RunReportStepInit[];
|
|
238
|
+
totalSteps?: number;
|
|
239
|
+
outputText?: string;
|
|
240
|
+
usage?: RunTelemetryUsage;
|
|
241
|
+
modelId?: string;
|
|
242
|
+
metadata?: Record<string, unknown>;
|
|
243
|
+
};
|
|
244
|
+
|
|
228
245
|
export type RunReportInit = {
|
|
229
246
|
threadId: string;
|
|
230
247
|
status: AssistantCloudRunReport["status"];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { AssistantCloud } from "../AssistantCloud";
|
|
3
3
|
import type { AssistantCloudTelemetryConfig } from "../AssistantCloudAPI";
|
|
4
4
|
|
|
@@ -13,6 +13,10 @@ const createCloud = (
|
|
|
13
13
|
});
|
|
14
14
|
|
|
15
15
|
describe("AssistantCloud telemetry config", () => {
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
vi.unstubAllGlobals();
|
|
18
|
+
});
|
|
19
|
+
|
|
16
20
|
it("defaults to enabled", () => {
|
|
17
21
|
expect(createCloud().telemetry.enabled).toBe(true);
|
|
18
22
|
expect(createCloud(true).telemetry.enabled).toBe(true);
|
|
@@ -58,4 +62,32 @@ describe("AssistantCloud telemetry config", () => {
|
|
|
58
62
|
tags: ["region:sg", "tier:paid"],
|
|
59
63
|
});
|
|
60
64
|
});
|
|
65
|
+
|
|
66
|
+
it("forwards registered SDK identities to requests and stream options", async () => {
|
|
67
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
68
|
+
ok: true,
|
|
69
|
+
headers: new Headers(),
|
|
70
|
+
text: vi.fn().mockResolvedValue(JSON.stringify({ threads: [] })),
|
|
71
|
+
});
|
|
72
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
73
|
+
|
|
74
|
+
const cloud = createCloud();
|
|
75
|
+
cloud.registerSdk({ name: "@assistant-ui/core", version: "0.3.18" });
|
|
76
|
+
|
|
77
|
+
await cloud.threads.list();
|
|
78
|
+
|
|
79
|
+
const [, init] = fetchMock.mock.calls[0]!;
|
|
80
|
+
expect(init.headers).toMatchObject({
|
|
81
|
+
"Aui-Sdk": expect.stringMatching(
|
|
82
|
+
/^assistant-cloud\/.* @assistant-ui\/core\/0\.3\.18$/,
|
|
83
|
+
),
|
|
84
|
+
});
|
|
85
|
+
await expect(
|
|
86
|
+
cloud.runs.__internal_getAssistantOptions("assistant-id").headers(),
|
|
87
|
+
).resolves.toMatchObject({
|
|
88
|
+
"Aui-Sdk": expect.stringMatching(
|
|
89
|
+
/^assistant-cloud\/.* @assistant-ui\/core\/0\.3\.18$/,
|
|
90
|
+
),
|
|
91
|
+
});
|
|
92
|
+
});
|
|
61
93
|
});
|
|
@@ -45,6 +45,7 @@ describe("AssistantCloudAPI", () => {
|
|
|
45
45
|
Authorization: "Bearer test-key",
|
|
46
46
|
"Aui-User-Id": "u-1",
|
|
47
47
|
"Aui-Workspace-Id": "w-1",
|
|
48
|
+
"Aui-Sdk": expect.stringMatching(/^assistant-cloud\//),
|
|
48
49
|
"Content-Type": "application/json",
|
|
49
50
|
"X-Test": "1",
|
|
50
51
|
});
|
|
@@ -53,6 +54,56 @@ describe("AssistantCloudAPI", () => {
|
|
|
53
54
|
expect(init.body).toBe(JSON.stringify({ hello: "world" }));
|
|
54
55
|
});
|
|
55
56
|
|
|
57
|
+
it("ignores identities that cannot travel in a header", () => {
|
|
58
|
+
const api = new AssistantCloudAPI({
|
|
59
|
+
apiKey: "test-key",
|
|
60
|
+
userId: "u-1",
|
|
61
|
+
workspaceId: "w-1",
|
|
62
|
+
});
|
|
63
|
+
api.registerSdk({ name: "bad name", version: "1.0.0" });
|
|
64
|
+
api.registerSdk({ name: "@scope/pkg", version: "1.0.0 ok" });
|
|
65
|
+
api.registerSdk({ name: "@scope/pkg\ttab", version: "1.0.0" });
|
|
66
|
+
api.registerSdk({ name: "@scope/ok", version: " 1.0.0 " });
|
|
67
|
+
|
|
68
|
+
expect(api.sdkHeader().split(" ")).toEqual([
|
|
69
|
+
expect.stringMatching(/^assistant-cloud\//),
|
|
70
|
+
"@scope/ok/1.0.0",
|
|
71
|
+
]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("sends each registered SDK identity once in registration order", async () => {
|
|
75
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
76
|
+
ok: true,
|
|
77
|
+
headers: new Headers(),
|
|
78
|
+
json: vi.fn().mockResolvedValue({}),
|
|
79
|
+
});
|
|
80
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
81
|
+
|
|
82
|
+
const api = new AssistantCloudAPI({
|
|
83
|
+
apiKey: "test-key",
|
|
84
|
+
userId: "u-1",
|
|
85
|
+
workspaceId: "w-1",
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
api.registerSdk({ name: " @assistant-ui/core ", version: " 0.3.18 " });
|
|
89
|
+
api.registerSdk({ name: "@assistant-ui/core", version: "0.3.18" });
|
|
90
|
+
api.registerSdk({ name: "@assistant-ui/ai-sdk", version: "0.0.5" });
|
|
91
|
+
api.registerSdk({ name: " ", version: "0.0.5" });
|
|
92
|
+
api.registerSdk({ name: "@assistant-ui/react-langgraph", version: " " });
|
|
93
|
+
|
|
94
|
+
await api.makeRawRequest("/threads", {
|
|
95
|
+
headers: { "Aui-Sdk": "overridden" },
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const [, init] = fetchMock.mock.calls[0]!;
|
|
99
|
+
expect(init.headers).toMatchObject({ "Aui-Sdk": api.sdkHeader() });
|
|
100
|
+
expect(api.sdkHeader().split(" ")).toEqual([
|
|
101
|
+
expect.stringMatching(/^assistant-cloud\//),
|
|
102
|
+
"@assistant-ui/core/0.3.18",
|
|
103
|
+
"@assistant-ui/ai-sdk/0.0.5",
|
|
104
|
+
]);
|
|
105
|
+
});
|
|
106
|
+
|
|
56
107
|
it("uses custom baseUrl when provided with apiKey config", async () => {
|
|
57
108
|
const fetchMock = vi.fn().mockResolvedValue({
|
|
58
109
|
ok: true,
|
|
@@ -950,4 +950,26 @@ describe("AssistantCloudJWTAuthStrategy", () => {
|
|
|
950
950
|
});
|
|
951
951
|
expect(authToken).toHaveBeenCalledTimes(2);
|
|
952
952
|
});
|
|
953
|
+
|
|
954
|
+
it("does not cache a malformed rotated token", async () => {
|
|
955
|
+
const authToken = vi
|
|
956
|
+
.fn<() => Promise<string | null>>()
|
|
957
|
+
.mockResolvedValue(accessToken);
|
|
958
|
+
const strategy = new AssistantCloudJWTAuthStrategy(authToken);
|
|
959
|
+
|
|
960
|
+
await expect(strategy.getAuthHeaders()).resolves.toEqual({
|
|
961
|
+
Authorization: `Bearer ${accessToken}`,
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
expect(() =>
|
|
965
|
+
strategy.readAuthHeaders(
|
|
966
|
+
new Headers({ Authorization: "Bearer malformed" }),
|
|
967
|
+
),
|
|
968
|
+
).toThrow("Unable to determine the token expiry");
|
|
969
|
+
|
|
970
|
+
await expect(strategy.getAuthHeaders()).resolves.toEqual({
|
|
971
|
+
Authorization: `Bearer ${accessToken}`,
|
|
972
|
+
});
|
|
973
|
+
expect(authToken).toHaveBeenCalledTimes(1);
|
|
974
|
+
});
|
|
953
975
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach,
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
2
|
import { AssistantCloudProjects } from "../AssistantCloudProjects";
|
|
3
3
|
import type { AssistantCloudAPI } from "../AssistantCloudAPI";
|
|
4
4
|
|
|
@@ -19,10 +19,6 @@ describe("AssistantCloudProjects", () => {
|
|
|
19
19
|
projects = new AssistantCloudProjects(mockApi);
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
-
afterEach(() => {
|
|
23
|
-
vi.clearAllMocks();
|
|
24
|
-
});
|
|
25
|
-
|
|
26
22
|
it("lists project threads with the query", async () => {
|
|
27
23
|
vi.mocked(mockApi.makeRequest).mockResolvedValue({ threads: [] });
|
|
28
24
|
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { AssistantCloud } from "../AssistantCloud";
|
|
3
|
+
import {
|
|
4
|
+
CloudEngagementReporter,
|
|
5
|
+
type EngagementIdResolver,
|
|
6
|
+
} from "../CloudEngagementReporter";
|
|
7
|
+
|
|
8
|
+
const createCloud = () => {
|
|
9
|
+
const track = vi.fn();
|
|
10
|
+
const cloud = { events: { track } } as unknown as AssistantCloud;
|
|
11
|
+
return { cloud, track };
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
15
|
+
|
|
16
|
+
describe("CloudEngagementReporter", () => {
|
|
17
|
+
it("resolves ids through the resolver and awaits the thread only for a send", async () => {
|
|
18
|
+
const { cloud, track } = createCloud();
|
|
19
|
+
const resolveIds = vi.fn<EngagementIdResolver>(
|
|
20
|
+
async (threadId, messageId) => ({
|
|
21
|
+
thread_id: `remote-${threadId}`,
|
|
22
|
+
...(messageId ? { message_id: `remote-${messageId}` } : undefined),
|
|
23
|
+
}),
|
|
24
|
+
);
|
|
25
|
+
const reporter = new CloudEngagementReporter(cloud, resolveIds);
|
|
26
|
+
|
|
27
|
+
reporter.messageSent("t1", { messageId: "m1", chars: 12, attachments: 1 });
|
|
28
|
+
reporter.messageCopied("t1", "m2");
|
|
29
|
+
await flush();
|
|
30
|
+
|
|
31
|
+
expect(resolveIds).toHaveBeenNthCalledWith(1, "t1", "m1", {
|
|
32
|
+
awaitThread: true,
|
|
33
|
+
});
|
|
34
|
+
expect(resolveIds).toHaveBeenNthCalledWith(2, "t1", "m2", {
|
|
35
|
+
awaitThread: false,
|
|
36
|
+
});
|
|
37
|
+
expect(track).toHaveBeenCalledWith({
|
|
38
|
+
kind: "message_sent",
|
|
39
|
+
thread_id: "remote-t1",
|
|
40
|
+
message_id: "remote-m1",
|
|
41
|
+
props: { chars: 12, attachments: 1 },
|
|
42
|
+
});
|
|
43
|
+
expect(track).toHaveBeenCalledWith({
|
|
44
|
+
kind: "message_copied",
|
|
45
|
+
thread_id: "remote-t1",
|
|
46
|
+
message_id: "remote-m2",
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("reports tool approval decisions with their resolved message IDs", async () => {
|
|
51
|
+
const { cloud, track } = createCloud();
|
|
52
|
+
const reporter = new CloudEngagementReporter(
|
|
53
|
+
cloud,
|
|
54
|
+
(threadId, messageId) => ({
|
|
55
|
+
thread_id: `remote-${threadId}`,
|
|
56
|
+
...(messageId !== undefined
|
|
57
|
+
? { message_id: `remote-${messageId}` }
|
|
58
|
+
: {}),
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
reporter.toolApproved("t1", "m1", "tool-1", "send_email");
|
|
63
|
+
reporter.toolRejected("t1", "m2", "tool-2", "delete_account");
|
|
64
|
+
await flush();
|
|
65
|
+
|
|
66
|
+
expect(track).toHaveBeenNthCalledWith(1, {
|
|
67
|
+
kind: "tool_approved",
|
|
68
|
+
thread_id: "remote-t1",
|
|
69
|
+
message_id: "remote-m1",
|
|
70
|
+
props: { toolCallId: "tool-1", toolName: "send_email" },
|
|
71
|
+
});
|
|
72
|
+
expect(track).toHaveBeenNthCalledWith(2, {
|
|
73
|
+
kind: "tool_rejected",
|
|
74
|
+
thread_id: "remote-t1",
|
|
75
|
+
message_id: "remote-m2",
|
|
76
|
+
props: { toolCallId: "tool-2", toolName: "delete_account" },
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("measures a stop against the run start and reports it once per run", async () => {
|
|
81
|
+
const { cloud, track } = createCloud();
|
|
82
|
+
const reporter = new CloudEngagementReporter(cloud);
|
|
83
|
+
|
|
84
|
+
vi.setSystemTime(new Date("2023-01-01T00:00:00.000Z"));
|
|
85
|
+
reporter.runStopped("t1");
|
|
86
|
+
reporter.runStarted("t1");
|
|
87
|
+
vi.setSystemTime(new Date("2023-01-01T00:00:01.500Z"));
|
|
88
|
+
reporter.runStopped("t1");
|
|
89
|
+
reporter.runStopped("t1");
|
|
90
|
+
await flush();
|
|
91
|
+
|
|
92
|
+
expect(track).toHaveBeenCalledOnce();
|
|
93
|
+
expect(track).toHaveBeenCalledWith({
|
|
94
|
+
kind: "run_stopped",
|
|
95
|
+
thread_id: "t1",
|
|
96
|
+
value: 1500,
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("carries the time since the previous run ended on the next send", async () => {
|
|
101
|
+
const { cloud, track } = createCloud();
|
|
102
|
+
const reporter = new CloudEngagementReporter(cloud);
|
|
103
|
+
|
|
104
|
+
vi.setSystemTime(new Date("2023-01-01T00:00:00.000Z"));
|
|
105
|
+
reporter.runStarted("t1");
|
|
106
|
+
reporter.runEnded("t1");
|
|
107
|
+
vi.setSystemTime(new Date("2023-01-01T00:00:04.000Z"));
|
|
108
|
+
reporter.messageSent("t1", { chars: 3, attachments: 0 });
|
|
109
|
+
await flush();
|
|
110
|
+
|
|
111
|
+
expect(track).toHaveBeenCalledWith(
|
|
112
|
+
expect.objectContaining({ kind: "message_sent", value: 4000 }),
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("shows one error per run and one suggestion list per thread", async () => {
|
|
117
|
+
const { cloud, track } = createCloud();
|
|
118
|
+
const reporter = new CloudEngagementReporter(cloud);
|
|
119
|
+
|
|
120
|
+
reporter.runStarted("t1");
|
|
121
|
+
reporter.errorShown("t1", { messageId: "m1", reason: "error" });
|
|
122
|
+
reporter.errorShown("t1", { messageId: "m1", reason: "error" });
|
|
123
|
+
reporter.runStarted("t1");
|
|
124
|
+
reporter.errorShown("t1", { reason: "timeout" });
|
|
125
|
+
reporter.suggestionsShown("t1", 3);
|
|
126
|
+
reporter.suggestionsShown("t1", 3);
|
|
127
|
+
await flush();
|
|
128
|
+
|
|
129
|
+
const kinds = track.mock.calls.map(([event]) => event.kind);
|
|
130
|
+
expect(kinds).toEqual(["error_shown", "error_shown", "suggestions_shown"]);
|
|
131
|
+
expect(track).toHaveBeenCalledWith({
|
|
132
|
+
kind: "suggestions_shown",
|
|
133
|
+
thread_id: "t1",
|
|
134
|
+
value: 3,
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("drops a thread switch the cloud cannot attribute and survives a failing resolver", async () => {
|
|
139
|
+
const { cloud, track } = createCloud();
|
|
140
|
+
const reporter = new CloudEngagementReporter(cloud, (threadId) =>
|
|
141
|
+
threadId === "known" ? { thread_id: "remote" } : {},
|
|
142
|
+
);
|
|
143
|
+
reporter.threadSwitched("unknown");
|
|
144
|
+
reporter.threadSwitched("known");
|
|
145
|
+
const failing = new CloudEngagementReporter(cloud, () => {
|
|
146
|
+
throw new Error("no ids");
|
|
147
|
+
});
|
|
148
|
+
failing.messageCopied("t1");
|
|
149
|
+
await flush();
|
|
150
|
+
|
|
151
|
+
expect(track).toHaveBeenCalledOnce();
|
|
152
|
+
expect(track).toHaveBeenCalledWith({
|
|
153
|
+
kind: "thread_switched",
|
|
154
|
+
thread_id: "remote",
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("keeps the state of the most recently touched threads only", async () => {
|
|
159
|
+
const { cloud, track } = createCloud();
|
|
160
|
+
const reporter = new CloudEngagementReporter(cloud);
|
|
161
|
+
for (let index = 0; index < 300; index++) {
|
|
162
|
+
reporter.suggestionsShown(`t${index}`, 1);
|
|
163
|
+
}
|
|
164
|
+
reporter.suggestionsShown("t0", 1);
|
|
165
|
+
reporter.suggestionsShown("t299", 1);
|
|
166
|
+
await flush();
|
|
167
|
+
|
|
168
|
+
expect(track).toHaveBeenCalledTimes(301);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("keeps a started run until it stops, however many threads ran since", async () => {
|
|
172
|
+
const { cloud, track } = createCloud();
|
|
173
|
+
const reporter = new CloudEngagementReporter(cloud);
|
|
174
|
+
for (let index = 0; index < 300; index++) {
|
|
175
|
+
reporter.runStarted(`t${index}`);
|
|
176
|
+
}
|
|
177
|
+
reporter.runStopped("t0");
|
|
178
|
+
await flush();
|
|
179
|
+
|
|
180
|
+
expect(track).toHaveBeenCalledWith(
|
|
181
|
+
expect.objectContaining({ kind: "run_stopped", thread_id: "t0" }),
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -252,6 +252,217 @@ describe("CloudMessagePersistence", () => {
|
|
|
252
252
|
await persistence.load("thread-1");
|
|
253
253
|
|
|
254
254
|
expect(persistence.isPersisted("msg-1")).toBe(true);
|
|
255
|
+
expect(await persistence.getRemoteId("msg-1")).toBe("msg-1");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("preserves a pending append mapping when load returns the same ID", async () => {
|
|
259
|
+
const messages = createCloudMessages(1);
|
|
260
|
+
let resolveLoad!: (value: { messages: typeof messages }) => void;
|
|
261
|
+
let resolveAppend!: (value: { message_id: string }) => void;
|
|
262
|
+
vi.mocked(cloud.threads.messages.list).mockImplementationOnce(
|
|
263
|
+
() =>
|
|
264
|
+
new Promise((resolve) => {
|
|
265
|
+
resolveLoad = resolve;
|
|
266
|
+
}),
|
|
267
|
+
);
|
|
268
|
+
vi.mocked(cloud.threads.messages.create).mockImplementationOnce(
|
|
269
|
+
() =>
|
|
270
|
+
new Promise((resolve) => {
|
|
271
|
+
resolveAppend = resolve;
|
|
272
|
+
}),
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
const load = persistence.load("thread-1");
|
|
276
|
+
const append = persistence.append(
|
|
277
|
+
"thread-1",
|
|
278
|
+
"message-1",
|
|
279
|
+
null,
|
|
280
|
+
"aui/v0",
|
|
281
|
+
{},
|
|
282
|
+
);
|
|
283
|
+
resolveLoad({ messages });
|
|
284
|
+
await load;
|
|
285
|
+
resolveAppend({ message_id: "remote-2" });
|
|
286
|
+
await append;
|
|
287
|
+
|
|
288
|
+
expect(await persistence.getRemoteId("message-1")).toBe("remote-2");
|
|
289
|
+
await persistence.update("thread-1", "message-1", "aui/v0", {
|
|
290
|
+
text: "updated",
|
|
291
|
+
});
|
|
292
|
+
expect(cloud.threads.messages.update).toHaveBeenCalledWith(
|
|
293
|
+
"thread-1",
|
|
294
|
+
"remote-2",
|
|
295
|
+
{ content: { text: "updated" } },
|
|
296
|
+
);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("preserves a resolved append mapping when load returns the same ID", async () => {
|
|
300
|
+
const messages = createCloudMessages(1);
|
|
301
|
+
vi.mocked(cloud.threads.messages.create).mockResolvedValue({
|
|
302
|
+
message_id: "remote-1",
|
|
303
|
+
});
|
|
304
|
+
vi.mocked(cloud.threads.messages.list).mockResolvedValue({
|
|
305
|
+
messages,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
await persistence.append("thread-1", "message-1", null, "aui/v0", {});
|
|
309
|
+
await persistence.load("thread-1");
|
|
310
|
+
|
|
311
|
+
expect(await persistence.getRemoteId("message-1")).toBe("remote-1");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("keeps the loaded ID when a pending append for it fails", async () => {
|
|
315
|
+
const messages = createCloudMessages(1);
|
|
316
|
+
const failure = new Error("create failed");
|
|
317
|
+
let resolveLoad!: (value: { messages: typeof messages }) => void;
|
|
318
|
+
let rejectAppend!: (error: Error) => void;
|
|
319
|
+
vi.mocked(cloud.threads.messages.list).mockImplementationOnce(
|
|
320
|
+
() =>
|
|
321
|
+
new Promise((resolve) => {
|
|
322
|
+
resolveLoad = resolve;
|
|
323
|
+
}),
|
|
324
|
+
);
|
|
325
|
+
vi.mocked(cloud.threads.messages.create).mockImplementationOnce(
|
|
326
|
+
() =>
|
|
327
|
+
new Promise((_resolve, reject) => {
|
|
328
|
+
rejectAppend = reject;
|
|
329
|
+
}),
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
const load = persistence.load("thread-1");
|
|
333
|
+
const append = persistence.append(
|
|
334
|
+
"thread-1",
|
|
335
|
+
"message-1",
|
|
336
|
+
null,
|
|
337
|
+
"aui/v0",
|
|
338
|
+
{},
|
|
339
|
+
);
|
|
340
|
+
resolveLoad({ messages });
|
|
341
|
+
await load;
|
|
342
|
+
rejectAppend(failure);
|
|
343
|
+
|
|
344
|
+
await expect(append).rejects.toBe(failure);
|
|
345
|
+
expect(persistence.isPersisted("message-1")).toBe(true);
|
|
346
|
+
expect(await persistence.getRemoteId("message-1")).toBe("message-1");
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("does not restore a loaded ID after reset when its pending append fails", async () => {
|
|
350
|
+
const messages = createCloudMessages(1);
|
|
351
|
+
const failure = new Error("create failed");
|
|
352
|
+
let rejectAppend!: (error: Error) => void;
|
|
353
|
+
vi.mocked(cloud.threads.messages.list).mockResolvedValue({ messages });
|
|
354
|
+
vi.mocked(cloud.threads.messages.create).mockImplementationOnce(
|
|
355
|
+
() =>
|
|
356
|
+
new Promise((_resolve, reject) => {
|
|
357
|
+
rejectAppend = reject;
|
|
358
|
+
}),
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
const append = persistence.append(
|
|
362
|
+
"thread-1",
|
|
363
|
+
"message-1",
|
|
364
|
+
null,
|
|
365
|
+
"aui/v0",
|
|
366
|
+
{},
|
|
367
|
+
);
|
|
368
|
+
await persistence.load("thread-1");
|
|
369
|
+
persistence.reset();
|
|
370
|
+
rejectAppend(failure);
|
|
371
|
+
|
|
372
|
+
await expect(append).rejects.toBe(failure);
|
|
373
|
+
expect(persistence.isPersisted("message-1")).toBe(false);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("does not restore IDs from a load that finishes after reset", async () => {
|
|
377
|
+
const oldMessages = createCloudMessages(1);
|
|
378
|
+
const newMessages = [{ ...oldMessages[0]!, id: "new-message" }];
|
|
379
|
+
let resolveOld!: (value: { messages: typeof oldMessages }) => void;
|
|
380
|
+
vi.mocked(cloud.threads.messages.list)
|
|
381
|
+
.mockImplementationOnce(
|
|
382
|
+
() =>
|
|
383
|
+
new Promise((resolve) => {
|
|
384
|
+
resolveOld = resolve;
|
|
385
|
+
}),
|
|
386
|
+
)
|
|
387
|
+
.mockResolvedValueOnce({ messages: newMessages });
|
|
388
|
+
|
|
389
|
+
const oldLoad = persistence.load("old-thread");
|
|
390
|
+
persistence.reset();
|
|
391
|
+
await persistence.load("new-thread");
|
|
392
|
+
resolveOld({ messages: oldMessages });
|
|
393
|
+
|
|
394
|
+
expect(await oldLoad).toEqual(oldMessages);
|
|
395
|
+
expect(persistence.isPersisted("message-1")).toBe(false);
|
|
396
|
+
expect(persistence.isPersisted("new-message")).toBe(true);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("keeps a new append mapping when an old load returns the same ID", async () => {
|
|
400
|
+
const messages = createCloudMessages(1);
|
|
401
|
+
let resolveOld!: (value: { messages: typeof messages }) => void;
|
|
402
|
+
vi.mocked(cloud.threads.messages.list).mockImplementationOnce(
|
|
403
|
+
() =>
|
|
404
|
+
new Promise((resolve) => {
|
|
405
|
+
resolveOld = resolve;
|
|
406
|
+
}),
|
|
407
|
+
);
|
|
408
|
+
vi.mocked(cloud.threads.messages.create).mockResolvedValue({
|
|
409
|
+
message_id: "new-remote-id",
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
const oldLoad = persistence.load("old-thread");
|
|
413
|
+
persistence.reset();
|
|
414
|
+
await persistence.append("new-thread", "message-1", null, "aui/v0", {});
|
|
415
|
+
resolveOld({ messages });
|
|
416
|
+
await oldLoad;
|
|
417
|
+
|
|
418
|
+
expect(await persistence.getRemoteId("message-1")).toBe("new-remote-id");
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("does not populate any old IDs when reset happens between history pages", async () => {
|
|
422
|
+
const messages = createCloudMessages(201);
|
|
423
|
+
let resolveLast!: (value: { messages: typeof messages }) => void;
|
|
424
|
+
vi.mocked(cloud.threads.messages.list)
|
|
425
|
+
.mockResolvedValueOnce({ messages: messages.slice(0, 200) })
|
|
426
|
+
.mockImplementationOnce(
|
|
427
|
+
() =>
|
|
428
|
+
new Promise((resolve) => {
|
|
429
|
+
resolveLast = resolve;
|
|
430
|
+
}),
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
const load = persistence.load("old-thread");
|
|
434
|
+
await vi.waitFor(() =>
|
|
435
|
+
expect(cloud.threads.messages.list).toHaveBeenCalledTimes(2),
|
|
436
|
+
);
|
|
437
|
+
persistence.reset();
|
|
438
|
+
resolveLast({ messages: messages.slice(200) });
|
|
439
|
+
|
|
440
|
+
expect(await load).toEqual(messages);
|
|
441
|
+
expect(
|
|
442
|
+
messages.some((message) => persistence.isPersisted(message.id)),
|
|
443
|
+
).toBe(false);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it("propagates an old load failure without disturbing post-reset state", async () => {
|
|
447
|
+
let rejectOld!: (error: Error) => void;
|
|
448
|
+
vi.mocked(cloud.threads.messages.list).mockImplementationOnce(
|
|
449
|
+
() =>
|
|
450
|
+
new Promise((_resolve, reject) => {
|
|
451
|
+
rejectOld = reject;
|
|
452
|
+
}),
|
|
453
|
+
);
|
|
454
|
+
vi.mocked(cloud.threads.messages.create).mockResolvedValue({
|
|
455
|
+
message_id: "new-remote-id",
|
|
456
|
+
});
|
|
457
|
+
const failure = new Error("history failed");
|
|
458
|
+
const oldLoad = persistence.load("old-thread");
|
|
459
|
+
const rejected = expect(oldLoad).rejects.toBe(failure);
|
|
460
|
+
persistence.reset();
|
|
461
|
+
await persistence.append("new-thread", "new-message", null, "aui/v0", {});
|
|
462
|
+
rejectOld(failure);
|
|
463
|
+
|
|
464
|
+
await rejected;
|
|
465
|
+
expect(await persistence.getRemoteId("new-message")).toBe("new-remote-id");
|
|
255
466
|
});
|
|
256
467
|
|
|
257
468
|
it("maps prototype-named loaded messages", async () => {
|
|
@@ -383,6 +594,30 @@ describe("CloudMessagePersistence", () => {
|
|
|
383
594
|
expect(persistence.isPersisted("local-1")).toBe(false);
|
|
384
595
|
});
|
|
385
596
|
|
|
597
|
+
it("does not restore an append mapping after reset", async () => {
|
|
598
|
+
let resolveAppend!: (value: { message_id: string }) => void;
|
|
599
|
+
vi.mocked(cloud.threads.messages.create).mockImplementationOnce(
|
|
600
|
+
() =>
|
|
601
|
+
new Promise((resolve) => {
|
|
602
|
+
resolveAppend = resolve;
|
|
603
|
+
}),
|
|
604
|
+
);
|
|
605
|
+
|
|
606
|
+
const append = persistence.append(
|
|
607
|
+
"thread-1",
|
|
608
|
+
"local-1",
|
|
609
|
+
null,
|
|
610
|
+
"aui/v0",
|
|
611
|
+
{},
|
|
612
|
+
);
|
|
613
|
+
persistence.reset();
|
|
614
|
+
resolveAppend({ message_id: "remote-1" });
|
|
615
|
+
|
|
616
|
+
await expect(append).resolves.toBeUndefined();
|
|
617
|
+
expect(persistence.isPersisted("local-1")).toBe(false);
|
|
618
|
+
expect(await persistence.getRemoteId("local-1")).toBeUndefined();
|
|
619
|
+
});
|
|
620
|
+
|
|
386
621
|
it("reset clears all ID mappings", async () => {
|
|
387
622
|
vi.mocked(cloud.threads.messages.create).mockResolvedValue({
|
|
388
623
|
message_id: "remote-1",
|