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.
Files changed (109) hide show
  1. package/README.md +48 -6
  2. package/dist/AssistantCloud.d.ts +3 -3
  3. package/dist/AssistantCloud.d.ts.map +1 -1
  4. package/dist/AssistantCloud.js +2 -0
  5. package/dist/AssistantCloud.js.map +1 -1
  6. package/dist/AssistantCloudAPI.d.ts +14 -6
  7. package/dist/AssistantCloudAPI.d.ts.map +1 -1
  8. package/dist/AssistantCloudAPI.js +17 -1
  9. package/dist/AssistantCloudAPI.js.map +1 -1
  10. package/dist/AssistantCloudAuthStrategy.d.ts +6 -7
  11. package/dist/AssistantCloudAuthStrategy.d.ts.map +1 -1
  12. package/dist/AssistantCloudAuthStrategy.js +4 -2
  13. package/dist/AssistantCloudAuthStrategy.js.map +1 -1
  14. package/dist/AssistantCloudAuthTokens.d.ts +1 -2
  15. package/dist/AssistantCloudAuthTokens.d.ts.map +1 -1
  16. package/dist/AssistantCloudEvents.d.ts +3 -4
  17. package/dist/AssistantCloudEvents.d.ts.map +1 -1
  18. package/dist/AssistantCloudEvents.js +5 -1
  19. package/dist/AssistantCloudEvents.js.map +1 -1
  20. package/dist/AssistantCloudFiles.d.ts +2 -3
  21. package/dist/AssistantCloudFiles.d.ts.map +1 -1
  22. package/dist/AssistantCloudProjectThreadMessages.d.ts +1 -2
  23. package/dist/AssistantCloudProjectThreadMessages.d.ts.map +1 -1
  24. package/dist/AssistantCloudProjectThreads.d.ts +1 -2
  25. package/dist/AssistantCloudProjectThreads.d.ts.map +1 -1
  26. package/dist/AssistantCloudProjects.d.ts +1 -2
  27. package/dist/AssistantCloudProjects.d.ts.map +1 -1
  28. package/dist/AssistantCloudRuns.d.ts +3 -3
  29. package/dist/AssistantCloudRuns.d.ts.map +1 -1
  30. package/dist/AssistantCloudRuns.js +2 -1
  31. package/dist/AssistantCloudRuns.js.map +1 -1
  32. package/dist/AssistantCloudScores.d.ts +3 -4
  33. package/dist/AssistantCloudScores.d.ts.map +1 -1
  34. package/dist/AssistantCloudThreadMessages.d.ts +7 -6
  35. package/dist/AssistantCloudThreadMessages.d.ts.map +1 -1
  36. package/dist/AssistantCloudThreadMessages.js +7 -2
  37. package/dist/AssistantCloudThreadMessages.js.map +1 -1
  38. package/dist/AssistantCloudThreads.d.ts +3 -4
  39. package/dist/AssistantCloudThreads.d.ts.map +1 -1
  40. package/dist/CloudEngagementReporter.d.ts +73 -0
  41. package/dist/CloudEngagementReporter.d.ts.map +1 -0
  42. package/dist/CloudEngagementReporter.js +132 -0
  43. package/dist/CloudEngagementReporter.js.map +1 -0
  44. package/dist/CloudMessagePersistence.d.ts +9 -11
  45. package/dist/CloudMessagePersistence.d.ts.map +1 -1
  46. package/dist/CloudMessagePersistence.js +18 -11
  47. package/dist/CloudMessagePersistence.js.map +1 -1
  48. package/dist/CloudRunReporter.d.ts +19 -0
  49. package/dist/CloudRunReporter.d.ts.map +1 -0
  50. package/dist/CloudRunReporter.js +47 -0
  51. package/dist/CloudRunReporter.js.map +1 -0
  52. package/dist/FormattedCloudPersistence.d.ts +5 -11
  53. package/dist/FormattedCloudPersistence.d.ts.map +1 -1
  54. package/dist/FormattedCloudPersistence.js +3 -8
  55. package/dist/FormattedCloudPersistence.js.map +1 -1
  56. package/dist/ai-sdk/index.d.ts +32 -0
  57. package/dist/ai-sdk/index.d.ts.map +1 -0
  58. package/dist/ai-sdk/index.js +147 -0
  59. package/dist/ai-sdk/index.js.map +1 -0
  60. package/dist/cloudResponse.d.ts +12 -13
  61. package/dist/cloudResponse.d.ts.map +1 -1
  62. package/dist/generateThreadTitle.d.ts +1 -2
  63. package/dist/generateThreadTitle.d.ts.map +1 -1
  64. package/dist/generateThreadTitle.js +1 -0
  65. package/dist/generateThreadTitle.js.map +1 -1
  66. package/dist/index.d.ts +5 -3
  67. package/dist/index.js +4 -2
  68. package/dist/instrumentMcpSampling.d.ts +6 -7
  69. package/dist/instrumentMcpSampling.d.ts.map +1 -1
  70. package/dist/instrumentMcpSampling.js +12 -2
  71. package/dist/instrumentMcpSampling.js.map +1 -1
  72. package/dist/runTelemetry.d.ts +30 -15
  73. package/dist/runTelemetry.d.ts.map +1 -1
  74. package/dist/runTelemetry.js.map +1 -1
  75. package/dist/telemetry/index.d.ts +8 -9
  76. package/dist/telemetry/index.d.ts.map +1 -1
  77. package/dist/version.d.ts +4 -0
  78. package/dist/version.d.ts.map +1 -0
  79. package/dist/version.js +6 -0
  80. package/dist/version.js.map +1 -0
  81. package/package.json +24 -10
  82. package/src/AssistantCloud.ts +3 -0
  83. package/src/AssistantCloudAPI.ts +31 -1
  84. package/src/AssistantCloudAuthStrategy.ts +4 -2
  85. package/src/AssistantCloudEvents.test.ts +68 -2
  86. package/src/AssistantCloudEvents.ts +6 -0
  87. package/src/AssistantCloudRuns.ts +1 -0
  88. package/src/AssistantCloudThreadMessages.test.ts +53 -2
  89. package/src/AssistantCloudThreadMessages.ts +13 -1
  90. package/src/CloudEngagementReporter.ts +231 -0
  91. package/src/CloudMessagePersistence.ts +24 -13
  92. package/src/CloudRunReporter.ts +50 -0
  93. package/src/FormattedCloudPersistence.ts +3 -8
  94. package/src/ai-sdk/index.test.ts +258 -0
  95. package/src/ai-sdk/index.ts +222 -0
  96. package/src/generateThreadTitle.test.ts +32 -0
  97. package/src/generateThreadTitle.ts +1 -0
  98. package/src/index.ts +11 -1
  99. package/src/instrumentMcpSampling.test.ts +102 -0
  100. package/src/instrumentMcpSampling.ts +16 -2
  101. package/src/runTelemetry.ts +17 -0
  102. package/src/tests/AssistantCloud.test.ts +33 -1
  103. package/src/tests/AssistantCloudAPI.test.ts +51 -0
  104. package/src/tests/AssistantCloudAuthStrategy.test.ts +22 -0
  105. package/src/tests/AssistantCloudProjects.test.ts +1 -5
  106. package/src/tests/CloudEngagementReporter.test.ts +184 -0
  107. package/src/tests/CloudMessagePersistence.test.ts +235 -0
  108. package/src/tests/CloudRunReporter.test.ts +135 -0
  109. package/src/version.ts +4 -0
@@ -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, afterEach, vi } from "vitest";
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",