@assistant-ui/ai-sdk 0.0.1

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 (167) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -0
  3. package/dist/adapters/aiSDKFormatAdapter.d.ts +8 -0
  4. package/dist/adapters/aiSDKFormatAdapter.d.ts.map +1 -0
  5. package/dist/adapters/aiSDKFormatAdapter.js +26 -0
  6. package/dist/adapters/aiSDKFormatAdapter.js.map +1 -0
  7. package/dist/adapters/vercelAttachmentAdapter.d.ts +6 -0
  8. package/dist/adapters/vercelAttachmentAdapter.d.ts.map +1 -0
  9. package/dist/adapters/vercelAttachmentAdapter.js +37 -0
  10. package/dist/adapters/vercelAttachmentAdapter.js.map +1 -0
  11. package/dist/aiSDKExtras.d.ts +11 -0
  12. package/dist/aiSDKExtras.d.ts.map +1 -0
  13. package/dist/aiSDKExtras.js +7 -0
  14. package/dist/aiSDKExtras.js.map +1 -0
  15. package/dist/converters/convertMessage.d.ts +28 -0
  16. package/dist/converters/convertMessage.d.ts.map +1 -0
  17. package/dist/converters/convertMessage.js +249 -0
  18. package/dist/converters/convertMessage.js.map +1 -0
  19. package/dist/converters/modelContentEnvelope.d.ts +16 -0
  20. package/dist/converters/modelContentEnvelope.d.ts.map +1 -0
  21. package/dist/converters/modelContentEnvelope.js +22 -0
  22. package/dist/converters/modelContentEnvelope.js.map +1 -0
  23. package/dist/converters/toCreateMessage.d.ts +7 -0
  24. package/dist/converters/toCreateMessage.d.ts.map +1 -0
  25. package/dist/converters/toCreateMessage.js +58 -0
  26. package/dist/converters/toCreateMessage.js.map +1 -0
  27. package/dist/converters/toolOutputConversion.d.ts +29 -0
  28. package/dist/converters/toolOutputConversion.d.ts.map +1 -0
  29. package/dist/converters/toolOutputConversion.js +31 -0
  30. package/dist/converters/toolOutputConversion.js.map +1 -0
  31. package/dist/hooks.d.ts +19 -0
  32. package/dist/hooks.d.ts.map +1 -0
  33. package/dist/hooks.js +20 -0
  34. package/dist/hooks.js.map +1 -0
  35. package/dist/index.d.ts +14 -0
  36. package/dist/index.js +13 -0
  37. package/dist/index.native.d.ts +12 -0
  38. package/dist/index.native.js +11 -0
  39. package/dist/model-context/injectInteractableContext.d.ts +37 -0
  40. package/dist/model-context/injectInteractableContext.d.ts.map +1 -0
  41. package/dist/model-context/injectInteractableContext.js +52 -0
  42. package/dist/model-context/injectInteractableContext.js.map +1 -0
  43. package/dist/model-context/injectQuoteContext.d.ts +27 -0
  44. package/dist/model-context/injectQuoteContext.d.ts.map +1 -0
  45. package/dist/model-context/injectQuoteContext.js +51 -0
  46. package/dist/model-context/injectQuoteContext.js.map +1 -0
  47. package/dist/runtime/AISDKChat.d.ts +16 -0
  48. package/dist/runtime/AISDKChat.d.ts.map +1 -0
  49. package/dist/runtime/AISDKChat.js +69 -0
  50. package/dist/runtime/AISDKChat.js.map +1 -0
  51. package/dist/runtime/AISDKThreads.d.ts +45 -0
  52. package/dist/runtime/AISDKThreads.d.ts.map +1 -0
  53. package/dist/runtime/AISDKThreads.js +203 -0
  54. package/dist/runtime/AISDKThreads.js.map +1 -0
  55. package/dist/runtime/useAISDKRuntime.d.ts +49 -0
  56. package/dist/runtime/useAISDKRuntime.d.ts.map +1 -0
  57. package/dist/runtime/useAISDKRuntime.js +266 -0
  58. package/dist/runtime/useAISDKRuntime.js.map +1 -0
  59. package/dist/runtime/useChatRuntime.d.ts +13 -0
  60. package/dist/runtime/useChatRuntime.d.ts.map +1 -0
  61. package/dist/runtime/useChatRuntime.js +52 -0
  62. package/dist/runtime/useChatRuntime.js.map +1 -0
  63. package/dist/runtime/useChatThread.d.ts +77 -0
  64. package/dist/runtime/useChatThread.d.ts.map +1 -0
  65. package/dist/runtime/useChatThread.js +134 -0
  66. package/dist/runtime/useChatThread.js.map +1 -0
  67. package/dist/runtime/useExternalHistory.d.ts +11 -0
  68. package/dist/runtime/useExternalHistory.d.ts.map +1 -0
  69. package/dist/runtime/useExternalHistory.js +278 -0
  70. package/dist/runtime/useExternalHistory.js.map +1 -0
  71. package/dist/runtime/useResourceCleanup.d.ts +5 -0
  72. package/dist/runtime/useResourceCleanup.d.ts.map +1 -0
  73. package/dist/runtime/useResourceCleanup.js +47 -0
  74. package/dist/runtime/useResourceCleanup.js.map +1 -0
  75. package/dist/runtime/useStreamingTiming.d.ts +15 -0
  76. package/dist/runtime/useStreamingTiming.d.ts.map +1 -0
  77. package/dist/runtime/useStreamingTiming.js +38 -0
  78. package/dist/runtime/useStreamingTiming.js.map +1 -0
  79. package/dist/tools/frontendTools.d.ts +33 -0
  80. package/dist/tools/frontendTools.d.ts.map +1 -0
  81. package/dist/tools/frontendTools.js +33 -0
  82. package/dist/tools/frontendTools.js.map +1 -0
  83. package/dist/tools/generativeTools.d.ts +73 -0
  84. package/dist/tools/generativeTools.d.ts.map +1 -0
  85. package/dist/tools/generativeTools.js +245 -0
  86. package/dist/tools/generativeTools.js.map +1 -0
  87. package/dist/tools/mcp-stdio.node.d.ts +2 -0
  88. package/dist/tools/mcp-stdio.node.js +2 -0
  89. package/dist/tools/mcp-stdio.unsupported.d.ts +6 -0
  90. package/dist/tools/mcp-stdio.unsupported.d.ts.map +1 -0
  91. package/dist/tools/mcp-stdio.unsupported.js +11 -0
  92. package/dist/tools/mcp-stdio.unsupported.js.map +1 -0
  93. package/dist/transport/AssistantChatTransport.d.ts +23 -0
  94. package/dist/transport/AssistantChatTransport.d.ts.map +1 -0
  95. package/dist/transport/AssistantChatTransport.js +134 -0
  96. package/dist/transport/AssistantChatTransport.js.map +1 -0
  97. package/dist/transport/resumable.d.ts +38 -0
  98. package/dist/transport/resumable.d.ts.map +1 -0
  99. package/dist/transport/resumable.js +113 -0
  100. package/dist/transport/resumable.js.map +1 -0
  101. package/dist/usage.d.ts +19 -0
  102. package/dist/usage.d.ts.map +1 -0
  103. package/dist/usage.js +127 -0
  104. package/dist/usage.js.map +1 -0
  105. package/dist/utils/getVercelAIMessages.d.ts +7 -0
  106. package/dist/utils/getVercelAIMessages.d.ts.map +1 -0
  107. package/dist/utils/getVercelAIMessages.js +9 -0
  108. package/dist/utils/getVercelAIMessages.js.map +1 -0
  109. package/dist/utils/sliceMessagesUntil.d.ts +6 -0
  110. package/dist/utils/sliceMessagesUntil.d.ts.map +1 -0
  111. package/dist/utils/sliceMessagesUntil.js +12 -0
  112. package/dist/utils/sliceMessagesUntil.js.map +1 -0
  113. package/package.json +100 -0
  114. package/src/__tests__/standalone.e2e.ts +59 -0
  115. package/src/adapters/aiSDKFormatAdapter.ts +41 -0
  116. package/src/adapters/vercelAttachmentAdapter.test.ts +29 -0
  117. package/src/adapters/vercelAttachmentAdapter.ts +36 -0
  118. package/src/aiSDKExtras.ts +11 -0
  119. package/src/converters/convertMessage.test.ts +1130 -0
  120. package/src/converters/convertMessage.ts +457 -0
  121. package/src/converters/modelContentEnvelope.ts +41 -0
  122. package/src/converters/toCreateMessage.test.ts +596 -0
  123. package/src/converters/toCreateMessage.ts +99 -0
  124. package/src/converters/toolOutputConversion.ts +26 -0
  125. package/src/hooks.ts +23 -0
  126. package/src/index.native.ts +21 -0
  127. package/src/index.ts +29 -0
  128. package/src/model-context/injectInteractableContext.test.ts +167 -0
  129. package/src/model-context/injectInteractableContext.ts +61 -0
  130. package/src/model-context/injectQuoteContext.test.ts +33 -0
  131. package/src/model-context/injectQuoteContext.ts +62 -0
  132. package/src/runtime/AISDKChat.integration.test.tsx +47 -0
  133. package/src/runtime/AISDKChat.react-compiler.test.ts +49 -0
  134. package/src/runtime/AISDKChat.test.ts +193 -0
  135. package/src/runtime/AISDKChat.ts +47 -0
  136. package/src/runtime/AISDKThreads.cloud.test.ts +136 -0
  137. package/src/runtime/AISDKThreads.test.ts +548 -0
  138. package/src/runtime/AISDKThreads.ts +223 -0
  139. package/src/runtime/__tests__/controlled-transport.ts +43 -0
  140. package/src/runtime/useAISDKRuntime.approval.test.tsx +69 -0
  141. package/src/runtime/useAISDKRuntime.test.ts +1021 -0
  142. package/src/runtime/useAISDKRuntime.tool-output.test.tsx +113 -0
  143. package/src/runtime/useAISDKRuntime.ts +524 -0
  144. package/src/runtime/useChatOnTap.test.ts +58 -0
  145. package/src/runtime/useChatRuntime.integration.test.tsx +67 -0
  146. package/src/runtime/useChatRuntime.test.ts +522 -0
  147. package/src/runtime/useChatRuntime.ts +49 -0
  148. package/src/runtime/useChatThread.ts +286 -0
  149. package/src/runtime/useExternalHistory.test.ts +1289 -0
  150. package/src/runtime/useExternalHistory.ts +435 -0
  151. package/src/runtime/useResourceCleanup.ts +32 -0
  152. package/src/runtime/useStreamingTiming.ts +68 -0
  153. package/src/tools/frontendTools.test.ts +205 -0
  154. package/src/tools/frontendTools.ts +83 -0
  155. package/src/tools/generativeTools.test.ts +785 -0
  156. package/src/tools/generativeTools.ts +470 -0
  157. package/src/tools/mcp-stdio.node.ts +1 -0
  158. package/src/tools/mcp-stdio.unsupported.ts +12 -0
  159. package/src/transport/AssistantChatTransport.test.ts +300 -0
  160. package/src/transport/AssistantChatTransport.ts +204 -0
  161. package/src/transport/resumable.test.ts +262 -0
  162. package/src/transport/resumable.ts +161 -0
  163. package/src/usage.test.ts +186 -0
  164. package/src/usage.ts +174 -0
  165. package/src/utils/getVercelAIMessages.ts +11 -0
  166. package/src/utils/sliceMessagesUntil.test.ts +55 -0
  167. package/src/utils/sliceMessagesUntil.ts +20 -0
