@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,328 @@
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 { SpeechSynthesisAdapter } from "../index";
8
+ import type {
9
+ ExternalThreadMessage,
10
+ ExternalThreadProps,
11
+ } from "../store/clients/external-thread";
12
+ import { ExternalThread } from "../store/clients/external-thread";
13
+
14
+ const MESSAGES = [
15
+ {
16
+ id: "u1",
17
+ role: "user",
18
+ content: [{ type: "text", text: "hi" }],
19
+ createdAt: new Date(0),
20
+ attachments: [],
21
+ metadata: { custom: {} },
22
+ },
23
+ {
24
+ id: "a1",
25
+ role: "assistant",
26
+ content: [{ type: "text", text: "hello there" }],
27
+ createdAt: new Date(0),
28
+ metadata: { custom: {} },
29
+ },
30
+ ] as unknown as readonly ExternalThreadMessage[];
31
+
32
+ type FakeUtterance = {
33
+ text: string;
34
+ cancel: ReturnType<typeof vi.fn>;
35
+ emit: (status: SpeechSynthesisAdapter.Status) => void;
36
+ };
37
+
38
+ const createFakeAdapter = () => {
39
+ const utterances: FakeUtterance[] = [];
40
+ const adapter: SpeechSynthesisAdapter = {
41
+ speak: (text) => {
42
+ const subscribers = new Set<() => void>();
43
+ // mirror WebSpeechSynthesisAdapter: cancel transitions to ended and notifies synchronously
44
+ const cancel = vi.fn(() => {
45
+ if (utterance.status.type === "ended") return;
46
+ utterance.status = { type: "ended", reason: "cancelled" };
47
+ for (const subscriber of subscribers) subscriber();
48
+ });
49
+ const utterance: SpeechSynthesisAdapter.Utterance = {
50
+ status: { type: "starting" },
51
+ cancel,
52
+ subscribe: (callback) => {
53
+ subscribers.add(callback);
54
+ return () => subscribers.delete(callback);
55
+ },
56
+ };
57
+ utterances.push({
58
+ text,
59
+ cancel,
60
+ emit: (status) => {
61
+ utterance.status = status;
62
+ for (const subscriber of subscribers) subscriber();
63
+ },
64
+ });
65
+ return utterance;
66
+ },
67
+ };
68
+ return { adapter, utterances };
69
+ };
70
+
71
+ const renderThreadWithProps = (props: Partial<ExternalThreadProps>) => {
72
+ const captured: { aui?: ReturnType<typeof useAui> } = {};
73
+ const Capture: FC = () => {
74
+ captured.aui = useAui();
75
+ return null;
76
+ };
77
+ const App: FC<{ props: Partial<ExternalThreadProps> }> = ({ props }) => {
78
+ const aui = useAui({
79
+ thread: ExternalThread({
80
+ messages: MESSAGES,
81
+ isRunning: false,
82
+ ...props,
83
+ }),
84
+ });
85
+ return (
86
+ <AuiProvider value={aui}>
87
+ <Capture />
88
+ </AuiProvider>
89
+ );
90
+ };
91
+
92
+ const view = render(<App props={props} />);
93
+ const aui = () => captured.aui!;
94
+ aui.rerender = (nextProps: Partial<ExternalThreadProps>) =>
95
+ view.rerender(<App props={nextProps} />);
96
+ aui.unmount = () => view.unmount();
97
+ return aui;
98
+ };
99
+
100
+ describe("ExternalThread speech", () => {
101
+ it("reports the speech capability based on adapter presence", () => {
102
+ const withoutAdapter = renderThreadWithProps({});
103
+ expect(withoutAdapter().thread.getState().capabilities.speech).toBe(false);
104
+
105
+ const { adapter } = createFakeAdapter();
106
+ const withAdapter = renderThreadWithProps({ speechAdapter: adapter });
107
+ expect(withAdapter().thread.getState().capabilities.speech).toBe(true);
108
+ });
109
+
110
+ it("throws on speak when no adapter is configured", () => {
111
+ const aui = renderThreadWithProps({});
112
+ expect(() => aui().thread.message({ id: "a1" }).speak()).toThrow(
113
+ "Speech adapter not configured",
114
+ );
115
+ });
116
+
117
+ it("tracks utterance status in thread and message state", async () => {
118
+ const { adapter, utterances } = createFakeAdapter();
119
+ const aui = renderThreadWithProps({ speechAdapter: adapter });
120
+
121
+ await act(async () => {
122
+ aui().thread.message({ id: "a1" }).speak();
123
+ });
124
+
125
+ expect(utterances[0]!.text).toBe("hello there");
126
+ await waitFor(() => {
127
+ expect(aui().thread.getState().speech).toEqual({
128
+ messageId: "a1",
129
+ status: { type: "starting" },
130
+ });
131
+ expect(aui().thread.message({ id: "a1" }).getState().speech).toEqual({
132
+ messageId: "a1",
133
+ status: { type: "starting" },
134
+ });
135
+ expect(
136
+ aui().thread.message({ id: "u1" }).getState().speech,
137
+ ).toBeUndefined();
138
+ });
139
+
140
+ await act(async () => {
141
+ utterances[0]!.emit({ type: "running" });
142
+ });
143
+ await waitFor(() => {
144
+ expect(aui().thread.getState().speech).toEqual({
145
+ messageId: "a1",
146
+ status: { type: "running" },
147
+ });
148
+ });
149
+
150
+ await act(async () => {
151
+ utterances[0]!.emit({ type: "ended", reason: "finished" });
152
+ });
153
+ await waitFor(() => {
154
+ expect(aui().thread.getState().speech).toBeUndefined();
155
+ expect(
156
+ aui().thread.message({ id: "a1" }).getState().speech,
157
+ ).toBeUndefined();
158
+ });
159
+ });
160
+
161
+ it("cancels the previous utterance when a new speak starts", async () => {
162
+ const { adapter, utterances } = createFakeAdapter();
163
+ const aui = renderThreadWithProps({ speechAdapter: adapter });
164
+
165
+ await act(async () => {
166
+ aui().thread.message({ id: "a1" }).speak();
167
+ });
168
+ await act(async () => {
169
+ aui().thread.message({ id: "u1" }).speak();
170
+ });
171
+
172
+ expect(utterances).toHaveLength(2);
173
+ expect(utterances[0]!.cancel).toHaveBeenCalledTimes(1);
174
+ await waitFor(() => {
175
+ expect(aui().thread.getState().speech).toEqual({
176
+ messageId: "u1",
177
+ status: { type: "starting" },
178
+ });
179
+ });
180
+
181
+ await act(async () => {
182
+ utterances[0]!.emit({ type: "ended", reason: "cancelled" });
183
+ });
184
+ expect(aui().thread.getState().speech).toEqual({
185
+ messageId: "u1",
186
+ status: { type: "starting" },
187
+ });
188
+ });
189
+
190
+ it("stopSpeaking cancels the utterance and clears state", async () => {
191
+ const { adapter, utterances } = createFakeAdapter();
192
+ const aui = renderThreadWithProps({ speechAdapter: adapter });
193
+
194
+ await act(async () => {
195
+ aui().thread.message({ id: "a1" }).speak();
196
+ });
197
+ await act(async () => {
198
+ aui().thread.stopSpeaking();
199
+ });
200
+
201
+ expect(utterances[0]!.cancel).toHaveBeenCalledTimes(1);
202
+ await waitFor(() => {
203
+ expect(aui().thread.getState().speech).toBeUndefined();
204
+ });
205
+ expect(() => aui().thread.stopSpeaking()).toThrow(
206
+ "No message is being spoken",
207
+ );
208
+ });
209
+
210
+ it("message stopSpeaking throws unless that message is being spoken", async () => {
211
+ const { adapter, utterances } = createFakeAdapter();
212
+ const aui = renderThreadWithProps({ speechAdapter: adapter });
213
+
214
+ expect(() => aui().thread.message({ id: "a1" }).stopSpeaking()).toThrow(
215
+ "Message is not being spoken",
216
+ );
217
+
218
+ // speak and stopSpeaking in the same tick, before any re-render
219
+ await act(async () => {
220
+ aui().thread.message({ id: "a1" }).speak();
221
+ expect(() => aui().thread.message({ id: "u1" }).stopSpeaking()).toThrow(
222
+ "Message is not being spoken",
223
+ );
224
+ aui().thread.message({ id: "a1" }).stopSpeaking();
225
+ });
226
+
227
+ expect(utterances[0]!.cancel).toHaveBeenCalledTimes(1);
228
+ await waitFor(() => {
229
+ expect(aui().thread.getState().speech).toBeUndefined();
230
+ });
231
+ });
232
+
233
+ it("cancels the utterance when the adapter is removed", async () => {
234
+ const { adapter, utterances } = createFakeAdapter();
235
+ const aui = renderThreadWithProps({ speechAdapter: adapter });
236
+
237
+ await act(async () => {
238
+ aui().thread.message({ id: "a1" }).speak();
239
+ });
240
+ await act(async () => {
241
+ aui.rerender({});
242
+ });
243
+
244
+ expect(utterances[0]!.cancel).toHaveBeenCalledTimes(1);
245
+ expect(aui().thread.getState().capabilities.speech).toBe(false);
246
+ await waitFor(() => {
247
+ expect(aui().thread.getState().speech).toBeUndefined();
248
+ });
249
+
250
+ await act(async () => {
251
+ utterances[0]!.emit({ type: "running" });
252
+ });
253
+ expect(aui().thread.getState().speech).toBeUndefined();
254
+ });
255
+
256
+ it("keeps the utterance alive when the adapter identity changes", async () => {
257
+ const first = createFakeAdapter();
258
+ const second = createFakeAdapter();
259
+ const aui = renderThreadWithProps({ speechAdapter: first.adapter });
260
+
261
+ await act(async () => {
262
+ aui().thread.message({ id: "a1" }).speak();
263
+ });
264
+ await act(async () => {
265
+ aui.rerender({ speechAdapter: second.adapter });
266
+ });
267
+
268
+ expect(first.utterances[0]!.cancel).not.toHaveBeenCalled();
269
+ expect(aui().thread.getState().speech).toEqual({
270
+ messageId: "a1",
271
+ status: { type: "starting" },
272
+ });
273
+
274
+ await act(async () => {
275
+ aui().thread.message({ id: "u1" }).speak();
276
+ });
277
+ expect(first.utterances[0]!.cancel).toHaveBeenCalledTimes(1);
278
+ expect(second.utterances[0]!.text).toBe("hi");
279
+ await waitFor(() => {
280
+ expect(aui().thread.getState().speech).toEqual({
281
+ messageId: "u1",
282
+ status: { type: "starting" },
283
+ });
284
+ });
285
+ });
286
+
287
+ it("handles utterances that end synchronously on subscribe", async () => {
288
+ const cancel = vi.fn();
289
+ const adapter: SpeechSynthesisAdapter = {
290
+ speak: () => ({
291
+ status: { type: "ended", reason: "finished" },
292
+ cancel,
293
+ subscribe: (callback) => {
294
+ callback();
295
+ return () => {};
296
+ },
297
+ }),
298
+ };
299
+ const aui = renderThreadWithProps({ speechAdapter: adapter });
300
+
301
+ await act(async () => {
302
+ aui().thread.message({ id: "a1" }).speak();
303
+ });
304
+
305
+ expect(aui().thread.getState().speech).toBeUndefined();
306
+ expect(() => aui().thread.stopSpeaking()).toThrow(
307
+ "No message is being spoken",
308
+ );
309
+ });
310
+
311
+ it("cancels the utterance on unmount", async () => {
312
+ const { adapter, utterances } = createFakeAdapter();
313
+ const aui = renderThreadWithProps({ speechAdapter: adapter });
314
+
315
+ await act(async () => {
316
+ aui().thread.message({ id: "a1" }).speak();
317
+ });
318
+ await act(async () => {
319
+ aui.unmount();
320
+ });
321
+
322
+ expect(utterances[0]!.cancel).toHaveBeenCalledTimes(1);
323
+ });
324
+
325
+ afterEach(() => {
326
+ vi.restoreAllMocks();
327
+ });
328
+ });
@@ -0,0 +1,59 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { cleanup, render } from "@testing-library/react";
4
+ import type { FC, ReactNode } from "react";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+ import { AuiProvider, useAui } from "@assistant-ui/store";
7
+ import { ExternalThread } from "../store/clients/external-thread";
8
+ import { SingleThreadList } from "../store/clients/single-thread-list";
9
+
10
+ let childAui!: ReturnType<typeof useAui>;
11
+
12
+ const CaptureChild: FC = () => {
13
+ childAui = useAui();
14
+ return null;
15
+ };
16
+
17
+ const Child: FC = () => {
18
+ const aui = useAui({ thread: ExternalThread({ messages: [] }) });
19
+ return (
20
+ <AuiProvider value={aui}>
21
+ <CaptureChild />
22
+ </AuiProvider>
23
+ );
24
+ };
25
+
26
+ const Parent: FC<{ children: ReactNode }> = ({ children }) => {
27
+ const aui = useAui({
28
+ threads: SingleThreadList({ thread: ExternalThread({ messages: [] }) }),
29
+ });
30
+ return <AuiProvider value={aui}>{children}</AuiProvider>;
31
+ };
32
+
33
+ afterEach(() => {
34
+ cleanup();
35
+ });
36
+
37
+ describe("ExternalThread suggestions scope", () => {
38
+ it("derives the scope from its own thread when standalone", () => {
39
+ render(<Child />);
40
+
41
+ expect(childAui.suggestions.getState()).toEqual({ suggestions: [] });
42
+ expect(childAui.suggestions.getState()).toBe(
43
+ childAui.thread.suggestions().getState(),
44
+ );
45
+ });
46
+
47
+ it("derives the scope from its own thread when nested under a threads parent", () => {
48
+ render(
49
+ <Parent>
50
+ <Child />
51
+ </Parent>,
52
+ );
53
+
54
+ expect(childAui.suggestions.getState()).toEqual({ suggestions: [] });
55
+ expect(childAui.suggestions.getState()).toBe(
56
+ childAui.thread.suggestions().getState(),
57
+ );
58
+ });
59
+ });
@@ -626,3 +626,29 @@ describe("createMessageQueue", () => {
626
626
  expect(adapter.items).toHaveLength(0);
627
627
  });
