@assistant-ui/react-ag-ui 0.0.64 → 0.0.66

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 (35) hide show
  1. package/dist/runtime/AgUiThreadRuntimeCore.d.ts +19 -3
  2. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
  3. package/dist/runtime/AgUiThreadRuntimeCore.js +193 -39
  4. package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
  5. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  6. package/dist/runtime/adapter/conversions.js +19 -29
  7. package/dist/runtime/adapter/conversions.js.map +1 -1
  8. package/dist/runtime/adapter/run-aggregator.d.ts +4 -16
  9. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  10. package/dist/runtime/adapter/run-aggregator.js +100 -23
  11. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  12. package/dist/runtime/event-parser.d.ts.map +1 -1
  13. package/dist/runtime/event-parser.js +16 -8
  14. package/dist/runtime/event-parser.js.map +1 -1
  15. package/dist/runtime/types.d.ts +30 -5
  16. package/dist/runtime/types.d.ts.map +1 -1
  17. package/dist/runtime/types.js +8 -0
  18. package/dist/runtime/types.js.map +1 -0
  19. package/dist/useAgUiRuntime.d.ts.map +1 -1
  20. package/dist/useAgUiRuntime.js +80 -33
  21. package/dist/useAgUiRuntime.js.map +1 -1
  22. package/package.json +7 -6
  23. package/src/runtime/AgUiThreadRuntimeCore.test.ts +380 -6
  24. package/src/runtime/AgUiThreadRuntimeCore.ts +314 -70
  25. package/src/runtime/adapter/conversions.content.test.ts +41 -0
  26. package/src/runtime/adapter/conversions.nested-tool-calls.test.ts +107 -0
  27. package/src/runtime/adapter/conversions.ts +37 -64
  28. package/src/runtime/adapter/run-aggregator.reasoning.test.ts +143 -0
  29. package/src/runtime/adapter/run-aggregator.ts +163 -40
  30. package/src/runtime/event-parser.ts +18 -4
  31. package/src/runtime/types.ts +43 -11
  32. package/src/useAgUiRuntime.external-store.test.tsx +51 -0
  33. package/src/useAgUiRuntime.queue.test.tsx +63 -2
  34. package/src/useAgUiRuntime.thread-switch.test.tsx +587 -3
  35. package/src/useAgUiRuntime.ts +106 -44
@@ -1,7 +1,6 @@
1
1
  "use client";
2
2
 
3
3
  import {
4
- useCallback,
5
4
  useEffect,
6
5
  useLayoutEffect,
7
6
  useMemo,
@@ -61,10 +60,9 @@ export function useAgUiRuntime(
61
60
  options: UseAgUiRuntimeOptions,
62
61
  ): AgUiAssistantRuntime {
63
62
  const logger = useMemo(() => makeLogger(options.logger), [options.logger]);
64
- const [_version, setVersion] = useState(0);
65
- const notifyUpdate = useCallback(() => setVersion((v) => v + 1), []);
66
63
  const coreRef = useRef<AgUiThreadRuntimeCore | null>(null);
67
64
  const threadSwitchGenerationRef = useRef(0);
65
+ const switchingGenerationRef = useRef<number | null>(null);
68
66
  const runtimeAdapters = useRuntimeAdapters();
69
67
 
70
68
  const historyAdapter = options.adapters?.history ?? runtimeAdapters?.history;
@@ -80,11 +78,16 @@ export function useAgUiRuntime(
80
78
  ...(options.onError && { onError: options.onError }),
81
79
  ...(options.onCancel && { onCancel: options.onCancel }),
82
80
  ...(historyAdapter && { history: historyAdapter }),
83
- notifyUpdate,
81
+ isThreadSwitching: () => switchingGenerationRef.current !== null,
84
82
  });
85
83
  }
86
84
 
87
85
  const core = coreRef.current;
