@assistant-ui/react 0.14.28 → 0.15.0

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 (190) hide show
  1. package/dist/client/ExternalThread.d.ts +11 -1
  2. package/dist/client/ExternalThread.d.ts.map +1 -1
  3. package/dist/client/ExternalThread.js +762 -516
  4. package/dist/client/ExternalThread.js.map +1 -1
  5. package/dist/client/InMemoryThreadList.js +3 -3
  6. package/dist/client/InMemoryThreadList.js.map +1 -1
  7. package/dist/context/index.d.ts +1 -8
  8. package/dist/context/index.js +1 -8
  9. package/dist/index.d.ts +1 -8
  10. package/dist/index.js +1 -8
  11. package/dist/legacy-runtime/cloud/auiV0.d.ts +12 -1
  12. package/dist/legacy-runtime/cloud/auiV0.d.ts.map +1 -1
  13. package/dist/legacy-runtime/cloud/auiV0.js +2 -1
  14. package/dist/legacy-runtime/cloud/auiV0.js.map +1 -1
  15. package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.d.ts +1 -1
  16. package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.js +7 -6
  17. package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.js.map +1 -1
  18. package/dist/legacy-runtime/runtime-cores/assistant-transport/runManager.d.ts.map +1 -1
  19. package/dist/legacy-runtime/runtime-cores/assistant-transport/runManager.js +30 -15
  20. package/dist/legacy-runtime/runtime-cores/assistant-transport/runManager.js.map +1 -1
  21. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.d.ts.map +1 -1
  22. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js +11 -7
  23. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js.map +1 -1
  24. package/dist/legacy-runtime/runtime-cores/assistant-transport/useConvertedState.js +1 -0
  25. package/dist/legacy-runtime/runtime-cores/assistant-transport/useConvertedState.js.map +1 -1
  26. package/dist/mcp-apps/McpAppRenderer.js +1 -1
  27. package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
  28. package/dist/model-context/makeAssistantVisible.js +26 -25
  29. package/dist/model-context/makeAssistantVisible.js.map +1 -1
  30. package/dist/primitives/actionBar/ActionBarExportMarkdown.js +3 -3
  31. package/dist/primitives/actionBar/ActionBarExportMarkdown.js.map +1 -1
  32. package/dist/primitives/actionBar/ActionBarRoot.js +3 -3
  33. package/dist/primitives/actionBar/ActionBarRoot.js.map +1 -1
  34. package/dist/primitives/actionBar/useActionBarFloatStatus.d.ts +7 -6
  35. package/dist/primitives/actionBar/useActionBarFloatStatus.d.ts.map +1 -1
  36. package/dist/primitives/actionBar/useActionBarFloatStatus.js +10 -11
  37. package/dist/primitives/actionBar/useActionBarFloatStatus.js.map +1 -1
  38. package/dist/primitives/attachment/AttachmentRemove.js +4 -3
  39. package/dist/primitives/attachment/AttachmentRemove.js.map +1 -1
  40. package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.js +4 -3
  41. package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.js.map +1 -1
  42. package/dist/primitives/composer/ComposerAddAttachment.d.ts.map +1 -1
  43. package/dist/primitives/composer/ComposerAddAttachment.js +11 -5
  44. package/dist/primitives/composer/ComposerAddAttachment.js.map +1 -1
  45. package/dist/primitives/composer/ComposerAttachmentDropzone.d.ts.map +1 -1
  46. package/dist/primitives/composer/ComposerAttachmentDropzone.js +2 -4
  47. package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
  48. package/dist/primitives/composer/ComposerInput.js +18 -16
  49. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  50. package/dist/primitives/composer/ComposerQuote.js +1 -1
  51. package/dist/primitives/composer/ComposerQuote.js.map +1 -1
  52. package/dist/primitives/composer/ComposerSend.js +1 -0
  53. package/dist/primitives/composer/ComposerSend.js.map +1 -1
  54. package/dist/primitives/composer/ComposerStopDictation.js +4 -3
  55. package/dist/primitives/composer/ComposerStopDictation.js.map +1 -1
  56. package/dist/primitives/composer/trigger/triggerSelectionResource.d.ts.map +1 -1
  57. package/dist/primitives/composer/trigger/triggerSelectionResource.js +3 -3
  58. package/dist/primitives/composer/trigger/triggerSelectionResource.js.map +1 -1
  59. package/dist/primitives/message/MessagePartsGrouped.d.ts.map +1 -1
  60. package/dist/primitives/message/MessagePartsGrouped.js +67 -89
  61. package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
  62. package/dist/primitives/message/MessageRoot.js +3 -3
  63. package/dist/primitives/message/MessageRoot.js.map +1 -1
  64. package/dist/primitives/queueItem/QueueItemRemove.js +4 -3
  65. package/dist/primitives/queueItem/QueueItemRemove.js.map +1 -1
  66. package/dist/primitives/queueItem/QueueItemSteer.js +4 -3
  67. package/dist/primitives/queueItem/QueueItemSteer.js.map +1 -1
  68. package/dist/primitives/selectionToolbar/SelectionToolbarQuote.js +1 -1
  69. package/dist/primitives/selectionToolbar/SelectionToolbarQuote.js.map +1 -1
  70. package/dist/primitives/suggestion/SuggestionTrigger.js +17 -15
  71. package/dist/primitives/suggestion/SuggestionTrigger.js.map +1 -1
  72. package/dist/primitives/thread/ThreadScrollToBottom.js +1 -0
  73. package/dist/primitives/thread/ThreadScrollToBottom.js.map +1 -1
  74. package/dist/unstable/useComposerInput.js +2 -2
  75. package/dist/unstable/useComposerInput.js.map +1 -1
  76. package/dist/unstable/useComposerInputHistory.js +6 -6
  77. package/dist/unstable/useComposerInputHistory.js.map +1 -1
  78. package/dist/unstable/useMentionAdapter.js +1 -1
  79. package/dist/unstable/useMentionAdapter.js.map +1 -1
  80. package/dist/utils/hooks/useOnResizeContent.js +1 -0
  81. package/dist/utils/hooks/useOnResizeContent.js.map +1 -1
  82. package/dist/utils/hooks/useSizeHandle.js +1 -0
  83. package/dist/utils/hooks/useSizeHandle.js.map +1 -1
  84. package/dist/utils/smooth/SmoothContext.js +3 -3
  85. package/dist/utils/smooth/SmoothContext.js.map +1 -1
  86. package/dist/utils/smooth/useSmooth.d.ts.map +1 -1
  87. package/dist/utils/smooth/useSmooth.js +3 -3
  88. package/dist/utils/smooth/useSmooth.js.map +1 -1
  89. package/package.json +9 -9
  90. package/src/client/ExternalThread.ts +345 -56
  91. package/src/client/InMemoryThreadList.ts +3 -3
  92. package/src/context/index.ts +0 -44
  93. package/src/legacy-runtime/cloud/auiV0.ts +14 -0
  94. package/src/legacy-runtime/runtime-cores/assistant-transport/commandQueue.ts +4 -1
  95. package/src/legacy-runtime/runtime-cores/assistant-transport/runManager.ts +37 -10
  96. package/src/legacy-runtime/runtime-cores/assistant-transport/transport-delivery.test.tsx +79 -0
  97. package/src/legacy-runtime/runtime-cores/assistant-transport/transport-parentid.test.tsx +97 -0
  98. package/src/legacy-runtime/runtime-cores/assistant-transport/transport-scheduling.test.ts +113 -2
  99. package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransport.spec.md +10 -2
  100. package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.test.tsx +185 -0
  101. package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.ts +21 -5
  102. package/src/mcp-apps/McpAppRenderer.tsx +1 -1
  103. package/src/model-context/makeAssistantVisible.test.tsx +1 -1
  104. package/src/model-context/makeAssistantVisible.tsx +1 -1
  105. package/src/primitives/actionBar/ActionBarExportMarkdown.tsx +1 -1
  106. package/src/primitives/actionBar/useActionBarFloatStatus.ts +7 -5
  107. package/src/primitives/attachment/AttachmentRemove.ts +1 -1
  108. package/src/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.ts +1 -1
  109. package/src/primitives/composer/ComposerAddAttachment.test.tsx +108 -0
  110. package/src/primitives/composer/ComposerAddAttachment.ts +11 -5
  111. package/src/primitives/composer/ComposerAttachmentDropzone.test.tsx +5 -4
  112. package/src/primitives/composer/ComposerAttachmentDropzone.tsx +4 -3
  113. package/src/primitives/composer/ComposerInput.test.tsx +104 -7
  114. package/src/primitives/composer/ComposerInput.tsx +22 -20
  115. package/src/primitives/composer/ComposerQuote.tsx +1 -1
  116. package/src/primitives/composer/ComposerStopDictation.ts +1 -1
  117. package/src/primitives/composer/trigger/triggerSelectionResource.ts +7 -9
  118. package/src/primitives/message/MessagePartsGrouped.tsx +6 -8
  119. package/src/primitives/message/MessageRoot.tsx +1 -1
  120. package/src/primitives/queueItem/QueueItemRemove.ts +1 -1
  121. package/src/primitives/queueItem/QueueItemSteer.ts +1 -1
  122. package/src/primitives/selectionToolbar/SelectionToolbarQuote.tsx +1 -1
  123. package/src/primitives/suggestion/SuggestionTrigger.ts +9 -9
  124. package/src/tests/DataRenderers.test.tsx +212 -0
  125. package/src/tests/RemoteThreadListRuntime.adapterProvider.test.tsx +1 -11
  126. package/src/tests/RemoteThreadListRuntime.deferredProvider.test.tsx +3 -3
  127. package/src/tests/external-thread-attachments.test.tsx +300 -11
  128. package/src/tests/external-thread-branches.test.tsx +10 -10
  129. package/src/tests/external-thread-parity.test.tsx +230 -0
  130. package/src/tests/generative-ui.test.tsx +1 -46
  131. package/src/tests/local-runtime-queue.test.tsx +29 -34
  132. package/src/tests/messagePartSwitchRace.test.tsx +46 -15
  133. package/src/tests/messagePartTypeMismatch.test.tsx +2 -2
  134. package/src/tests/threadMessageById.test.tsx +0 -7
  135. package/src/unstable/useComposerInput.test.tsx +2 -2
  136. package/src/unstable/useComposerInput.ts +2 -2
  137. package/src/unstable/useComposerInputHistory.test.tsx +3 -3
  138. package/src/unstable/useComposerInputHistory.ts +6 -6
  139. package/src/unstable/useMentionAdapter.ts +1 -1
  140. package/src/utils/smooth/SmoothContext.tsx +1 -1
  141. package/src/utils/smooth/useSmooth.test.tsx +1 -1
  142. package/src/utils/smooth/useSmooth.ts +8 -5
  143. package/dist/context/react/utils/createStateHookForRuntime.d.ts +0 -34
  144. package/dist/context/react/utils/createStateHookForRuntime.d.ts.map +0 -1
  145. package/dist/context/react/utils/createStateHookForRuntime.js +0 -21
  146. package/dist/context/react/utils/createStateHookForRuntime.js.map +0 -1
  147. package/dist/legacy-runtime/hooks/AssistantContext.d.ts +0 -74
  148. package/dist/legacy-runtime/hooks/AssistantContext.d.ts.map +0 -1
  149. package/dist/legacy-runtime/hooks/AssistantContext.js +0 -29
  150. package/dist/legacy-runtime/hooks/AssistantContext.js.map +0 -1
  151. package/dist/legacy-runtime/hooks/AttachmentContext.d.ts +0 -1007
  152. package/dist/legacy-runtime/hooks/AttachmentContext.d.ts.map +0 -1
  153. package/dist/legacy-runtime/hooks/AttachmentContext.js +0 -47
  154. package/dist/legacy-runtime/hooks/AttachmentContext.js.map +0 -1
  155. package/dist/legacy-runtime/hooks/ComposerContext.d.ts +0 -141
  156. package/dist/legacy-runtime/hooks/ComposerContext.d.ts.map +0 -1
  157. package/dist/legacy-runtime/hooks/ComposerContext.js +0 -65
  158. package/dist/legacy-runtime/hooks/ComposerContext.js.map +0 -1
  159. package/dist/legacy-runtime/hooks/MessageContext.d.ts +0 -155
  160. package/dist/legacy-runtime/hooks/MessageContext.d.ts.map +0 -1
  161. package/dist/legacy-runtime/hooks/MessageContext.js +0 -68
  162. package/dist/legacy-runtime/hooks/MessageContext.js.map +0 -1
  163. package/dist/legacy-runtime/hooks/MessagePartContext.d.ts +0 -44
  164. package/dist/legacy-runtime/hooks/MessagePartContext.d.ts.map +0 -1
  165. package/dist/legacy-runtime/hooks/MessagePartContext.js +0 -26
  166. package/dist/legacy-runtime/hooks/MessagePartContext.js.map +0 -1
  167. package/dist/legacy-runtime/hooks/ThreadContext.d.ts +0 -139
  168. package/dist/legacy-runtime/hooks/ThreadContext.d.ts.map +0 -1
  169. package/dist/legacy-runtime/hooks/ThreadContext.js +0 -84
  170. package/dist/legacy-runtime/hooks/ThreadContext.js.map +0 -1
  171. package/dist/legacy-runtime/hooks/ThreadListItemContext.d.ts +0 -44
  172. package/dist/legacy-runtime/hooks/ThreadListItemContext.d.ts.map +0 -1
  173. package/dist/legacy-runtime/hooks/ThreadListItemContext.js +0 -26
  174. package/dist/legacy-runtime/hooks/ThreadListItemContext.js.map +0 -1
  175. package/src/context/react/utils/createStateHookForRuntime.ts +0 -82
  176. package/src/legacy-runtime/hooks/AssistantContext.ts +0 -67
  177. package/src/legacy-runtime/hooks/AttachmentContext.ts +0 -99
  178. package/src/legacy-runtime/hooks/ComposerContext.ts +0 -131
  179. package/src/legacy-runtime/hooks/MessageContext.ts +0 -127
  180. package/src/legacy-runtime/hooks/MessagePartContext.ts +0 -32
  181. package/src/legacy-runtime/hooks/ThreadContext.ts +0 -120
  182. package/src/legacy-runtime/hooks/ThreadListItemContext.ts +0 -36
  183. package/src/tests/BaseComposerRuntimeCore.test.ts +0 -621
  184. package/src/tests/BaseSubscribable.test.ts +0 -54
  185. package/src/tests/DataRenderers.test.ts +0 -355
  186. package/src/tests/ExternalStoreThreadRuntimeCore.test.ts +0 -685
  187. package/src/tests/LocalThreadRuntimeCore.test.ts +0 -324
  188. package/src/tests/auiV0Encode.test.ts +0 -188
  189. package/src/tests/external-message-converter.test.ts +0 -346
  190. package/src/tests/shouldContinue.test.ts +0 -122
