@assistant-ui/react 0.15.7 → 0.15.9

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 (25) hide show
  1. package/LICENSE +21 -0
  2. package/dist/client/ExternalThread.d.ts +3 -1
  3. package/dist/client/ExternalThread.d.ts.map +1 -1
  4. package/dist/client/ExternalThread.js +524 -335
  5. package/dist/client/ExternalThread.js.map +1 -1
  6. package/dist/legacy-runtime/cloud/auiV0.d.ts +1 -0
  7. package/dist/legacy-runtime/cloud/auiV0.d.ts.map +1 -1
  8. package/dist/legacy-runtime/cloud/auiV0.js +2 -1
  9. package/dist/legacy-runtime/cloud/auiV0.js.map +1 -1
  10. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.d.ts.map +1 -1
  11. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js +2 -0
  12. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js.map +1 -1
  13. package/dist/unstable/useLiveCompletionAdapter.js +32 -7
  14. package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
  15. package/dist/utils/useToolArgsFieldStatus.d.ts +2 -2
  16. package/package.json +13 -15
  17. package/src/client/ExternalThread.ts +172 -9
  18. package/src/legacy-runtime/cloud/auiV0.ts +8 -1
  19. package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransport.spec.md +1 -0
  20. package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.test.tsx +79 -0
  21. package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.ts +2 -0
  22. package/src/tests/external-thread-feedback.test.tsx +204 -0
  23. package/src/tests/external-thread-speech.test.tsx +328 -0
  24. package/src/unstable/useLiveCompletionAdapter.test.tsx +90 -0
  25. package/src/unstable/useLiveCompletionAdapter.ts +32 -7
@@ -32,6 +32,7 @@ type AuiV0MessagePart =
32
32
  | {
33
33
  readonly type: "reasoning";
34
34
  readonly text: string;
35
+ readonly unstable_summary?: string;
35
36
  }
