@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.
- package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
- package/dist/runtime/AgUiThreadRuntimeCore.js +14 -9
- package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
- package/dist/runtime/adapter/conversions.d.ts.map +1 -1
- package/dist/runtime/adapter/conversions.js +9 -18
- package/dist/runtime/adapter/conversions.js.map +1 -1
- package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
- package/dist/runtime/adapter/run-aggregator.js +20 -8
- package/dist/runtime/adapter/run-aggregator.js.map +1 -1
- package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
- package/dist/runtime/adapter/subscriber.js +1 -0
- package/dist/runtime/adapter/subscriber.js.map +1 -1
- package/dist/runtime/event-parser.d.ts.map +1 -1
- package/dist/runtime/event-parser.js +16 -8
- package/dist/runtime/event-parser.js.map +1 -1
- package/dist/runtime/types.d.ts +8 -0
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/useAgUiRuntime.d.ts.map +1 -1
- package/dist/useAgUiRuntime.js +13 -2
- package/dist/useAgUiRuntime.js.map +1 -1
- package/package.json +8 -7
- package/src/runtime/AgUiThreadRuntimeCore.ts +16 -14
- package/src/runtime/adapter/conversions.nested-tool-calls.test.ts +107 -0
- package/src/runtime/adapter/conversions.ts +21 -45
- package/src/runtime/adapter/run-aggregator.ts +35 -7
- package/src/runtime/adapter/subscriber.ts +1 -0
- package/src/runtime/event-parser.ts +18 -4
- package/src/runtime/types.ts +18 -6
- package/src/useAgUiRuntime.fast-refresh.test.tsx +107 -1
- package/src/useAgUiRuntime.queue.test.tsx +63 -2
- 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
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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": {
|
package/src/runtime/types.ts
CHANGED
|
@@ -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
|
-
| {
|
|
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
|
-
| {
|
|
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 {
|
|
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
|
-
|
|
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",
|
package/src/useAgUiRuntime.ts
CHANGED
|
@@ -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
|
|
196
|
+
queueController.notifyBusy();
|
|
178
197
|
} else {
|
|
179
|
-
queueController
|
|
198
|
+
queueController.notifyIdle();
|
|
180
199
|
}
|
|
181
200
|
}, [queueBusy, queueController]);
|
|
182
201
|
|