@@ -2,10 +2,35 @@
2
2
 
3
3
  import { act, render, waitFor } from "@testing-library/react";
4
4
  import type { FC } from "react";
5
- import { describe, expect, it, vi } from "vitest";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import { AuiProvider, useAui } from "@assistant-ui/store";
7
+ import type {
8
+ ExternalThreadMessage,
9
+ ExternalThreadProps,
10
+ } from "../client/ExternalThread";
7
11
  import { ExternalThread } from "../client/ExternalThread";
8
12
 
13
+ const renderThreadWithProps = (props: Partial<ExternalThreadProps>) => {
14
+ const captured: { aui?: ReturnType<typeof useAui> } = {};
15
+ const Capture: FC = () => {
16
+ captured.aui = useAui();
17
+ return null;
18
+ };
19
+ const App: FC = () => {
20
+ const aui = useAui({
21
+ thread: ExternalThread({ messages: [], isRunning: false, ...props }),
22
+ });
23
+ return (
24
+ <AuiProvider value={aui}>
25
+ <Capture />
26
+ </AuiProvider>
27
+ );
28
+ };
29
+
30
+ render(<App />);
31
+ return () => captured.aui!;
32
+ };
33
+
9
34
  const renderThread = () => {
10
35
  const captured: { aui?: ReturnType<typeof useAui> } = {};
11
36
  const Capture: FC = () => {
@@ -49,14 +74,12 @@ describe("ExternalThread attachments", () => {
49
74
  }
50
75
 
51
76
  await waitFor(() => {
52
- expect(aui().thread().composer().getState().attachments[0]).toMatchObject(
53
- {
54
- type: "document",
55
- name: "notes.txt",
56
- contentType: "text/plain",
57
- content: [{ type: "text", text: "hello" }],
58
- },
59
- );
77
+ expect(aui().thread.composer().getState().attachments[0]).toMatchObject({
78
+ type: "document",
79
+ name: "notes.txt",
80
+ contentType: "text/plain",
81
+ content: [{ type: "text", text: "hello" }],
82
+ });
60
83
  });
61
84
  });
62
85
 
@@ -69,13 +92,279 @@ describe("ExternalThread attachments", () => {
69
92
  content: [{ type: "text", text: "implementation detail" }],
70
93
  } as File;
71
94
 
72
- await act(() => aui().thread().composer().addAttachment(foreignFile));
95
+ await act(() => aui().thread.composer().addAttachment(foreignFile));
73
96
 
74
- expect(aui().thread().composer().getState().attachments[0]).toMatchObject({
97
+ expect(aui().thread.composer().getState().attachments[0]).toMatchObject({
75
98
  type: "file",
76
99
  name: "photo.png",
77
100
  contentType: "image/png",
78
101
  file: foreignFile,
79
102
  });
80
103
  });
104
+
105
+ it("rejects files that do not match the adapter accept string", async () => {
106
+ const add = vi.fn(async () => ({}) as never);
107
+ const aui = renderThreadWithProps({
108
+ attachmentAdapter: {
109
+ accept: "image/*",
110
+ add,
111
+ send: async () => ({}) as never,
112
+ remove: async () => {},
113
+ },
114
+ });
115
+
116
+ await expect(
117
+ aui()
118
+ .thread()
119
+ .composer()
120
+ .addAttachment(
121
+ new File(["data"], "archive.zip", { type: "application/zip" }),
122
+ ),
123
+ ).rejects.toThrow(
124
+ "File type application/zip is not accepted. Accepted types: image/*",
125
+ );
126
+ expect(add).not.toHaveBeenCalled();
127
+ expect(aui().thread.composer().getState().attachments).toHaveLength(0);
128
+ });
129
+
130
+ it("rejects prepared attachments that do not match the adapter accept string", async () => {
131
+ const aui = renderThreadWithProps({
132
+ attachmentAdapter: {
133
+ accept: ".pdf",
134
+ add: async () => ({}) as never,
135
+ send: async () => ({}) as never,
136
+ remove: async () => {},
137
+ },
138
+ });
139
+
140
+ await expect(
141
+ aui()
142
+ .thread()
143
+ .composer()
144
+ .addAttachment({
145
+ name: "notes.txt",
146
+ contentType: "text/plain",
147
+ content: [{ type: "text", text: "hello" }],
148
+ }),
149
+ ).rejects.toThrow(
150
+ "File type text/plain is not accepted. Accepted types: .pdf",
151
+ );
152
+ expect(aui().thread.composer().getState().attachments).toHaveLength(0);
153
+ });
154
+
155
+ it("restores the draft when an attachment upload fails on send", async () => {
156
+ const consoleError = vi
157
+ .spyOn(console, "error")
158
+ .mockImplementation(() => {});
159
+ const sent: unknown[] = [];
160
+ let rejectSend!: (reason: unknown) => void;
161
+ const adapter = {
162
+ accept: "*",
163
+ add: async ({ file }: { file: File }) => ({
164
+ id: "att-1",
165
+ type: "file" as const,
166
+ name: file.name,
167
+ contentType: file.type,
168
+ file,
169
+ status: {
170
+ type: "requires-action" as const,
171
+ reason: "composer-send" as const,
172
+ },
173
+ }),
174
+ send: () =>
175
+ new Promise((_resolve, reject) => {
176
+ rejectSend = reject;
177
+ }) as never,
178
+ remove: async () => {},
179
+ };
180
+
181
+ const aui = renderThreadWithProps({
182
+ attachmentAdapter: adapter,
183
+ onNew: (message) => sent.push(message),
184
+ });
185
+
186
+ await act(() =>
187
+ aui()
188
+ .thread()
189
+ .composer()
190
+ .addAttachment(new File(["data"], "notes.txt", { type: "text/plain" })),
191
+ );
192
+ await act(async () => {
193
+ aui().thread.composer().setText("hello");
194
+ });
195
+ await act(async () => {
196
+ aui().thread.composer().send();
197
+ });
198
+
199
+ // The draft is cleared optimistically while the upload runs.
200
+ expect(aui().thread.composer().getState().text).toBe("");
201
+
202
+ await act(async () => {
203
+ rejectSend(new Error("upload failed"));
204
+ });
205
+
206
+ await waitFor(() => {
207
+ const state = aui().thread.composer().getState();
208
+ expect(state.text).toBe("hello");
209
+ expect(state.attachments).toHaveLength(1);
210
+ });
211
+ expect(sent).toHaveLength(0);
212
+ expect(consoleError).toHaveBeenCalledWith(
213
+ "Failed to send attachments",
214
+ expect.any(Error),
215
+ );
216
+ });
217
+
218
+ it.each([
219
+ [
220
+ "clearAttachments",
221
+ (c: { clearAttachments(): Promise<void> }) => c.clearAttachments(),
222
+ ],
223
+ ["reset", (c: { reset(): Promise<void> }) => c.reset()],
224
+ ] as const)(
225
+ "calls adapter.remove for pending attachments on %s",
226
+ async (_name, invoke) => {
227
+ const remove = vi.fn(async () => {});
228
+ const adapter = {
229
+ accept: "*",
230
+ add: async ({ file }: { file: File }) => ({
231
+ id: "att-1",
232
+ type: "file" as const,
233
+ name: file.name,
234
+ contentType: file.type,
235
+ file,
236
+ status: {
237
+ type: "requires-action" as const,
238
+ reason: "composer-send" as const,
239
+ },
240
+ }),
241
+ send: async () => ({}) as never,
242
+ remove,
243
+ };
244
+
245
+ const aui = renderThreadWithProps({ attachmentAdapter: adapter });
246
+
247
+ await act(() =>
248
+ aui()
249
+ .thread()
250
+ .composer()
251
+ .addAttachment(
252
+ new File(["data"], "notes.txt", { type: "text/plain" }),
253
+ ),
254
+ );
255
+
256
+ await act(() => invoke(aui().thread.composer()));
257
+
258
+ expect(remove).toHaveBeenCalledTimes(1);
259
+ expect(remove).toHaveBeenCalledWith(
260
+ expect.objectContaining({ id: "att-1" }),
261
+ );
262
+ expect(aui().thread.composer().getState().attachments).toHaveLength(0);
263
+ },
264
+ );
265
+
266
+ it("routes edit-composer attachments through the adapter", async () => {
267
+ const add = vi.fn(async ({ file }: { file: File }) => ({
268
+ id: "att-edit",
269
+ type: "file" as const,
270
+ name: file.name,
271
+ contentType: file.type,
272
+ file,
273
+ status: {
274
+ type: "requires-action" as const,
275
+ reason: "composer-send" as const,
276
+ },
277
+ }));
278
+ const aui = renderThreadWithProps({
279
+ messages: [
280
+ {
281
+ id: "u1",
282
+ role: "user",
283
+ content: [{ type: "text", text: "hi" }],
284
+ createdAt: new Date(0),
285
+ attachments: [],
286
+ metadata: { custom: {} },
287
+ } as unknown as ExternalThreadMessage,
288
+ ],
289
+ onEdit: () => {},
290
+ attachmentAdapter: {
291
+ accept: "*",
292
+ add,
293
+ send: async () => ({}) as never,
294
+ remove: async () => {},
295
+ },
296
+ });
297
+
298
+ const composer = () => aui().thread.message({ id: "u1" }).composer();
299
+ await act(async () => {
300
+ composer().beginEdit();
301
+ });
302
+ await act(() =>
303
+ composer().addAttachment(
304
+ new File(["data"], "notes.txt", { type: "text/plain" }),
305
+ ),
306
+ );
307
+
308
+ expect(add).toHaveBeenCalledTimes(1);
309
+ expect(composer().getState().attachments[0]).toMatchObject({
310
+ id: "att-edit",
311
+ name: "notes.txt",
312
+ });
313
+ });
314
+
315
+ it("merges the failed send into a draft the user already modified", async () => {
316
+ vi.spyOn(console, "error").mockImplementation(() => {});
317
+ let rejectSend!: (reason: unknown) => void;
318
+ const adapter = {
319
+ accept: "*",
320
+ add: async ({ file }: { file: File }) => ({
321
+ id: "att-1",
322
+ type: "file" as const,
323
+ name: file.name,
324
+ contentType: file.type,
325
+ file,
326
+ status: {
327
+ type: "requires-action" as const,
328
+ reason: "composer-send" as const,
329
+ },
330
+ }),
331
+ send: () =>
332
+ new Promise((_resolve, reject) => {
333
+ rejectSend = reject;
334
+ }) as never,
335
+ remove: async () => {},
336
+ };
337
+
338
+ const aui = renderThreadWithProps({
339
+ attachmentAdapter: adapter,
340
+ onNew: () => {},
341
+ });
342
+
343
+ await act(() =>
344
+ aui()
345
+ .thread()
346
+ .composer()
347
+ .addAttachment(new File(["data"], "notes.txt", { type: "text/plain" })),
348
+ );
349
+ await act(async () => {
350
+ aui().thread.composer().setText("hello");
351
+ aui().thread.composer().send();
352
+ });
353
+ await act(async () => {
354
+ aui().thread.composer().setText("meanwhile");
355
+ });
356
+
357
+ await act(async () => {
358
+ rejectSend(new Error("upload failed"));
359
+ });
360
+
361
+ const state = aui().thread.composer().getState();
362
+ expect(state.text).toBe("hello\nmeanwhile");
363
+ expect(state.attachments).toHaveLength(1);
364
+ expect(state.attachments[0]).toMatchObject({ id: "att-1" });
365
+ });
366
+
367
+ afterEach(() => {
368
+ vi.restoreAllMocks();
369
+ });
81
370
  });
