@assistant-ui/react-ag-ui 0.0.63 → 0.0.65

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 (31) hide show
  1. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
  2. package/dist/runtime/AgUiThreadRuntimeCore.js +14 -9
  3. package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
  4. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  5. package/dist/runtime/adapter/conversions.js +9 -18
  6. package/dist/runtime/adapter/conversions.js.map +1 -1
  7. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  8. package/dist/runtime/adapter/run-aggregator.js +20 -8
  9. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  10. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  11. package/dist/runtime/adapter/subscriber.js +1 -0
  12. package/dist/runtime/adapter/subscriber.js.map +1 -1
  13. package/dist/runtime/event-parser.d.ts.map +1 -1
  14. package/dist/runtime/event-parser.js +16 -8
  15. package/dist/runtime/event-parser.js.map +1 -1
  16. package/dist/runtime/types.d.ts +8 -0
  17. package/dist/runtime/types.d.ts.map +1 -1
  18. package/dist/useAgUiRuntime.d.ts.map +1 -1
  19. package/dist/useAgUiRuntime.js +13 -2
  20. package/dist/useAgUiRuntime.js.map +1 -1
  21. package/package.json +8 -7
  22. package/src/runtime/AgUiThreadRuntimeCore.ts +16 -14
  23. package/src/runtime/adapter/conversions.nested-tool-calls.test.ts +107 -0
  24. package/src/runtime/adapter/conversions.ts +21 -45
  25. package/src/runtime/adapter/run-aggregator.ts +35 -7
  26. package/src/runtime/adapter/subscriber.ts +1 -0
  27. package/src/runtime/event-parser.ts +18 -4
  28. package/src/runtime/types.ts +18 -6
  29. package/src/useAgUiRuntime.fast-refresh.test.tsx +107 -1
  30. package/src/useAgUiRuntime.queue.test.tsx +63 -2
  31. package/src/useAgUiRuntime.ts +21 -2
@@ -6,14 +6,12 @@ import type {
6
6
  ThreadMessageLike as CoreThreadMessageLike,
7
7
  PartProviderMetadata,
8
8
  ReasoningMessagePart,
9
- ThreadMessage,
10
- ToolCallMessagePartMcpMetadata,
11
- ToolModelContentPart,
12
9
  } from "@assistant-ui/core";
13
10
  import {
14
11
  getAutoStatus,
15
12
  parseDataUrl,
16
13
  resolveFilePartSource,
14
+ walkToolCallTree,
17
15
  } from "@assistant-ui/core/internal";
18
16
  import { type Tool, toToolsJSONSchema } from "assistant-stream";
19
17
  import type { ReadonlyJSONObject } from "assistant-stream/utils";
@@ -99,29 +97,17 @@ export type AgUiMessage =
99
97
  error?: string;
100
98
  };
101
99
 
102
- type ToolCallPart = {
103
- type: "tool-call";
104
- toolCallId?: string;
105
- toolName: string;
106
- argsText?: string;
107
- args?: ReadonlyJSONObject;
100
+ type CoreToolCallPart = Extract<
101
+ Exclude<CoreThreadMessageLike["content"], string>[number],
102
+ { type: "tool-call" }
103
+ >;
104
+
105
+ type ToolCallPart = Omit<CoreToolCallPart, "result" | "isError"> & {
108
106
  result?: unknown;
109
- artifact?: unknown;
110
- isError?: boolean;
111
- modelContent?: readonly ToolModelContentPart[];
107
+ isError?: boolean | undefined;
112
108
  unstable_toolMessageId?: string;
113
- mcp?: ToolCallMessagePartMcpMetadata;
114
- messages?: readonly ThreadMessage[];
115
- approval?: CoreToolCallPartApproval;
116
109
  };
117
110
 
118
- type CoreToolCallPartApproval = NonNullable<
119
- Extract<
120
- Exclude<CoreThreadMessageLike["content"], string>[number],
121
- { type: "tool-call" }
122
- >["approval"]
123
- >;
124
-
125
111
  const isObject = (value: unknown): value is Record<string, unknown> =>
126
112
  typeof value === "object" && value !== null;
127
113
 
@@ -215,6 +201,9 @@ function normalizeToolCall(part: ToolCallPart): {
215
201
  };
216
202
  }