628
628
  });
629
+
630
+ describe("createMessageQueue interrupt with a runtime-routed cancel", () => {
631
+ it("keeps draining when the driver's cancel notifies back", () => {
632
+ const runs: string[] = [];
633
+ const controller = createMessageQueue({
634
+ run: (message) => {
635
+ runs.push(
636
+ message.content[0]!.type === "text" ? message.content[0].text : "",
637
+ );
638
+ },
639
+ cancel: () => controller.notifyCancelled(),
640
+ });
641
+
642
+ controller.adapter.enqueue(msg("first"));
643
+ controller.adapter.enqueue(msg("second"));
644
+ controller.adapter.steer(msg("steered"));
645
+
646
+ expect(runs).toEqual(["first", "steered"]);
647
+
648
+ // the interrupted run and the steer run each settle once
649
+ controller.notifyIdle();
650
+ controller.notifyIdle();
651
+
652
+ expect(runs).toEqual(["first", "steered", "second"]);
653
+ });
654
+ });
@@ -382,3 +382,42 @@ describe("fromThreadMessageLike", () => {
382
382
  });
383
383
  });
384
384
  });
385
+
386
+ describe("fromThreadMessageLike provider metadata", () => {
387
+ it("keeps it on image and file parts", () => {
388
+ const result = fromThreadMessageLike(
389
+ {
390
+ role: "user",
391
+ content: [
392
+ {
393
+ type: "image",
394
+ image: "https://example.com/cat.png",
395
+ providerMetadata: { agui: { file_id: "f_1" } },
396
+ },
397
+ {
398
+ type: "file",
399
+ data: "https://example.com/spec.pdf",
400
+ mimeType: "application/pdf",
401
+ providerMetadata: { agui: { file_id: "f_2" } },
402
+ },
403
+ ],
404
+ },
405
+ fallbackId,
406
+ fallbackStatus,
407
+ );
408
+
409
+ expect(result.content).toEqual([
410
+ {
411
+ type: "image",
412
+ image: "https://example.com/cat.png",
413
+ providerMetadata: { agui: { file_id: "f_1" } },
414
+ },
415
+ {
416
+ type: "file",
417
+ data: "https://example.com/spec.pdf",
418
+ mimeType: "application/pdf",
419
+ providerMetadata: { agui: { file_id: "f_2" } },
420
+ },
421
+ ]);
422
+ });
423
+ });
@@ -0,0 +1,136 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, render } from "@testing-library/react";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import { AuiProvider, useAui, useAuiEvent } from "@assistant-ui/store";
6
+ import { RuntimeAdapter } from "../react/RuntimeAdapter";
7
+ import { AssistantRuntimeImpl } from "../runtime/api/assistant-runtime";
8
+ import { ExternalStoreRuntimeCore } from "../runtimes/external-store/external-store-runtime-core";
9
+ import type { ExternalStoreAdapter } from "../runtimes/external-store/external-store-adapter";
10
+
11
+ type DemoMessage = { id: string; role: "user" | "assistant"; text: string };
12
+
13
+ const createRuntime = () => {
14
+ const threads = [
15
+ {
16
+ id: "t1",
17
+ title: "one",
18
+ messages: [{ id: "m1", role: "user" as const, text: "a" }],
19
+ },
20
+ {
21
+ id: "t2",
22
+ title: "two",
23
+ messages: [{ id: "m2", role: "user" as const, text: "b" }],
24
+ },
25
+ ];
26
+ let currentId = "t1";
27
+ const makeAdapter = (): ExternalStoreAdapter<DemoMessage> => ({
28
+ messages: threads.find((t) => t.id === currentId)!.messages,
29
+ convertMessage: (m) => ({
30
+ id: m.id,
31
+ role: m.role,
32
+ content: [{ type: "text", text: m.text }],
33
+ }),
34
+ onNew: async () => {},
35
+ adapters: {
36
+ threadList: {
37
+ threadId: currentId,
38
+ threads: threads.map((t) => ({
39
+ status: "regular" as const,
40
+ id: t.id,
41
+ title: t.title,
42
+ })),
43
+ onSwitchToThread: (threadId: string) => {
44
+ currentId = threadId;
45
+ sync();
46
+ },
47
+ onSwitchToNewThread: () => {},
48
+ },
49
+ },
50
+ });
51
+ const core = new ExternalStoreRuntimeCore(makeAdapter());
52
+ const runtime = new AssistantRuntimeImpl(core);
53
+ const sync = () => core.setAdapter(makeAdapter());
54
+ return runtime;
55
+ };
56
+
57
+ describe("thread switch events", () => {
58
+ it("delivers switchedTo to default-scope, star-scope, and aui.on listeners", async () => {
59
+ const runtime = createRuntime();
60
+ const defaultScope = vi.fn();
61
+ const starScope = vi.fn();
62
+ const auiOn = vi.fn();
63
+ const switchedAwayStar = vi.fn();
64
+ let aui!: ReturnType<typeof useAui>;
65
+ const Consumer = () => {
66
+ useAuiEvent("threadListItem.switchedTo" as never, defaultScope as never);
67
+ useAuiEvent(
68
+ { scope: "*", event: "threadListItem.switchedTo" } as never,
69
+ starScope as never,
70
+ );
71
+ useAuiEvent(
72
+ { scope: "*", event: "threadListItem.switchedAway" } as never,
73
+ switchedAwayStar as never,
74
+ );
75
+ return null;
76
+ };
77
+ const Harness = () => {
78
+ aui = useAui({ threads: RuntimeAdapter(runtime) } as never);
79
+ return (
80
+ <AuiProvider value={aui}>
81
+ <Consumer />
82
+ </AuiProvider>
83
+ );
84
+ };
85
+ render(<Harness />);
86
+ await act(async () => {});
87
+
88
+ aui.on("threadListItem.switchedTo" as never, auiOn as never);
89
+
90
+ await act(async () => {
91
+ aui.threads.item({ index: 1 }).switchTo();
92
+ });
93
+ await act(async () => {});
94
+
95
+ expect(defaultScope).toHaveBeenCalledExactlyOnceWith({ threadId: "t2" });
96
+ expect(starScope).toHaveBeenCalledExactlyOnceWith({ threadId: "t2" });
97
+ expect(auiOn).toHaveBeenCalledExactlyOnceWith({ threadId: "t2" });
98
+ expect(switchedAwayStar).toHaveBeenCalledExactlyOnceWith({
99
+ threadId: "t1",
100
+ });
101
+
102
+ await act(async () => {
103
+ aui.threads.item({ index: 0 }).switchTo();
104
+ });
105
+ await act(async () => {});
106
+
107
+ expect(defaultScope).toHaveBeenCalledTimes(2);
108
+ expect(defaultScope).toHaveBeenLastCalledWith({ threadId: "t1" });
109
+ expect(switchedAwayStar).toHaveBeenCalledTimes(2);
110
+ expect(switchedAwayStar).toHaveBeenLastCalledWith({ threadId: "t2" });
111
+ });
112
+
113
+ it("does not emit for the initially selected thread on mount", async () => {
114
+ const runtime = createRuntime();
115
+ const anySwitch = vi.fn();
116
+ const Consumer = () => {
117
+ useAuiEvent(
118
+ { scope: "*", event: "threadListItem.switchedTo" } as never,
119
+ anySwitch as never,
120
+ );
121
+ return null;
122
+ };
123
+ const Harness = () => {
124
+ const aui = useAui({ threads: RuntimeAdapter(runtime) } as never);
125
+ return (
126
+ <AuiProvider value={aui}>
127
+ <Consumer />
128
+ </AuiProvider>
129
+ );
130
+ };
131
+ render(<Harness />);
132
+ await act(async () => {});
133
+
134
+ expect(anySwitch).not.toHaveBeenCalled();
135
+ });
136
+ });
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Module augmentation namespace for assistant-ui type extensions.
3
+ *
4
+ * @example
5
+ * ```typescript
6
+ * declare module "@assistant-ui/core" {
7
+ * namespace Assistant {
8
+ * interface Commands {
9
+ * myCustomCommand: {
10
+ * type: "my-custom-command";
11
+ * data: string;
12
+ * };
13
+ * }
14
+ *
15
+ * interface ExternalState {
16
+ * myCustomState: {
17
+ * foo: string;
18
+ * };
19
+ * }
20
+ * }
21
+ * }
22
+ * ```
23
+ */
24
+ export namespace Assistant {
25
+ export interface Commands {}
26
+
27
+ export interface ExternalState {}
28
+ }
29
+
30
+ export type UserCommands = Assistant.Commands[keyof Assistant.Commands];
31
+ export type UserExternalState = keyof Assistant.ExternalState extends never
32
+ ? Record<string, unknown>
33
+ : Assistant.ExternalState[keyof Assistant.ExternalState];
@@ -52,6 +52,30 @@ export const isAssistantError = (value: unknown): value is AssistantError => {
52
52
  return true;
53
53
  };