36
37
  | {
37
38
  readonly type: "source";
@@ -226,7 +227,13 @@ export function auiV0Encode(message: ThreadMessage): AuiV0Message {
226
227
  return { type: "text", text: part.text };
227
228
 
228
229
  case "reasoning":
229
- return { type: "reasoning", text: part.text };
230
+ return {
231
+ type: "reasoning",
232
+ text: part.text,
233
+ ...(part.unstable_summary !== undefined
234
+ ? { unstable_summary: part.unstable_summary }
235
+ : undefined),
236
+ };
230
237
 
231
238
  case "source":
232
239
  if (part.sourceType === "url") {
@@ -23,6 +23,7 @@ Resume State
23
23
  - The resume request carries `runId` and no `state`: the server replays from the snapshot it retained for that run, so neither `body` overrides nor a `prepareSendCommandsRequest` rebuild can substitute a different base. `runId` takes precedence over `body` fields and is re-attached after `prepareSendCommandsRequest` so a rebuilt body cannot drop it. The local base is replaced by the snapshot only after the resume stream responds OK, so a rejected resume keeps the local state.
24
24
  - A 204 response means no active run: the resume is skipped without error, and queued commands are flushed in a follow-up run.
25
25
  - A malformed snapshot response fails the resume before the replay request is sent.
26
+ - If a pending resume is dropped by an error or cancellation, later commands start normal runs.
26
27
  - Runs execute on a `queueMicrotask`, so multiple synchronous enqueues coalesce into a single request: the first run's flush takes all of them, and the coalesced follow-up run no-ops.
27
28
 
28
29
  Command Queue
@@ -75,6 +75,35 @@ const installFetch = () => {
75
75
  return { requests, servers };
76
76
  };
77
77
 
78
+ const installPendingFetch = () => {
79
+ const requests: RecordedRequest[] = [];
80
+ const pending: {
81
+ resolve: (response: Response) => void;
82
+ reject: (reason: unknown) => void;
83
+ }[] = [];
84
+
85
+ vi.stubGlobal("fetch", (url: RequestInfo | URL, init: RequestInit = {}) => {
86
+ requests.push({
87
+ url: String(url),
88
+ init,
89
+ body: JSON.parse(init.body as string),
90
+ });
91
+
92
+ return new Promise<Response>((resolve, reject) => {
93
+ pending.push({ resolve, reject });
94
+ init.signal?.addEventListener(
95
+ "abort",
96
+ () => reject(init.signal?.reason),
97
+ {
98
+ once: true,
99
+ },
100
+ );
101
+ });
102
+ });
103
+
104
+ return { requests, pending };
105
+ };
106
+
78
107
  const mountRuntime = (
79
108
  options?: Partial<AssistantTransportOptions<unknown>>,
80
109
  ) => {
@@ -216,6 +245,56 @@ describe("useAssistantTransportRuntime", () => {
216
245
  await waitFor(() => expect(aui().thread.getState().isRunning).toBe(false));
217
246
  });
218
247
 
248
+ it.each(["error", "cancellation"] as const)(
249
+ "does not apply a dropped resume after run %s",
250
+ async (settlement) => {
251
+ const fetchMock = installPendingFetch();
252
+ const onError = vi.fn();
253
+ const { aui, sendCommand } = mountRuntime({
254
+ resumeApi: "https://example.com/resume",
255
+ onError,
256
+ });
257
+ await waitFor(() =>
258
+ expect(
259
+ (aui().thread.getState().extras as { sendCommand?: unknown })
260
+ ?.sendCommand,
261
+ ).toBeTypeOf("function"),
262
+ );
263
+
264
+ act(() => sendCommand(createMessageCommand("a")));
265
+ await waitFor(() => expect(fetchMock.requests).toHaveLength(1));
266
+
267
+ await act(async () => {
268
+ await aui().thread.resumeRun({ parentId: null });
269
+ });
270
+ if (settlement === "cancellation") {
271
+ act(() => aui().thread.cancelRun());
272
+ } else {
273
+ await act(async () => {
274
+ fetchMock.pending[0]!.reject(new Error("request failed"));
275
+ });
276
+ }
277
+ await waitFor(() =>
278
+ expect(aui().thread.getState().isRunning).toBe(false),
279
+ );
280
+
281
+ act(() => sendCommand(createMessageCommand("b")));
282
+ await waitFor(() => expect(fetchMock.requests).toHaveLength(2));
283
+ expect(fetchMock.requests[1]!.url).toBe("https://example.com/api");
284
+ expect(fetchMock.requests[1]!.body["commands"]).toEqual([
285
+ createMessageCommand("b"),
286
+ ]);
287
+
288
+ await act(async () => {
289
+ fetchMock.pending[1]!.resolve(new Response("", { status: 200 }));
290
+ });
291
+ await waitFor(() =>
292
+ expect(aui().thread.getState().isRunning).toBe(false),
293
+ );
294
+ expect(onError).toHaveBeenCalledTimes(settlement === "error" ? 1 : 0);
295
+ },
296
+ );
297
+
219
298
  it("applies resumed operations to the retained initial state", async () => {
220
299
  const requests: RecordedRequest[] = [];
221
300
  vi.stubGlobal(
@@ -348,6 +348,7 @@ const useAssistantTransportThreadRuntime = <T>(
348
348
  });
349
349
  },
350
350
  onError: async (error) => {
351
+ resumeFlagRef.current = false;
351
352
  setIsReplaying(false);
352
353
  const inTransitCmds = [...commandQueue.state.inTransit];
353
354
  const queuedCmds = [...commandQueue.state.queued];
@@ -422,6 +423,7 @@ const useAssistantTransportThreadRuntime = <T>(
422
423
  },
423
424
  }),
424
425
  onCancel: async () => {
426
+ resumeFlagRef.current = false;
425
427
  runManager.cancel();
426
428
  },
427
429
  onResume: async () => {
@@ -0,0 +1,204 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, render, waitFor } from "@testing-library/react";
4
+ import type { FC } from "react";
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import { AuiProvider, useAui } from "@assistant-ui/store";
7
+ import type { FeedbackAdapter } 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
+ const createFakeAdapter = () => {
33
+ const submit = vi.fn();
34
+ const adapter: FeedbackAdapter = { submit };
35
+ return { adapter, submit };
36
+ };
37
+
38
+ const renderThreadWithProps = (props: Partial<ExternalThreadProps>) => {
39
+ const captured: { aui?: ReturnType<typeof useAui> } = {};
40
+ const Capture: FC = () => {
41
+ captured.aui = useAui();
42
+ return null;
43
+ };
44
+ const App: FC<{ props: Partial<ExternalThreadProps> }> = ({ props }) => {
45
+ const aui = useAui({
46
+ thread: ExternalThread({
47
+ messages: MESSAGES,
48
+ isRunning: false,
49
+ ...props,
50
+ }),
51
+ });
52
+ return (
53
+ <AuiProvider value={aui}>
54
+ <Capture />
55
+ </AuiProvider>
56
+ );
57
+ };
58
+
59
+ const view = render(<App props={props} />);
60
+ return {
61
+ aui: () => captured.aui!,
62
+ rerender: (nextProps: Partial<ExternalThreadProps>) =>
63
+ view.rerender(<App props={nextProps} />),
64
+ };
65
+ };
66
+
67
+ describe("ExternalThread feedback", () => {
68
+ it("reports the feedback capability based on adapter presence", () => {
69
+ const { aui: withoutAdapter } = renderThreadWithProps({});
70
+ expect(withoutAdapter().thread.getState().capabilities.feedback).toBe(
71
+ false,
72
+ );
73
+
74
+ const { adapter } = createFakeAdapter();
75
+ const { aui: withAdapter } = renderThreadWithProps({
76
+ feedbackAdapter: adapter,
77
+ });
78
+ expect(withAdapter().thread.getState().capabilities.feedback).toBe(true);
79
+ });
80
+
81
+ it("throws on submitFeedback when no adapter is configured", () => {
82
+ const { aui } = renderThreadWithProps({});
83
+ expect(() =>
84
+ aui().thread.message({ id: "a1" }).submitFeedback({ type: "positive" }),
85
+ ).toThrow("Feedback adapter not configured");
86
+ });
87
+
88
+ it("submits feedback to the adapter and marks the assistant message", async () => {
89
+ const { adapter, submit } = createFakeAdapter();
90
+ const { aui } = renderThreadWithProps({ feedbackAdapter: adapter });
91
+
92
+ await act(async () => {
93
+ aui().thread.message({ id: "a1" }).submitFeedback({ type: "positive" });
94
+ });
95
+
96
+ expect(submit).toHaveBeenCalledTimes(1);
97
+ expect(submit).toHaveBeenCalledWith({
98
+ message: MESSAGES[1],
99
+ type: "positive",
100
+ });
101
+ await waitFor(() => {
102
+ expect(
103
+ aui().thread.message({ id: "a1" }).getState().metadata
104
+ .submittedFeedback,
105
+ ).toEqual({ type: "positive" });
106
+ });
107
+
108
+ await act(async () => {
109
+ aui().thread.message({ id: "a1" }).submitFeedback({ type: "negative" });
110
+ });
111
+
112
+ expect(submit).toHaveBeenLastCalledWith({
113
+ message: MESSAGES[1],
114
+ type: "negative",
115
+ });
116
+ await waitFor(() => {
117
+ expect(
118
+ aui().thread.message({ id: "a1" }).getState().metadata
119
+ .submittedFeedback,
120
+ ).toEqual({ type: "negative" });
121
+ });
122
+ });
123
+
124
+ it("prefers owner-supplied submittedFeedback over the local overlay", async () => {
125
+ const { adapter } = createFakeAdapter();
126
+ const { aui, rerender } = renderThreadWithProps({
127
+ feedbackAdapter: adapter,
128
+ });
129
+
130
+ await act(async () => {
131
+ aui().thread.message({ id: "a1" }).submitFeedback({ type: "positive" });
132
+ });
133
+ await waitFor(() => {
134
+ expect(
135
+ aui().thread.message({ id: "a1" }).getState().metadata
136
+ .submittedFeedback,
137
+ ).toEqual({ type: "positive" });
138
+ });
139
+
140
+ const ownerMessages = [
141
+ MESSAGES[0]!,
142
+ {
143
+ ...MESSAGES[1]!,
144
+ metadata: { custom: {}, submittedFeedback: { type: "negative" } },
145
+ },
146
+ ] as unknown as readonly ExternalThreadMessage[];
147
+ await act(async () => {
148
+ rerender({ feedbackAdapter: adapter, messages: ownerMessages });
149
+ });
150
+
151
+ expect(
152
+ aui().thread.message({ id: "a1" }).getState().metadata.submittedFeedback,
153
+ ).toEqual({ type: "negative" });
154
+ });
155
+
156
+ it("re-rates an owner-marked message locally, then honors an owner clear", async () => {
157
+ const { adapter } = createFakeAdapter();
158
+ const ratedMessages = [
159
+ MESSAGES[0]!,
160
+ {
161
+ ...MESSAGES[1]!,
162
+ metadata: { custom: {}, submittedFeedback: { type: "positive" } },
163
+ },
164
+ ] as unknown as readonly ExternalThreadMessage[];
165
+ const { aui, rerender } = renderThreadWithProps({
166
+ feedbackAdapter: adapter,
167
+ messages: ratedMessages,
168
+ });
169
+
170
+ await act(async () => {
171
+ aui().thread.message({ id: "a1" }).submitFeedback({ type: "negative" });
172
+ });
173
+ await waitFor(() => {
174
+ expect(
175
+ aui().thread.message({ id: "a1" }).getState().metadata
176
+ .submittedFeedback,
177
+ ).toEqual({ type: "negative" });
178
+ });
179
+
180
+ await act(async () => {
181
+ rerender({ feedbackAdapter: adapter, messages: MESSAGES });
182
+ });
183
+ expect(
184
+ aui().thread.message({ id: "a1" }).getState().metadata.submittedFeedback,
185
+ ).toBeUndefined();
186
+ });
187
+
188
+ it("submits user message feedback without marking the message", async () => {
189
+ const { adapter, submit } = createFakeAdapter();
190
+ const { aui } = renderThreadWithProps({ feedbackAdapter: adapter });
191
+
192
+ await act(async () => {
193
+ aui().thread.message({ id: "u1" }).submitFeedback({ type: "negative" });
194
+ });
195
+
196
+ expect(submit).toHaveBeenCalledWith({
197
+ message: MESSAGES[0],
198
+ type: "negative",
199
+ });
200
+ expect(
201
+ aui().thread.message({ id: "u1" }).getState().metadata.submittedFeedback,
202
+ ).toBeUndefined();
203
+ });
204
+ });
@@ -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 "@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
+ });