@assistant-ui/react 0.15.11 → 0.15.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.
@@ -1,328 +0,0 @@
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 "@assistant-ui/core";
8
- import type {
9
- ExternalThreadMessage,
10
- ExternalThreadProps,
11
- } from "../client/ExternalThread";
12
- import { ExternalThread } from "../client/ExternalThread";
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
- });