86
+ const snapshot = useSyncExternalStore(
87
+ core.subscribe,
88
+ core.getSnapshot,
89
+ core.getSnapshot,
90
+ );
88
91
  useEffect(() => {
89
92
  core.updateOptions({
90
93
  agent: options.agent,
@@ -105,7 +108,7 @@ export function useAgUiRuntime(
105
108
  const hasExecutingTools = Object.values(toolStatuses).some(
106
109
  (s) => s?.type === "executing",
107
110
  );
108
- const isRunning = core.isRunning() || hasExecutingTools;
111
+ const isRunning = snapshot.isRunning || hasExecutingTools;
109
112
 
110
113
  // The driver sends through the agent core rather than the runtime, because
111
114
  // the runtime's append routes every tail append back into this queue.
@@ -122,6 +125,12 @@ export function useAgUiRuntime(
122
125
  queueRef.current = createMessageQueue({
123
126
  run: (message) => {
124
127
  const controller = queueRef.current;
128
+ // A send dispatched mid-switch is dropped, and the queue released, or
129
+ // it would wait for an idle edge that never comes.
130
+ if (switchingGenerationRef.current !== null) {
131
+ controller?.notifyIdle();
132
+ return;
133
+ }
125
134
  const edgesAtDispatch = busyEdgesRef.current;
126
135
  // The queue drops the item before dispatching and stays busy until an
127
136
  // idle edge. An append observed as busy is released by that falling
@@ -175,6 +184,14 @@ export function useAgUiRuntime(
175
184
  // and the queue drops an item before dispatching it, so draining into that
176
185
  // refusal would lose the message instead of keeping it visible.
177
186
  const queueBusy = isRunning || core.getPendingInterrupts() !== null;
187
+ useEffect(() => {
188
+ if (options.isSendDisabled === true) {
189
+ queueController?.hold();
190
+ } else {
191
+ queueController?.release();
192
+ }
193
+ }, [options.isSendDisabled, queueController]);
194
+
178
195
  useEffect(() => {
179
196
  if (!queueController) return;
180
197
  if (
@@ -197,43 +214,82 @@ export function useAgUiRuntime(
197
214
  const { onSwitchToNewThread, onSwitchToThread, ...rest } =
198
215
  threadListAdapter;
199
216
 
217
+ const abandonPreviousThread = () => {
218
+ queueRef.current?.notifyCancelled();
219
+ if (core.isRunning()) {
220
+ void core.cancel().catch((error: unknown) => {
221
+ logger.error?.(
222
+ "[agui] cancelling the run on thread switch failed",
223
+ error,
224
+ );
225
+ });
226
+ }
227
+ queueRef.current?.clear();
228
+ };
229
+
230
+ const releaseSwitch = (generation: number) => {
231
+ if (switchingGenerationRef.current === generation) {
232
+ switchingGenerationRef.current = null;
233
+ }
234
+ };
235
+
200
236
  return {
201
237
  ...rest,
202
238
  onSwitchToNewThread: onSwitchToNewThread
203
239
  ? async () => {
204
240
  const generation = ++threadSwitchGenerationRef.current;
205
- // Clear before the thread id flips, or the old messages leak
206
- // into the new thread as a sibling branch.
207
- core.applyExternalMessages([]);
208
- core.resetThreadState();
209
- await onSwitchToNewThread();
210
- if (generation !== threadSwitchGenerationRef.current) return;
211
- core.applyExternalMessages([]);
212
- core.resetThreadState();
241
+ switchingGenerationRef.current = generation;
242
+ try {
243
+ abandonPreviousThread();
244
+ if (generation !== threadSwitchGenerationRef.current) return;
245
+ // Clear before the thread id flips, or the old messages leak
246
+ // into the new thread as a sibling branch.
247
+ core.applyExternalMessages([]);
248
+ core.resetThreadState();
249
+ await onSwitchToNewThread();
250
+ if (generation !== threadSwitchGenerationRef.current) return;
251
+ abandonPreviousThread();
252
+ if (generation !== threadSwitchGenerationRef.current) return;
253
+ core.applyExternalMessages([]);
254
+ core.resetThreadState();
255
+ } finally {
256
+ releaseSwitch(generation);
257
+ }
213
258
  }
214
259
  : undefined,
215
260
  onSwitchToThread: onSwitchToThread
216
261
  ? async (threadId: string) => {
217
262
  const generation = ++threadSwitchGenerationRef.current;
218
- // Clear before the thread id flips, or the old messages leak
219
- // into the new thread as a sibling branch.
220
- core.applyExternalMessages([]);
221
- core.resetThreadState();
222
- const result = await onSwitchToThread(threadId);
223
- if (generation !== threadSwitchGenerationRef.current) return;
224
- core.applyExternalMessages([]);
225
- core.resetThreadState();
226
- core.applyExternalMessages(result.messages);
227
- if (result.state !== undefined) {
228
- core.loadExternalState(result.state);
229
- }
230
- if (result.unstable_resume) {
231
- void core.resumeInFlightRun(result.messages);
263
+ switchingGenerationRef.current = generation;
264
+ try {
265
+ abandonPreviousThread();
266
+ if (generation !== threadSwitchGenerationRef.current) return;
267
+ // Clear before the thread id flips, or the old messages leak
268
+ // into the new thread as a sibling branch.
269
+ core.applyExternalMessages([]);
270
+ core.resetThreadState();
271
+ const result = await onSwitchToThread(threadId);
272
+ if (generation !== threadSwitchGenerationRef.current) return;
273
+ abandonPreviousThread();
274
+ if (generation !== threadSwitchGenerationRef.current) return;
275
+ core.applyExternalMessages([]);
276
+ core.resetThreadState();
277
+ core.applyExternalMessages(result.messages);
278
+ if (result.state !== undefined) {
279
+ core.loadExternalState(result.state);
280
+ }
281
+ if (generation !== threadSwitchGenerationRef.current) return;
282
+ releaseSwitch(generation);
283
+ if (result.unstable_resume) {
284
+ void core.resumeInFlightRun(result.messages);
285
+ }
286
+ } finally {
287
+ releaseSwitch(generation);
232
288
  }
233
289
  }
234
290
  : undefined,
235
291
  };
236
- }, [threadListAdapter, core]);
292
+ }, [threadListAdapter, core, logger]);
237
293
 
238
294
  const adapters = options.adapters;
239
295
  const adapterAdapters = useMemo(
@@ -251,14 +307,12 @@ export function useAgUiRuntime(
251
307
  const shared = useExternalStoreSharedOptions(options);
252
308
  const store = useMemo(
253
309
  () => {
254
- void _version; // rerender on version change
255
-
256
310
  return {
257
311
  ...shared,
258
- isLoading: core.isLoading,
259
- messageRepository: core.getMessageRepository(),
260
- state: core.getState(),
261
- isRunning: core.isRunning(),
312
+ isLoading: snapshot.isLoading,
313
+ messageRepository: snapshot.messageRepository,
314
+ state: snapshot.state,
315
+ isRunning: snapshot.isRunning,
262
316
  extras: agUiExtras.provide({
263
317
  interrupts:
264
318
  core.getPendingInterrupts()?.interrupts ?? EMPTY_INTERRUPTS,
@@ -266,13 +320,16 @@ export function useAgUiRuntime(
266
320
  submitInterruptResponses: (responses) =>
267
321
  core.submitInterruptResponses(responses),
268
322
  steerAway: (message, responses) => core.steerAway(message, responses),
269
- state: core.getState(),
323
+ state: snapshot.state,
270
324
  setState: (next) => core.setState(next),
271
325
  }),
272
326
  unstable_enableToolInvocations: true,
273
327
  unstable_persistsHistory: true,
274
328
  setToolStatuses,
275
- onNew: (message: AppendMessage) => core.append(message),
329
+ onNew: (message: AppendMessage) =>
330
+ switchingGenerationRef.current !== null
331
+ ? Promise.resolve()
332
+ : core.append(message),
276
333
  onVoiceTranscript: (message) => core.appendVoiceTranscript(message),
277
334
  onEdit: (message: AppendMessage) => {
278
335
  queueController?.clear();
@@ -305,29 +362,34 @@ export function useAgUiRuntime(
305
362
  ...(queueController && { queue: queueController.adapter }),
306
363
  } satisfies ExternalStoreAdapter<ThreadMessage>;
307
364
  },
308
- // _version is intentionally included to trigger re-computation when core state changes via notifyUpdate
309
365
  // toolInvocations intentionally excluded: abort/resume use refs internally and work with stale captures
310
366
  [
311
367
  adapterAdapters,
312
368
  core,
313
- _version,
314
369
  isRunning,
315
370
  queueController,
316
371
  queueItems,
317
372
  steerQueueItems,
318
373
  shared,
374
+ snapshot,
319
375
  ],
320
376
  );
321
377
 
322
378
  const baseRuntime = useExternalStoreRuntime(store);
323
379
 
324
380
  const createRuntime = (): AgUiAssistantRuntime => {
325
- const wrapper = Object.create(baseRuntime) as AgUiAssistantRuntime;
326
- wrapper.unstable_getPendingInterrupts = () =>
327
- core.getPendingInterrupts()?.interrupts ?? [];
328
- wrapper.unstable_submitInterruptResponses = (responses) =>
329
- core.submitInterruptResponses(responses);
330
- return wrapper;
381
+ return {
382
+ threads: baseRuntime.threads,
383
+ get thread() {
384
+ return baseRuntime.thread;
385
+ },
386
+ registerModelContextProvider: (provider) =>
387
+ baseRuntime.registerModelContextProvider(provider),
388
+ unstable_getPendingInterrupts: () =>
389
+ core.getPendingInterrupts()?.interrupts ?? [],
390
+ unstable_submitInterruptResponses: (responses) =>
391
+ core.submitInterruptResponses(responses),
392
+ };
331
393
  };
332
394
  const [pinnedRuntime, setPinnedRuntime] = useState(() => ({
333
395
  baseRuntime,