@assistant-ui/react 0.15.8 → 0.15.10

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 (26) 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 +556 -363
  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 +14 -16
  17. package/src/client/ExternalThread.ts +175 -10
  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-parity.test.tsx +29 -0
  24. package/src/tests/external-thread-speech.test.tsx +328 -0
  25. package/src/unstable/useLiveCompletionAdapter.test.tsx +90 -0
  26. package/src/unstable/useLiveCompletionAdapter.ts +32 -7
@@ -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
+ });
@@ -129,6 +129,96 @@ describe("unstable_useLiveCompletionAdapter", () => {
129
129
  expect(result.current.adapter.search!("ab")).toEqual([item("ab")]);
130
130
  });
131
131
 
132
+ it("allows a failed query to be retried", async () => {
133
+ const fetcher = vi
134
+ .fn<(query: string) => Promise<readonly Unstable_TriggerItem[]>>()
135
+ .mockRejectedValueOnce(new Error("temporarily unavailable"))
136
+ .mockRejectedValueOnce(new Error("still unavailable"))
137
+ .mockResolvedValueOnce([item("alice")]);
138
+ const { result } = renderHook(() =>
139
+ unstable_useLiveCompletionAdapter({ fetcher, debounceMs: 0 }),
140
+ );
141
+
142
+ for (const attempt of [1, 2]) {
143
+ await act(async () => {
144
+ result.current.adapter.search!("alice");
145
+ await vi.advanceTimersByTimeAsync(0);
146
+ });
147
+ expect(fetcher).toHaveBeenCalledTimes(attempt);
148
+ expect(result.current.isLoading).toBe(false);
149
+ }
150
+
151
+ await act(async () => {
152
+ result.current.adapter.search!("alice");
153
+ await vi.advanceTimersByTimeAsync(0);
154
+ });
155
+ expect(fetcher).toHaveBeenCalledTimes(3);
156
+ expect(result.current.adapter.search!("alice")).toEqual([item("alice")]);
157
+ });
158
+
159
+ it("does not automatically retry when search runs during every render", async () => {
160
+ const fetcher = vi
161
+ .fn<(query: string) => Promise<readonly Unstable_TriggerItem[]>>()
162
+ .mockRejectedValueOnce(new Error("temporarily unavailable"))
163
+ .mockImplementation(
164
+ () => new Promise<readonly Unstable_TriggerItem[]>(() => {}),
165
+ );
166
+ const { result } = renderHook(() => {
167
+ const completion = unstable_useLiveCompletionAdapter({
168
+ fetcher,
169
+ debounceMs: 0,
170
+ });
171
+ completion.adapter.search!("alice");
172
+ return completion;
173
+ });
174
+
175
+ await act(async () => {
176
+ await vi.advanceTimersByTimeAsync(0);
177
+ });
178
+ expect(result.current.isLoading).toBe(false);
179
+
180
+ await act(async () => {
181
+ await vi.advanceTimersByTimeAsync(1_000);
182
+ });
183
+ expect(fetcher).toHaveBeenCalledTimes(1);
184
+ });
185
+
186
+ it("re-arms a failed query when its pending retry is interrupted", async () => {
187
+ const fetcher = vi
188
+ .fn<(query: string) => Promise<readonly Unstable_TriggerItem[]>>()
189
+ .mockRejectedValueOnce(new Error("temporarily unavailable"))
190
+ .mockImplementationOnce(
191
+ () => new Promise<readonly Unstable_TriggerItem[]>(() => {}),
192
+ )
193
+ .mockResolvedValueOnce([item("alice")]);
194
+ const { result } = renderHook(() =>
195
+ unstable_useLiveCompletionAdapter({ fetcher, debounceMs: 0 }),
196
+ );
197
+
198
+ await act(async () => {
199
+ result.current.adapter.search!("alice");
200
+ await vi.advanceTimersByTimeAsync(0);
201
+ });
202
+
203
+ await act(async () => {
204
+ result.current.adapter.search!("alice");
205
+ await vi.advanceTimersByTimeAsync(0);
206
+ });
207
+ expect(fetcher).toHaveBeenCalledTimes(2);
208
+
209
+ await act(async () => {
210
+ result.current.adapter.search!("alicex");
211
+ });
212
+ await act(async () => {
213
+ result.current.adapter.search!("alice");
214
+ await vi.advanceTimersByTimeAsync(0);
215
+ });
216
+
217
+ expect(fetcher).toHaveBeenCalledTimes(3);
218
+ expect(fetcher).toHaveBeenLastCalledWith("alice");
219
+ expect(result.current.adapter.search!("alice")).toEqual([item("alice")]);
220
+ });
221
+
132
222
  it("drops an in-flight fetch when the query returns to a cached value", async () => {
133
223
  const resolvers: Record<
134
224
  string,
@@ -57,7 +57,8 @@ export function unstable_useLiveCompletionAdapter(
57
57
  const [state, setState] = useState<{
58
58
  query: string;
59
59
  items: readonly Unstable_TriggerItem[];
60
- }>({ query: NO_QUERY, items: [] });
60
+ failed: boolean;
61
+ }>({ query: NO_QUERY, items: [], failed: false });
61
62
  const [isLoading, setIsLoading] = useState(false);
62
63
 
63
64
  const fetcherRef = useRef(fetcher);
@@ -66,6 +67,8 @@ export function unstable_useLiveCompletionAdapter(
66
67
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
67
68
  const tokenRef = useRef(0);
68
69
  const pendingQueryRef = useRef<string | null>(null);
70
+ const retryableQueryRef = useRef<string | null>(null);
71
+ const pendingRetryQueryRef = useRef<string | null>(null);
69
72
 
70
73
  const cancelTimer = useCallback(() => {
71
74
  if (timerRef.current !== null) {
@@ -74,10 +77,22 @@ export function unstable_useLiveCompletionAdapter(
74
77
  }
75
78
  }, []);
76
79
 
80
+ const rearmPendingRetry = useCallback(() => {
81
+ const query = pendingRetryQueryRef.current;
82
+ if (query === null) return;
83
+ retryableQueryRef.current = query;
84
+ pendingRetryQueryRef.current = null;
85
+ }, []);
86
+
77
87
  const scheduleFetch = useCallback(
78
88
  (query: string) => {
79
89
  if (!enabled) return;
80
90
  if (pendingQueryRef.current === query) return;
91
+ rearmPendingRetry();
92
+ if (retryableQueryRef.current === query) {
93
+ retryableQueryRef.current = null;
94
+ pendingRetryQueryRef.current = query;
95
+ }
81
96
  pendingQueryRef.current = query;
82
97
  cancelTimer();
83
98
  const token = ++tokenRef.current;
@@ -87,37 +102,47 @@ export function unstable_useLiveCompletionAdapter(
87
102
  fetcherRef.current(query).then(
88
103
  (items) => {
89
104
  if (token !== tokenRef.current) return;
90
- setState({ query, items });
105
+ pendingRetryQueryRef.current = null;
106
+ setState({ query, items, failed: false });
91
107
  setIsLoading(false);
92
108
  },
93
109
  () => {
94
110
  if (token !== tokenRef.current) return;
95
- setState({ query, items: [] });
111
+ pendingQueryRef.current = null;
112
+ pendingRetryQueryRef.current = null;
113
+ setState({ query, items: [], failed: true });
96
114
  setIsLoading(false);
97
115
  },
98
116
  );
99
117
  }, debounceMs);
100
118
  },
101
- [enabled, debounceMs, cancelTimer],
119
+ [enabled, debounceMs, cancelTimer, rearmPendingRetry],
102
120
  );
103
121
 
104
122
  const invalidatePending = useCallback(() => {
123
+ rearmPendingRetry();
105
124
  cancelTimer();
106
125
  pendingQueryRef.current = null;
107
126
  tokenRef.current += 1;
108
127
  setIsLoading(false);
109
- }, [cancelTimer]);
128
+ }, [cancelTimer, rearmPendingRetry]);
110
129
 
111
130
  useEffect(() => {
112
131
  if (enabled) return;
113
132
  invalidatePending();
114
133
  setState((s) =>
115
- s.query === NO_QUERY ? s : { query: NO_QUERY, items: [] },
134
+ s.query === NO_QUERY ? s : { query: NO_QUERY, items: [], failed: false },
116
135
  );
117
136
  }, [enabled, invalidatePending]);
118
137
 
119
138
  useEffect(() => cancelTimer, [cancelTimer]);
120
139
 
140
+ // Arm retries only after the failed state commits. Arming during rejection
141
+ // would let the failure render immediately schedule another request.
142
+ useEffect(() => {
143
+ retryableQueryRef.current = state.failed ? state.query : null;
144
+ }, [state]);
145
+
121
146
  const adapter = useMemo<Unstable_TriggerAdapter>(
122
147
  () => ({
123
148
  categories: () => [],
@@ -125,7 +150,7 @@ export function unstable_useLiveCompletionAdapter(
125
150
  search: (query: string) => {
126
151
  // search() runs inside the popover's render; defer state updates with
127
152
  // queueMicrotask so they are not dispatched while another component renders.
128
- if (query !== state.query) {
153
+ if (query !== state.query || retryableQueryRef.current === query) {
129
154
  queueMicrotask(() => scheduleFetch(query));
130
155
  } else if (
131
156
  pendingQueryRef.current !== null &&