@assistant-ui/core 0.3.11 → 0.3.13

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 (132) hide show
  1. package/dist/index.d.ts +6 -5
  2. package/dist/index.js +2 -2
  3. package/dist/index.js.map +1 -1
  4. package/dist/internal.d.ts +6 -5
  5. package/dist/internal.js +5 -4
  6. package/dist/react/client/interactable-model-context.d.ts.map +1 -1
  7. package/dist/react/client/interactable-model-context.js +5 -1
  8. package/dist/react/client/interactable-model-context.js.map +1 -1
  9. package/dist/runtime/api/message-runtime.d.ts +2 -3
  10. package/dist/runtime/api/message-runtime.d.ts.map +1 -1
  11. package/dist/runtime/api/message-runtime.js +2 -16
  12. package/dist/runtime/api/message-runtime.js.map +1 -1
  13. package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
  14. package/dist/runtime/api/thread-runtime.js +5 -1
  15. package/dist/runtime/api/thread-runtime.js.map +1 -1
  16. package/dist/runtime/base/base-composer-runtime-core.d.ts +25 -0
  17. package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
  18. package/dist/runtime/base/base-composer-runtime-core.js +64 -11
  19. package/dist/runtime/base/base-composer-runtime-core.js.map +1 -1
  20. package/dist/runtime/base/base-thread-runtime-core.d.ts +14 -0
  21. package/dist/runtime/base/base-thread-runtime-core.d.ts.map +1 -1
  22. package/dist/runtime/base/base-thread-runtime-core.js +31 -0
  23. package/dist/runtime/base/base-thread-runtime-core.js.map +1 -1
  24. package/dist/runtime/base/default-edit-composer-runtime-core.d.ts.map +1 -1
  25. package/dist/runtime/base/default-edit-composer-runtime-core.js +1 -6
  26. package/dist/runtime/base/default-edit-composer-runtime-core.js.map +1 -1
  27. package/dist/runtime/base/default-thread-composer-runtime-core.d.ts.map +1 -1
  28. package/dist/runtime/base/default-thread-composer-runtime-core.js +1 -4
  29. package/dist/runtime/base/default-thread-composer-runtime-core.js.map +1 -1
  30. package/dist/runtime/internal.d.ts +4 -4
  31. package/dist/runtime/internal.js +4 -4
  32. package/dist/runtime/queue/external-thread-queue-adapter.d.ts +15 -0
  33. package/dist/runtime/queue/external-thread-queue-adapter.d.ts.map +1 -1
  34. package/dist/runtime/queue/message-queue.js +23 -10
  35. package/dist/runtime/queue/message-queue.js.map +1 -1
  36. package/dist/runtimes/external-store/external-store-adapter.d.ts +7 -0
  37. package/dist/runtimes/external-store/external-store-adapter.d.ts.map +1 -1
  38. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts +3 -1
  39. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  40. package/dist/runtimes/external-store/external-store-thread-runtime-core.js +49 -13
  41. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  42. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  43. package/dist/runtimes/local/local-thread-runtime-core.js +2 -1
  44. package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
  45. package/dist/store/clients/external-thread.d.ts +61 -0
  46. package/dist/store/clients/external-thread.d.ts.map +1 -0
  47. package/dist/store/clients/external-thread.js +1445 -0
  48. package/dist/store/clients/external-thread.js.map +1 -0
  49. package/dist/store/clients/runtime-adapter.d.ts.map +1 -1
  50. package/dist/store/clients/runtime-adapter.js +5 -2
  51. package/dist/store/clients/runtime-adapter.js.map +1 -1
  52. package/dist/store/clients/single-thread-list.d.ts +9 -0
  53. package/dist/store/clients/single-thread-list.d.ts.map +1 -0
  54. package/dist/store/clients/single-thread-list.js +175 -0
  55. package/dist/store/clients/single-thread-list.js.map +1 -0
  56. package/dist/store/clients/suggestions.d.ts +3 -1
  57. package/dist/store/clients/suggestions.d.ts.map +1 -1
  58. package/dist/store/clients/suggestions.js +65 -37
  59. package/dist/store/clients/suggestions.js.map +1 -1
  60. package/dist/store/index.d.ts +5 -3
  61. package/dist/store/index.js +4 -2
  62. package/dist/store/runtime-clients/thread-list-item-runtime-client.d.ts.map +1 -1
  63. package/dist/store/runtime-clients/thread-list-item-runtime-client.js +64 -50
  64. package/dist/store/runtime-clients/thread-list-item-runtime-client.js.map +1 -1
  65. package/dist/store/runtime-clients/thread-runtime-client.d.ts.map +1 -1
  66. package/dist/store/runtime-clients/thread-runtime-client.js +98 -82
  67. package/dist/store/runtime-clients/thread-runtime-client.js.map +1 -1
  68. package/dist/store/scope-registration.d.ts +2 -2
  69. package/dist/store/scopes/suggestions.d.ts +6 -1
  70. package/dist/store/scopes/suggestions.d.ts.map +1 -1
  71. package/dist/store/scopes/thread.d.ts +17 -0
  72. package/dist/store/scopes/thread.d.ts.map +1 -1
  73. package/dist/types/augmentations.d.ts +33 -0
  74. package/dist/types/augmentations.d.ts.map +1 -0
  75. package/dist/types/augmentations.js +0 -0
  76. package/dist/types/error.d.ts +15 -1
  77. package/dist/types/error.d.ts.map +1 -1
  78. package/dist/types/error.js +18 -1
  79. package/dist/types/error.js.map +1 -1
  80. package/dist/types/message.d.ts +2 -0
  81. package/dist/types/message.d.ts.map +1 -1
  82. package/dist/types/message.js.map +1 -1
  83. package/dist/utils/normalizePartStatus.d.ts +3 -2
  84. package/dist/utils/normalizePartStatus.d.ts.map +1 -1
  85. package/dist/utils/normalizePartStatus.js +15 -1
  86. package/dist/utils/normalizePartStatus.js.map +1 -1
  87. package/package.json +5 -5
  88. package/src/index.ts +7 -0
  89. package/src/internal.ts +1 -1
  90. package/src/react/client/interactable-model-context.ts +6 -1
  91. package/src/react/runtimes/useExternalStoreRuntime.suggestions.test.tsx +114 -0
  92. package/src/runtime/api/message-runtime.test.ts +1 -1
  93. package/src/runtime/api/message-runtime.ts +2 -35
  94. package/src/runtime/api/thread-runtime.ts +8 -1
  95. package/src/runtime/base/base-composer-runtime-core.ts +100 -8
  96. package/src/runtime/base/base-thread-runtime-core.ts +36 -0
  97. package/src/runtime/base/default-edit-composer-runtime-core.ts +1 -15
  98. package/src/runtime/base/default-thread-composer-runtime-core.ts +1 -11
  99. package/src/runtime/queue/external-thread-queue-adapter.ts +17 -0
  100. package/src/runtime/queue/message-queue.ts +26 -9
  101. package/src/runtimes/external-store/external-store-adapter.ts +7 -0
  102. package/src/runtimes/external-store/external-store-thread-runtime-core.ts +100 -26
  103. package/src/runtimes/local/local-thread-runtime-core.ts +4 -1
  104. package/src/store/clients/external-thread.ts +1255 -0
  105. package/src/store/clients/runtime-adapter.ts +5 -2
  106. package/src/store/clients/single-thread-list.ts +116 -0
  107. package/src/store/clients/suggestions.ts +39 -13
  108. package/src/store/index.ts +6 -0
  109. package/src/store/runtime-clients/thread-list-item-runtime-client.ts +13 -28
  110. package/src/store/runtime-clients/thread-runtime-client.ts +5 -0
  111. package/src/store/scopes/suggestions.ts +6 -0
  112. package/src/store/scopes/thread.ts +17 -0
  113. package/src/tests/append-interactable-snapshots.test.ts +350 -0
  114. package/src/tests/base-composer-runtime-core-send.test.ts +126 -0
  115. package/src/tests/base-composer-runtime-core.test.ts +108 -1
  116. package/src/tests/default-edit-composer-runtime-core.test.ts +0 -68
  117. package/src/tests/external-store-thread-runtime-core-adapter.test.ts +300 -0
  118. package/src/tests/external-thread-attachments.test.tsx +584 -0
  119. package/src/tests/external-thread-branches.test.tsx +157 -0
  120. package/src/tests/external-thread-feedback.test.tsx +204 -0
  121. package/src/tests/external-thread-parity.test.tsx +548 -0
  122. package/src/tests/external-thread-refetch.test.tsx +117 -0
  123. package/src/tests/external-thread-speech.test.tsx +328 -0
  124. package/src/tests/external-thread-suggestions.test.tsx +59 -0
  125. package/src/tests/message-queue.test.ts +26 -0
  126. package/src/tests/thread-message-like.test.ts +39 -0
  127. package/src/tests/thread-switch-events.test.tsx +136 -0
  128. package/src/types/augmentations.ts +33 -0
  129. package/src/types/error.ts +24 -0
  130. package/src/types/message.ts +2 -0
  131. package/src/utils/normalizePartStatus.ts +27 -0
  132. package/src/tests/default-thread-composer-runtime-core.test.ts +0 -82