217
203
 
204
+ const isExportableNestedToolCall = (part: { readonly toolCallId?: unknown }) =>
205
+ typeof part.toolCallId === "string" && !part.toolCallId.startsWith("a2ui:");
206
+
218
207
  function extractText(content: unknown): string {
219
208
  if (typeof content === "string") return content;
220
209
  if (!Array.isArray(content)) return "";
@@ -1316,7 +1305,16 @@ function convertAssistantMessage(
1316
1305
  part: ToolCallPart;
1317
1306
  }[] = [];
1318
1307
  for (const { part } of toolCalls) {
1319
- collectNestedToolCalls(part, nestedToolCalls);
1308
+ for (const { part: nestedToolCall } of walkToolCallTree(
1309
+ part.messages ?? [],
1310
+ { shouldDescend: isExportableNestedToolCall },
1311
+ )) {
1312
+ if (!isExportableNestedToolCall(nestedToolCall)) continue;
1313
+ nestedToolCalls.push({
1314
+ ...normalizeToolCall(nestedToolCall),
1315
+ part: nestedToolCall,
1316
+ });
1317
+ }
1320
1318
  }
1321
1319
 
1322
1320
  converted.push({
@@ -1348,28 +1346,6 @@ function convertAssistantMessage(
1348
1346
  }
1349
1347
  }
1350
1348
 
1351
- function collectNestedToolCalls(
1352
- part: ToolCallPart,
1353
- out: { id: string; call: AgUiToolCall; part: ToolCallPart }[],
1354
- ): void {
1355
- for (const nested of part.messages ?? []) {
1356
- if (!isObject(nested) || nested.role !== "assistant") continue;
1357
- const nestedContent = Array.isArray(nested.content) ? nested.content : [];
1358
- for (const nestedPart of nestedContent) {
1359
- if (!isObject(nestedPart) || nestedPart.type !== "tool-call") continue;
1360
- const nestedToolCall = nestedPart as ToolCallPart;
1361
- if (
1362
- typeof nestedToolCall.toolCallId !== "string" ||
1363
- nestedToolCall.toolCallId.startsWith("a2ui:")
1364
- ) {
1365
- continue;
1366
- }
1367
- out.push({ ...normalizeToolCall(nestedToolCall), part: nestedToolCall });
1368
- collectNestedToolCalls(nestedToolCall, out);
1369
- }
1370
- }
1371
- }
1372
-
1373
1349
  function emitToolResult(
1374
1350
  toolCallId: string,
1375
1351
  part: ToolCallPart,
@@ -3,6 +3,7 @@
3
3
  import {
4
4
  isMcpAppUri,
5
5
  type ChatModelRunResult,
6
+ type MessagePartTiming,
6
7
  type MessageStatus,
7
8
  type MessageTiming,
8
9
  type ThreadAssistantMessagePart,
@@ -217,7 +218,10 @@ export class RunAggregator {
217
218
  private interrupts: AgUiInterrupt[] | undefined;
218
219
  private readonly textParts = new Map<string, { buffer: string }>();
219
220
  private readonly activeTextMessageIdByScope = new Map<string, string>();
220
- private readonly reasoningParts = new Map<string, string>(); // key → buffer
221
+ private readonly reasoningParts = new Map<
222
+ string,
223
+ { buffer: string; timing?: MessagePartTiming }
224
+ >();
221
225
  private readonly reasoningSignatures = new Map<string, string>();
222
226
  private readonly reasoningSignatureIds = new Map<string, string>();
223
227
  // Signatures captured while thinking is hidden have no block to live on;
@@ -396,6 +400,7 @@ export class RunAggregator {
396
400
  this.scopeOf("subagentRunId" in event ? event : {}),
397
401
  "messageId" in event ? event.messageId : undefined,
398
402
  event.type === "REASONING_MESSAGE_START",
403
+ event.timestamp,
399
404
  );
400
405
  break;
401
406
  case "REASONING_ENCRYPTED_VALUE":
@@ -454,11 +459,11 @@ export class RunAggregator {
454
459
  break;
455
460
  case "THINKING_TEXT_MESSAGE_END":
456
461
  case "THINKING_END":
457
- this.handleReasoningEnd(ROOT_SCOPE);
462
+ this.handleReasoningEnd(ROOT_SCOPE, event.timestamp);
458
463
  break;
459
464
  case "REASONING_MESSAGE_END":
460
465
  case "REASONING_END":
461
- this.handleReasoningEnd(this.scopeOf(event));
466
+ this.handleReasoningEnd(this.scopeOf(event), event.timestamp);
462
467
  break;
463
468
 
464
469
  case "TOOL_CALL_START": {
@@ -1222,7 +1227,8 @@ export class RunAggregator {
1222
1227
 
1223
1228
  if (part.kind === "reasoning") {
1224
1229
  if (this.showThinking) {
1225
- const buffer = this.reasoningParts.get(part.key) ?? "";
1230
+ const entry = this.reasoningParts.get(part.key);
1231
+ const buffer = entry?.buffer ?? "";
1226
1232
  const isActive =
1227
1233
  this.activeReasoningKeyByScope.get(rawScope) === part.key;
1228
1234
  if (buffer.length > 0 || isActive) {
@@ -1235,6 +1241,7 @@ export class RunAggregator {
1235
1241
  snapshot.push({
1236
1242
  type: "reasoning",
1237
1243
  text: buffer,
1244
+ ...(entry?.timing ? { timing: entry.timing } : {}),
1238
1245
  ...(Object.keys(meta).length > 0
1239
1246
  ? { providerMetadata: { [AG_UI_METADATA_NAMESPACE]: meta } }
1240
1247
  : {}),
@@ -1441,6 +1448,7 @@ export class RunAggregator {
1441
1448
  scope: string,
1442
1449
  messageId?: string,
1443
1450
  isMessageId = false,
1451
+ timestamp?: number,
1444
1452
  ): void {
1445
1453
  if (!this.showThinking) {
1446
1454
  // Hidden-reasoning bookkeeping stays root-only for this task — a
@@ -1473,13 +1481,23 @@ export class RunAggregator {
1473
1481
  this.reasoningMessageIds.set(key, messageId);
1474
1482
  }
1475
1483
  if (!this.reasoningParts.has(key)) {
1476
- this.reasoningParts.set(key, "");
1484
+ this.reasoningParts.set(key, { buffer: "" });
1477
1485
  this.partOrder.push(
1478
1486
  scope === ROOT_SCOPE
1479
1487
  ? { kind: "reasoning", key }
1480
1488
  : { kind: "reasoning", key, subagentRunId: scope },
1481
1489
  );
1482
1490
  }
1491
+ const entry = this.reasoningParts.get(key);
1492
+ if (entry?.timing) {
1493
+ entry.timing = { startedAt: entry.timing.startedAt };
1494
+ } else if (
1495
+ entry &&
1496
+ typeof timestamp === "number" &&
1497
+ Number.isFinite(timestamp)
1498
+ ) {
1499
+ entry.timing = { startedAt: timestamp };
1500
+ }
1483
1501
  this.activeReasoningKeyByScope.set(scope, key);
1484
1502
  this.emit();
1485
1503
  }
@@ -1506,15 +1524,25 @@ export class RunAggregator {
1506
1524
  }
1507
1525
  const key = this.activeReasoningKeyByScope.get(scope);
1508
1526
  if (!key) return;
1509
- this.reasoningParts.set(key, (this.reasoningParts.get(key) ?? "") + delta);
1527
+ const entry = this.reasoningParts.get(key);
1528
+ if (entry) entry.buffer += delta;
1510
1529
  this.emit();
1511
1530
  }
1512
1531
 
1513
- private handleReasoningEnd(scope: string): void {
1532
+ private handleReasoningEnd(scope: string, timestamp?: number): void {
1514
1533
  if (!this.showThinking) {
1515
1534
  if (scope === ROOT_SCOPE) this.hiddenActiveReasoning = "none";
1516
1535
  return;
1517
1536
  }
1537
+ const key = this.activeReasoningKeyByScope.get(scope);
1538
+ const entry = key ? this.reasoningParts.get(key) : undefined;
1539
+ if (
1540
+ entry?.timing &&
1541
+ typeof timestamp === "number" &&
1542
+ Number.isFinite(timestamp)
1543
+ ) {
1544
+ entry.timing = { ...entry.timing, completedAt: timestamp };
1545
+ }
1518
1546
  this.activeReasoningKeyByScope.delete(scope);
1519
1547
  this.emit();
1520
1548
  }
@@ -188,6 +188,7 @@ export const createAgUiSubscriber = (
188
188
  dispatch({ type: "RUN_FINISHED", runId });
189
189
  },
190
190
  onRunFailed: ({ error }) => {
191
+ if (runFinishedDispatched) return;
191
192
  runFinishedDispatched = true;
192
193
  onRunFailed?.(error);
193
194
  if (isAbortError(error)) {
@@ -146,6 +146,10 @@ export const parseAgUiEvent = (
146
146
 
147
147
  const getString = (key: string) =>
148
148
  isString(payload[key]) ? (payload[key] as string) : undefined;
149
+ const timestamp =
150
+ typeof payload.timestamp === "number" && Number.isFinite(payload.timestamp)
151
+ ? payload.timestamp
152
+ : undefined;
149
153
 
150
154
  switch (typeValue) {
151
155
  case "RUN_STARTED": {
@@ -219,24 +223,31 @@ export const parseAgUiEvent = (
219
223
  case "THINKING_START":
220
224
  return withOptional(
221
225
  { type: "THINKING_START" as const },
222
- { title: getString("title") },
226
+ { title: getString("title"), timestamp },
223
227
  );
224
228
  case "THINKING_TEXT_MESSAGE_START":
225
- return { type: "THINKING_TEXT_MESSAGE_START" };
229
+ return withOptional(
230
+ { type: "THINKING_TEXT_MESSAGE_START" as const },
231
+ { timestamp },
232
+ );
226
233
  case "THINKING_TEXT_MESSAGE_CONTENT": {
227
234
  const delta = getString("delta") ?? "";
228
235
  return { type: "THINKING_TEXT_MESSAGE_CONTENT", delta };
229
236
  }
230
237
  case "THINKING_TEXT_MESSAGE_END":
231
- return { type: "THINKING_TEXT_MESSAGE_END" };
238
+ return withOptional(
239
+ { type: "THINKING_TEXT_MESSAGE_END" as const },
240
+ { timestamp },
241
+ );
232
242
  case "THINKING_END":
233
- return { type: "THINKING_END" };
243
+ return withOptional({ type: "THINKING_END" as const }, { timestamp });
234
244
  case "REASONING_START":
235
245
  return withOptional(
236
246
  { type: "REASONING_START" as const },
237
247
  {
238
248
  messageId: getString("messageId"),
239
249
  subagentRunId: getString("subagentRunId"),
250
+ timestamp,
240
251
  },
241
252
  );
242
253
  case "REASONING_MESSAGE_START":
@@ -245,6 +256,7 @@ export const parseAgUiEvent = (
245
256
  {
246
257
  messageId: getString("messageId"),
247
258
  subagentRunId: getString("subagentRunId"),
259
+ timestamp,
248
260
  },
249
261
  );
250
262
  case "REASONING_MESSAGE_CONTENT": {
@@ -263,6 +275,7 @@ export const parseAgUiEvent = (
263
275
  {
264
276
  messageId: getString("messageId"),
265
277
  subagentRunId: getString("subagentRunId"),
278
+ timestamp,
266
279
  },
267
280
  );
268
281
  case "REASONING_ENCRYPTED_VALUE": {
@@ -292,6 +305,7 @@ export const parseAgUiEvent = (
292
305
  {
293
306
  messageId: getString("messageId"),
294
307
  subagentRunId: getString("subagentRunId"),
308
+ timestamp,
295
309
  },
296
310
  );
297
311
  case "TOOL_CALL_START": {
@@ -180,16 +180,22 @@ export type AgUiEvent =
180
180
  delta: string;
181
181
  subagentRunId?: string;
182
182
  }
183
- | { type: "THINKING_START"; title?: string }
184
- | { type: "THINKING_TEXT_MESSAGE_START" }
183
+ | { type: "THINKING_START"; title?: string; timestamp?: number }
184
+ | { type: "THINKING_TEXT_MESSAGE_START"; timestamp?: number }
185
185
  | { type: "THINKING_TEXT_MESSAGE_CONTENT"; delta: string }
186
- | { type: "THINKING_TEXT_MESSAGE_END" }
187
- | { type: "THINKING_END" }
188
- | { type: "REASONING_START"; messageId?: string; subagentRunId?: string }
186
+ | { type: "THINKING_TEXT_MESSAGE_END"; timestamp?: number }
187
+ | { type: "THINKING_END"; timestamp?: number }
188
+ | {
189
+ type: "REASONING_START";
190
+ messageId?: string;
191
+ subagentRunId?: string;
192
+ timestamp?: number;
193
+ }
189
194
  | {
190
195
  type: "REASONING_MESSAGE_START";
191
196
  messageId?: string;
192
197
  subagentRunId?: string;
198
+ timestamp?: number;
193
199
  }
194
200
  | {
195
201
  type: "REASONING_MESSAGE_CONTENT";
@@ -201,6 +207,7 @@ export type AgUiEvent =
201
207
  type: "REASONING_MESSAGE_END";
202
208
  messageId?: string;
203
209
  subagentRunId?: string;
210
+ timestamp?: number;
204
211
  }
205
212
  | {
206
213
  type: "REASONING_ENCRYPTED_VALUE";
@@ -209,7 +216,12 @@ export type AgUiEvent =
209
216
  encryptedValue: string;
210
217
  subagentRunId?: string;
211
218
  }
212
- | { type: "REASONING_END"; messageId?: string; subagentRunId?: string }
219
+ | {
220
+ type: "REASONING_END";
221
+ messageId?: string;
222
+ subagentRunId?: string;
223
+ timestamp?: number;
224
+ }
213
225
  | {
214
226
  type: "TOOL_CALL_START";
215
227
  toolCallId: string;
@@ -3,7 +3,10 @@
3
3
  import { act, Activity, version, type ComponentType } from "react";
4
4
  import { afterAll, afterEach, expect, it, vi } from "vitest";
5
5
  import type { HttpAgent } from "@ag-ui/client";
6
- import type { AssistantRuntime } from "@assistant-ui/core";
6
+ import type {
7
+ AssistantRuntime,
8
+ MessageQueueController,
9
+ } from "@assistant-ui/core";
7
10
  import { AgUiThreadRuntimeCore } from "./runtime/AgUiThreadRuntimeCore";
8
11
  import type { UseAgUiRuntimeOptions } from "./runtime/types";
9
12
  import { useAgUiRuntime, type AgUiAssistantRuntime } from "./useAgUiRuntime";
@@ -11,6 +14,34 @@ import { useAgUiRuntime, type AgUiAssistantRuntime } from "./useAgUiRuntime";
11
14
  const onReact18 = version.startsWith("18.");
12
15
 
13
16
  const base = vi.hoisted(() => ({ version: 0 }));
17
+ const queue = vi.hoisted(() => ({
18
+ controller: null as MessageQueueController | null,
19
+ busy: vi.fn(),
20
+ idle: vi.fn(),
21
+ }));
22
+ vi.mock("@assistant-ui/core", async (importOriginal) => {
23
+ const actual = await importOriginal<typeof import("@assistant-ui/core")>();
24
+ return {
25
+ ...actual,
26
+ createMessageQueue: (
27
+ ...args: Parameters<typeof actual.createMessageQueue>
28
+ ) => {
29
+ const controller = actual.createMessageQueue(...args);
30
+ queue.controller = {
31
+ ...controller,
32
+ notifyBusy: () => {
33
+ queue.busy();
34
+ controller.notifyBusy();
35
+ },
36
+ notifyIdle: () => {
37
+ queue.idle();
38
+ controller.notifyIdle();
39
+ },
40
+ };
41
+ return queue.controller;
42
+ },
43
+ };
44
+ });
14
45
  vi.mock("@assistant-ui/core/react", async (importOriginal) => {
15
46
  const actual =
16
47
  await importOriginal<typeof import("@assistant-ui/core/react")>();
@@ -61,6 +92,7 @@ afterEach(() => {
61
92
  fiberRoots.clear();
62
93
  vi.restoreAllMocks();
63
94
  base.version = 0;
95
+ queue.controller = null;
64
96
  });
65
97
  afterAll(() => vi.unstubAllGlobals());
66
98
 
@@ -112,6 +144,80 @@ it("keeps the wrapper across refresh and replaces it when the base runtime chang
112
144
  expect(runtime).not.toBe(wrapper);
113
145
  });
114
146
 
147
+ it("emits queue edges once across refresh and dispatches the queued message", async () => {
148
+ let finishFirst!: () => void;
149
+ const firstRun = new Promise<void>((resolve) => {
150
+ finishFirst = resolve;
151
+ });
152
+ const runAgent = vi.fn(
153
+ async (_input: unknown, subscriber: { onRunFinalized?: () => void }) => {
154
+ if (runAgent.mock.calls.length === 1) await firstRun;
155
+ subscriber.onRunFinalized?.();
156
+ },
157
+ );
158
+ options = {
159
+ agent: { runAgent, abortRun: vi.fn() } as unknown as HttpAgent,
160
+ unstable_enableMessageQueue: true,
161
+ };
162
+ const { Before, After } = createProbe();
163
+ const view = render(<Before />);
164
+ expect(queue.idle).toHaveBeenCalledTimes(1);
165
+
166
+ await act(async () => {
167
+ await runtime.thread.append({
168
+ role: "user",
169
+ content: [{ type: "text", text: "first" }],
170
+ });
171
+ });
172
+ await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(1));
173
+ expect(queue.busy).toHaveBeenCalledTimes(1);
174
+
175
+ await act(async () => {
176
+ await runtime.thread.append({
177
+ role: "user",
178
+ content: [{ type: "text", text: "second" }],
179
+ parentId: runtime.thread.getState().messages.at(-1)?.id ?? null,
180
+ });
181
+ });
182
+ expect(runAgent).toHaveBeenCalledTimes(1);
183
+ expect(
184
+ [
185
+ ...queue.controller!.adapter.items,
186
+ ...queue.controller!.adapter.steerItems,
187
+ ].map((item) => item.prompt),
188
+ ).toEqual(["second"]);
189
+
190
+ await refresh(Before, After);
191
+ expect(queue.busy).toHaveBeenCalledTimes(1);
192
+ expect(queue.idle).toHaveBeenCalledTimes(1);
193
+
194
+ await act(async () => {
195
+ finishFirst();
196
+ await firstRun;
197
+ });
198
+ await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(2));
199
+ await waitFor(() =>
200
+ expect([
201
+ ...queue.controller!.adapter.items,
202
+ ...queue.controller!.adapter.steerItems,
203
+ ]).toHaveLength(0),
204
+ );
205
+ await waitFor(() => expect(queue.busy).toHaveBeenCalledTimes(2));
206
+ await waitFor(() => expect(queue.idle).toHaveBeenCalledTimes(3));
207
+
208
+ await refresh(Before, After);
209
+ expect(queue.busy).toHaveBeenCalledTimes(2);
210
+ expect(queue.idle).toHaveBeenCalledTimes(3);
211
+
212
+ const firstController = queue.controller;
213
+ options = { ...options, unstable_enableMessageQueue: false };
214
+ view.rerender(<After />);
215
+ options = { ...options, unstable_enableMessageQueue: true };
216
+ view.rerender(<After />);
217
+ expect(queue.controller).not.toBe(firstController);
218
+ expect(queue.idle).toHaveBeenCalledTimes(4);
219
+ });
220
+
115
221
  const expectInFlightRunAcrossRefresh = async (
116
222
  exit: "hidden" | "unmount",
117
223
  inActivity: boolean,
@@ -96,11 +96,67 @@ describe("useAgUiRuntime unstable_enableMessageQueue", () => {
96
96
  );
97
97
  });
98
98
 
99
+ it("holds queued sends while sending is disabled", async () => {
100
+ const { agent, runAgent, release } = gatedAgent();
101
+
102
+ const { result, rerender } = renderHook(
103
+ ({ isSendDisabled }) =>
104
+ useAgUiRuntime({
105
+ agent,
106
+ unstable_enableMessageQueue: true,
107
+ isSendDisabled,
108
+ }),
109
+ { initialProps: { isSendDisabled: false } },
110
+ );
111
+ mount(result.current);
112
+
113
+ await act(async () => {
114
+ await result.current.thread.append({
115
+ role: "user",
116
+ content: [{ type: "text", text: "first" }],
117
+ });
118
+ });
119
+ await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(1));
120
+
121
+ await act(async () => {
122
+ await result.current.thread.append({
123
+ role: "user",
124
+ content: [{ type: "text", text: "second" }],
125
+ parentId: result.current.thread.getState().messages.at(-1)?.id ?? null,
126
+ });
127
+ });
128
+ await waitFor(() =>
129
+ expect(screen.getByTestId("queued").textContent).toBe("second"),
130
+ );
131
+ rerender({ isSendDisabled: true });
132
+
133
+ await act(async () => {
134
+ release();
135
+ });
136
+ await waitFor(() =>
137
+ expect(result.current.thread.getState().isRunning).toBe(false),
138
+ );
139
+ expect(runAgent).toHaveBeenCalledTimes(1);
140
+ expect(screen.getByTestId("queued").textContent).toBe("second");
141
+
142
+ rerender({ isSendDisabled: false });
143
+ await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(2));
144
+ await waitFor(() =>
145
+ expect(screen.getByTestId("queued").textContent).toBe(""),
146
+ );
147
+ });
148
+
99
149
  it("preserves queued sends when the active run is cancelled", async () => {
100
150
  const { agent, runAgent, release } = gatedAgent();
101
151
 
102
- const { result } = renderHook(() =>
103
- useAgUiRuntime({ agent, unstable_enableMessageQueue: true }),
152
+ const { result, rerender } = renderHook(
153
+ ({ isSendDisabled }) =>
154
+ useAgUiRuntime({
155
+ agent,
156
+ unstable_enableMessageQueue: true,
157
+ isSendDisabled,
158
+ }),
159
+ { initialProps: { isSendDisabled: false } },
104
160
  );
105
161
  mount(result.current);
106
162
 
@@ -135,6 +191,11 @@ describe("useAgUiRuntime unstable_enableMessageQueue", () => {
135
191
  expect(screen.getByTestId("queued").textContent).toBe("second"),
136
192
  );
137
193
 
194
+ rerender({ isSendDisabled: true });
195
+ rerender({ isSendDisabled: false });
196
+ expect(runAgent).toHaveBeenCalledTimes(1);
197
+ expect(screen.getByTestId("queued").textContent).toBe("second");
198
+
138
199
  await act(async () => {
139
200
  await result.current.thread.append({
140
201
  role: "user",
@@ -114,6 +114,10 @@ export function useAgUiRuntime(
114
114
  // A dispatch whose count never moves was never observed as busy, so no
115
115
  // falling edge is coming to release the queue.
116
116
  const busyEdgesRef = useRef(0);
117
+ const lastQueueEdgeRef = useRef<{
118
+ controller: MessageQueueController;
119
+ busy: boolean;
120
+ } | null>(null);
117
121
  if (options.unstable_enableMessageQueue && !queueRef.current) {
118
122
  queueRef.current = createMessageQueue({
119
123
  run: (message) => {
@@ -172,11 +176,26 @@ export function useAgUiRuntime(
172
176
  // refusal would lose the message instead of keeping it visible.
173
177
  const queueBusy = isRunning || core.getPendingInterrupts() !== null;
174
178
  useEffect(() => {
179
+ if (options.isSendDisabled === true) {
180
+ queueController?.hold();
181
+ } else {
182
+ queueController?.release();
183
+ }
184
+ }, [options.isSendDisabled, queueController]);
185
+
186
+ useEffect(() => {
187
+ if (!queueController) return;
188
+ if (
189
+ lastQueueEdgeRef.current?.controller === queueController &&
190
+ lastQueueEdgeRef.current.busy === queueBusy
191
+ )
192
+ return;
193
+ lastQueueEdgeRef.current = { controller: queueController, busy: queueBusy };
175
194
  if (queueBusy) {
176
195
  busyEdgesRef.current++;
177
- queueController?.notifyBusy();
196
+ queueController.notifyBusy();
178
197
  } else {
179
- queueController?.notifyIdle();
198
+ queueController.notifyIdle();
180
199
  }
181
200
  }, [queueBusy, queueController]);
182
201