@assistant-ui/react-ag-ui 0.0.65 → 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.
@@ -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
@@ -205,43 +214,82 @@ export function useAgUiRuntime(
205
214
  const { onSwitchToNewThread, onSwitchToThread, ...rest } =
206
215
  threadListAdapter;
207
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
+
208
236
  return {
209
237
  ...rest,
210
238
  onSwitchToNewThread: onSwitchToNewThread
211
239
  ? async () => {
212
240
  const generation = ++threadSwitchGenerationRef.current;
213
- // Clear before the thread id flips, or the old messages leak
214
- // into the new thread as a sibling branch.
215
- core.applyExternalMessages([]);
216
- core.resetThreadState();
217
- await onSwitchToNewThread();
218
- if (generation !== threadSwitchGenerationRef.current) return;
219
- core.applyExternalMessages([]);
220
- 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
+ }
221
258
  }
222
259
  : undefined,
223
260
  onSwitchToThread: onSwitchToThread
224
261
  ? async (threadId: string) => {
225
262
  const generation = ++threadSwitchGenerationRef.current;
226
- // Clear before the thread id flips, or the old messages leak
227
- // into the new thread as a sibling branch.
228
- core.applyExternalMessages([]);
229
- core.resetThreadState();
230
- const result = await onSwitchToThread(threadId);
231
- if (generation !== threadSwitchGenerationRef.current) return;
232
- core.applyExternalMessages([]);
233
- core.resetThreadState();
234
- core.applyExternalMessages(result.messages);
235
- if (result.state !== undefined) {
236
- core.loadExternalState(result.state);
237
- }
238
- if (result.unstable_resume) {
239
- 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);
240
288
  }
241
289
  }
242
290
  : undefined,
243
291
  };
244
- }, [threadListAdapter, core]);
292
+ }, [threadListAdapter, core, logger]);
245
293
 
246
294
  const adapters = options.adapters;
247
295
  const adapterAdapters = useMemo(
@@ -259,14 +307,12 @@ export function useAgUiRuntime(
259
307
  const shared = useExternalStoreSharedOptions(options);
260
308
  const store = useMemo(
261
309
  () => {
262
- void _version; // rerender on version change
263
-
264
310
  return {
265
311
  ...shared,
266
- isLoading: core.isLoading,
267
- messageRepository: core.getMessageRepository(),
268
- state: core.getState(),
269
- isRunning: core.isRunning(),
312
+ isLoading: snapshot.isLoading,
313
+ messageRepository: snapshot.messageRepository,
314
+ state: snapshot.state,
315
+ isRunning: snapshot.isRunning,
270
316
  extras: agUiExtras.provide({
271
317
  interrupts:
272
318
  core.getPendingInterrupts()?.interrupts ?? EMPTY_INTERRUPTS,
@@ -274,13 +320,16 @@ export function useAgUiRuntime(
274
320
  submitInterruptResponses: (responses) =>
275
321
  core.submitInterruptResponses(responses),
276
322
  steerAway: (message, responses) => core.steerAway(message, responses),
277
- state: core.getState(),
323
+ state: snapshot.state,
278
324
  setState: (next) => core.setState(next),
279
325
  }),
280
326
  unstable_enableToolInvocations: true,
281
327
  unstable_persistsHistory: true,
282
328
  setToolStatuses,
283
- onNew: (message: AppendMessage) => core.append(message),
329
+ onNew: (message: AppendMessage) =>
330
+ switchingGenerationRef.current !== null
331
+ ? Promise.resolve()
332
+ : core.append(message),
284
333
  onVoiceTranscript: (message) => core.appendVoiceTranscript(message),
285
334
  onEdit: (message: AppendMessage) => {
286
335
  queueController?.clear();
@@ -313,29 +362,34 @@ export function useAgUiRuntime(
313
362
  ...(queueController && { queue: queueController.adapter }),
314
363
  } satisfies ExternalStoreAdapter<ThreadMessage>;
315
364
  },
316
- // _version is intentionally included to trigger re-computation when core state changes via notifyUpdate
317
365
  // toolInvocations intentionally excluded: abort/resume use refs internally and work with stale captures
318
366
  [
319
367
  adapterAdapters,
320
368
  core,
321
- _version,
322
369
  isRunning,
323
370
  queueController,
324
371
  queueItems,
325
372
  steerQueueItems,
326
373
  shared,
374
+ snapshot,
327
375
  ],
328
376
  );
329
377
 
330
378
  const baseRuntime = useExternalStoreRuntime(store);
331
379
 
332
380
  const createRuntime = (): AgUiAssistantRuntime => {
333
- const wrapper = Object.create(baseRuntime) as AgUiAssistantRuntime;
334
- wrapper.unstable_getPendingInterrupts = () =>
335
- core.getPendingInterrupts()?.interrupts ?? [];
336
- wrapper.unstable_submitInterruptResponses = (responses) =>
337
- core.submitInterruptResponses(responses);
338
- 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
+ };
339
393
  };
340
394
  const [pinnedRuntime, setPinnedRuntime] = useState(() => ({
341
395
  baseRuntime,