@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,548 @@
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 "../index";
11
+ import {
12
+ ExternalThread,
13
+ type ExternalThreadProps,
14
+ type ExternalThreadMessage,
15
+ } from "../store/clients/external-thread";
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 assistantMessageWithContent = (
40
+ status: ThreadMessage["status"],
41
+ content: ExternalThreadMessage["content"],
42
+ id = "a1",
43
+ ): ExternalThreadMessage =>
44
+ ({
45
+ id,
46
+ role: "assistant",
47
+ content,
48
+ createdAt: new Date(0),
49
+ status,
50
+ metadata: { custom: {} },
51
+ }) as unknown as ExternalThreadMessage;
52
+
53
+ const assistantMessage = (
54
+ status: ThreadMessage["status"],
55
+ result?: string,
56
+ ): ExternalThreadMessage =>
57
+ assistantMessageWithContent(status, [
58
+ { type: "text", text: "let me check" },
59
+ {
60
+ type: "tool-call",
61
+ toolCallId: "tc1",
62
+ toolName: "probe_tool",
63
+ args: {},
64
+ argsText: "{}",
65
+ ...(result !== undefined && { result }),
66
+ },
67
+ ]);
68
+
69
+ describe("ExternalThread part status", () => {
70
+ it("gives an unresolved tool call its message's status", () => {
71
+ const { aui } = renderThread({
72
+ messages: [assistantMessage({ type: "running" })],
73
+ isRunning: true,
74
+ });
75
+ const part = (toolCallId: string) =>
76
+ aui().thread.message({ id: "a1" }).part({ toolCallId }).getState();
77
+ expect(part("tc1").status).toEqual({ type: "running" });
78
+ });
79
+
80
+ it("uses positional fallback for statusless parts and resolved tool calls", () => {
81
+ const { aui } = renderThread({
82
+ messages: [
83
+ assistantMessage({ type: "running" }, "ok"),
84
+ assistantMessageWithContent(
85
+ { type: "running" },
86
+ [
87
+ { type: "text", text: "first" },
88
+ { type: "reasoning", text: "last" },
89
+ ],
90
+ "a2",
91
+ ),
92
+ ],
93
+ isRunning: true,
94
+ });
95
+ const state = aui().thread.message({ id: "a1" }).getState();
96
+ expect(state.parts[0]!.status).toEqual({ type: "complete" });
97
+ expect(state.parts[1]!.status).toEqual({ type: "complete" });
98
+ const fallbackState = aui().thread.message({ id: "a2" }).getState();
99
+ expect(fallbackState.parts[0]!.status).toEqual({ type: "complete" });
100
+ expect(fallbackState.parts[1]!.status).toEqual({ type: "running" });
101
+ });
102
+
103
+ it("honours supplied statuses while the message is running", () => {
104
+ const { aui } = renderThread({
105
+ messages: [
106
+ assistantMessageWithContent({ type: "running" }, [
107
+ { type: "text", text: "first", status: { type: "running" } },
108
+ {
109
+ type: "reasoning",
110
+ text: "last",
111
+ status: { type: "complete" },
112
+ },
113
+ ]),
114
+ ],
115
+ isRunning: true,
116
+ });
117
+ const state = aui().thread.message({ id: "a1" }).getState();
118
+
119
+ expect(state.parts[0]!.status).toEqual({ type: "running" });
120
+ expect(state.parts[1]!.status).toEqual({ type: "complete" });
121
+ });
122
+
123
+ it("ignores supplied statuses after the message completes", () => {
124
+ const { aui } = renderThread({
125
+ messages: [
126
+ assistantMessageWithContent({ type: "complete", reason: "stop" }, [
127
+ { type: "text", text: "truncated", status: { type: "running" } },
128
+ ]),
129
+ ],
130
+ isRunning: false,
131
+ });
132
+
133
+ expect(
134
+ aui().thread.message({ id: "a1" }).part({ index: 0 }).getState().status,
135
+ ).toEqual({ type: "complete", reason: "stop" });
136
+ });
137
+
138
+ it("normalizes supplied upstream statuses", () => {
139
+ const { aui } = renderThread({
140
+ messages: [
141
+ assistantMessageWithContent(
142
+ { type: "running" },
143
+ // assistant-stream sends shapes core's MessagePartStatus does not
144
+ // declare (a reason on complete, an unlisted incomplete reason);
145
+ // the normalizer absorbs them.
146
+ [
147
+ {
148
+ type: "text",
149
+ text: "done",
150
+ status: { type: "complete", reason: "unknown" },
151
+ },
152
+ {
153
+ type: "reasoning",
154
+ text: "interrupted",
155
+ status: {
156
+ type: "incomplete",
157
+ reason: "unknown",
158
+ error: "upstream error",
159
+ },
160
+ },
161
+ ] as unknown as ExternalThreadMessage["content"],
162
+ ),
163
+ ],
164
+ isRunning: true,
165
+ });
166
+ const state = aui().thread.message({ id: "a1" }).getState();
167
+
168
+ expect(state.parts[0]!.status).toEqual({ type: "complete" });
169
+ expect(state.parts[1]!.status).toEqual({
170
+ type: "incomplete",
171
+ reason: "other",
172
+ });
173
+ });
174
+
175
+ it("keeps parts complete on requires-action and user messages", () => {
176
+ const { aui } = renderThread({
177
+ messages: [
178
+ {
179
+ id: "u1",
180
+ role: "user",
181
+ content: [{ type: "text", text: "hi" }],
182
+ createdAt: new Date(0),
183
+ attachments: [],
184
+ metadata: { custom: {} },
185
+ } as unknown as ExternalThreadMessage,
186
+ assistantMessage({ type: "requires-action", reason: "tool-calls" }),
187
+ ],
188
+ isRunning: false,
189
+ });
190
+ expect(
191
+ aui().thread.message({ id: "u1" }).part({ index: 0 }).getState().status,
192
+ ).toEqual({ type: "complete" });
193
+ expect(
194
+ aui()
195
+ .thread()
196
+ .message({ id: "a1" })
197
+ .part({ toolCallId: "tc1" })
198
+ .getState().status,
199
+ ).toEqual({ type: "requires-action", reason: "tool-calls" });
200
+ expect(
201
+ aui().thread.message({ id: "a1" }).part({ index: 0 }).getState().status,
202
+ ).toEqual({ type: "complete" });
203
+ });
204
+ });
205
+
206
+ describe("ExternalThread unset optional callbacks", () => {
207
+ it("throws a capability error when the callback prop is not set", () => {
208
+ const { aui } = renderThread({
209
+ messages: [
210
+ assistantMessage({ type: "requires-action", reason: "tool-calls" }),
211
+ ],
212
+ isRunning: false,
213
+ });
214
+ const part = () =>
215
+ aui().thread.message({ id: "a1" }).part({ toolCallId: "tc1" });
216
+
217
+ expect(() => part().addToolResult("ok")).toThrow(
218
+ "Runtime does not support tool results (onAddToolResult is not set).",
219
+ );
220
+ expect(() => part().resumeToolCall(undefined)).toThrow(
221
+ "Runtime does not support resuming tool calls (onResumeToolCall is not set).",
222
+ );
223
+ expect(() => aui().thread.resumeRun()).toThrow(
224
+ "Runtime does not support resuming runs (onResume is not set).",
225
+ );
226
+ expect(() => aui().thread.importExternalState({})).toThrow(
227
+ "Runtime does not support importing external states (onLoadExternalState is not set).",
228
+ );
229
+ });
230
+ });
231
+
232
+ describe("ExternalThread composer", () => {
233
+ it("dispatches a synchronous setText + send sequence", async () => {
234
+ const onNew = vi.fn();
235
+ const { aui } = renderThread({ messages: [], isRunning: false, onNew });
236
+
237
+ aui().thread.composer().setText("hello");
238
+ aui().thread.composer().send();
239
+
240
+ await waitFor(() => expect(onNew).toHaveBeenCalledTimes(1));
241
+ expect(onNew.mock.calls[0]![0].content).toEqual([
242
+ { type: "text", text: "hello" },
243
+ ]);
244
+ await waitFor(() => expect(aui().thread.getState().composer.text).toBe(""));
245
+ });
246
+
247
+ it("stamps the thread head as parentId on queue-adapter sends", async () => {
248
+ const enqueue = vi.fn();
249
+ const steer = vi.fn();
250
+ const { aui } = renderThread({
251
+ messages: [
252
+ {
253
+ id: "u1",
254
+ role: "user",
255
+ content: [{ type: "text", text: "hi" }],
256
+ createdAt: new Date(0),
257
+ attachments: [],
258
+ metadata: { custom: {} },
259
+ } as unknown as ExternalThreadMessage,
260
+ ],
261
+ isRunning: true,
262
+ queue: {
263
+ items: [],
264
+ steerItems: [],
265
+ enqueue,
266
+ steer,
267
+ move: vi.fn(),
268
+ edit: vi.fn(),
269
+ remove: vi.fn(),
270
+ },
271
+ });
272
+
273
+ aui().thread.composer().setText("queued");
274
+ aui().thread.composer().send();
275
+
276
+ // mid-run sends default to the steer lane
277
+ await waitFor(() => expect(steer).toHaveBeenCalledTimes(1));
278
+ expect(steer.mock.calls[0]![0].parentId).toBe("u1");
279
+ expect(enqueue).not.toHaveBeenCalled();
280
+ });
281
+
282
+ it("pauses the queue on cancel", async () => {
283
+ const notifyCancelled = vi.fn();
284
+ const onCancel = vi.fn();
285
+ const { aui } = renderThread({
286
+ messages: [],
287
+ isRunning: true,
288
+ onCancel,
289
+ queue: {
290
+ items: [],
291
+ steerItems: [],
292
+ enqueue: vi.fn(),
293
+ steer: vi.fn(),
294
+ move: vi.fn(),
295
+ edit: vi.fn(),
296
+ remove: vi.fn(),
297
+ __internal_notifyCancelled: notifyCancelled,
298
+ },
299
+ });
300
+
301
+ aui().thread.cancelRun();
302
+
303
+ expect(notifyCancelled).toHaveBeenCalledTimes(1);
304
+ expect(onCancel).toHaveBeenCalledTimes(1);
305
+ expect(notifyCancelled.mock.invocationCallOrder[0]!).toBeLessThan(
306
+ onCancel.mock.invocationCallOrder[0]!,
307
+ );
308
+ expect(aui().thread.getState().capabilities.cancel).toBe(true);
309
+ expect(aui().thread.composer().getState().canCancel).toBe(true);
310
+ });
311
+
312
+ it("leaves the queue alone when the host cannot cancel", async () => {
313
+ const notifyCancelled = vi.fn();
314
+ const { aui } = renderThread({
315
+ messages: [],
316
+ isRunning: true,
317
+ queue: {
318
+ items: [],
319
+ steerItems: [],
320
+ enqueue: vi.fn(),
321
+ steer: vi.fn(),
322
+ move: vi.fn(),
323
+ edit: vi.fn(),
324
+ remove: vi.fn(),
325
+ __internal_notifyCancelled: notifyCancelled,
326
+ },
327
+ });
328
+
329
+ aui().thread.cancelRun();
330
+
331
+ expect(notifyCancelled).not.toHaveBeenCalled();
332
+ expect(aui().thread.getState().capabilities.cancel).toBe(false);
333
+ expect(aui().thread.composer().getState().canCancel).toBe(false);
334
+ });
335
+
336
+ it("routes edit-composer sends to onEdit with sourceId, bypassing the queue", async () => {
337
+ const onEdit = vi.fn();
338
+ const enqueue = vi.fn();
339
+ const steer = vi.fn();
340
+ const { aui } = renderThread({
341
+ messages: [
342
+ {
343
+ id: "u1",
344
+ role: "user",
345
+ content: [{ type: "text", text: "hi" }],
346
+ createdAt: new Date(0),
347
+ attachments: [],
348
+ metadata: { custom: {} },
349
+ } as unknown as ExternalThreadMessage,
350
+ ],
351
+ isRunning: false,
352
+ onEdit,
353
+ queue: {
354
+ items: [],
355
+ steerItems: [],
356
+ enqueue,
357
+ steer,
358
+ move: vi.fn(),
359
+ edit: vi.fn(),
360
+ remove: vi.fn(),
361
+ },
362
+ });
363
+
364
+ const composer = () => aui().thread.message({ id: "u1" }).composer();
365
+ composer().beginEdit();
366
+ await waitFor(() => expect(composer().getState().isEditing).toBe(true));
367
+ composer().setText("edited");
368
+ composer().send();
369
+
370
+ await waitFor(() => expect(onEdit).toHaveBeenCalledTimes(1));
371
+ expect(onEdit.mock.calls[0]![0]).toMatchObject({
372
+ sourceId: "u1",
373
+ content: [{ type: "text", text: "edited" }],
374
+ });
375
+ expect(enqueue).not.toHaveBeenCalled();
376
+ expect(steer).not.toHaveBeenCalled();
377
+ });
378
+
379
+ it("dispatches a same-tick beginEdit + setText + send sequence", async () => {
380
+ const onEdit = vi.fn();
381
+ const { aui } = renderThread({
382
+ messages: [
383
+ {
384
+ id: "u1",
385
+ role: "user",
386
+ content: [{ type: "text", text: "hi" }],
387
+ createdAt: new Date(0),
388
+ attachments: [],
389
+ metadata: { custom: {} },
390
+ } as unknown as ExternalThreadMessage,
391
+ ],
392
+ isRunning: false,
393
+ onEdit,
394
+ });
395
+
396
+ const composer = () => aui().thread.message({ id: "u1" }).composer();
397
+ composer().beginEdit();
398
+ composer().setText("edited");
399
+ composer().send();
400
+
401
+ await waitFor(() => expect(onEdit).toHaveBeenCalledTimes(1));
402
+ expect(onEdit.mock.calls[0]![0]).toMatchObject({
403
+ sourceId: "u1",
404
+ content: [{ type: "text", text: "edited" }],
405
+ });
406
+ await waitFor(() => expect(composer().getState().isEditing).toBe(false));
407
+ });
408
+
409
+ it("prefills the edit composer from the message on beginEdit", async () => {
410
+ const onEdit = vi.fn();
411
+ const { aui } = renderThread({
412
+ messages: [
413
+ {
414
+ id: "a1",
415
+ role: "assistant",
416
+ content: [{ type: "text", text: "original answer" }],
417
+ createdAt: new Date(0),
418
+ attachments: [
419
+ {
420
+ id: "att1",
421
+ type: "file",
422
+ name: "a.txt",
423
+ contentType: "text/plain",
424
+ status: { type: "complete" },
425
+ content: [],
426
+ },
427
+ ],
428
+ metadata: { custom: {} },
429
+ } as unknown as ExternalThreadMessage,
430
+ ],
431
+ isRunning: false,
432
+ onEdit,
433
+ });
434
+
435
+ const composer = () => aui().thread.message({ id: "a1" }).composer();
436
+ composer().beginEdit();
437
+ await waitFor(() => {
438
+ const state = composer().getState();
439
+ expect(state.text).toBe("original answer");
440
+ expect(state.role).toBe("assistant");
441
+ expect(state.attachments).toHaveLength(1);
442
+ });
443
+ expect(() => composer().beginEdit()).toThrow("Edit already in progress");
444
+
445
+ composer().send();
446
+ await waitFor(() => expect(onEdit).toHaveBeenCalledTimes(1));
447
+ expect(onEdit.mock.calls[0]![0]).toMatchObject({
448
+ sourceId: "a1",
449
+ content: [{ type: "text", text: "original answer" }],
450
+ });
451
+ });
452
+
453
+ it("throws on edit-composer send before beginEdit", () => {
454
+ const { aui } = renderThread({
455
+ messages: [
456
+ {
457
+ id: "u1",
458
+ role: "user",
459
+ content: [{ type: "text", text: "hi" }],
460
+ createdAt: new Date(0),
461
+ attachments: [],
462
+ metadata: { custom: {} },
463
+ } as unknown as ExternalThreadMessage,
464
+ ],
465
+ isRunning: false,
466
+ onEdit: vi.fn(),
467
+ });
468
+
469
+ expect(() => aui().thread.message({ id: "u1" }).composer().send()).toThrow(
470
+ "Composer is not available",
471
+ );
472
+ });
473
+
474
+ it("throws on beginEdit when the runtime has no edit handler", () => {
475
+ const { aui } = renderThread({
476
+ messages: [
477
+ {
478
+ id: "u1",
479
+ role: "user",
480
+ content: [{ type: "text", text: "hi" }],
481
+ createdAt: new Date(0),
482
+ attachments: [],
483
+ metadata: { custom: {} },
484
+ } as unknown as ExternalThreadMessage,
485
+ ],
486
+ isRunning: false,
487
+ queue: {
488
+ items: [],
489
+ steerItems: [],
490
+ enqueue: vi.fn(),
491
+ steer: vi.fn(),
492
+ move: vi.fn(),
493
+ edit: vi.fn(),
494
+ remove: vi.fn(),
495
+ },
496
+ });
497
+
498
+ expect(() =>
499
+ aui().thread.message({ id: "u1" }).composer().beginEdit(),
500
+ ).toThrow("Runtime does not support editing.");
501
+ });
502
+
503
+ it("still refuses to send an empty composer synchronously after a send", async () => {
504
+ const onNew = vi.fn();
505
+ const { aui } = renderThread({ messages: [], isRunning: false, onNew });
506
+
507
+ aui().thread.composer().send();
508
+ aui().thread.composer().setText("first");
509
+ aui().thread.composer().send();
510
+ aui().thread.composer().send();
511
+
512
+ await new Promise((resolve) => setTimeout(resolve, 0));
513
+ expect(onNew).toHaveBeenCalledTimes(1);
514
+ });
515
+ });
516
+
517
+ describe("ExternalThread duplicate message ids", () => {
518
+ const userMessage = (id: string, text: string): ExternalThreadMessage =>
519
+ ({
520
+ id,
521
+ role: "user",
522
+ content: [{ type: "text", text }],
523
+ createdAt: new Date(0),
524
+ metadata: { custom: {} },
525
+ }) as unknown as ExternalThreadMessage;
526
+
527
+ it("warns and keeps the last occurrence instead of throwing on a duplicate id", () => {
528
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
529
+ try {
530
+ const { aui } = renderThread({
531
+ messages: [
532
+ userMessage("u1", "hi"),
533
+ userMessage("dup", "stale"),
534
+ userMessage("dup", "fresh"),
535
+ ],
536
+ });
537
+
538
+ const state = aui().thread.getState();
539
+ expect(state.messages.map((m) => m.id)).toEqual(["u1", "dup"]);
540
+
541
+ const dup = aui().thread.message({ id: "dup" }).getState();
542
+ expect(dup.parts[0]).toMatchObject({ type: "text", text: "fresh" });
543
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('"dup"'));
544
+ } finally {
545
+ warn.mockRestore();
546
+ }
547
+ });
548
+ });
@@ -0,0 +1,117 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { cleanup, render, waitFor } from "@testing-library/react";
4
+ import type { FC } from "react";
5
+ import { useState } from "react";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+ import { AuiProvider, useAui } from "@assistant-ui/store";
8
+ import { ExternalThread } from "../store/clients/external-thread";
9
+ import type { ThreadMessage } from "../types/message";
10
+
11
+ const message = (id: string, role: "user" | "assistant"): ThreadMessage =>
12
+ ({
13
+ id,
14
+ role,
15
+ content: [{ type: "text", text: `text of ${id}` }],
16
+ createdAt: new Date(1718000000000),
17
+ ...(role === "assistant"
18
+ ? { status: { type: "complete", reason: "stop" } }
19
+ : { attachments: [] }),
20
+ metadata: { custom: {} },
21
+ }) as ThreadMessage;
22
+
23
+ let aui!: ReturnType<typeof useAui>;
24
+
25
+ const Capture: FC = () => {
26
+ aui = useAui();
27
+ return null;
28
+ };
29
+
30
+ const renderThread = (props: Parameters<typeof ExternalThread>[0]) => {
31
+ const App: FC = () => {
32
+ const value = useAui({ thread: ExternalThread(props) });
33
+ return (
34
+ <AuiProvider value={value}>
35
+ <Capture />
36
+ </AuiProvider>
37
+ );
38
+ };
39
+ render(<App />);
40
+ };
41
+
42
+ afterEach(() => {
43
+ cleanup();
44
+ });
45
+
46
+ describe("ExternalThread refetch", () => {
47
+ it("routes reloadMainThread to onRefetchThread and reports the capability", async () => {
48
+ const onRefetchThread = vi.fn(async () => {});
49
+ renderThread({ messages: [], onRefetchThread });
50
+
51
+ await waitFor(() =>
52
+ expect(
53
+ aui.threads.thread("main").getState().capabilities.refetchThread,
54
+ ).toBe(true),
55
+ );
56
+
57
+ await aui.threads.reloadMainThread();
58
+ expect(onRefetchThread).toHaveBeenCalledTimes(1);
59
+ });
60
+
61
+ it("resolves reloadMainThread without a callback and reports the capability off", async () => {
62
+ renderThread({ messages: [] });
63
+
64
+ await waitFor(() =>
65
+ expect(
66
+ aui.threads.thread("main").getState().capabilities.refetchThread,
67
+ ).toBe(false),
68
+ );
69
+
70
+ await expect(aui.threads.reloadMainThread()).resolves.toBeUndefined();
71
+ });
72
+
73
+ it("propagates a refetch rejection to the caller", async () => {
74
+ const onRefetchThread = vi.fn(async () => {
75
+ throw new Error("refetch failed");
76
+ });
77
+ renderThread({ messages: [], onRefetchThread });
78
+
79
+ await waitFor(() =>
80
+ expect(
81
+ aui.threads.thread("main").getState().capabilities.refetchThread,
82
+ ).toBe(true),
83
+ );
84
+
85
+ await expect(aui.threads.reloadMainThread()).rejects.toThrow(
86
+ "refetch failed",
87
+ );
88
+ });
89
+
90
+ it("applies refetched messages in place with the composer draft intact", async () => {
91
+ const App: FC = () => {
92
+ const [messages, setMessages] = useState<readonly ThreadMessage[]>([]);
93
+ const value = useAui({
94
+ thread: ExternalThread({
95
+ messages,
96
+ onRefetchThread: async () => {
97
+ setMessages([message("a1", "assistant")]);
98
+ },
99
+ }),
100
+ });
101
+ return (
102
+ <AuiProvider value={value}>
103
+ <Capture />
104
+ </AuiProvider>
105
+ );
106
+ };
107
+ render(<App />);
108
+
109
+ aui.thread.composer().setText("draft");
110
+ await aui.threads.reloadMainThread();
111
+
112
+ await waitFor(() =>
113
+ expect(aui.thread.getState().messages.map((m) => m.id)).toEqual(["a1"]),
114
+ );
115
+ expect(aui.thread.composer().getState().text).toBe("draft");
116
+ });
117
+ });