@@ -0,0 +1,1289 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, renderHook, waitFor } from "@testing-library/react";
4
+ import { beforeEach, describe, expect, it, onTestFinished, vi } from "vitest";
5
+ import { bindExternalStoreMessage } from "@assistant-ui/core";
6
+ import type {
7
+ AssistantRuntime,
8
+ MessageFormatAdapter,
9
+ MessageFormatRepository,
10
+ ThreadAssistantMessage,
11
+ ThreadHistoryAdapter,
12
+ ThreadMessage,
13
+ } from "@assistant-ui/core";
14
+
15
+ const mocks = vi.hoisted(() => ({
16
+ remoteId: undefined as string | undefined,
17
+ hasThreadListItem: false,
18
+ listeners: new Set<() => void>(),
19
+ }));
20
+
21
+ const auiClient = {
22
+ threadListItem: {
23
+ get source() {
24
+ return mocks.hasThreadListItem ? "threads" : null;
25
+ },
26
+ getState: () => ({ remoteId: mocks.remoteId }),
27
+ },
28
+ subscribe: (listener: () => void) => {
29
+ mocks.listeners.add(listener);
30
+ return () => {
31
+ mocks.listeners.delete(listener);
32
+ };
33
+ },
34
+ };
35
+
36
+ vi.mock("@assistant-ui/store", () => ({
37
+ useAui: () => auiClient,
38
+ }));
39
+
40
+ import { MessageRepository } from "@assistant-ui/core/internal";
41
+ import {
42
+ toExportedMessageRepository,
43
+ useExternalHistory,
44
+ } from "./useExternalHistory";
45
+
46
+ const noopThread = {
47
+ subscribe: () => () => {},
48
+ getState: () => ({ isRunning: false, messages: [] }),
49
+ import: () => {},
50
+ export: () => ({ headId: null, messages: [] }),
51
+ } as unknown as AssistantRuntime["thread"];
52
+
53
+ const runtimeRef = {
54
+ current: { thread: noopThread } as AssistantRuntime,
55
+ };
56
+
57
+ const storageFormat: MessageFormatAdapter<unknown, Record<string, unknown>> = {
58
+ format: "test",
59
+ encode: (item) => ({ data: item.message }),
60
+ decode: (stored) => ({
61
+ parentId: stored.parent_id,
62
+ message: stored.content,
63
+ }),
64
+ getId: () => "id",
65
+ };
66
+
67
+ const toThreadMessages = (_messages: unknown[]): ThreadMessage[] => [];
68
+ const onSetMessages = () => {};
69
+
70
+ describe("useExternalHistory withFormat contract", () => {
71
+ beforeEach(() => {
72
+ mocks.remoteId = undefined;
73
+ mocks.hasThreadListItem = false;
74
+ mocks.listeners.clear();
75
+ });
76
+
77
+ it("throws when the adapter omits withFormat", () => {
78
+ const adapterWithoutWithFormat: ThreadHistoryAdapter = {
79
+ load: vi.fn().mockResolvedValue({ headId: null, messages: [] }),
80
+ append: vi.fn().mockResolvedValue(undefined),
81
+ };
82
+
83
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
84
+
85
+ expect(() =>
86
+ renderHook(() =>
87
+ useExternalHistory(
88
+ runtimeRef,
89
+ adapterWithoutWithFormat,
90
+ toThreadMessages,
91
+ storageFormat,
92
+ onSetMessages,
93
+ ),
94
+ ),
95
+ ).toThrow(/withFormat/);
96
+
97
+ errorSpy.mockRestore();
98
+ });
99
+
100
+ it("does not throw when no adapter is supplied", () => {
101
+ expect(() =>
102
+ renderHook(() =>
103
+ useExternalHistory(
104
+ runtimeRef,
105
+ undefined,
106
+ toThreadMessages,
107
+ storageFormat,
108
+ onSetMessages,
109
+ ),
110
+ ),
111
+ ).not.toThrow();
112
+ });
113
+
114
+ it("accepts an adapter that implements withFormat", () => {
115
+ const withFormatResult = {
116
+ load: vi.fn().mockResolvedValue({ headId: null, messages: [] }),
117
+ append: vi.fn().mockResolvedValue(undefined),
118
+ };
119
+ const adapter: ThreadHistoryAdapter = {
120
+ load: vi.fn(),
121
+ append: vi.fn(),
122
+ withFormat: vi.fn().mockReturnValue(withFormatResult),
123
+ };
124
+
125
+ expect(() =>
126
+ renderHook(() =>
127
+ useExternalHistory(
128
+ runtimeRef,
129
+ adapter,
130
+ toThreadMessages,
131
+ storageFormat,
132
+ onSetMessages,
133
+ ),
134
+ ),
135
+ ).not.toThrow();
136
+
137
+ expect(adapter.withFormat).toHaveBeenCalledWith(storageFormat);
138
+ });
139
+
140
+ it("loads history when threadListItem.remoteId appears after the first paint", async () => {
141
+ const load = vi.fn().mockResolvedValue({ headId: null, messages: [] });
142
+ const adapter: ThreadHistoryAdapter = {
143
+ load: vi.fn(),
144
+ append: vi.fn(),
145
+ withFormat: vi.fn().mockReturnValue({
146
+ load,
147
+ append: vi.fn().mockResolvedValue(undefined),
148
+ }),
149
+ };
150
+
151
+ const { rerender } = renderHook(() =>
152
+ useExternalHistory(
153
+ runtimeRef,
154
+ adapter,
155
+ toThreadMessages,
156
+ storageFormat,
157
+ onSetMessages,
158
+ ),
159
+ );
160
+
161
+ await act(async () => {});
162
+ expect(load).not.toHaveBeenCalled();
163
+
164
+ mocks.hasThreadListItem = true;
165
+ mocks.remoteId = "remote-thread";
166
+ await act(async () => {
167
+ for (const listener of mocks.listeners) listener();
168
+ });
169
+ rerender();
170
+
171
+ await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
172
+ });
173
+
174
+ it("loads when a mounted thread later receives a remoteId", async () => {
175
+ mocks.hasThreadListItem = true;
176
+ const load = vi.fn().mockResolvedValue({ headId: null, messages: [] });
177
+ const adapter: ThreadHistoryAdapter = {
178
+ load: vi.fn(),
179
+ append: vi.fn(),
180
+ withFormat: vi.fn().mockReturnValue({
181
+ load,
182
+ append: vi.fn().mockResolvedValue(undefined),
183
+ }),
184
+ };
185
+
186
+ renderHook(() =>
187
+ useExternalHistory(
188
+ runtimeRef,
189
+ adapter,
190
+ toThreadMessages,
191
+ storageFormat,
192
+ onSetMessages,
193
+ ),
194
+ );
195
+
196
+ await act(async () => {});
197
+ expect(load).not.toHaveBeenCalled();
198
+
199
+ mocks.remoteId = "remote-thread";
200
+ await act(async () => {
201
+ for (const listener of mocks.listeners) listener();
202
+ });
203
+
204
+ await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
205
+ });
206
+
207
+ it("does not load history when remoteId appears during an active run", async () => {
208
+ mocks.hasThreadListItem = true;
209
+ const load = vi.fn().mockResolvedValue({ headId: null, messages: [] });
210
+ const adapter: ThreadHistoryAdapter = {
211
+ load: vi.fn(),
212
+ append: vi.fn(),
213
+ withFormat: vi.fn().mockReturnValue({
214
+ load,
215
+ append: vi.fn().mockResolvedValue(undefined),
216
+ }),
217
+ };
218
+ const runningRuntimeRef = {
219
+ current: {
220
+ thread: {
221
+ subscribe: () => () => {},
222
+ getState: () => ({ isRunning: true, messages: [{ id: "u1" }] }),
223
+ import: () => {},
224
+ export: () => ({ headId: null, messages: [] }),
225
+ },
226
+ } as unknown as AssistantRuntime,
227
+ };
228
+
229
+ renderHook(() =>
230
+ useExternalHistory(
231
+ runningRuntimeRef,
232
+ adapter,
233
+ toThreadMessages,
234
+ storageFormat,
235
+ onSetMessages,
236
+ ),
237
+ );
238
+
239
+ await act(async () => {});
240
+ expect(load).not.toHaveBeenCalled();
241
+
242
+ mocks.remoteId = "remote-thread";
243
+ await act(async () => {
244
+ for (const listener of mocks.listeners) listener();
245
+ });
246
+
247
+ await act(async () => {});
248
+ expect(load).not.toHaveBeenCalled();
249
+ });
250
+
251
+ it("reports loading before an asynchronous history load settles", async () => {
252
+ mocks.hasThreadListItem = true;
253
+ mocks.remoteId = "remote-thread";
254
+ let resolveLoad!: (repo: MessageFormatRepository<unknown>) => void;
255
+ const load = vi.fn(
256
+ () =>
257
+ new Promise<MessageFormatRepository<unknown>>((resolve) => {
258
+ resolveLoad = resolve;
259
+ }),
260
+ );
261
+ const adapter: ThreadHistoryAdapter = {
262
+ load: vi.fn(),
263
+ append: vi.fn(),
264
+ withFormat: vi.fn().mockReturnValue({
265
+ load,
266
+ append: vi.fn().mockResolvedValue(undefined),
267
+ }),
268
+ };
269
+
270
+ const { result } = renderHook(() =>
271
+ useExternalHistory(
272
+ runtimeRef,
273
+ adapter,
274
+ toThreadMessages,
275
+ storageFormat,
276
+ onSetMessages,
277
+ ),
278
+ );
279
+
280
+ expect(result.current.isLoading).toBe(true);
281
+ await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
282
+
283
+ await act(async () => {
284
+ resolveLoad({ headId: null, messages: [] });
285
+ });
286
+
287
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
288
+ });
289
+ });
290
+
291
+ describe("toExportedMessageRepository", () => {
292
+ const convert = (items: { id: string; ok: boolean }[]): ThreadMessage[] =>
293
+ items[0]!.ok ? [{ id: items[0]!.id } as ThreadMessage] : [];
294
+
295
+ it("drops a malformed row together with its now-orphaned descendants", () => {
296
+ const repo: MessageFormatRepository<{ id: string; ok: boolean }> = {
297
+ headId: "c",
298
+ messages: [
299
+ { parentId: null, message: { id: "a", ok: true } },
300
+ { parentId: "a", message: { id: "b", ok: false } },
301
+ { parentId: "b", message: { id: "c", ok: true } },
302
+ ],
303
+ };
304
+
305
+ const result = toExportedMessageRepository(convert, repo);
306
+
307
+ expect(result.messages.map((m) => m.message.id)).toEqual(["a"]);
308
+ expect(result.headId).toBeNull();
309
+ expect(() => new MessageRepository().import(result)).not.toThrow();
310
+ });
311
+
312
+ it("drops a headId that points at a filtered row", () => {
313
+ const repo: MessageFormatRepository<{ id: string; ok: boolean }> = {
314
+ headId: "b",
315
+ messages: [
316
+ { parentId: null, message: { id: "a", ok: true } },
317
+ { parentId: "a", message: { id: "b", ok: false } },
318
+ ],
319
+ };
320
+
321
+ const result = toExportedMessageRepository(convert, repo);
322
+
323
+ expect(result.messages.map((m) => m.message.id)).toEqual(["a"]);
324
+ expect(result.headId).toBeNull();
325
+ expect(() => new MessageRepository().import(result)).not.toThrow();
326
+ });
327
+
328
+ it("drops a malformed root and its entire subtree", () => {
329
+ const repo: MessageFormatRepository<{ id: string; ok: boolean }> = {
330
+ headId: "b",
331
+ messages: [
332
+ { parentId: null, message: { id: "a", ok: false } },
333
+ { parentId: "a", message: { id: "b", ok: true } },
334
+ ],
335
+ };
336
+
337
+ const result = toExportedMessageRepository(convert, repo);
338
+
339
+ expect(result.messages).toHaveLength(0);
340
+ expect(result.headId).toBeNull();
341
+ expect(() => new MessageRepository().import(result)).not.toThrow();
342
+ });
343
+ });
344
+
345
+ describe("useExternalHistory persistence", () => {
346
+ beforeEach(() => {
347
+ mocks.remoteId = undefined;
348
+ mocks.hasThreadListItem = false;
349
+ mocks.listeners.clear();
350
+ });
351
+
352
+ type InnerMessage = { id: string; parts: string[] };
353
+
354
+ const persistenceStorageFormat: MessageFormatAdapter<
355
+ InnerMessage,
356
+ Record<string, unknown>
357
+ > = {
358
+ format: "test",
359
+ encode: (item) => ({ data: item.message }),
360
+ decode: (stored) => ({
361
+ parentId: stored.parent_id,
362
+ message: stored.content as unknown as InnerMessage,
363
+ }),
364
+ getId: (message) => message.id,
365
+ };
366
+
367
+ const createAssistantMessage = (
368
+ status: ThreadAssistantMessage["status"],
369
+ innerMessages: InnerMessage[],
370
+ id = "assistant-a",
371
+ ): ThreadMessage => {
372
+ const message: ThreadAssistantMessage = {
373
+ id,
374
+ role: "assistant",
375
+ content: [],
376
+ createdAt: new Date(),
377
+ status,
378
+ metadata: {
379
+ unstable_state: null,
380
+ unstable_annotations: [],
381
+ unstable_data: [],
382
+ steps: [],
383
+ custom: {},
384
+ },
385
+ };
386
+ bindExternalStoreMessage(message, innerMessages);
387
+ return message;
388
+ };
389
+
390
+ const createPersistenceHarness = (
391
+ supportsUpdate: boolean,
392
+ options?: {
393
+ loadMessages?: MessageFormatRepository<InnerMessage>;
394
+ toThreadMessages?: (messages: InnerMessage[]) => ThreadMessage[];
395
+ },
396
+ ) => {
397
+ const append = vi.fn(
398
+ async (_item: { parentId: string | null; message: InnerMessage }) => {},
399
+ );
400
+ const update = vi.fn(
401
+ async (
402
+ _item: { parentId: string | null; message: InnerMessage },
403
+ _localMessageId: string,
404
+ ) => {},
405
+ );
406
+ const deleteItems = vi.fn(
407
+ async (
408
+ _items: { parentId: string | null; message: InnerMessage }[],
409
+ ) => {},
410
+ );
411
+ const reportTelemetry = vi.fn();
412
+ const load = vi
413
+ .fn()
414
+ .mockResolvedValue(options?.loadMessages ?? { messages: [] });
415
+ const formattedAdapter = {
416
+ load,
417
+ append,
418
+ delete: deleteItems,
419
+ reportTelemetry,
420
+ ...(supportsUpdate ? { update } : {}),
421
+ };
422
+ const historyAdapter: ThreadHistoryAdapter = {
423
+ load: vi.fn(),
424
+ append: vi.fn(),
425
+ withFormat: vi.fn().mockReturnValue(formattedAdapter),
426
+ };
427
+
428
+ let listener: (() => void) | undefined;
429
+ let isRunning = false;
430
+ let messages: ThreadMessage[] = [];
431
+ const getState = vi.fn(() => ({ isRunning, messages }));
432
+ const thread = {
433
+ subscribe: (nextListener: () => void) => {
434
+ listener = nextListener;
435
+ return () => {};
436
+ },
437
+ getState,
438
+ import: vi.fn(),
439
+ export: vi.fn(() => ({ headId: null, messages: [] })),
440
+ } as unknown as AssistantRuntime["thread"];
441
+ const persistenceRuntimeRef = {
442
+ current: { thread } as AssistantRuntime,
443
+ };
444
+
445
+ mocks.hasThreadListItem = true;
446
+ mocks.remoteId = "remote-thread";
447
+
448
+ const { result, unmount } = renderHook(() =>
449
+ useExternalHistory(
450
+ persistenceRuntimeRef,
451
+ historyAdapter,
452
+ options?.toThreadMessages ?? (() => []),
453
+ persistenceStorageFormat,
454
+ () => {},
455
+ ),
456
+ );
457
+
458
+ const runCycle = async (nextMessages: ThreadMessage[]) => {
459
+ await act(async () => {
460
+ messages = nextMessages;
461
+ isRunning = true;
462
+ listener?.();
463
+ });
464
+ await act(async () => {
465
+ isRunning = false;
466
+ listener?.();
467
+ });
468
+ };
469
+
470
+ const flush = () =>
471
+ act(async () => {
472
+ await new Promise((resolve) => setTimeout(resolve, 0));
473
+ });
474
+
475
+ const step = (partial: {
476
+ isRunning?: boolean;
477
+ messages?: ThreadMessage[];
478
+ }) =>
479
+ act(async () => {
480
+ if (partial.messages !== undefined) messages = partial.messages;
481
+ if (partial.isRunning !== undefined) isRunning = partial.isRunning;
482
+ listener?.();
483
+ });
484
+
485
+ return {
486
+ append,
487
+ update,
488
+ deleteItems,
489
+ formattedAdapter,
490
+ deleteMessage: result.current.deleteMessage,
491
+ reportTelemetry,
492
+ load,
493
+ runCycle,
494
+ flush,
495
+ step,
496
+ unmount,
497
+ };
498
+ };
499
+
500
+ it("retries a failed append on the next persistence pass", async () => {
501
+ const consoleError = vi
502
+ .spyOn(console, "error")
503
+ .mockImplementation(() => {});
504
+ onTestFinished(() => consoleError.mockRestore());
505
+ const { append, runCycle, flush } = createPersistenceHarness(false);
506
+ const innerMessage = { id: "inner-a", parts: ["final"] };
507
+ const message = createAssistantMessage(
508
+ { type: "complete", reason: "stop" },
509
+ [innerMessage],
510
+ );
511
+ append.mockRejectedValueOnce(new Error("temporary storage failure"));
512
+
513
+ await runCycle([message]);
514
+ await flush();
515
+ expect(append).toHaveBeenCalledTimes(1);
516
+
517
+ await runCycle([message]);
518
+ await flush();
519
+ expect(append).toHaveBeenCalledTimes(2);
520
+ expect(append).toHaveBeenLastCalledWith({
521
+ parentId: null,
522
+ message: innerMessage,
523
+ });
524
+
525
+ await runCycle([message]);
526
+ await flush();
527
+ expect(append).toHaveBeenCalledTimes(2);
528
+ });
529
+
530
+ it("does not replay appends that succeeded before a mid-batch failure", async () => {
531
+ const consoleError = vi
532
+ .spyOn(console, "error")
533
+ .mockImplementation(() => {});
534
+ onTestFinished(() => consoleError.mockRestore());
535
+ const { append, runCycle, flush } = createPersistenceHarness(false);
536
+ const first = { id: "inner-1", parts: ["first"] };
537
+ const second = { id: "inner-2", parts: ["second"] };
538
+ const message = createAssistantMessage(
539
+ { type: "complete", reason: "stop" },
540
+ [first, second],
541
+ );
542
+ append.mockImplementationOnce(async () => {});
543
+ append.mockRejectedValueOnce(new Error("temporary storage failure"));
544
+
545
+ await runCycle([message]);
546
+ await flush();
547
+ expect(append).toHaveBeenCalledTimes(2);
548
+
549
+ await runCycle([message]);
550
+ await flush();
551
+ expect(append).toHaveBeenCalledTimes(3);
552
+ expect(append.mock.calls.map((c) => c[0].message.id)).toEqual([
553
+ "inner-1",
554
+ "inner-2",
555
+ "inner-2",
556
+ ]);
557
+ });
558
+
559
+ it("persists assistant messages awaiting tool approval when the adapter supports update", async () => {
560
+ const { append, runCycle } = createPersistenceHarness(true);
561
+ const innerMessage = { id: "inner-a", parts: ["pending"] };
562
+
563
+ await runCycle([
564
+ createAssistantMessage(
565
+ { type: "requires-action", reason: "tool-calls" },
566
+ [innerMessage],
567
+ ),
568
+ ]);
569
+
570
+ await waitFor(() =>
571
+ expect(append).toHaveBeenCalledWith({
572
+ parentId: null,
573
+ message: innerMessage,
574
+ }),
575
+ );
576
+ expect(append).toHaveBeenCalledTimes(1);
577
+ });
578
+
579
+ it("keeps paused messages unpersisted when the adapter lacks update", async () => {
580
+ const { append, runCycle, flush } = createPersistenceHarness(false);
581
+ const finalInnerMessage = { id: "inner-a", parts: ["pending", "final"] };
582
+
583
+ await runCycle([
584
+ createAssistantMessage(
585
+ { type: "requires-action", reason: "tool-calls" },
586
+ [{ id: "inner-a", parts: ["pending"] }],
587
+ ),
588
+ ]);
589
+
590
+ await flush();
591
+ expect(append).not.toHaveBeenCalled();
592
+
593
+ await runCycle([
594
+ createAssistantMessage({ type: "complete", reason: "stop" }, [
595
+ finalInnerMessage,
596
+ ]),
597
+ ]);
598
+
599
+ await waitFor(() =>
600
+ expect(append).toHaveBeenCalledWith({
601
+ parentId: null,
602
+ message: finalInnerMessage,
603
+ }),
604
+ );
605
+ expect(append).toHaveBeenCalledTimes(1);
606
+ });
607
+
608
+ it("updates the previously persisted message when its run completes", async () => {
609
+ const { append, update, runCycle } = createPersistenceHarness(true);
610
+ const pendingInnerMessage = { id: "inner-a", parts: ["pending"] };
611
+ const finalInnerMessage = { id: "inner-a", parts: ["pending", "final"] };
612
+
613
+ await runCycle([
614
+ createAssistantMessage(
615
+ { type: "requires-action", reason: "tool-calls" },
616
+ [pendingInnerMessage],
617
+ ),
618
+ ]);
619
+ await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
620
+
621
+ await runCycle([
622
+ createAssistantMessage({ type: "complete", reason: "stop" }, [
623
+ finalInnerMessage,
624
+ ]),
625
+ ]);
626
+
627
+ await waitFor(() =>
628
+ expect(update).toHaveBeenCalledWith(
629
+ { parentId: null, message: finalInnerMessage },
630
+ "inner-a",
631
+ ),
632
+ );
633
+ expect(append).toHaveBeenCalledTimes(1);
634
+ });
635
+
636
+ it("appends continuation inner messages after approval", async () => {
637
+ const { append, update, runCycle } = createPersistenceHarness(true);
638
+ const pendingInnerMessage = { id: "inner-a", parts: ["pending"] };
639
+ const finalInnerMessage = { id: "inner-a", parts: ["pending", "final"] };
640
+ const continuationInnerMessage = { id: "inner-b", parts: ["answer"] };
641
+
642
+ await runCycle([
643
+ createAssistantMessage(
644
+ { type: "requires-action", reason: "tool-calls" },
645
+ [pendingInnerMessage],
646
+ ),
647
+ ]);
648
+ await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
649
+
650
+ await runCycle([
651
+ createAssistantMessage({ type: "complete", reason: "stop" }, [
652
+ finalInnerMessage,
653
+ continuationInnerMessage,
654
+ ]),
655
+ ]);
656
+
657
+ await waitFor(() =>
658
+ expect(append).toHaveBeenCalledWith({
659
+ parentId: "inner-a",
660
+ message: continuationInnerMessage,
661
+ }),
662
+ );
663
+ expect(append.mock.calls.map(([item]) => item.message.id)).toEqual([
664
+ "inner-a",
665
+ "inner-b",
666
+ ]);
667
+ expect(update).toHaveBeenCalledTimes(1);
668
+ expect(update).toHaveBeenCalledWith(
669
+ { parentId: null, message: finalInnerMessage },
670
+ "inner-a",
671
+ );
672
+ });
673
+
674
+ it("defers run telemetry until the paused message completes", async () => {
675
+ const { reportTelemetry, runCycle, flush } = createPersistenceHarness(true);
676
+ const pendingInnerMessage = { id: "inner-a", parts: ["pending"] };
677
+ const finalInnerMessage = { id: "inner-a", parts: ["pending", "final"] };
678
+ const continuationInnerMessage = { id: "inner-b", parts: ["answer"] };
679
+
680
+ await runCycle([
681
+ createAssistantMessage(
682
+ { type: "requires-action", reason: "tool-calls" },
683
+ [pendingInnerMessage],
684
+ ),
685
+ ]);
686
+ await flush();
687
+ expect(reportTelemetry).not.toHaveBeenCalled();
688
+
689
+ await runCycle([
690
+ createAssistantMessage({ type: "complete", reason: "stop" }, [
691
+ finalInnerMessage,
692
+ continuationInnerMessage,
693
+ ]),
694
+ ]);
695
+ await flush();
696
+
697
+ expect(reportTelemetry).toHaveBeenCalledTimes(1);
698
+ expect(reportTelemetry).toHaveBeenCalledWith(
699
+ [
700
+ { parentId: null, message: finalInnerMessage },
701
+ { parentId: "inner-a", message: continuationInnerMessage },
702
+ ],
703
+ expect.any(Object),
704
+ );
705
+ });
706
+
707
+ it("restores deferred telemetry for reloaded paused messages", async () => {
708
+ const { append, update, reportTelemetry, load, runCycle, flush } =
709
+ createPersistenceHarness(true, {
710
+ loadMessages: {
711
+ messages: [
712
+ {
713
+ parentId: null,
714
+ message: { id: "inner-a", parts: ["pending"] },
715
+ },
716
+ ],
717
+ },
718
+ toThreadMessages: (msgs) => [
719
+ createAssistantMessage(
720
+ { type: "requires-action", reason: "tool-calls" },
721
+ msgs,
722
+ ),
723
+ ],
724
+ });
725
+
726
+ await waitFor(() => expect(load).toHaveBeenCalled());
727
+ await flush();
728
+
729
+ const finalInnerMessage = { id: "inner-a", parts: ["pending", "final"] };
730
+ const continuationInnerMessage = { id: "inner-b", parts: ["answer"] };
731
+
732
+ await runCycle([
733
+ createAssistantMessage({ type: "complete", reason: "stop" }, [
734
+ finalInnerMessage,
735
+ continuationInnerMessage,
736
+ ]),
737
+ ]);
738
+ await flush();
739
+
740
+ expect(append).toHaveBeenCalledTimes(1);
741
+ expect(append).toHaveBeenCalledWith({
742
+ parentId: "inner-a",
743
+ message: continuationInnerMessage,
744
+ });
745
+ expect(update).toHaveBeenCalledWith(
746
+ { parentId: null, message: finalInnerMessage },
747
+ "inner-a",
748
+ );
749
+ expect(reportTelemetry).toHaveBeenCalledTimes(1);
750
+ expect(reportTelemetry).toHaveBeenCalledWith(
751
+ [
752
+ { parentId: null, message: finalInnerMessage },
753
+ { parentId: "inner-a", message: continuationInnerMessage },
754
+ ],
755
+ expect.any(Object),
756
+ );
757
+ });
758
+
759
+ it("serializes overlapping persistence passes", async () => {
760
+ const { append, runCycle, flush } = createPersistenceHarness(true);
761
+ let releaseFirstAppend!: () => void;
762
+ const firstAppend = new Promise<void>((resolve) => {
763
+ releaseFirstAppend = resolve;
764
+ });
765
+ append.mockImplementationOnce(() => firstAppend);
766
+
767
+ const message = createAssistantMessage(
768
+ { type: "complete", reason: "stop" },
769
+ [{ id: "inner-a", parts: ["answer"] }],
770
+ );
771
+
772
+ await runCycle([message]);
773
+ await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
774
+
775
+ await runCycle([message]);
776
+ await flush();
777
+ const callsWhileFirstAppendWasPending = append.mock.calls.length;
778
+
779
+ releaseFirstAppend();
780
+ await flush();
781
+ await flush();
782
+
783
+ expect(callsWhileFirstAppendWasPending).toBe(1);
784
+ expect(append).toHaveBeenCalledTimes(1);
785
+ });
786
+
787
+ it("preserves telemetry timing for runs queued behind a pending append", async () => {
788
+ const { append, reportTelemetry, step, flush } =
789
+ createPersistenceHarness(false);
790
+ let releaseFirstAppend!: () => void;
791
+ const firstAppend = new Promise<void>((resolve) => {
792
+ releaseFirstAppend = resolve;
793
+ });
794
+ append.mockImplementationOnce(() => firstAppend);
795
+
796
+ let now = 0;
797
+ const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now);
798
+ const firstMessage = createAssistantMessage(
799
+ { type: "complete", reason: "stop" },
800
+ [{ id: "inner-a", parts: ["first"] }],
801
+ "assistant-a",
802
+ );
803
+ const secondMessage = createAssistantMessage(
804
+ { type: "complete", reason: "stop" },
805
+ [{ id: "inner-b", parts: ["second"] }],
806
+ "assistant-b",
807
+ );
808
+ const thirdMessage = createAssistantMessage(
809
+ { type: "complete", reason: "stop" },
810
+ [{ id: "inner-c", parts: ["third"] }],
811
+ "assistant-c",
812
+ );
813
+
814
+ try {
815
+ await step({ isRunning: true, messages: [firstMessage] });
816
+ now = 10;
817
+ await step({ isRunning: false });
818
+ await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
819
+
820
+ now = 100;
821
+ await step({
822
+ isRunning: true,
823
+ messages: [firstMessage, secondMessage],
824
+ });
825
+ now = 110;
826
+ await step({ isRunning: false });
827
+ await flush();
828
+
829
+ now = 200;
830
+ await step({
831
+ isRunning: true,
832
+ messages: [firstMessage, secondMessage, thirdMessage],
833
+ });
834
+ now = 230;
835
+ await step({ isRunning: false });
836
+ await flush();
837
+
838
+ releaseFirstAppend();
839
+ await waitFor(() => expect(reportTelemetry).toHaveBeenCalledTimes(3));
840
+
841
+ const durations = Object.fromEntries(
842
+ reportTelemetry.mock.calls.map(([items, options]) => [
843
+ items[0]?.message.id,
844
+ options.durationMs,
845
+ ]),
846
+ );
847
+ expect(durations).toEqual({
848
+ "inner-a": 10,
849
+ "inner-b": 10,
850
+ "inner-c": 30,
851
+ });
852
+ } finally {
853
+ releaseFirstAppend();
854
+ nowSpy.mockRestore();
855
+ }
856
+ });
857
+
858
+ it("finishes queued persistence passes after unmount", async () => {
859
+ const { append, runCycle, flush, unmount } =
860
+ createPersistenceHarness(false);
861
+ let releaseFirstAppend!: () => void;
862
+ const firstAppend = new Promise<void>((resolve) => {
863
+ releaseFirstAppend = resolve;
864
+ });
865
+ append.mockImplementationOnce(() => firstAppend);
866
+
867
+ const firstMessage = createAssistantMessage(
868
+ { type: "complete", reason: "stop" },
869
+ [{ id: "inner-a", parts: ["first"] }],
870
+ "assistant-a",
871
+ );
872
+ const secondMessage = createAssistantMessage(
873
+ { type: "complete", reason: "stop" },
874
+ [{ id: "inner-b", parts: ["second"] }],
875
+ "assistant-b",
876
+ );
877
+
878
+ await runCycle([firstMessage]);
879
+ await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
880
+
881
+ await runCycle([firstMessage, secondMessage]);
882
+ await flush();
883
+ unmount();
884
+
885
+ releaseFirstAppend();
886
+ await flush();
887
+ await flush();
888
+
889
+ expect(append.mock.calls.map(([item]) => item.message.id)).toEqual([
890
+ "inner-a",
891
+ "inner-b",
892
+ ]);
893
+ });
894
+
895
+ it("serializes deletion after pending persistence passes", async () => {
896
+ const { append, deleteItems, deleteMessage, runCycle, flush } =
897
+ createPersistenceHarness(false);
898
+ const events: string[] = [];
899
+ let releaseFirstAppend!: () => void;
900
+ const firstAppend = new Promise<void>((resolve) => {
901
+ releaseFirstAppend = resolve;
902
+ });
903
+ append.mockImplementation(async (item) => {
904
+ events.push(`append:${item.message.id}`);
905
+ });
906
+ append.mockImplementationOnce(async (item) => {
907
+ events.push(`append:${item.message.id}:start`);
908
+ await firstAppend;
909
+ events.push(`append:${item.message.id}:end`);
910
+ });
911
+ deleteItems.mockImplementation(async (items) => {
912
+ events.push(`delete:${items[0]?.message.id}`);
913
+ });
914
+
915
+ const firstMessage = createAssistantMessage(
916
+ { type: "complete", reason: "stop" },
917
+ [{ id: "inner-a", parts: ["first"] }],
918
+ "assistant-a",
919
+ );
920
+ const secondMessage = createAssistantMessage(
921
+ { type: "complete", reason: "stop" },
922
+ [{ id: "inner-b", parts: ["second"] }],
923
+ "assistant-b",
924
+ );
925
+
926
+ try {
927
+ await runCycle([firstMessage]);
928
+ await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
929
+
930
+ await runCycle([firstMessage, secondMessage]);
931
+ await flush();
932
+ const deletion = deleteMessage("assistant-a");
933
+ await flush();
934
+ expect(deleteItems).not.toHaveBeenCalled();
935
+
936
+ releaseFirstAppend();
937
+ await deletion;
938
+
939
+ expect(events).toEqual([
940
+ "append:inner-a:start",
941
+ "append:inner-a:end",
942
+ "append:inner-b",
943
+ "delete:inner-a",
944
+ ]);
945
+ } finally {
946
+ releaseFirstAppend();
947
+ }
948
+ });
949
+
950
+ it("uses the requested deletion snapshot after runtime state changes", async () => {
951
+ const { append, deleteItems, deleteMessage, runCycle, flush, step } =
952
+ createPersistenceHarness(false);
953
+ let releaseAppend!: () => void;
954
+ const pendingAppend = new Promise<void>((resolve) => {
955
+ releaseAppend = resolve;
956
+ });
957
+ append.mockImplementationOnce(() => pendingAppend);
958
+
959
+ const message = createAssistantMessage(
960
+ { type: "complete", reason: "stop" },
961
+ [{ id: "inner-a", parts: ["first"] }],
962
+ "assistant-a",
963
+ );
964
+
965
+ try {
966
+ await runCycle([message]);
967
+ await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
968
+
969
+ const deletion = deleteMessage("assistant-a");
970
+ await step({ messages: [] });
971
+ await flush();
972
+ expect(deleteItems).not.toHaveBeenCalled();
973
+
974
+ releaseAppend();
975
+ await deletion;
976
+
977
+ expect(deleteItems).toHaveBeenCalledWith([
978
+ {
979
+ parentId: null,
980
+ message: { id: "inner-a", parts: ["first"] },
981
+ },
982
+ ]);
983
+ } finally {
984
+ releaseAppend();
985
+ }
986
+ });
987
+
988
+ it("preserves the history adapter receiver during deletion", async () => {
989
+ const { deleteItems, formattedAdapter, deleteMessage, step } =
990
+ createPersistenceHarness(false);
991
+ const message = createAssistantMessage(
992
+ { type: "complete", reason: "stop" },
993
+ [{ id: "inner-a", parts: ["first"] }],
994
+ "assistant-a",
995
+ );
996
+
997
+ await step({ messages: [message] });
998
+ await deleteMessage("assistant-a");
999
+
1000
+ expect(deleteItems).toHaveBeenCalledOnce();
1001
+ expect(deleteItems.mock.contexts[0]).toBe(formattedAdapter);
1002
+ });
1003
+
1004
+ it("reports telemetry after retrying a failed append", async () => {
1005
+ const { append, reportTelemetry, runCycle, flush } =
1006
+ createPersistenceHarness(false);
1007
+ append.mockRejectedValueOnce(new Error("temporary storage failure"));
1008
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
1009
+ const message = createAssistantMessage(
1010
+ { type: "complete", reason: "stop" },
1011
+ [{ id: "inner-a", parts: ["answer"] }],
1012
+ );
1013
+
1014
+ try {
1015
+ await runCycle([message]);
1016
+ await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
1017
+ await flush();
1018
+ expect(reportTelemetry).not.toHaveBeenCalled();
1019
+
1020
+ await runCycle([message]);
1021
+ await waitFor(() => expect(append).toHaveBeenCalledTimes(2));
1022
+ await waitFor(() => expect(reportTelemetry).toHaveBeenCalledTimes(1));
1023
+
1024
+ expect(reportTelemetry).toHaveBeenCalledWith(
1025
+ [{ parentId: null, message: { id: "inner-a", parts: ["answer"] } }],
1026
+ expect.any(Object),
1027
+ );
1028
+ } finally {
1029
+ errorSpy.mockRestore();
1030
+ }
1031
+ });
1032
+
1033
+ it("does not reappend a stored inner message whose outer message was filtered", async () => {
1034
+ const innerMessage = { id: "inner-a", parts: ["stored"] };
1035
+ const { append, load, runCycle, flush } = createPersistenceHarness(false, {
1036
+ loadMessages: {
1037
+ messages: [{ parentId: null, message: innerMessage }],
1038
+ },
1039
+ toThreadMessages: () => [],
1040
+ });
1041
+
1042
+ await waitFor(() => expect(load).toHaveBeenCalled());
1043
+ await flush();
1044
+
1045
+ await runCycle([
1046
+ createAssistantMessage({ type: "complete", reason: "stop" }, [
1047
+ innerMessage,
1048
+ ]),
1049
+ ]);
1050
+ await flush();
1051
+
1052
+ expect(append).not.toHaveBeenCalled();
1053
+ });
1054
+
1055
+ it("skips unchanged persisted messages on later runs", async () => {
1056
+ const { append, update, reportTelemetry, runCycle, flush } =
1057
+ createPersistenceHarness(true);
1058
+ const completeStatus: ThreadAssistantMessage["status"] = {
1059
+ type: "complete",
1060
+ reason: "stop",
1061
+ };
1062
+ const old = createAssistantMessage(
1063
+ completeStatus,
1064
+ [{ id: "inner-old", parts: ["old"] }],
1065
+ "old",
1066
+ );
1067
+ const active = createAssistantMessage(
1068
+ completeStatus,
1069
+ [{ id: "inner-active", parts: ["initial"] }],
1070
+ "active",
1071
+ );
1072
+ const tail = createAssistantMessage(
1073
+ completeStatus,
1074
+ [{ id: "inner-tail", parts: ["tail"] }],
1075
+ "tail",
1076
+ );
1077
+
1078
+ await runCycle([old, active, tail]);
1079
+ await waitFor(() => expect(append).toHaveBeenCalledTimes(3));
1080
+
1081
+ append.mockClear();
1082
+ update.mockClear();
1083
+ reportTelemetry.mockClear();
1084
+
1085
+ const changedActive = createAssistantMessage(
1086
+ completeStatus,
1087
+ [{ id: "inner-active", parts: ["completed"] }],
1088
+ "active",
1089
+ );
1090
+ await runCycle([old, changedActive, tail]);
1091
+ await flush();
1092
+
1093
+ expect(update).toHaveBeenCalledTimes(1);
1094
+ expect(update).toHaveBeenCalledWith(
1095
+ {
1096
+ parentId: "inner-old",
1097
+ message: { id: "inner-active", parts: ["completed"] },
1098
+ },
1099
+ "inner-active",
1100
+ );
1101
+ expect(append).not.toHaveBeenCalled();
1102
+ expect(reportTelemetry).not.toHaveBeenCalled();
1103
+ });
1104
+
1105
+ it("absorbs agentic flickers without losing change detection", async () => {
1106
+ const { append, update, reportTelemetry, runCycle, flush, step } =
1107
+ createPersistenceHarness(true);
1108
+ const completeStatus: ThreadAssistantMessage["status"] = {
1109
+ type: "complete",
1110
+ reason: "stop",
1111
+ };
1112
+ const old = createAssistantMessage(
1113
+ completeStatus,
1114
+ [{ id: "inner-old", parts: ["old"] }],
1115
+ "old",
1116
+ );
1117
+ const active = createAssistantMessage(
1118
+ completeStatus,
1119
+ [{ id: "inner-active", parts: ["initial"] }],
1120
+ "active",
1121
+ );
1122
+ const tail = createAssistantMessage(
1123
+ completeStatus,
1124
+ [{ id: "inner-tail", parts: ["tail"] }],
1125
+ "tail",
1126
+ );
1127
+
1128
+ await runCycle([old, active, tail]);
1129
+ await waitFor(() => expect(append).toHaveBeenCalledTimes(3));
1130
+
1131
+ append.mockClear();
1132
+ update.mockClear();
1133
+ reportTelemetry.mockClear();
1134
+
1135
+ const changedActive = createAssistantMessage(
1136
+ completeStatus,
1137
+ [{ id: "inner-active", parts: ["completed"] }],
1138
+ "active",
1139
+ );
1140
+ await step({ isRunning: true });
1141
+ await step({ messages: [old, changedActive, tail] });
1142
+ await step({ isRunning: false });
1143
+ await step({ isRunning: true });
1144
+ await step({ isRunning: false });
1145
+ await flush();
1146
+
1147
+ await waitFor(() => expect(update).toHaveBeenCalledTimes(1));
1148
+ expect(update).toHaveBeenCalledWith(
1149
+ {
1150
+ parentId: "inner-old",
1151
+ message: { id: "inner-active", parts: ["completed"] },
1152
+ },
1153
+ "inner-active",
1154
+ );
1155
+ expect(append).not.toHaveBeenCalled();
1156
+ expect(reportTelemetry).not.toHaveBeenCalled();
1157
+ });
1158
+
1159
+ it("persists idle-time changes on the next run stop", async () => {
1160
+ const { append, update, reportTelemetry, runCycle, flush, step } =
1161
+ createPersistenceHarness(true);
1162
+ const initialActive = createAssistantMessage(
1163
+ { type: "complete", reason: "stop" },
1164
+ [{ id: "inner-a", parts: ["initial"] }],
1165
+ "active",
1166
+ );
1167
+
1168
+ await runCycle([initialActive]);
1169
+ await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
1170
+
1171
+ append.mockClear();
1172
+ update.mockClear();
1173
+ reportTelemetry.mockClear();
1174
+
1175
+ const changedActive = createAssistantMessage(
1176
+ { type: "complete", reason: "stop" },
1177
+ [{ id: "inner-a", parts: ["initial", "tool-result"] }],
1178
+ "active",
1179
+ );
1180
+ await step({ messages: [changedActive] });
1181
+
1182
+ await runCycle([changedActive]);
1183
+ await flush();
1184
+
1185
+ expect(update).toHaveBeenCalledTimes(1);
1186
+ expect(update).toHaveBeenCalledWith(
1187
+ {
1188
+ parentId: null,
1189
+ message: { id: "inner-a", parts: ["initial", "tool-result"] },
1190
+ },
1191
+ "inner-a",
1192
+ );
1193
+ expect(append).not.toHaveBeenCalled();
1194
+ });
1195
+
1196
+ it("retries a failed update on the next run", async () => {
1197
+ const { append, update, reportTelemetry, runCycle, flush } =
1198
+ createPersistenceHarness(true);
1199
+ const initialActive = createAssistantMessage(
1200
+ { type: "complete", reason: "stop" },
1201
+ [{ id: "inner-a", parts: ["initial"] }],
1202
+ "active",
1203
+ );
1204
+
1205
+ await runCycle([initialActive]);
1206
+ await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
1207
+
1208
+ append.mockClear();
1209
+ update.mockClear();
1210
+ reportTelemetry.mockClear();
1211
+ update.mockRejectedValueOnce(new Error("boom"));
1212
+
1213
+ const changedActive = createAssistantMessage(
1214
+ { type: "complete", reason: "stop" },
1215
+ [{ id: "inner-a", parts: ["changed"] }],
1216
+ "active",
1217
+ );
1218
+ const changedMessages = [changedActive];
1219
+
1220
+ await runCycle(changedMessages);
1221
+ await flush();
1222
+ await runCycle(changedMessages);
1223
+ await flush();
1224
+
1225
+ expect(update).toHaveBeenCalledTimes(2);
1226
+ expect(update).toHaveBeenNthCalledWith(
1227
+ 1,
1228
+ {
1229
+ parentId: null,
1230
+ message: { id: "inner-a", parts: ["changed"] },
1231
+ },
1232
+ "inner-a",
1233
+ );
1234
+ expect(update).toHaveBeenNthCalledWith(
1235
+ 2,
1236
+ {
1237
+ parentId: null,
1238
+ message: { id: "inner-a", parts: ["changed"] },
1239
+ },
1240
+ "inner-a",
1241
+ );
1242
+ expect(append).not.toHaveBeenCalled();
1243
+ });
1244
+
1245
+ it("detects changes on non-assistant messages", async () => {
1246
+ const { append, update, runCycle, flush } = createPersistenceHarness(true);
1247
+ const makeUserMessage = (inner: InnerMessage): ThreadMessage => {
1248
+ const message: ThreadMessage = {
1249
+ id: "user-1",
1250
+ role: "user",
1251
+ content: [{ type: "text", text: "hi" }],
1252
+ attachments: [],
1253
+ createdAt: new Date(),
1254
+ metadata: { custom: {} },
1255
+ };
1256
+ bindExternalStoreMessage(message, [inner]);
1257
+ return message;
1258
+ };
1259
+ const assistant = createAssistantMessage(
1260
+ { type: "complete", reason: "stop" },
1261
+ [{ id: "inner-a", parts: ["answer"] }],
1262
+ );
1263
+
1264
+ await runCycle([
1265
+ makeUserMessage({ id: "inner-user", parts: ["hi"] }),
1266
+ assistant,
1267
+ ]);
1268
+ await waitFor(() => expect(append).toHaveBeenCalledTimes(2));
1269
+
1270
+ append.mockClear();
1271
+ update.mockClear();
1272
+
1273
+ await runCycle([
1274
+ makeUserMessage({ id: "inner-user", parts: ["hi", "attachment"] }),
1275
+ assistant,
1276
+ ]);
1277
+ await flush();
1278
+
1279
+ expect(update).toHaveBeenCalledTimes(1);
1280
+ expect(update).toHaveBeenCalledWith(
1281
+ {
1282
+ parentId: null,
1283
+ message: { id: "inner-user", parts: ["hi", "attachment"] },
1284
+ },
1285
+ "inner-user",
1286
+ );
1287
+ expect(append).not.toHaveBeenCalled();
1288
+ });
1289
+ });