@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.
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 +14 -16
  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
@@ -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 &&