@assistant-ui/core 0.2.6 → 0.2.8

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 (192) hide show
  1. package/dist/adapters/attachment.d.ts.map +1 -1
  2. package/dist/adapters/speech.d.ts.map +1 -1
  3. package/dist/adapters/speech.js.map +1 -1
  4. package/dist/index.d.ts +4 -1
  5. package/dist/index.js +8 -1
  6. package/dist/index.js.map +1 -0
  7. package/dist/internal/duplicate-detection.d.ts +5 -0
  8. package/dist/internal/duplicate-detection.d.ts.map +1 -0
  9. package/dist/internal/duplicate-detection.js +11 -0
  10. package/dist/internal/duplicate-detection.js.map +1 -0
  11. package/dist/internal.d.ts +2 -2
  12. package/dist/internal.js +2 -2
  13. package/dist/model-context/frame/host.d.ts.map +1 -1
  14. package/dist/model-context/frame/host.js.map +1 -1
  15. package/dist/model-context/frame/provider.d.ts.map +1 -1
  16. package/dist/model-context/frame/provider.js.map +1 -1
  17. package/dist/model-context/registry.d.ts.map +1 -1
  18. package/dist/model-context/tool.d.ts.map +1 -1
  19. package/dist/react/AssistantProvider.d.ts.map +1 -1
  20. package/dist/react/AssistantProvider.js.map +1 -1
  21. package/dist/react/client/Interactables.js.map +1 -1
  22. package/dist/react/client/Tools.d.ts.map +1 -1
  23. package/dist/react/client/Tools.js +26 -15
  24. package/dist/react/client/Tools.js.map +1 -1
  25. package/dist/react/index.d.ts +5 -4
  26. package/dist/react/index.js +2 -2
  27. package/dist/react/model-context/toolbox.d.ts +29 -2
  28. package/dist/react/model-context/toolbox.d.ts.map +1 -1
  29. package/dist/react/model-context/toolbox.js +18 -0
  30. package/dist/react/model-context/toolbox.js.map +1 -0
  31. package/dist/react/model-context/useAssistantTool.d.ts.map +1 -1
  32. package/dist/react/model-context/useAssistantTool.js +6 -3
  33. package/dist/react/model-context/useAssistantTool.js.map +1 -1
  34. package/dist/react/model-context/useAssistantToolUI.d.ts +6 -0
  35. package/dist/react/model-context/useAssistantToolUI.d.ts.map +1 -1
  36. package/dist/react/model-context/useAssistantToolUI.js +4 -2
  37. package/dist/react/model-context/useAssistantToolUI.js.map +1 -1
  38. package/dist/react/model-context/useInlineRender.js.map +1 -1
  39. package/dist/react/primitives/chainOfThought/ChainOfThoughtParts.js.map +1 -1
  40. package/dist/react/primitives/message/MessageGroupedParts.d.ts +49 -7
  41. package/dist/react/primitives/message/MessageGroupedParts.d.ts.map +1 -1
  42. package/dist/react/primitives/message/MessageGroupedParts.js +28 -3
  43. package/dist/react/primitives/message/MessageGroupedParts.js.map +1 -1
  44. package/dist/react/primitives/message/MessageParts.d.ts.map +1 -1
  45. package/dist/react/primitives/message/MessageParts.js +2 -7
  46. package/dist/react/primitives/message/MessageParts.js.map +1 -1
  47. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  48. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
  49. package/dist/react/runtimes/RuntimeAdapterProvider.d.ts.map +1 -1
  50. package/dist/react/runtimes/RuntimeAdapterProvider.js +6 -5
  51. package/dist/react/runtimes/RuntimeAdapterProvider.js.map +1 -1
  52. package/dist/react/runtimes/cloud/CloudFileAttachmentAdapter.d.ts.map +1 -1
  53. package/dist/react/runtimes/cloud/useCloudThreadListAdapter.d.ts.map +1 -1
  54. package/dist/react/runtimes/cloud/useCloudThreadListAdapter.js.map +1 -1
  55. package/dist/react/runtimes/external-message-converter.d.ts +1 -1
  56. package/dist/react/runtimes/external-message-converter.d.ts.map +1 -1
  57. package/dist/react/runtimes/external-message-converter.js +1 -0
  58. package/dist/react/runtimes/external-message-converter.js.map +1 -1
  59. package/dist/react/runtimes/useExternalStoreSharedOptions.d.ts +7 -0
  60. package/dist/react/runtimes/useExternalStoreSharedOptions.d.ts.map +1 -0
  61. package/dist/react/runtimes/useExternalStoreSharedOptions.js +21 -0
  62. package/dist/react/runtimes/useExternalStoreSharedOptions.js.map +1 -0
  63. package/dist/react/runtimes/useLocalRuntime.d.ts.map +1 -1
  64. package/dist/react/runtimes/useLocalRuntime.js.map +1 -1
  65. package/dist/react/runtimes/useRemoteThreadListRuntime.d.ts.map +1 -1
  66. package/dist/react/runtimes/useRemoteThreadListRuntime.js.map +1 -1
  67. package/dist/react/types/scopes/tools.d.ts +19 -2
  68. package/dist/react/types/scopes/tools.d.ts.map +1 -1
  69. package/dist/react/utils/groupParts.d.ts +32 -11
  70. package/dist/react/utils/groupParts.d.ts.map +1 -1
  71. package/dist/react/utils/groupParts.js +13 -6
  72. package/dist/react/utils/groupParts.js.map +1 -1
  73. package/dist/runtime/api/assistant-runtime.d.ts.map +1 -1
  74. package/dist/runtime/api/attachment-runtime.d.ts.map +1 -1
  75. package/dist/runtime/api/attachment-runtime.js.map +1 -1
  76. package/dist/runtime/api/composer-runtime.d.ts.map +1 -1
  77. package/dist/runtime/api/message-part-runtime.d.ts.map +1 -1
  78. package/dist/runtime/api/message-runtime.d.ts.map +1 -1
  79. package/dist/runtime/api/thread-list-item-runtime.d.ts.map +1 -1
  80. package/dist/runtime/api/thread-list-runtime.d.ts.map +1 -1
  81. package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
  82. package/dist/runtime/base/base-assistant-runtime-core.d.ts.map +1 -1
  83. package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
  84. package/dist/runtime/base/base-thread-runtime-core.d.ts.map +1 -1
  85. package/dist/runtime/base/default-edit-composer-runtime-core.d.ts.map +1 -1
  86. package/dist/runtime/base/default-thread-composer-runtime-core.d.ts.map +1 -1
  87. package/dist/runtime/interfaces/thread-runtime-core.d.ts +8 -0
  88. package/dist/runtime/interfaces/thread-runtime-core.d.ts.map +1 -1
  89. package/dist/runtime/utils/message-repository.d.ts +9 -1
  90. package/dist/runtime/utils/message-repository.d.ts.map +1 -1
  91. package/dist/runtime/utils/message-repository.js +34 -14
  92. package/dist/runtime/utils/message-repository.js.map +1 -1
  93. package/dist/runtime/utils/thread-message-like.d.ts +1 -0
  94. package/dist/runtime/utils/thread-message-like.d.ts.map +1 -1
  95. package/dist/runtime/utils/thread-message-like.js +2 -1
  96. package/dist/runtime/utils/thread-message-like.js.map +1 -1
  97. package/dist/runtimes/external-store/external-store-adapter.d.ts +31 -0
  98. package/dist/runtimes/external-store/external-store-adapter.d.ts.map +1 -1
  99. package/dist/runtimes/external-store/external-store-shared-options.d.ts +8 -0
  100. package/dist/runtimes/external-store/external-store-shared-options.d.ts.map +1 -0
  101. package/dist/runtimes/external-store/external-store-shared-options.js +11 -0
  102. package/dist/runtimes/external-store/external-store-shared-options.js.map +1 -0
  103. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.d.ts.map +1 -1
  104. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.js.map +1 -1
  105. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts +25 -2
  106. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  107. package/dist/runtimes/external-store/external-store-thread-runtime-core.js +106 -26
  108. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  109. package/dist/runtimes/external-store/thread-message-converter.d.ts.map +1 -1
  110. package/dist/runtimes/local/local-thread-list-runtime-core.d.ts.map +1 -1
  111. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  112. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts.map +1 -1
  113. package/dist/runtimes/remote-thread-list/adapter/in-memory.d.ts.map +1 -1
  114. package/dist/runtimes/remote-thread-list/optimistic-state.d.ts.map +1 -1
  115. package/dist/runtimes/tool-invocations/ToolInvocationTracker.d.ts +168 -0
  116. package/dist/runtimes/tool-invocations/ToolInvocationTracker.d.ts.map +1 -0
  117. package/dist/runtimes/tool-invocations/ToolInvocationTracker.js +449 -0
  118. package/dist/runtimes/tool-invocations/ToolInvocationTracker.js.map +1 -0
  119. package/dist/subscribable/subscribable.d.ts.map +1 -1
  120. package/dist/subscribable/subscribable.js.map +1 -1
  121. package/dist/tests/remote-thread-list-test-helpers.d.ts.map +1 -1
  122. package/dist/types/message.d.ts +6 -0
  123. package/dist/types/message.d.ts.map +1 -1
  124. package/dist/types/message.js.map +1 -1
  125. package/dist/utils/composite-context-provider.d.ts.map +1 -1
  126. package/dist/utils/id.d.ts +1 -3
  127. package/dist/utils/id.d.ts.map +1 -1
  128. package/dist/utils/id.js +1 -4
  129. package/dist/utils/id.js.map +1 -1
  130. package/package.json +10 -10
  131. package/src/adapters/index.ts +1 -4
  132. package/src/adapters/speech.ts +0 -1
  133. package/src/index.ts +12 -0
  134. package/src/internal/duplicate-detection.ts +26 -0
  135. package/src/internal.ts +0 -2
  136. package/src/model-context/frame/host.ts +0 -1
  137. package/src/model-context/frame/provider.ts +0 -1
  138. package/src/react/AssistantProvider.tsx +2 -3
  139. package/src/react/client/Interactables.ts +0 -1
  140. package/src/react/client/Tools.ts +50 -25
  141. package/src/react/index.ts +9 -8
  142. package/src/react/model-context/toolbox.ts +46 -1
  143. package/src/react/model-context/useAssistantTool.ts +8 -3
  144. package/src/react/model-context/useAssistantToolUI.ts +9 -2
  145. package/src/react/model-context/useInlineRender.ts +0 -1
  146. package/src/react/primitives/chainOfThought/ChainOfThoughtParts.tsx +1 -2
  147. package/src/react/primitives/message/MessageAttachments.test.tsx +1 -1
  148. package/src/react/primitives/message/MessageGroupedParts.tsx +102 -13
  149. package/src/react/primitives/message/MessageParts.tsx +4 -7
  150. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +0 -3
  151. package/src/react/runtimes/RuntimeAdapterProvider.tsx +12 -7
  152. package/src/react/runtimes/cloud/useCloudThreadListAdapter.tsx +0 -3
  153. package/src/react/runtimes/external-message-converter.ts +5 -1
  154. package/src/react/runtimes/useExternalStoreSharedOptions.ts +23 -0
  155. package/src/react/runtimes/useLocalRuntime.ts +0 -10
  156. package/src/react/runtimes/useRemoteThreadListRuntime.ts +0 -6
  157. package/src/react/types/scopes/tools.ts +20 -1
  158. package/src/react/utils/groupParts.ts +49 -18
  159. package/src/runtime/api/attachment-runtime.ts +1 -2
  160. package/src/runtime/interfaces/thread-runtime-core.ts +8 -0
  161. package/src/runtime/internal.ts +1 -4
  162. package/src/runtime/utils/message-repository.ts +57 -16
  163. package/src/runtime/utils/thread-message-like.ts +2 -0
  164. package/src/runtimes/external-store/external-store-adapter.ts +33 -0
  165. package/src/runtimes/external-store/external-store-shared-options.ts +18 -0
  166. package/src/runtimes/external-store/external-store-thread-list-runtime-core.ts +1 -3
  167. package/src/runtimes/external-store/external-store-thread-runtime-core.ts +179 -37
  168. package/src/runtimes/tool-invocations/EDGE_CASES.md +194 -0
  169. package/src/runtimes/tool-invocations/ToolInvocationTracker.test.ts +1054 -0
  170. package/src/runtimes/tool-invocations/ToolInvocationTracker.ts +782 -0
  171. package/src/subscribable/subscribable.ts +3 -3
  172. package/src/tests/MessageRepository.test.ts +83 -52
  173. package/src/tests/OptimisticState-delete-crash.test.ts +2 -0
  174. package/src/tests/OptimisticState-list-race.test.ts +2 -4
  175. package/src/tests/RemoteThreadListThreadListRuntimeCore-loadMore.test.ts +5 -5
  176. package/src/tests/auiV0Encode.test.ts +1 -1
  177. package/src/tests/composer-can-send.test.ts +8 -4
  178. package/src/tests/duplicate-detection.test.ts +34 -0
  179. package/src/tests/external-store-thread-list-runtime-core.test.ts +1 -1
  180. package/src/tests/external-store-thread-runtime-core.test.ts +112 -79
  181. package/src/tests/groupParts.test.ts +70 -0
  182. package/src/tests/no-unsafe-process-env.test.ts +1 -0
  183. package/src/tests/remote-thread-list-isLoading.test.ts +2 -5
  184. package/src/tests/thread-message-like.test.ts +4 -1
  185. package/src/types/index.ts +1 -4
  186. package/src/types/message.ts +6 -0
  187. package/src/utils/id.ts +0 -4
  188. package/dist/react/runtimes/useToolInvocations.d.ts +0 -53
  189. package/dist/react/runtimes/useToolInvocations.d.ts.map +0 -1
  190. package/dist/react/runtimes/useToolInvocations.js +0 -380
  191. package/dist/react/runtimes/useToolInvocations.js.map +0 -1
  192. package/src/react/runtimes/useToolInvocations.ts +0 -694