54
54
 
55
+ const MESSAGE_NOT_SENT = Symbol.for("assistant-ui.message-not-sent");
56
+
57
+ /**
58
+ * Rejection reason for a send that never reached the backend, so nothing ran
59
+ * and nothing is recoverable from the thread. A runtime adapter throws it from
60
+ * `onNew` to hand the message back to the thread composer, which restores the
61
+ * draft it cleared at dispatch time when nothing has claimed the composer
62
+ * since. An edit composer closes at dispatch, so a rejected edit is not
63
+ * restored.
64
+ */
65
+ export class MessageNotSentError extends Error {
66
+ readonly [MESSAGE_NOT_SENT] = true;
67
+
68
+ constructor(message = "The message was not sent.") {
69
+ super(message);
70
+ this.name = "MessageNotSentError";
71
+ }
72
+ }
73
+
74
+ export const isMessageNotSentError = (
75
+ error: unknown,
76
+ ): error is MessageNotSentError =>
77
+ typeof error === "object" && error !== null && MESSAGE_NOT_SENT in error;
78
+
55
79
  export const toAssistantError = (error: unknown): AssistantError => {
56
80
  if (isAssistantError(error)) return error;
57
81
  if (error instanceof Error) {
@@ -56,6 +56,7 @@ export type ImageMessagePart = {
56
56
  readonly type: "image";
57
57
  readonly image: string;
58
58
  readonly filename?: string;
59
+ readonly providerMetadata?: PartProviderMetadata;
59
60
  };
60
61
 
61
62
  export type FileMessagePart = {
@@ -65,6 +66,7 @@ export type FileMessagePart = {
65
66
  readonly mimeType: string;
66
67
  /** How `data` goes on the wire: a url or id reference; omitted = inferred (http(s) → url, else base64). "url" is honored by the LangChain-family, A2A, AG-UI, and Google ADK runtimes; "id" by the LangChain family only. */
67
68
  readonly sourceType?: "url" | "id";
69
+ readonly providerMetadata?: PartProviderMetadata;
68
70
  readonly parentId?: string;
69
71
  };
70
72