@@ -66,18 +66,18 @@ const adapterFor = (
66
66
  describe("ExternalThread branches", () => {
67
67
  it("defaults to single-branch state without an adapter", () => {
68
68
  const { aui } = renderThread(baseProps());
69
- const state = aui().thread().getState();
69
+ const state = aui().thread.getState();
70
70
  expect(state.messages[1]!.branchNumber).toBe(1);
71
71
  expect(state.messages[1]!.branchCount).toBe(1);
72
72
  expect(state.capabilities.switchToBranch).toBe(false);
73
73
  expect(() =>
74
- aui().thread().message({ index: 1 }).switchToBranch({ position: "next" }),
74
+ aui().thread.message({ index: 1 }).switchToBranch({ position: "next" }),
75
75
  ).not.toThrow();
76
76
  });
77
77
 
78
78
  it("derives branchNumber and branchCount from the adapter", () => {
79
79
  const { aui } = renderThread(baseProps(adapterFor(["a1", "a2", "a3"])));
80
- const state = aui().thread().getState();
80
+ const state = aui().thread.getState();
81
81
  expect(state.messages[1]!.branchNumber).toBe(2);
82
82
  expect(state.messages[1]!.branchCount).toBe(3);
83
83
  expect(state.capabilities.switchToBranch).toBe(true);
@@ -88,7 +88,7 @@ describe("ExternalThread branches", () => {
88
88
  const { aui } = renderThread(
89
89
  baseProps(adapterFor(["a1", "a2", "a3"], switchToBranch)),
90
90
  );
91
- const msg = () => aui().thread().message({ index: 1 });
91
+ const msg = () => aui().thread.message({ index: 1 });
92
92
 
93
93
  msg().switchToBranch({ position: "previous" });
94
94
  expect(switchToBranch).toHaveBeenLastCalledWith("a1");
@@ -101,7 +101,7 @@ describe("ExternalThread branches", () => {
101
101
  isRunning: false,
102
102
  branches: adapterFor(["a1", "a2"], switchToBranch),
103
103
  });
104
- edge.aui().thread().message({ index: 1 }).switchToBranch({
104
+ edge.aui().thread.message({ index: 1 }).switchToBranch({
105
105
  position: "previous",
106
106
  });
107
107
  expect(switchToBranch).not.toHaveBeenCalled();
@@ -112,7 +112,7 @@ describe("ExternalThread branches", () => {
112
112
  const { aui } = renderThread(
113
113
  baseProps(adapterFor(["a1", "a2"], switchToBranch)),
114
114
  );
115
- const msg = () => aui().thread().message({ index: 1 });
115
+ const msg = () => aui().thread.message({ index: 1 });
116
116
 
117
117
  msg().switchToBranch({ branchId: "not-in-the-list" });
118
118
  expect(switchToBranch).toHaveBeenLastCalledWith("not-in-the-list");
@@ -130,11 +130,11 @@ describe("ExternalThread branches", () => {
130
130
  switchToBranch,
131
131
  }),
132
132
  );
133
- const state = aui().thread().getState();
133
+ const state = aui().thread.getState();
134
134
  expect(state.messages[1]!.branchNumber).toBe(1);
135
135
  expect(state.messages[1]!.branchCount).toBe(1);
136
136
 
137
- aui().thread().message({ index: 1 }).switchToBranch({ position: "next" });
137
+ aui().thread.message({ index: 1 }).switchToBranch({ position: "next" });
138
138
  expect(switchToBranch).not.toHaveBeenCalled();
139
139
  });
140
140
 
@@ -145,14 +145,14 @@ describe("ExternalThread branches", () => {
145
145
  isRunning: false,
146
146
  branches: adapterFor(["a1", "a2", "a3"]),
147
147
  });
148
- const before = aui().thread().getState().messages[1];
148
+ const before = aui().thread.getState().messages[1];
149
149
 
150
150
  rerender({
151
151
  messages,
152
152
  isRunning: false,
153
153
  branches: adapterFor(["a1", "a2", "a3"]),
154
154
  });
155
- const after = aui().thread().getState().messages[1];
155
+ const after = aui().thread.getState().messages[1];
156
156
 
157
157
  expect(after!.branchNumber).toBe(2);
158
158
  expect(before!.parts[0]).toBe(after!.parts[0]);
@@ -0,0 +1,230 @@
1
+ // @vitest-environment jsdom
2
+
3
+ // Legacy-runtime parity: part statuses derive from the message status, and
4
+ // imperative composer call sequences observe writes before React re-renders.
5
+
6
+ import { render, waitFor } from "@testing-library/react";
7
+ import type { FC } from "react";
8
+ import { describe, it, expect, vi } from "vitest";
9
+ import { useAui, AuiProvider } from "@assistant-ui/store";
10
+ import type { ThreadMessage } from "@assistant-ui/core";
11
+ import {
12
+ ExternalThread,
13
+ type ExternalThreadProps,
14
+ type ExternalThreadMessage,
15
+ } from "../client/ExternalThread";
16
+
17
+ const renderThread = (props: ExternalThreadProps) => {
18
+ const captured: { aui?: ReturnType<typeof useAui> } = {};
19
+ const Capture: FC = () => {
20
+ captured.aui = useAui();
21
+ return null;
22
+ };
23
+ const App: FC<{ threadProps: ExternalThreadProps }> = ({ threadProps }) => {
24
+ const aui = useAui({ thread: ExternalThread(threadProps) });
25
+ return (
26
+ <AuiProvider value={aui}>
27
+ <Capture />
28
+ </AuiProvider>
29
+ );
30
+ };
31
+ const utils = render(<App threadProps={props} />);
32
+ return {
33
+ aui: () => captured.aui!,
34
+ rerender: (next: ExternalThreadProps) =>
35
+ utils.rerender(<App threadProps={next} />),
36
+ };
37
+ };
38
+
39
+ const assistantMessage = (
40
+ status: ThreadMessage["status"],
41
+ result?: string,
42
+ ): ExternalThreadMessage =>
43
+ ({
44
+ id: "a1",
45
+ role: "assistant",
46
+ content: [
47
+ { type: "text", text: "let me check" },
48
+ {
49
+ type: "tool-call",
50
+ toolCallId: "tc1",
51
+ toolName: "probe_tool",
52
+ args: {},
53
+ argsText: "{}",
54
+ ...(result !== undefined && { result }),
55
+ },
56
+ ],
57
+ createdAt: new Date(0),
58
+ status,
59
+ metadata: { custom: {} },
60
+ }) as unknown as ExternalThreadMessage;
61
+
62
+ describe("ExternalThread part status", () => {
63
+ it("gives an unresolved tool call its message's status", () => {
64
+ const { aui } = renderThread({
65
+ messages: [assistantMessage({ type: "running" })],
66
+ isRunning: true,
67
+ });
68
+ const part = (toolCallId: string) =>
69
+ aui().thread.message({ id: "a1" }).part({ toolCallId }).getState();
70
+ expect(part("tc1").status).toEqual({ type: "running" });
71
+ });
72
+
73
+ it("marks a resolved tool call complete and streams only the last part", () => {
74
+ const { aui } = renderThread({
75
+ messages: [assistantMessage({ type: "running" }, "ok")],
76
+ isRunning: true,
77
+ });
78
+ const state = aui().thread.message({ id: "a1" }).getState();
79
+ expect(state.parts[0]!.status).toEqual({ type: "complete" });
80
+ expect(state.parts[1]!.status).toEqual({ type: "complete" });
81
+ });
82
+
83
+ it("keeps parts complete on requires-action and user messages", () => {
84
+ const { aui } = renderThread({
85
+ messages: [
86
+ {
87
+ id: "u1",
88
+ role: "user",
89
+ content: [{ type: "text", text: "hi" }],
90
+ createdAt: new Date(0),
91
+ attachments: [],
92
+ metadata: { custom: {} },
93
+ } as unknown as ExternalThreadMessage,
94
+ assistantMessage({ type: "requires-action", reason: "tool-calls" }),
95
+ ],
96
+ isRunning: false,
97
+ });
98
+ expect(
99
+ aui().thread.message({ id: "u1" }).part({ index: 0 }).getState().status,
100
+ ).toEqual({ type: "complete" });
101
+ expect(
102
+ aui()
103
+ .thread()
104
+ .message({ id: "a1" })
105
+ .part({ toolCallId: "tc1" })
106
+ .getState().status,
107
+ ).toEqual({ type: "requires-action", reason: "tool-calls" });
108
+ expect(
109
+ aui().thread.message({ id: "a1" }).part({ index: 0 }).getState().status,
110
+ ).toEqual({ type: "complete" });
111
+ });
112
+ });
113
+
114
+ describe("ExternalThread unset optional callbacks", () => {
115
+ it("throws a capability error when the callback prop is not set", () => {
116
+ const { aui } = renderThread({
117
+ messages: [
118
+ assistantMessage({ type: "requires-action", reason: "tool-calls" }),
119
+ ],
120
+ isRunning: false,
121
+ });
122
+ const part = () =>
123
+ aui().thread.message({ id: "a1" }).part({ toolCallId: "tc1" });
124
+
125
+ expect(() => part().addToolResult("ok")).toThrow(
126
+ "Runtime does not support tool results (onAddToolResult is not set).",
127
+ );
128
+ expect(() => part().resumeToolCall(undefined)).toThrow(
129
+ "Runtime does not support resuming tool calls (onResumeToolCall is not set).",
130
+ );
131
+ expect(() => aui().thread.resumeRun()).toThrow(
132
+ "Runtime does not support resuming runs (onResume is not set).",
133
+ );
134
+ expect(() => aui().thread.importExternalState({})).toThrow(
135
+ "Runtime does not support importing external states (onLoadExternalState is not set).",
136
+ );
137
+ });
138
+ });
139
+
140
+ describe("ExternalThread composer", () => {
141
+ it("dispatches a synchronous setText + send sequence", async () => {
142
+ const onNew = vi.fn();
143
+ const { aui } = renderThread({ messages: [], isRunning: false, onNew });
144
+
145
+ aui().thread.composer().setText("hello");
146
+ aui().thread.composer().send();
147
+
148
+ await waitFor(() => expect(onNew).toHaveBeenCalledTimes(1));
149
+ expect(onNew.mock.calls[0]![0].content).toEqual([
150
+ { type: "text", text: "hello" },
151
+ ]);
152
+ await waitFor(() => expect(aui().thread.getState().composer.text).toBe(""));
153
+ });
154
+
155
+ it("stamps the thread head as parentId on queue-adapter sends", async () => {
156
+ const enqueue = vi.fn();
157
+ const { aui } = renderThread({
158
+ messages: [
159
+ {
160
+ id: "u1",
161
+ role: "user",
162
+ content: [{ type: "text", text: "hi" }],
163
+ createdAt: new Date(0),
164
+ attachments: [],
165
+ metadata: { custom: {} },
166
+ } as unknown as ExternalThreadMessage,
167
+ ],
168
+ isRunning: true,
169
+ queue: {
170
+ items: [],
171
+ enqueue,
172
+ steer: vi.fn(),
173
+ remove: vi.fn(),
174
+ clear: vi.fn(),
175
+ },
176
+ });
177
+
178
+ aui().thread.composer().setText("queued");
179
+ aui().thread.composer().send();
180
+
181
+ await waitFor(() => expect(enqueue).toHaveBeenCalledTimes(1));
182
+ expect(enqueue.mock.calls[0]![0].parentId).toBe("u1");
183
+ });
184
+
185
+ it("still refuses to send an empty composer synchronously after a send", async () => {
186
+ const onNew = vi.fn();
187
+ const { aui } = renderThread({ messages: [], isRunning: false, onNew });
188
+
189
+ aui().thread.composer().send();
190
+ aui().thread.composer().setText("first");
191
+ aui().thread.composer().send();
192
+ aui().thread.composer().send();
193
+
194
+ await new Promise((resolve) => setTimeout(resolve, 0));
195
+ expect(onNew).toHaveBeenCalledTimes(1);
196
+ });
197
+ });
198
+
199
+ describe("ExternalThread duplicate message ids", () => {
200
+ const userMessage = (id: string, text: string): ExternalThreadMessage =>
201
+ ({
202
+ id,
203
+ role: "user",
204
+ content: [{ type: "text", text }],
205
+ createdAt: new Date(0),
206
+ metadata: { custom: {} },
207
+ }) as unknown as ExternalThreadMessage;
208
+
209
+ it("warns and keeps the last occurrence instead of throwing on a duplicate id", () => {
210
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
211
+ try {
212
+ const { aui } = renderThread({
213
+ messages: [
214
+ userMessage("u1", "hi"),
215
+ userMessage("dup", "stale"),
216
+ userMessage("dup", "fresh"),
217
+ ],
218
+ });
219
+
220
+ const state = aui().thread.getState();
221
+ expect(state.messages.map((m) => m.id)).toEqual(["u1", "dup"]);
222
+
223
+ const dup = aui().thread.message({ id: "dup" }).getState();
224
+ expect(dup.parts[0]).toMatchObject({ type: "text", text: "fresh" });
225
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('"dup"'));
226
+ } finally {
227
+ warn.mockRestore();
228
+ }
229
+ });
230
+ });