@@ -207,9 +207,9 @@ export class LazyMemoizeSubject<TState extends object, TPath>
207
207
  }
208
208
 
209
209
  export class NestedSubscriptionSubject<
210
- TState extends Subscribable | undefined,
211
- TPath,
212
- >
210
+ TState extends Subscribable | undefined,
211
+ TPath,
212
+ >
213
213
  extends BaseSubject
214
214
  implements
215
215
  SubscribableWithState<TState, TPath>,
@@ -7,20 +7,14 @@ import type { ThreadMessage } from "../types/message";
7
7
  import type { TextMessagePart } from "../types/message";
8
8
  import type { ThreadMessageLike } from "../runtime/utils/thread-message-like";
9
9
 
10
- // Mock generateId and generateOptimisticId to make tests deterministic
10
+ // Mock generateId to make tests deterministic
11
11
  const mockGenerateId = vi.fn();
12
- const mockGenerateOptimisticId = vi.fn();
13
- const mockIsOptimisticId = vi.fn((id: string) =>
14
- id.startsWith("__optimistic__"),
15
- );
16
12
 
17
13
  vi.mock("../utils/id", async (importOriginal) => {
18
14
  const original = await importOriginal<typeof import("../utils/id")>();
19
15
  return {
20
16
  ...original,
21
17
  generateId: () => mockGenerateId(),
22
- generateOptimisticId: () => mockGenerateOptimisticId(),
23
- isOptimisticId: (id: string) => mockIsOptimisticId(id),
24
18
  };
25
19
  });
