@assistant-ui/react-ag-ui 0.0.50 → 0.0.51

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,12 +1,23 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ useSyncExternalStore,
10
+ } from "react";
4
11
  import {
5
12
  useExternalStoreRuntime,
6
13
  useExternalStoreSharedOptions,
7
14
  useRuntimeAdapters,
8
15
  } from "@assistant-ui/core/react";
9
- import type { ToolExecutionStatus } from "@assistant-ui/core";
16
+ import { createMessageQueue } from "@assistant-ui/core";
17
+ import type {
18
+ MessageQueueController,
19
+ ToolExecutionStatus,
20
+ } from "@assistant-ui/core";
10
21
  import type {
11
22
  AssistantRuntime,
12
23
  AppendMessage,
@@ -22,8 +33,11 @@ import type {
22
33
  } from "./runtime/types";
23
34
  import { AgUiThreadRuntimeCore } from "./runtime/AgUiThreadRuntimeCore";
24
35
  import { agUiExtras } from "./agUiExtras";
36
+ import type { QueueItemState } from "@assistant-ui/core/store";
25
37
 
26
38
  const EMPTY_INTERRUPTS: readonly AgUiInterrupt[] = [];
39
+ const EMPTY_QUEUE_ITEMS: readonly QueueItemState[] = Object.freeze([]);
40
+ const subscribeNoop = () => () => {};
27
41
 
28
42
  export type AgUiAssistantRuntime = AssistantRuntime & {
29
43
  /**
@@ -84,6 +98,77 @@ export function useAgUiRuntime(
84
98
  const hasExecutingTools = Object.values(toolStatuses).some(
85
99
  (s) => s?.type === "executing",
86
100
  );
101
+ const isRunning = core.isRunning() || hasExecutingTools;
102
+
103
+ // The driver sends through the agent core rather than the runtime, because
104
+ // the runtime's append routes every tail append back into this queue.
105
+ const queueRef = useRef<MessageQueueController | null>(null);
106
+ // Counts rendered busy edges, whether a run start or an interrupt arriving.
107
+ // A dispatch whose count never moves was never observed as busy, so no
108
+ // falling edge is coming to release the queue.
109
+ const busyEdgesRef = useRef(0);
110
+ if (options.unstable_enableMessageQueue && !queueRef.current) {
111
+ queueRef.current = createMessageQueue({
112
+ run: (message) => {
113
+ const controller = queueRef.current;
114
+ const edgesAtDispatch = busyEdgesRef.current;
115
+ // The queue drops the item before dispatching and stays busy until an
116
+ // idle edge. An append observed as busy is released by that falling
117
+ // edge, and releasing again here would advance the queue twice; one
118
+ // that never runs (a pre-run rejection, or startRun false) has to be
119
+ // released here or every later send buffers forever. An interrupt is
120
+ // rechecked because a run that resolves without yielding to React
121
+ // reaches here before the effect below has seen either edge.
122
+ const releaseIfNoRun = () => {
123
+ if (
124
+ queueRef.current !== controller ||
125
+ busyEdgesRef.current !== edgesAtDispatch ||
126
+ core.getPendingInterrupts() !== null
127
+ )
128
+ return;
129
+ controller?.notifyIdle();
130
+ };
131
+ void core.append(message).then(releaseIfNoRun, (e: unknown) => {
132
+ releaseIfNoRun();
133
+ logger.error?.("[agui] queued message failed to send", e);
134
+ });
135
+ },
136
+ });
137
+ } else if (!options.unstable_enableMessageQueue && queueRef.current) {
138
+ queueRef.current.clear();
139
+ queueRef.current = null;
140
+ }
141
+ const queueController = options.unstable_enableMessageQueue
142
+ ? queueRef.current
143
+ : null;
144
+
145
+ // Feeds the store memo below: the runtime core skips an adapter whose
146
+ // identity is unchanged, so queue items have to move the store reference or
147
+ // subscribers never see composer.queue change.
148
+ const queueItems = useSyncExternalStore(
149
+ queueController?.subscribe ?? subscribeNoop,
150
+ () => queueController?.adapter.items ?? EMPTY_QUEUE_ITEMS,
151
+ () => EMPTY_QUEUE_ITEMS,
152
+ );
153
+ const steerQueueItems = useSyncExternalStore(
154
+ queueController?.subscribe ?? subscribeNoop,
155
+ () => queueController?.adapter.steerItems ?? EMPTY_QUEUE_ITEMS,
156
+ () => EMPTY_QUEUE_ITEMS,
157
+ );
158
+
159
+ // Holds the queue while client-side tools are still executing, and while an
160
+ // interrupt is unanswered: `append` refuses a new run until it is resolved,
161
+ // and the queue drops an item before dispatching it, so draining into that
162
+ // refusal would lose the message instead of keeping it visible.
163
+ const queueBusy = isRunning || core.getPendingInterrupts() !== null;
164
+ useEffect(() => {
165
+ if (queueBusy) {
166
+ busyEdgesRef.current++;
167
+ queueController?.notifyBusy();
168
+ } else {
169
+ queueController?.notifyIdle();
170
+ }
171
+ }, [queueBusy, queueController]);
87
172
 
88
173
  const threadList = useMemo(() => {
89
174
  if (!threadListAdapter) return undefined;
@@ -143,7 +228,7 @@ export function useAgUiRuntime(
143
228
  isLoading: core.isLoading,
144
229
  messageRepository: core.getMessageRepository(),
145
230
  state: core.getState(),
146
- isRunning: core.isRunning() || hasExecutingTools,
231
+ isRunning,
147
232
  extras: agUiExtras.provide({
148
233
  interrupts:
149
234
  core.getPendingInterrupts()?.interrupts ?? EMPTY_INTERRUPTS,
@@ -157,10 +242,16 @@ export function useAgUiRuntime(
157
242
  unstable_enableToolInvocations: true,
158
243
  setToolStatuses,
159
244
  onNew: (message: AppendMessage) => core.append(message),
160
- onEdit: (message: AppendMessage) => core.edit(message),
161
- onReload: (parentId: string | null, config: { runConfig?: any }) =>
162
- core.reload(parentId, config),
245
+ onEdit: (message: AppendMessage) => {
246
+ queueController?.clear();
247
+ return core.edit(message);
248
+ },
249
+ onReload: (parentId: string | null, config: { runConfig?: any }) => {
250
+ queueController?.clear();
251
+ return core.reload(parentId, config);
252
+ },
163
253
  onCancel: async () => {
254
+ queueController?.clear();
164
255
  core.cancel();
165
256
  },
166
257
  onAddToolResult: (options) => core.addToolResult(options),
@@ -172,11 +263,21 @@ export function useAgUiRuntime(
172
263
  onLoadExternalState: (state: ReadonlyJSONValue) =>
173
264
  core.loadExternalState(state),
174
265
  adapters: adapterAdapters,
266
+ ...(queueController && { queue: queueController.adapter }),
175
267
  } satisfies ExternalStoreAdapter<ThreadMessage>;
176
268
  },
177
269
  // _version is intentionally included to trigger re-computation when core state changes via notifyUpdate
178
270
  // toolInvocations intentionally excluded: abort/resume use refs internally and work with stale captures
179
- [adapterAdapters, core, _version, hasExecutingTools, shared],
271
+ [
272
+ adapterAdapters,
273
+ core,
274
+ _version,
275
+ isRunning,
276
+ queueController,
277
+ queueItems,
278
+ steerQueueItems,
279
+ shared,
280
+ ],
180
281
  );
181
282
 
182
283
  const baseRuntime = useExternalStoreRuntime(store);