@assistant-ui/react 0.15.8 → 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.
- package/LICENSE +21 -0
- package/dist/client/ExternalThread.d.ts +3 -1
- package/dist/client/ExternalThread.d.ts.map +1 -1
- package/dist/client/ExternalThread.js +524 -335
- package/dist/client/ExternalThread.js.map +1 -1
- package/dist/legacy-runtime/cloud/auiV0.d.ts +1 -0
- package/dist/legacy-runtime/cloud/auiV0.d.ts.map +1 -1
- package/dist/legacy-runtime/cloud/auiV0.js +2 -1
- package/dist/legacy-runtime/cloud/auiV0.js.map +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.d.ts.map +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js +2 -0
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js.map +1 -1
- package/dist/unstable/useLiveCompletionAdapter.js +32 -7
- package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
- package/dist/utils/useToolArgsFieldStatus.d.ts +2 -2
- package/package.json +14 -16
- package/src/client/ExternalThread.ts +172 -9
- package/src/legacy-runtime/cloud/auiV0.ts +8 -1
- package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransport.spec.md +1 -0
- package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.test.tsx +79 -0
- package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.ts +2 -0
- package/src/tests/external-thread-feedback.test.tsx +204 -0
- package/src/tests/external-thread-speech.test.tsx +328 -0
- package/src/unstable/useLiveCompletionAdapter.test.tsx +90 -0
- 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 {
|
|
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
|
package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.test.tsx
CHANGED
|
@@ -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(
|
package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.ts
CHANGED
|
@@ -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
|
+
});
|