26
20
 
@@ -58,23 +52,11 @@ describe("MessageRepository", () => {
58
52
  ...overrides,
59
53
  });
60
54
 
61
- /**
62
- * Creates a test CoreMessage with the given overrides.
63
- */
64
- const createThreadMessageLike = (overrides = {}): ThreadMessageLike => ({
65
- role: "assistant",
66
- content: [{ type: "text", text: "Test message" }],
67
- ...overrides,
68
- });
69
-
70
55
  beforeEach(() => {
71
56
  repository = new MessageRepository();
72
57
  // Reset mocks with predictable counter-based values
73
58
  nextMockId = 1;
74
59
  mockGenerateId.mockImplementation(() => `mock-id-${nextMockId++}`);
75
- mockGenerateOptimisticId.mockImplementation(
76
- () => `__optimistic__mock-id-${nextMockId++}`,
77
- );
78
60
  });
79
61
 
80
62
  afterEach(() => {
@@ -297,53 +279,102 @@ describe("MessageRepository", () => {
297
279
  });
298
280
 
299
281
  describe("Optimistic messages", () => {
300
- it("should create an optimistic message with a unique ID", () => {
301
- mockGenerateOptimisticId.mockReturnValue("__optimistic__generated-id");
282
+ const optimistic = (overrides = {}) =>
283
+ createTestMessage({
284
+ status: { type: "running" },
285
+ metadata: {
286
+ unstable_state: null,
287
+ unstable_annotations: [],
288
+ unstable_data: [],
289
+ steps: [],
290
+ custom: {},
291
+ isOptimistic: true,
292
+ },
293
+ ...overrides,
294
+ });
302
295
 
303
- const coreMessage = createThreadMessageLike();
304
- const optimisticId = repository.appendOptimisticMessage(
305
- null,
306
- coreMessage,
307
- );
296
+ it("excludes optimistic messages from export()", () => {
297
+ const parent = createTestMessage({ id: "u" });
298
+ repository.addOrUpdateMessage(null, parent);
299
+ repository.addOrUpdateMessage("u", optimistic({ id: "placeholder" }));
300
+ repository.resetHead("placeholder");
308
301
 
309
- expect(optimisticId).toBe("__optimistic__generated-id");
310
- expect(repository.getMessage(optimisticId).message.status?.type).toBe(
311
- "running",
312
- );
302
+ const exported = repository.export();
303
+
304
+ expect(exported.messages.map((m) => m.message.id)).toEqual(["u"]);
305
+ // head was the optimistic placeholder; the exported head must fall back
306
+ // to the nearest persisted ancestor so it always resolves on import.
307
+ expect(exported.headId).toBe("u");
313
308
  });
314
309
 
315
- it("should create an optimistic message as a child of a specified parent", () => {
316
- const parent = createTestMessage({ id: "parent-id" });
310
+ it("round-trips through export/import without resurrecting the placeholder", () => {
311
+ const parent = createTestMessage({ id: "u" });
312
+ const real = createTestMessage({ id: "a" });
317
313
  repository.addOrUpdateMessage(null, parent);
314
+ repository.addOrUpdateMessage("u", real);
315
+ repository.resetHead("a");
318
316
 
319
- const coreMessage = createThreadMessageLike();
320
- const optimisticId = repository.appendOptimisticMessage(
321
- "parent-id",
322
- coreMessage,
323
- );
317
+ const restored = new MessageRepository();
318
+ restored.import(repository.export());
324
319
 
325
- const result = repository.getMessage(optimisticId);
326
- expect(result.parentId).toBe("parent-id");
320
+ expect(restored.export().messages.map((m) => m.message.id)).toEqual([
321
+ "u",
322
+ "a",
323
+ ]);
327
324
  });
328
325
 
329
- it("should retry generating unique optimistic IDs if initial one exists", () => {
330
- mockGenerateOptimisticId.mockReturnValueOnce("__optimistic__existing-id");
326
+ describe("HEAD-branch invariant", () => {
327
+ it("evicts an off-branch optimistic sibling when resetHead moves the head", () => {
328
+ // u -> { client_id (optimistic), server_id (optimistic) }. When the
329
+ // head moves to server_id, the dangling client_id sibling is evicted.
330
+ const parent = createTestMessage({ id: "u" });
331
+ repository.addOrUpdateMessage(null, parent);
332
+ repository.addOrUpdateMessage("u", optimistic({ id: "client_id" }));
333
+ repository.addOrUpdateMessage("u", optimistic({ id: "server_id" }));
334
+
335
+ repository.resetHead("server_id");
331
336
 
332
- const existingMessage = createTestMessage({
333
- id: "__optimistic__existing-id",
337
+ expect(repository.getBranches("server_id")).toEqual(["server_id"]);
338
+ expect(() => repository.getMessage("client_id")).toThrow();
334
339
  });
335
- repository.addOrUpdateMessage(null, existingMessage);
336
340
 
337
- mockGenerateOptimisticId.mockReturnValueOnce("__optimistic__unique-id");
341
+ it("keeps the optimistic message that is on the head branch", () => {
342
+ const parent = createTestMessage({ id: "u" });
343
+ repository.addOrUpdateMessage(null, parent);
344
+ repository.addOrUpdateMessage("u", optimistic({ id: "a" }));
338
345
 
339
- const coreMessage = createThreadMessageLike();
340
- const optimisticId = repository.appendOptimisticMessage(
341
- null,
342
- coreMessage,
343
- );
346
+ repository.resetHead("a");
347
+
348
+ expect(repository.getMessage("a").message.id).toBe("a");
349
+ });
344
350
 
345
- expect(optimisticId).toBe("__optimistic__unique-id");
346
- expect(mockGenerateOptimisticId).toHaveBeenCalledTimes(2);
351
+ it("never evicts real (non-optimistic) sibling branches", () => {
352
+ const parent = createTestMessage({ id: "u" });
353
+ repository.addOrUpdateMessage(null, parent);
354
+ repository.addOrUpdateMessage("u", createTestMessage({ id: "a1" }));
355
+ repository.addOrUpdateMessage("u", createTestMessage({ id: "a2" }));
356
+
357
+ repository.resetHead("a2");
358
+
359
+ // a1 is off the head branch but not optimistic, so it survives.
360
+ expect(repository.getBranches("a2")).toEqual(["a1", "a2"]);
361
+ });
362
+
363
+ it("evicts optimistic messages from the previous branch on switchToBranch", () => {
364
+ // Two real branches under u; the head branch (a2) carries an optimistic
365
+ // child. Switching to a1 must drop the optimistic message left on a2.
366
+ const parent = createTestMessage({ id: "u" });
367
+ repository.addOrUpdateMessage(null, parent);
368
+ repository.addOrUpdateMessage("u", createTestMessage({ id: "a1" }));
369
+ repository.addOrUpdateMessage("u", createTestMessage({ id: "a2" }));
370
+ repository.addOrUpdateMessage("a2", optimistic({ id: "opt" }));
371
+ repository.resetHead("opt");
372
+
373
+ repository.switchToBranch("a1");
374
+
375
+ expect(() => repository.getMessage("opt")).toThrow();
376
+ expect(repository.getBranches("a1")).toEqual(["a1", "a2"]);
377
+ });
347
378
  });
348
379
  });
349
380
 
@@ -24,6 +24,8 @@ const createThreadState = (
24
24
  const mappingId = createThreadMappingId(threadId);
25
25
  return {
26
26
  isLoading: false,
27
+ isLoadingMore: false,
28
+ cursor: undefined,
27
29
  newThreadId: undefined,
28
30
  threadIds: status === "regular" ? [threadId] : [],
29
31
  archivedThreadIds: status === "archived" ? [threadId] : [],
@@ -22,6 +22,8 @@ import { deferred } from "./remote-thread-list-test-helpers";
22
22
 
23
23
  const EMPTY_STATE: RemoteThreadState = {
24
24
  isLoading: false,
25
+ isLoadingMore: false,
26
+ cursor: undefined,
25
27
  newThreadId: undefined,
26
28
  threadIds: [],
27
29
  archivedThreadIds: [],
@@ -87,7 +89,6 @@ describe("list + delete race condition", () => {
87
89
  const listPromise = state.optimisticUpdate({
88
90
  execute: () => listDeferred.promise,
89
91
  loading: (s) => ({ ...s, isLoading: true }),
90
- // biome-ignore lint/suspicious/noThenProperty: OptimisticState reducer pattern
91
92
  then: applyListResult,
92
93
  });
93
94
 
@@ -145,7 +146,6 @@ describe("list + delete race condition", () => {
145
146
  const listPromise = state.optimisticUpdate({
146
147
  execute: () => listDeferred.promise,
147
148
  loading: (s) => ({ ...s, isLoading: true }),
148
- // biome-ignore lint/suspicious/noThenProperty: OptimisticState reducer pattern
149
149
  then: applyListResult,
150
150
  });
151
151
 
@@ -183,7 +183,6 @@ describe("list + delete race condition", () => {
183
183
  const listPromise = state.optimisticUpdate({
184
184
  execute: () => listDeferred.promise,
185
185
  loading: (s) => ({ ...s, isLoading: true }),
186
- // biome-ignore lint/suspicious/noThenProperty: OptimisticState reducer pattern
187
186
  then: applyListResult,
188
187
  });
189
188
 
@@ -221,7 +220,6 @@ describe("list + delete race condition", () => {
221
220
  const listPromise = state.optimisticUpdate({
222
221
  execute: () => listDeferred.promise,
223
222
  loading: (s) => ({ ...s, isLoading: true }),
224
- // biome-ignore lint/suspicious/noThenProperty: OptimisticState reducer pattern
225
223
  then: applyListResult,
226
224
  });
227
225
 
@@ -20,7 +20,7 @@ describe("RemoteThreadListThreadListRuntimeCore.loadMore", () => {
20
20
 
21
21
  it("initial list response with nextCursor sets hasMore=true", async () => {
22
22
  const adapter = makeAdapter({
23
- list: vi.fn(async () => ({
23
+ list: vi.fn<ListFn>(async () => ({
24
24
  threads: [
25
25
  {
26
26
  status: "regular",
@@ -41,7 +41,7 @@ describe("RemoteThreadListThreadListRuntimeCore.loadMore", () => {
41
41
 
42
42
  it("absent nextCursor leaves hasMore=false", async () => {
43
43
  const adapter = makeAdapter({
44
- list: vi.fn(async () => ({
44
+ list: vi.fn<ListFn>(async () => ({
45
45
  threads: [
46
46
  {
47
47
  status: "regular",
@@ -243,7 +243,7 @@ describe("RemoteThreadListThreadListRuntimeCore.loadMore", () => {
243
243
 
244
244
  it("__internal_setOptions clears cursor and dedup handles on adapter swap, then refetches via the new adapter", async () => {
245
245
  const firstAdapter = makeAdapter({
246
- list: vi.fn(async () => ({
246
+ list: vi.fn<ListFn>(async () => ({
247
247
  threads: [{ status: "regular", remoteId: "old", externalId: "old" }],
248
248
  nextCursor: "old-cursor",
249
249
  })),
@@ -253,7 +253,7 @@ describe("RemoteThreadListThreadListRuntimeCore.loadMore", () => {
253
253
  expect(core.threadIds).toEqual(["old"]);
254
254
  expect(core.hasMore).toBe(true);
255
255
 
256
- const secondList = vi.fn(async () => ({
256
+ const secondList = vi.fn<ListFn>(async () => ({
257
257
  threads: [{ status: "regular", remoteId: "new", externalId: "new" }],
258
258
  }));
259
259
  const secondAdapter = makeAdapter({ list: secondList });
@@ -290,7 +290,7 @@ describe("RemoteThreadListThreadListRuntimeCore.loadMore", () => {
290
290
  const stale = core.loadMore();
291
291
 
292
292
  const secondAdapter = makeAdapter({
293
- list: vi.fn(async () => ({
293
+ list: vi.fn<ListFn>(async () => ({
294
294
  threads: [{ status: "regular", remoteId: "ignored", externalId: "x" }],
295
295
  })),
296
296
  });
@@ -9,7 +9,7 @@ describe("auiV0Encode", () => {
9
9
  role: "assistant",
10
10
  status: { type: "complete", reason: "stop" },
11
11
  metadata: {
12
- unstable_state: undefined,
12
+ unstable_state: null,
13
13
  unstable_annotations: [],
14
14
  unstable_data: [],
15
15
  steps: [],
@@ -102,10 +102,14 @@ describe("BaseComposerRuntimeCore.send", () => {
102
102
  describe("DefaultEditComposerRuntimeCore.canSend", () => {
103
103
  it("ignores runtime.isSendDisabled (thread-scoped flag does not block edits)", () => {
104
104
  const stub = makeRuntimeStub({ isSendDisabled: true });
105
- const composer = new DefaultEditComposerRuntimeCore(stub, () => {}, {
106
- parentId: null,
107
- message: makeUserMessage("seed"),
108
- });
105
+ const composer = new DefaultEditComposerRuntimeCore(
106
+ stub as unknown as ThreadRuntimeCore,
107
+ () => {},
108
+ {
109
+ parentId: null,
110
+ message: makeUserMessage("seed"),
111
+ },
112
+ );
109
113
 
110
114
  expect(composer.canSend).toBe(true);
111
115
  });
@@ -0,0 +1,34 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { checkDuplicateCore } from "../internal/duplicate-detection";
3
+
4
+ const KEY = Symbol.for("@assistant-ui/core.loaded");
5
+
6
+ function reset(): void {
7
+ delete (globalThis as unknown as Record<symbol, unknown>)[KEY];
8
+ }
9
+
10
+ describe("checkDuplicateCore", () => {
11
+ let warn: ReturnType<typeof vi.spyOn>;
12
+
13
+ beforeEach(() => {
14
+ reset();
15
+ warn = vi.spyOn(console, "warn").mockImplementation(() => {});
16
+ });
17
+
18
+ afterEach(() => {
19
+ warn.mockRestore();
20
+ reset();
21
+ });
22
+
23
+ it("does not warn on the first load", () => {
24
+ checkDuplicateCore();
25
+ expect(warn).not.toHaveBeenCalled();
26
+ });
27
+
28
+ it("warns when a second copy registers in the same runtime", () => {
29
+ checkDuplicateCore();
30
+ checkDuplicateCore();
31
+ expect(warn).toHaveBeenCalledTimes(1);
32
+ expect(warn.mock.calls[0]![0]).toMatch(/npx assistant-ui doctor/);
33
+ });
34
+ });
@@ -184,7 +184,7 @@ describe("ExternalStoreThreadListRuntimeCore - isMain via ThreadListRuntimeImpl"
184
184
  const core = new ExternalStoreThreadListRuntimeCore(adapter, makeFactory());
185
185
  return new ThreadListRuntimeImpl(
186
186
  core,
187
- NoopThreadRuntime as unknown as Parameters<
187
+ NoopThreadRuntime as unknown as ConstructorParameters<
188
188
  typeof ThreadListRuntimeImpl
189
189
  >[1],
190
190
  );
@@ -2,18 +2,20 @@ import { describe, expect, it, vi } from "vitest";
2
2
  import { ExternalStoreThreadRuntimeCore } from "../runtimes/external-store/external-store-thread-runtime-core";
3
3
  import type { ExternalStoreAdapter } from "../runtimes/external-store/external-store-adapter";
4
4
  import type { ModelContextProvider } from "../model-context/types";
5
+ import type { ThreadMessageLike } from "../runtime/utils/thread-message-like";
5
6
 
6
7
  const mockContextProvider: ModelContextProvider = {
7
8
  getModelContext: () => ({}),
8
9
  };
9
10
 
10
11
  const makeStore = (
11
- overrides?: Partial<ExternalStoreAdapter>,
12
- ): ExternalStoreAdapter => ({
13
- messages: [],
14
- onNew: vi.fn(),
15
- ...overrides,
16
- });
12
+ overrides?: Partial<ExternalStoreAdapter> | Record<string, unknown>,
13
+ ): ExternalStoreAdapter =>
14
+ ({
15
+ messages: [],
16
+ onNew: vi.fn(),
17
+ ...overrides,
18
+ }) as ExternalStoreAdapter;
17
19
 
18
20
  describe("ExternalStoreThreadRuntimeCore - state reference stability", () => {
19
21
  describe("capabilities", () => {
@@ -150,117 +152,148 @@ describe("ExternalStoreThreadRuntimeCore - state reference stability", () => {
150
152
  expect(runtime.capabilities).toBe(capsBefore);
151
153
  });
152
154
  });
155
+ describe("ExternalStoreThreadRuntimeCore - optimistic message reconciliation", () => {
156
+ type Raw = {
157
+ id: string;
158
+ role: "user" | "assistant";
159
+ text: string;
160
+ optimistic?: boolean;
161
+ };
162
+
163
+ const convertMessage = (m: Raw): ThreadMessageLike => ({
164
+ id: m.id,
165
+ role: m.role,
166
+ content: [{ type: "text", text: m.text }],
167
+ ...(m.optimistic && { metadata: { isOptimistic: true } }),
168
+ });
153
169
 
154
- describe("ExternalStoreThreadRuntimeCore - messages reconciliation", () => {
155
- const user = { id: "u", role: "user" as const, content: [] };
156
-
157
- it("drops ids that disappear between syncs (same length, swapped assistant id)", () => {
158
- const a1 = { id: "a1", role: "assistant" as const, content: [] };
159
- const a2 = { id: "a2", role: "assistant" as const, content: [] };
170
+ const childrenOf = (
171
+ runtime: ExternalStoreThreadRuntimeCore,
172
+ parentId: string,
173
+ ) =>
174
+ runtime
175
+ .export()
176
+ .messages.filter((m) => m.parentId === parentId)
177
+ .map((m) => m.message.id);
160
178
 
179
+ it("drops the orphaned placeholder when an optimistic id is swapped mid-run", () => {
180
+ const u: Raw = { id: "u", role: "user", text: "hi" };
161
181
  const runtime = new ExternalStoreThreadRuntimeCore(
162
182
  mockContextProvider,
163
- makeStore({ messages: [user, a1] }),
183
+ makeStore({
184
+ messages: [
185
+ u,
186
+ { id: "client_id", role: "assistant", text: "", optimistic: true },
187
+ ],
188
+ convertMessage,
189
+ isRunning: true,
190
+ }),
164
191
  );
165
192
 
166
- runtime.__internal_setAdapter(makeStore({ messages: [user, a2] }));
193
+ // AI SDK v6 swaps the client-generated id for the server-provided one.
194
+ runtime.__internal_setAdapter(
195
+ makeStore({
196
+ messages: [
197
+ u,
198
+ {
199
+ id: "server_id",
200
+ role: "assistant",
201
+ text: "hello",
202
+ optimistic: true,
203
+ },
204
+ ],
205
+ convertMessage,
206
+ isRunning: true,
207
+ }),
208
+ );
167
209
 
168
- const exported = runtime.export();
169
- expect(exported.messages.map((m) => m.message.id)).toEqual(["u", "a2"]);
170
- const userChildren = exported.messages
171
- .filter((m) => m.parentId === "u")
172
- .map((m) => m.message.id);
173
- expect(userChildren).toEqual(["a2"]);
210
+ // No phantom sibling in the live tree (what BranchPicker reads): the user
211
+ // message has a single child. export() omits the still-optimistic
212
+ // streaming message, so assert against the live getBranches instead.
213
+ expect(runtime.getBranches("server_id")).toEqual(["server_id"]);
174
214
  });
175
215
 
176
- it("keeps prior ids when they remain in the new sync", () => {
177
- const a = { id: "a", role: "assistant" as const, content: [] };
178
-
216
+ it("clears the optimistic flag once the run settles", () => {
217
+ const u: Raw = { id: "u", role: "user", text: "hi" };
179
218
  const runtime = new ExternalStoreThreadRuntimeCore(
180
219
  mockContextProvider,
181
- makeStore({ messages: [user, a] }),
220
+ makeStore({
221
+ messages: [
222
+ u,
223
+ { id: "a", role: "assistant", text: "...", optimistic: true },
224
+ ],
225
+ convertMessage,
226
+ isRunning: true,
227
+ }),
182
228
  );
183
229
 
184
- runtime.__internal_setAdapter(makeStore({ messages: [user, a] }));
230
+ runtime.__internal_setAdapter(
231
+ makeStore({
232
+ messages: [u, { id: "a", role: "assistant", text: "done" }],
233
+ convertMessage,
234
+ isRunning: false,
235
+ }),
236
+ );
185
237
 
186
- expect(runtime.export().messages.map((m) => m.message.id)).toEqual([
187
- "u",
188
- "a",
189
- ]);
238
+ const settled = runtime.export().messages.find((m) => m.message.id === "a");
239
+ expect(settled?.message.metadata.isOptimistic).toBeFalsy();
240
+ expect(childrenOf(runtime, "u")).toEqual(["a"]);
190
241
  });
191
242
 
192
- it("removes trailing messages dropped from the new sync", () => {
193
- const a = { id: "a", role: "assistant" as const, content: [] };
194
- const u2 = { id: "u2", role: "user" as const, content: [] };
195
-
243
+ it("removes the runtime placeholder once the store provides the assistant message", () => {
244
+ const u: Raw = { id: "u", role: "user", text: "hi" };
196
245
  const runtime = new ExternalStoreThreadRuntimeCore(
197
246
  mockContextProvider,
198
- makeStore({ messages: [user, a, u2] }),
247
+ makeStore({ messages: [u], convertMessage, isRunning: true }),
199
248
  );
200
249
 
201
- runtime.__internal_setAdapter(makeStore({ messages: [user, a] }));
250
+ // Running with a trailing user message: a placeholder is appended to the
251
+ // live tree. export() omits optimistic messages, so inspect the live
252
+ // messages (which include the placeholder on the head path). It's a plain
253
+ // assistant message flagged optimistic (no special id scheme).
254
+ const live = runtime.messages;
255
+ expect(live).toHaveLength(2);
256
+ expect(live[1]!.role).toBe("assistant");
257
+ expect(live[1]!.metadata.isOptimistic).toBe(true);
258
+
259
+ // The store now yields the real assistant message; the placeholder (whose
260
+ // synthetic id never appears in the snapshot) must be gone, leaving a
261
+ // single child under the user message.
262
+ runtime.__internal_setAdapter(
263
+ makeStore({
264
+ messages: [u, { id: "a", role: "assistant", text: "done" }],
265
+ convertMessage,
266
+ isRunning: false,
267
+ }),
268
+ );
202
269
 
203
270
  expect(runtime.export().messages.map((m) => m.message.id)).toEqual([
204
271
  "u",
205
272
  "a",
206
273
  ]);
274
+ expect(runtime.getBranches("a")).toEqual(["a"]);
207
275
  });
208
276
 
209
- it("does not crash on the next sync after cancelRun removes a leaf user", () => {
210
- const userWithText = {
211
- id: "u",
212
- role: "user" as const,
213
- content: [{ type: "text" as const, text: "hi" }],
214
- };
215
-
277
+ it("keeps real sibling branches that were never flagged optimistic", () => {
278
+ const u: Raw = { id: "u", role: "user", text: "hi" };
216
279
  const runtime = new ExternalStoreThreadRuntimeCore(
217
280
  mockContextProvider,
218
281
  makeStore({
219
- messages: [userWithText],
220
- onCancel: vi.fn(),
221
- isRunning: true,
222
- }),
223
- );
224
-
225
- runtime.cancelRun();
226
-
227
- expect(() => {
228
- runtime.__internal_setAdapter(makeStore({ messages: [] }));
229
- }).not.toThrow();
230
- });
231
-
232
- it("drops phantom sibling when convertMessage swaps the assistant id", () => {
233
- type Raw = { id: string; role: "user" | "assistant"; text: string };
234
- const rawU: Raw = { id: "u", role: "user", text: "hi" };
235
- const rawA1: Raw = { id: "client_id", role: "assistant", text: "" };
236
- const rawA2: Raw = { id: "server_id", role: "assistant", text: "" };
237
-
238
- const convertMessage = (m: Raw) => ({
239
- id: m.id,
240
- role: m.role,
241
- content: [{ type: "text" as const, text: m.text }],
242
- });
243
-
244
- const runtime = new ExternalStoreThreadRuntimeCore(
245
- mockContextProvider,
246
- makeStore({
247
- messages: [rawU, rawA1] as any,
248
- convertMessage: convertMessage as any,
282
+ messages: [u, { id: "a1", role: "assistant", text: "first" }],
283
+ convertMessage,
249
284
  }),
250
285
  );
251
286
 
287
+ // Simulates onEdit/onReload producing a new branch under the same parent;
288
+ // the prior branch must survive (regression guard for #4131).
252
289
  runtime.__internal_setAdapter(
253
290
  makeStore({
254
- messages: [rawU, rawA2] as any,
255
- convertMessage: convertMessage as any,
291
+ messages: [u, { id: "a2", role: "assistant", text: "second" }],
292
+ convertMessage,
256
293
  }),
257
294
  );
258
295
 
259
- const userChildren = runtime
260
- .export()
261
- .messages.filter((m) => m.parentId === "u")
262
- .map((m) => m.message.id);
263
- expect(userChildren).toEqual(["server_id"]);
296
+ expect(childrenOf(runtime, "u")).toEqual(["a1", "a2"]);
264
297
  });
265
298
  });
266
299