@@ -0,0 +1,584 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, render, waitFor } from "@testing-library/react";
4
+ import type { FC } from "react";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { AuiProvider, useAui } from "@assistant-ui/store";
7
+ import type {
8
+ ExternalThreadMessage,
9
+ ExternalThreadProps,
10
+ } from "../store/clients/external-thread";
11
+ import { ExternalThread } from "../store/clients/external-thread";
12
+ import type {
13
+ CompleteAttachment,
14
+ PendingAttachment,
15
+ } from "../types/attachment";
16
+
17
+ const renderThreadWithProps = (props: Partial<ExternalThreadProps>) => {
18
+ const captured: { aui?: ReturnType<typeof useAui> } = {};
19
+ const Capture: FC = () => {
20
+ captured.aui = useAui();
21
+ return null;
22
+ };
23
+ const App: FC = () => {
24
+ const aui = useAui({
25
+ thread: ExternalThread({ messages: [], isRunning: false, ...props }),
26
+ });
27
+ return (
28
+ <AuiProvider value={aui}>
29
+ <Capture />
30
+ </AuiProvider>
31
+ );
32
+ };
33
+
34
+ render(<App />);
35
+ return () => captured.aui!;
36
+ };
37
+
38
+ const renderThread = () => {
39
+ const captured: { aui?: ReturnType<typeof useAui> } = {};
40
+ const Capture: FC = () => {
41
+ captured.aui = useAui();
42
+ return null;
43
+ };
44
+ const App: FC = () => {
45
+ const aui = useAui({
46
+ thread: ExternalThread({ messages: [], isRunning: false }),
47
+ });
48
+ return (
49
+ <AuiProvider value={aui}>
50
+ <Capture />
51
+ </AuiProvider>
52
+ );
53
+ };
54
+
55
+ render(<App />);
56
+ return () => captured.aui!;
57
+ };
58
+
59
+ describe("ExternalThread attachments", () => {
60
+ it("adds prepared attachments when File is unavailable", async () => {
61
+ const aui = renderThread();
62
+ const fileConstructor = globalThis.File;
63
+ vi.stubGlobal("File", undefined);
64
+
65
+ try {
66
+ await act(async () => {
67
+ await aui()
68
+ .thread()
69
+ .composer()
70
+ .addAttachment({
71
+ name: "notes.txt",
72
+ contentType: "text/plain",
73
+ content: [{ type: "text", text: "hello" }],
74
+ });
75
+ });
76
+ } finally {
77
+ vi.stubGlobal("File", fileConstructor);
78
+ }
79
+
80
+ await waitFor(() => {
81
+ expect(aui().thread.composer().getState().attachments[0]).toMatchObject({
82
+ type: "document",
83
+ name: "notes.txt",
84
+ contentType: "text/plain",
85
+ content: [{ type: "text", text: "hello" }],
86
+ });
87
+ });
88
+ });
89
+
90
+ it("preserves foreign files that expose content", async () => {
91
+ const aui = renderThread();
92
+ const foreignFile = {
93
+ name: "photo.png",
94
+ type: "image/png",
95
+ lastModified: 0,
96
+ content: [{ type: "text", text: "implementation detail" }],
97
+ } as File;
98
+
99
+ await act(() => aui().thread.composer().addAttachment(foreignFile));
100
+
101
+ expect(aui().thread.composer().getState().attachments[0]).toMatchObject({
102
+ type: "file",
103
+ name: "photo.png",
104
+ contentType: "image/png",
105
+ file: foreignFile,
106
+ });
107
+ });
108
+
109
+ it("rejects files that do not match the adapter accept string", async () => {
110
+ const add = vi.fn(async () => ({}) as never);
111
+ const aui = renderThreadWithProps({
112
+ attachmentAdapter: {
113
+ accept: "image/*",
114
+ add,
115
+ send: async () => ({}) as never,
116
+ remove: async () => {},
117
+ },
118
+ });
119
+
120
+ await expect(
121
+ aui()
122
+ .thread()
123
+ .composer()
124
+ .addAttachment(
125
+ new File(["data"], "archive.zip", { type: "application/zip" }),
126
+ ),
127
+ ).rejects.toThrow(
128
+ "File type application/zip is not accepted. Accepted types: image/*",
129
+ );
130
+ expect(add).not.toHaveBeenCalled();
131
+ expect(aui().thread.composer().getState().attachments).toHaveLength(0);
132
+ });
133
+
134
+ it("rejects prepared attachments that do not match the adapter accept string", async () => {
135
+ const aui = renderThreadWithProps({
136
+ attachmentAdapter: {
137
+ accept: ".pdf",
138
+ add: async () => ({}) as never,
139
+ send: async () => ({}) as never,
140
+ remove: async () => {},
141
+ },
142
+ });
143
+
144
+ await expect(
145
+ aui()
146
+ .thread()
147
+ .composer()
148
+ .addAttachment({
149
+ name: "notes.txt",
150
+ contentType: "text/plain",
151
+ content: [{ type: "text", text: "hello" }],
152
+ }),
153
+ ).rejects.toThrow(
154
+ "File type text/plain is not accepted. Accepted types: .pdf",
155
+ );
156
+ expect(aui().thread.composer().getState().attachments).toHaveLength(0);
157
+ });
158
+
159
+ it("restores the draft when an attachment upload fails on send", async () => {
160
+ const consoleError = vi
161
+ .spyOn(console, "error")
162
+ .mockImplementation(() => {});
163
+ const sent: unknown[] = [];
164
+ let rejectSend!: (reason: unknown) => void;
165
+ const adapter = {
166
+ accept: "*",
167
+ add: async ({ file }: { file: File }) => ({
168
+ id: "att-1",
169
+ type: "file" as const,
170
+ name: file.name,
171
+ contentType: file.type,
172
+ file,
173
+ status: {
174
+ type: "requires-action" as const,
175
+ reason: "composer-send" as const,
176
+ },
177
+ }),
178
+ send: () =>
179
+ new Promise((_resolve, reject) => {
180
+ rejectSend = reject;
181
+ }) as never,
182
+ remove: async () => {},
183
+ };
184
+
185
+ const aui = renderThreadWithProps({
186
+ attachmentAdapter: adapter,
187
+ onNew: (message) => sent.push(message),
188
+ });
189
+
190
+ await act(() =>
191
+ aui()
192
+ .thread()
193
+ .composer()
194
+ .addAttachment(new File(["data"], "notes.txt", { type: "text/plain" })),
195
+ );
196
+ await act(async () => {
197
+ aui().thread.composer().setText("hello");
198
+ });
199
+ await act(async () => {
200
+ aui().thread.composer().send();
201
+ });
202
+
203
+ // The draft is cleared optimistically while the upload runs.
204
+ expect(aui().thread.composer().getState().text).toBe("");
205
+
206
+ await act(async () => {
207
+ rejectSend(new Error("upload failed"));
208
+ });
209
+
210
+ await waitFor(() => {
211
+ const state = aui().thread.composer().getState();
212
+ expect(state.text).toBe("hello");
213
+ expect(state.attachments).toHaveLength(1);
214
+ });
215
+ expect(sent).toHaveLength(0);
216
+ expect(consoleError).toHaveBeenCalledWith(
217
+ "Failed to send attachments",
218
+ expect.any(Error),
219
+ );
220
+ });
221
+
222
+ it("preserves composer state while attachments are prepared for send", async () => {
223
+ let resolveSend!: (attachment: CompleteAttachment) => void;
224
+ const onNew = vi.fn();
225
+ const file = new File(["data"], "notes.txt", { type: "text/plain" });
226
+ const adapter = {
227
+ accept: "*",
228
+ add: async () => ({
229
+ id: "att-1",
230
+ type: "file" as const,
231
+ name: file.name,
232
+ contentType: file.type,
233
+ file,
234
+ status: {
235
+ type: "requires-action" as const,
236
+ reason: "composer-send" as const,
237
+ },
238
+ }),
239
+ send: () =>
240
+ new Promise<CompleteAttachment>((resolve) => {
241
+ resolveSend = resolve;
242
+ }),
243
+ remove: async () => {},
244
+ };
245
+ const aui = renderThreadWithProps({ attachmentAdapter: adapter, onNew });
246
+ const composer = () => aui().thread.composer();
247
+
248
+ await act(() => composer().addAttachment(file));
249
+ act(() => {
250
+ composer().setText("first message");
251
+ composer().setRole("assistant");
252
+ composer().setRunConfig({ model: "model-a" });
253
+ composer().send();
254
+ composer().setRole("system");
255
+ composer().setRunConfig({ model: "model-b" });
256
+ });
257
+ await act(async () => {
258
+ resolveSend({
259
+ id: "att-1",
260
+ type: "file",
261
+ name: file.name,
262
+ contentType: file.type,
263
+ status: { type: "complete" },
264
+ content: [],
265
+ });
266
+ });
267
+
268
+ await waitFor(() => expect(onNew).toHaveBeenCalledTimes(1));
269
+ expect(onNew.mock.calls[0]![0]).toMatchObject({
270
+ role: "assistant",
271
+ runConfig: { model: "model-a" },
272
+ });
273
+ });
274
+
275
+ it.each([
276
+ [
277
+ "clearAttachments",
278
+ (c: { clearAttachments(): Promise<void> }) => c.clearAttachments(),
279
+ ],
280
+ ["reset", (c: { reset(): Promise<void> }) => c.reset()],
281
+ ] as const)(
282
+ "calls adapter.remove for pending attachments on %s",
283
+ async (_name, invoke) => {
284
+ const remove = vi.fn(async () => {});
285
+ const adapter = {
286
+ accept: "*",
287
+ add: async ({ file }: { file: File }) => ({
288
+ id: "att-1",
289
+ type: "file" as const,
290
+ name: file.name,
291
+ contentType: file.type,
292
+ file,
293
+ status: {
294
+ type: "requires-action" as const,
295
+ reason: "composer-send" as const,
296
+ },
297
+ }),
298
+ send: async () => ({}) as never,
299
+ remove,
300
+ };
301
+
302
+ const aui = renderThreadWithProps({ attachmentAdapter: adapter });
303
+
304
+ await act(() =>
305
+ aui()
306
+ .thread()
307
+ .composer()
308
+ .addAttachment(
309
+ new File(["data"], "notes.txt", { type: "text/plain" }),
310
+ ),
311
+ );
312
+
313
+ await act(() => invoke(aui().thread.composer()));
314
+
315
+ expect(remove).toHaveBeenCalledTimes(1);
316
+ expect(remove).toHaveBeenCalledWith(
317
+ expect.objectContaining({ id: "att-1" }),
318
+ );
319
+ expect(aui().thread.composer().getState().attachments).toHaveLength(0);
320
+ },
321
+ );
322
+
323
+ it.each([
324
+ [
325
+ "clearAttachments",
326
+ (c: { clearAttachments(): Promise<void> }) => c.clearAttachments(),
327
+ ],
328
+ ["reset", (c: { reset(): Promise<void> }) => c.reset()],
329
+ ] as const)(
330
+ "does not restore an attachment that resolves after %s",
331
+ async (_name, invoke) => {
332
+ let resolveAdd!: (attachment: PendingAttachment) => void;
333
+ const add = vi.fn(
334
+ () =>
335
+ new Promise<PendingAttachment>((resolve) => {
336
+ resolveAdd = resolve;
337
+ }),
338
+ );
339
+ const aui = renderThreadWithProps({
340
+ attachmentAdapter: {
341
+ accept: "*",
342
+ add,
343
+ send: async () => ({}) as never,
344
+ remove: async () => {},
345
+ },
346
+ });
347
+ const file = new File(["data"], "notes.txt", { type: "text/plain" });
348
+
349
+ let addPromise!: Promise<void>;
350
+ act(() => {
351
+ addPromise = aui().thread.composer().addAttachment(file);
352
+ });
353
+ await act(() => invoke(aui().thread.composer()));
354
+ await act(async () => {
355
+ resolveAdd({
356
+ id: "att-1",
357
+ type: "file",
358
+ name: file.name,
359
+ contentType: file.type,
360
+ file,
361
+ status: { type: "requires-action", reason: "composer-send" },
362
+ });
363
+ await addPromise;
364
+ });
365
+
366
+ expect(aui().thread.composer().getState().attachments).toHaveLength(0);
367
+ },
368
+ );
369
+
370
+ it("stops attachment generator updates after removal", async () => {
371
+ let resumeAdd!: () => void;
372
+ const drainedAfterRemoval = vi.fn();
373
+ const file = new File(["data"], "notes.txt", { type: "text/plain" });
374
+ const pendingAttachment: PendingAttachment = {
375
+ id: "att-1",
376
+ type: "file",
377
+ name: file.name,
378
+ contentType: file.type,
379
+ file,
380
+ status: { type: "requires-action", reason: "composer-send" },
381
+ };
382
+ const add = vi.fn(async function* () {
383
+ yield pendingAttachment;
384
+ await new Promise<void>((resolve) => {
385
+ resumeAdd = resolve;
386
+ });
387
+ yield {
388
+ ...pendingAttachment,
389
+ status: { type: "running", reason: "uploading", progress: 1 },
390
+ } satisfies PendingAttachment;
391
+ drainedAfterRemoval();
392
+ });
393
+ const aui = renderThreadWithProps({
394
+ attachmentAdapter: {
395
+ accept: "*",
396
+ add,
397
+ send: async () => ({}) as never,
398
+ remove: async () => {},
399
+ },
400
+ });
401
+ const composer = () => aui().thread.composer();
402
+
403
+ let addPromise!: Promise<void>;
404
+ act(() => {
405
+ addPromise = composer().addAttachment(file);
406
+ });
407
+ await waitFor(() =>
408
+ expect(composer().getState().attachments).toHaveLength(1),
409
+ );
410
+ await act(() => composer().attachment({ id: "att-1" }).remove());
411
+ await act(async () => {
412
+ resumeAdd();
413
+ await addPromise;
414
+ });
415
+
416
+ expect(composer().getState().attachments).toHaveLength(0);
417
+ expect(drainedAfterRemoval).not.toHaveBeenCalled();
418
+ });
419
+
420
+ it("does not restore attachment generator updates after send", async () => {
421
+ let resumeAdd!: () => void;
422
+ const drainedAfterSend = vi.fn();
423
+ const onNew = vi.fn();
424
+ const file = new File(["data"], "notes.txt", { type: "text/plain" });
425
+ const pendingAttachment: PendingAttachment = {
426
+ id: "att-1",
427
+ type: "file",
428
+ name: file.name,
429
+ contentType: file.type,
430
+ file,
431
+ status: { type: "requires-action", reason: "composer-send" },
432
+ };
433
+ const add = vi.fn(async function* () {
434
+ yield pendingAttachment;
435
+ await new Promise<void>((resolve) => {
436
+ resumeAdd = resolve;
437
+ });
438
+ yield {
439
+ ...pendingAttachment,
440
+ status: { type: "running", reason: "uploading", progress: 1 },
441
+ } satisfies PendingAttachment;
442
+ drainedAfterSend();
443
+ });
444
+ const aui = renderThreadWithProps({
445
+ attachmentAdapter: {
446
+ accept: "*",
447
+ add,
448
+ send: async (attachment) => ({
449
+ ...attachment,
450
+ status: { type: "complete" },
451
+ content: [],
452
+ }),
453
+ remove: async () => {},
454
+ },
455
+ onNew,
456
+ });
457
+ const composer = () => aui().thread.composer();
458
+
459
+ let addPromise!: Promise<void>;
460
+ act(() => {
461
+ addPromise = composer().addAttachment(file);
462
+ });
463
+ await waitFor(() =>
464
+ expect(composer().getState().attachments).toHaveLength(1),
465
+ );
466
+ act(() => {
467
+ composer().setText("hello");
468
+ composer().send();
469
+ });
470
+ await waitFor(() => expect(onNew).toHaveBeenCalledTimes(1));
471
+ await act(async () => {
472
+ resumeAdd();
473
+ await addPromise;
474
+ });
475
+
476
+ expect(composer().getState().attachments).toHaveLength(0);
477
+ expect(drainedAfterSend).not.toHaveBeenCalled();
478
+ });
479
+
480
+ it("routes edit-composer attachments through the adapter", async () => {
481
+ const add = vi.fn(async ({ file }: { file: File }) => ({
482
+ id: "att-edit",
483
+ type: "file" as const,
484
+ name: file.name,
485
+ contentType: file.type,
486
+ file,
487
+ status: {
488
+ type: "requires-action" as const,
489
+ reason: "composer-send" as const,
490
+ },
491
+ }));
492
+ const aui = renderThreadWithProps({
493
+ messages: [
494
+ {
495
+ id: "u1",
496
+ role: "user",
497
+ content: [{ type: "text", text: "hi" }],
498
+ createdAt: new Date(0),
499
+ attachments: [],
500
+ metadata: { custom: {} },
501
+ } as unknown as ExternalThreadMessage,
502
+ ],
503
+ onEdit: () => {},
504
+ attachmentAdapter: {
505
+ accept: "*",
506
+ add,
507
+ send: async () => ({}) as never,
508
+ remove: async () => {},
509
+ },
510
+ });
511
+
512
+ const composer = () => aui().thread.message({ id: "u1" }).composer();
513
+ await act(async () => {
514
+ composer().beginEdit();
515
+ });
516
+ await act(() =>
517
+ composer().addAttachment(
518
+ new File(["data"], "notes.txt", { type: "text/plain" }),
519
+ ),
520
+ );
521
+
522
+ expect(add).toHaveBeenCalledTimes(1);
523
+ expect(composer().getState().attachments[0]).toMatchObject({
524
+ id: "att-edit",
525
+ name: "notes.txt",
526
+ });
527
+ });
528
+
529
+ it("merges the failed send into a draft the user already modified", async () => {
530
+ vi.spyOn(console, "error").mockImplementation(() => {});
531
+ let rejectSend!: (reason: unknown) => void;
532
+ const adapter = {
533
+ accept: "*",
534
+ add: async ({ file }: { file: File }) => ({
535
+ id: "att-1",
536
+ type: "file" as const,
537
+ name: file.name,
538
+ contentType: file.type,
539
+ file,
540
+ status: {
541
+ type: "requires-action" as const,
542
+ reason: "composer-send" as const,
543
+ },
544
+ }),
545
+ send: () =>
546
+ new Promise((_resolve, reject) => {
547
+ rejectSend = reject;
548
+ }) as never,
549
+ remove: async () => {},
550
+ };
551
+
552
+ const aui = renderThreadWithProps({
553
+ attachmentAdapter: adapter,
554
+ onNew: () => {},
555
+ });
556
+
557
+ await act(() =>
558
+ aui()
559
+ .thread()
560
+ .composer()
561
+ .addAttachment(new File(["data"], "notes.txt", { type: "text/plain" })),
562
+ );
563
+ await act(async () => {
564
+ aui().thread.composer().setText("hello");
565
+ aui().thread.composer().send();
566
+ });
567
+ await act(async () => {
568
+ aui().thread.composer().setText("meanwhile");
569
+ });
570
+
571
+ await act(async () => {
572
+ rejectSend(new Error("upload failed"));
573
+ });
574
+
575
+ const state = aui().thread.composer().getState();
576
+ expect(state.text).toBe("hello\nmeanwhile");
577
+ expect(state.attachments).toHaveLength(1);
578
+ expect(state.attachments[0]).toMatchObject({ id: "att-1" });
579
+ });
580
+
581
+ afterEach(() => {
582
+ vi.restoreAllMocks();
583
+ });
584
+ });