@assistant-ui/react-ag-ui 0.1.5 → 0.1.6
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/AgUiThreadController.d.ts.map +1 -1
- package/dist/runtime/AgUiThreadController.js +80 -13
- package/dist/runtime/AgUiThreadController.js.map +1 -1
- package/dist/runtime/adapter/run-aggregator.d.ts +37 -31
- package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
- package/dist/runtime/adapter/run-aggregator.js +363 -218
- package/dist/runtime/adapter/run-aggregator.js.map +1 -1
- package/package.json +1 -1
- package/src/runtime/AgUiThreadController.disconnect.test.ts +198 -3
- package/src/runtime/AgUiThreadController.replay.test.ts +57 -0
- package/src/runtime/AgUiThreadController.snapshot-head-history.test.ts +34 -2
- package/src/runtime/AgUiThreadController.snapshot-head-move.test.ts +510 -0
- package/src/runtime/AgUiThreadController.ts +179 -28
- package/src/runtime/adapter/run-aggregator.routing.test.ts +359 -0
- package/src/runtime/adapter/run-aggregator.ts +539 -258
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
isPlainObject,
|
|
61
61
|
RunAggregator,
|
|
62
62
|
tryParseJSON,
|
|
63
|
+
type RetainedMessageState,
|
|
63
64
|
} from "./adapter/run-aggregator";
|
|
64
65
|
import {
|
|
65
66
|
fromAgUiSnapshotMessages,
|
|
@@ -250,6 +251,17 @@ type CoreOptions = {
|
|
|
250
251
|
|
|
251
252
|
const FALLBACK_USER_STATUS = { type: "complete", reason: "unknown" } as const;
|
|
252
253
|
|
|
254
|
+
const snapshotRecordToolCallIds = (record: unknown): Set<string> => {
|
|
255
|
+
const toolCalls = (record as { toolCalls?: unknown } | undefined)?.toolCalls;
|
|
256
|
+
return new Set(
|
|
257
|
+
Array.isArray(toolCalls)
|
|
258
|
+
? toolCalls
|
|
259
|
+
.map((call: { id?: unknown } | undefined) => call?.id)
|
|
260
|
+
.filter((id): id is string => typeof id === "string")
|
|
261
|
+
: [],
|
|
262
|
+
);
|
|
263
|
+
};
|
|
264
|
+
|
|
253
265
|
const textOf = (content: ThreadAssistantMessage["content"]) =>
|
|
254
266
|
content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("");
|
|
255
267
|
|
|
@@ -1768,6 +1780,12 @@ export class AgUiThreadController {
|
|
|
1768
1780
|
let settlingRun = false;
|
|
1769
1781
|
let latestSnapshot: ThreadAssistantMessage | undefined;
|
|
1770
1782
|
let snapshotToolCallIds = new Set<string>();
|
|
1783
|
+
const snapshotResultIds = new Set<string>();
|
|
1784
|
+
let announcingMove = false;
|
|
1785
|
+
const retainedSnapshots = new Map<
|
|
1786
|
+
string,
|
|
1787
|
+
{ message: ThreadAssistantMessage; toolCallIds: ReadonlySet<string> }
|
|
1788
|
+
>();
|
|
1771
1789
|
const ensureAssistant = (allowRecreate = false): string => {
|
|
1772
1790
|
const cached = assistantMessageId;
|
|
1773
1791
|
if (cached !== undefined && this.session.tryGetMessage(cached))
|
|
@@ -1834,6 +1852,8 @@ export class AgUiThreadController {
|
|
|
1834
1852
|
}
|
|
1835
1853
|
for (const [messageId, openToolCallIds] of heldMessages) {
|
|
1836
1854
|
const existing = this.session.tryGetMessage(messageId);
|
|
1855
|
+
if (!canWaitForTools && this.isTerminalStatus(existing?.message.status))
|
|
1856
|
+
continue;
|
|
1837
1857
|
if (existing)
|
|
1838
1858
|
this.markPendingAssistantHistory(messageId, existing.parentId);
|
|
1839
1859
|
let requiresToolResult = false;
|
|
@@ -2166,6 +2186,85 @@ export class AgUiThreadController {
|
|
|
2166
2186
|
}
|
|
2167
2187
|
};
|
|
2168
2188
|
|
|
2189
|
+
// A message a snapshot moved the head past keeps streaming until nothing
|
|
2190
|
+
// on it is open; held, it settles with the run.
|
|
2191
|
+
const applyRetainedUpdate = (
|
|
2192
|
+
update: ChatModelRunResult,
|
|
2193
|
+
{ messageId, streaming, openToolCallIds }: RetainedMessageState,
|
|
2194
|
+
) => {
|
|
2195
|
+
const item = this.session.tryGetMessage(messageId);
|
|
2196
|
+
if (item?.message.role !== "assistant") return;
|
|
2197
|
+
this.markPendingAssistantHistory(messageId, item.parentId);
|
|
2198
|
+
let snapshot = retainedSnapshots.get(messageId);
|
|
2199
|
+
if (!snapshot) {
|
|
2200
|
+
snapshot = {
|
|
2201
|
+
message: item.message as ThreadAssistantMessage,
|
|
2202
|
+
toolCallIds: new Set(
|
|
2203
|
+
Array.from(
|
|
2204
|
+
iterateToolCallParts(item.message.content),
|
|
2205
|
+
(part) => part.toolCallId,
|
|
2206
|
+
),
|
|
2207
|
+
),
|
|
2208
|
+
};
|
|
2209
|
+
retainedSnapshots.set(messageId, snapshot);
|
|
2210
|
+
}
|
|
2211
|
+
const content =
|
|
2212
|
+
update.content !== undefined
|
|
2213
|
+
? this.mergeSnapshotAssistantContent(
|
|
2214
|
+
snapshot.message.content,
|
|
2215
|
+
update.content as ThreadAssistantMessage["content"],
|
|
2216
|
+
update.content.some((part) => part.type === "reasoning"),
|
|
2217
|
+
update.content.some(isActivityPart),
|
|
2218
|
+
snapshot.toolCallIds,
|
|
2219
|
+
)
|
|
2220
|
+
: update.content;
|
|
2221
|
+
// A call only the snapshot carried stays open until a result arrives.
|
|
2222
|
+
const unresolved = [
|
|
2223
|
+
...new Set([
|
|
2224
|
+
...openToolCallIds,
|
|
2225
|
+
...Array.from(iterateToolCallParts(content ?? item.message.content))
|
|
2226
|
+
.filter(
|
|
2227
|
+
(part) =>
|
|
2228
|
+
snapshot.toolCallIds.has(part.toolCallId) &&
|
|
2229
|
+
!isResolvedToolCall(part),
|
|
2230
|
+
)
|
|
2231
|
+
.map((part) => part.toolCallId),
|
|
2232
|
+
]),
|
|
2233
|
+
].filter((id) => !snapshotResultIds.has(id));
|
|
2234
|
+
// A terminal event settles a held message with the run instead.
|
|
2235
|
+
const held =
|
|
2236
|
+
streaming ||
|
|
2237
|
+
unresolved.length > 0 ||
|
|
2238
|
+
(settlingRun && heldMessages.has(messageId));
|
|
2239
|
+
if (held) heldMessages.set(messageId, new Set(unresolved));
|
|
2240
|
+
else heldMessages.delete(messageId);
|
|
2241
|
+
this.updateAssistantMessage(messageId, {
|
|
2242
|
+
...update,
|
|
2243
|
+
...(content !== undefined ? { content } : {}),
|
|
2244
|
+
status: held
|
|
2245
|
+
? { type: "running" }
|
|
2246
|
+
: { type: "complete", reason: "unknown" },
|
|
2247
|
+
});
|
|
2248
|
+
const current = this.session.tryGetMessage(messageId)?.message;
|
|
2249
|
+
if (current?.role === "assistant")
|
|
2250
|
+
retainedSnapshots.set(messageId, { ...snapshot, message: current });
|
|
2251
|
+
// The run is still streaming, which the message it streams into shows
|
|
2252
|
+
// as soon as any event arrives after the move.
|
|
2253
|
+
const head =
|
|
2254
|
+
assistantMessageId === undefined
|
|
2255
|
+
? undefined
|
|
2256
|
+
: this.session.tryGetMessage(assistantMessageId)?.message;
|
|
2257
|
+
if (
|
|
2258
|
+
!settlingRun &&
|
|
2259
|
+
!announcingMove &&
|
|
2260
|
+
head?.role === "assistant" &&
|
|
2261
|
+
head.status?.type !== "running" &&
|
|
2262
|
+
!runStartMessageIds.has(head.id) &&
|
|
2263
|
+
!aggregator.isRetained(head.id)
|
|
2264
|
+
)
|
|
2265
|
+
this.updateAssistantMessage(head.id, { status: { type: "running" } });
|
|
2266
|
+
};
|
|
2267
|
+
|
|
2169
2268
|
const abortController = new AbortController();
|
|
2170
2269
|
const abortSignal = abortController.signal;
|
|
2171
2270
|
const aggregator = new RunAggregator({
|
|
@@ -2268,10 +2367,24 @@ export class AgUiThreadController {
|
|
|
2268
2367
|
? current
|
|
2269
2368
|
: undefined;
|
|
2270
2369
|
},
|
|
2370
|
+
onRetainedMessage: applyRetainedUpdate,
|
|
2371
|
+
canRetainMessage: (messageId) =>
|
|
2372
|
+
messageId !== assistantMessageId &&
|
|
2373
|
+
messageId !== this.session.headId &&
|
|
2374
|
+
!runStartMessageIds.has(messageId) &&
|
|
2375
|
+
this.session.tryGetMessage(messageId)?.message.role === "assistant",
|
|
2271
2376
|
onServerMessageId: (serverId) => {
|
|
2272
2377
|
adoptServerMessageId(serverId, false);
|
|
2273
2378
|
},
|
|
2274
2379
|
onTextMessageStart: (serverId, openToolCallIds) => {
|
|
2380
|
+
const outgoingId =
|
|
2381
|
+
assistantMessageId !== undefined &&
|
|
2382
|
+
!assistantCollided &&
|
|
2383
|
+
!runStartMessageIds.has(assistantMessageId) &&
|
|
2384
|
+
!replayContexts.has(assistantMessageId) &&
|
|
2385
|
+
this.session.hasMessage(assistantMessageId)
|
|
2386
|
+
? assistantMessageId
|
|
2387
|
+
: undefined;
|
|
2275
2388
|
const owners = this.getTrailingAssistantTurn().filter((message) =>
|
|
2276
2389
|
replayContexts.has(message.id),
|
|
2277
2390
|
);
|
|
@@ -2293,6 +2406,7 @@ export class AgUiThreadController {
|
|
|
2293
2406
|
}
|
|
2294
2407
|
frameOwnerIds.clear();
|
|
2295
2408
|
adoptServerMessageId(serverId, true, frameOpenIds);
|
|
2409
|
+
return outgoingId;
|
|
2296
2410
|
},
|
|
2297
2411
|
});
|
|
2298
2412
|
const settleRunSubagents = (status: MessageStatus) => {
|
|
@@ -2345,6 +2459,17 @@ export class AgUiThreadController {
|
|
|
2345
2459
|
openToolCallIds.delete(event.toolCallId);
|
|
2346
2460
|
}
|
|
2347
2461
|
}
|
|
2462
|
+
// A result a snapshot delivers answers its call the way a streamed
|
|
2463
|
+
// TOOL_CALL_RESULT does.
|
|
2464
|
+
if (event.type === "MESSAGES_SNAPSHOT") {
|
|
2465
|
+
for (const record of event.messages) {
|
|
2466
|
+
if (record?.role !== "tool" || typeof record.toolCallId !== "string")
|
|
2467
|
+
continue;
|
|
2468
|
+
snapshotResultIds.add(record.toolCallId);
|
|
2469
|
+
for (const openToolCallIds of heldMessages.values())
|
|
2470
|
+
openToolCallIds.delete(record.toolCallId);
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2348
2473
|
settlingRun =
|
|
2349
2474
|
event.type === "RUN_FINISHED" ||
|
|
2350
2475
|
event.type === "RUN_ERROR" ||
|
|
@@ -2360,6 +2485,7 @@ export class AgUiThreadController {
|
|
|
2360
2485
|
joinStrategy,
|
|
2361
2486
|
runStartMessageIds,
|
|
2362
2487
|
hasRunUserBoundary,
|
|
2488
|
+
heldMessages,
|
|
2363
2489
|
candidateContexts.size ? reconcileReplaySnapshot : undefined,
|
|
2364
2490
|
);
|
|
2365
2491
|
if (event.type === "MESSAGES_SNAPSHOT") {
|
|
@@ -2406,18 +2532,31 @@ export class AgUiThreadController {
|
|
|
2406
2532
|
);
|
|
2407
2533
|
latestSnapshot =
|
|
2408
2534
|
imported?.role === "assistant" && source ? imported : undefined;
|
|
2409
|
-
snapshotToolCallIds =
|
|
2410
|
-
Array.isArray(source?.toolCalls)
|
|
2411
|
-
? source.toolCalls
|
|
2412
|
-
.map((call: { id?: unknown }) => call.id)
|
|
2413
|
-
.filter((id: unknown): id is string => typeof id === "string")
|
|
2414
|
-
: [],
|
|
2415
|
-
);
|
|
2535
|
+
snapshotToolCallIds = snapshotRecordToolCallIds(source);
|
|
2416
2536
|
if (!replayEnded && assistantMessageId === undefined) {
|
|
2417
2537
|
const tail = this.session.getMessages().at(-1);
|
|
2418
2538
|
snapshotAssistantId =
|
|
2419
2539
|
tail?.role === "assistant" ? tail.id : undefined;
|
|
2420
2540
|
}
|
|
2541
|
+
for (const record of event.messages) {
|
|
2542
|
+
if (
|
|
2543
|
+
record?.role !== "assistant" ||
|
|
2544
|
+
!aggregator.isRetained(record.id)
|
|
2545
|
+
)
|
|
2546
|
+
continue;
|
|
2547
|
+
const message = this.session.tryGetMessage(record.id)?.message;
|
|
2548
|
+
if (message?.role !== "assistant") continue;
|
|
2549
|
+
retainedSnapshots.set(record.id, {
|
|
2550
|
+
message,
|
|
2551
|
+
toolCallIds: snapshotRecordToolCallIds(record),
|
|
2552
|
+
});
|
|
2553
|
+
}
|
|
2554
|
+
announcingMove = true;
|
|
2555
|
+
try {
|
|
2556
|
+
aggregator.announceRetained();
|
|
2557
|
+
} finally {
|
|
2558
|
+
announcingMove = false;
|
|
2559
|
+
}
|
|
2421
2560
|
}
|
|
2422
2561
|
if (event.type === "RUN_ERROR") {
|
|
2423
2562
|
settleRunSubagents({
|
|
@@ -2516,10 +2655,11 @@ export class AgUiThreadController {
|
|
|
2516
2655
|
const current = assistantMessageId
|
|
2517
2656
|
? this.session.tryGetMessage(assistantMessageId)?.message
|
|
2518
2657
|
: undefined;
|
|
2658
|
+
const currentRunning =
|
|
2659
|
+
current?.role === "assistant" && current.status?.type === "running";
|
|
2519
2660
|
if (
|
|
2520
2661
|
this.abortController === abortController &&
|
|
2521
|
-
|
|
2522
|
-
current.status?.type === "running"
|
|
2662
|
+
(currentRunning || heldMessages.size > 0)
|
|
2523
2663
|
) {
|
|
2524
2664
|
persistRunAssistants(true);
|
|
2525
2665
|
// Finalization is transport cleanup, not proof of a terminal event.
|
|
@@ -2529,10 +2669,11 @@ export class AgUiThreadController {
|
|
|
2529
2669
|
error: "The connection ended before the answer finished.",
|
|
2530
2670
|
};
|
|
2531
2671
|
settleHeldMessages(status);
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2672
|
+
if (currentRunning)
|
|
2673
|
+
applyUpdate({
|
|
2674
|
+
status,
|
|
2675
|
+
content: settleNestedContent(current.content, status),
|
|
2676
|
+
});
|
|
2536
2677
|
}
|
|
2537
2678
|
if (this.abortController === abortController && !runFinished) {
|
|
2538
2679
|
// The run ended without RUN_FINISHED, so no sub-agent call it left
|
|
@@ -3371,6 +3512,7 @@ export class AgUiThreadController {
|
|
|
3371
3512
|
joinStrategy: UseAgUiRuntimeOptions["joinStrategy"],
|
|
3372
3513
|
runStartMessageIds: ReadonlySet<string>,
|
|
3373
3514
|
hasRunUserBoundary: boolean,
|
|
3515
|
+
heldMessages: ReadonlyMap<string, unknown>,
|
|
3374
3516
|
reconcileSnapshot?: SnapshotMessageReconciler,
|
|
3375
3517
|
): string | undefined {
|
|
3376
3518
|
if (
|
|
@@ -3426,6 +3568,7 @@ export class AgUiThreadController {
|
|
|
3426
3568
|
joinStrategy,
|
|
3427
3569
|
runStartMessageIds,
|
|
3428
3570
|
hasRunUserBoundary,
|
|
3571
|
+
heldMessages,
|
|
3429
3572
|
reconcileSnapshot,
|
|
3430
3573
|
);
|
|
3431
3574
|
const headId = this.session.headId;
|
|
@@ -3444,17 +3587,22 @@ export class AgUiThreadController {
|
|
|
3444
3587
|
) {
|
|
3445
3588
|
const nextActiveAssistantId =
|
|
3446
3589
|
head?.role === "assistant" ? (headId ?? undefined) : undefined;
|
|
3447
|
-
|
|
3448
|
-
activeAssistantId !== nextActiveAssistantId
|
|
3449
|
-
|
|
3450
|
-
|
|
3590
|
+
const previousActiveId =
|
|
3591
|
+
activeAssistantId !== nextActiveAssistantId
|
|
3592
|
+
? activeAssistantId
|
|
3593
|
+
: undefined;
|
|
3594
|
+
const retained =
|
|
3595
|
+
nextActiveAssistantId !== undefined &&
|
|
3596
|
+
joinStrategy !== "concat-content" &&
|
|
3597
|
+
aggregator.rebaseMessage(nextActiveAssistantId, previousActiveId);
|
|
3598
|
+
if (previousActiveId && !retained) {
|
|
3451
3599
|
const previous =
|
|
3452
|
-
this.session.tryGetMessage(
|
|
3600
|
+
this.session.tryGetMessage(previousActiveId)?.message;
|
|
3453
3601
|
if (
|
|
3454
3602
|
previous?.role === "assistant" &&
|
|
3455
3603
|
previous.status?.type === "running"
|
|
3456
3604
|
) {
|
|
3457
|
-
this.updateAssistantMessage(
|
|
3605
|
+
this.updateAssistantMessage(previousActiveId, {
|
|
3458
3606
|
status: FALLBACK_USER_STATUS,
|
|
3459
3607
|
content: settleNestedContent(
|
|
3460
3608
|
previous.content,
|
|
@@ -3464,14 +3612,15 @@ export class AgUiThreadController {
|
|
|
3464
3612
|
}
|
|
3465
3613
|
}
|
|
3466
3614
|
activeAssistantId = nextActiveAssistantId;
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3615
|
+
}
|
|
3616
|
+
if (
|
|
3617
|
+
hasNewPendingToolCallAnswer &&
|
|
3618
|
+
head &&
|
|
3619
|
+
head.id === activeAssistantId
|
|
3620
|
+
) {
|
|
3621
|
+
this.updateAssistantMessage(head.id, {
|
|
3622
|
+
status: { type: "running" },
|
|
3623
|
+
});
|
|
3475
3624
|
}
|
|
3476
3625
|
this.updateActiveAssistantState(activeAssistantId);
|
|
3477
3626
|
break;
|
|
@@ -3698,6 +3847,7 @@ export class AgUiThreadController {
|
|
|
3698
3847
|
joinStrategy: UseAgUiRuntimeOptions["joinStrategy"],
|
|
3699
3848
|
runStartMessageIds: ReadonlySet<string>,
|
|
3700
3849
|
hasRunUserBoundary: boolean,
|
|
3850
|
+
heldMessages: ReadonlyMap<string, unknown>,
|
|
3701
3851
|
reconcileSnapshot?: SnapshotMessageReconciler,
|
|
3702
3852
|
) {
|
|
3703
3853
|
try {
|
|
@@ -3754,7 +3904,8 @@ export class AgUiThreadController {
|
|
|
3754
3904
|
const snapshot =
|
|
3755
3905
|
reconcileSnapshot?.(convertedMessage, previous) ?? convertedMessage;
|
|
3756
3906
|
const preservesRunningStatus =
|
|
3757
|
-
convertedMessage.id === activeAssistantId
|
|
3907
|
+
(convertedMessage.id === activeAssistantId ||
|
|
3908
|
+
heldMessages.has(convertedMessage.id)) &&
|
|
3758
3909
|
previous?.status?.type === "running" &&
|
|
3759
3910
|
snapshot.status?.type === "requires-action" &&
|
|
3760
3911
|
snapshot.status.reason === "tool-calls";
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { ChatModelRunResult } from "@assistant-ui/core";
|
|
3
|
+
import type { JoinStrategy } from "@assistant-ui/core/react";
|
|
4
|
+
import { RunAggregator, type RetainedMessageState } from "./run-aggregator";
|
|
5
|
+
import type { AgUiEvent } from "../types";
|
|
6
|
+
|
|
7
|
+
const setup = (joinStrategy?: JoinStrategy) => {
|
|
8
|
+
const current: ChatModelRunResult[] = [];
|
|
9
|
+
const retained: {
|
|
10
|
+
update: ChatModelRunResult;
|
|
11
|
+
state: RetainedMessageState;
|
|
12
|
+
}[] = [];
|
|
13
|
+
const aggregator = new RunAggregator({
|
|
14
|
+
showThinking: true,
|
|
15
|
+
joinStrategy,
|
|
16
|
+
logger: { debug: vi.fn(), error: vi.fn() },
|
|
17
|
+
emit: (update) => current.push(update),
|
|
18
|
+
onRetainedMessage: (update, state) => retained.push({ update, state }),
|
|
19
|
+
onTextMessageStart: () => {},
|
|
20
|
+
});
|
|
21
|
+
const send = (...events: AgUiEvent[]) => {
|
|
22
|
+
for (const event of events) aggregator.handle(event);
|
|
23
|
+
};
|
|
24
|
+
send(
|
|
25
|
+
{ type: "RUN_STARTED", runId: "run" },
|
|
26
|
+
{ type: "TEXT_MESSAGE_START", messageId: "answer" },
|
|
27
|
+
{ type: "TEXT_MESSAGE_CONTENT", messageId: "answer", delta: "a1" },
|
|
28
|
+
{
|
|
29
|
+
type: "TOOL_CALL_START",
|
|
30
|
+
toolCallId: "task",
|
|
31
|
+
toolCallName: "task",
|
|
32
|
+
parentMessageId: "answer",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
type: "SUBAGENT_STARTED",
|
|
36
|
+
subagentRunId: "sub",
|
|
37
|
+
name: "worker",
|
|
38
|
+
parentToolCallId: "task",
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
aggregator.rebaseMessage("other", "answer");
|
|
42
|
+
aggregator.announceRetained();
|
|
43
|
+
const lastRetained = () => retained.at(-1);
|
|
44
|
+
const lastCurrent = () => current.at(-1)?.content ?? [];
|
|
45
|
+
const retainedTask = () =>
|
|
46
|
+
lastRetained()?.update.content?.find(
|
|
47
|
+
(part) => part.type === "tool-call" && part.toolCallId === "task",
|
|
48
|
+
);
|
|
49
|
+
return {
|
|
50
|
+
aggregator,
|
|
51
|
+
send,
|
|
52
|
+
current,
|
|
53
|
+
retained,
|
|
54
|
+
lastRetained,
|
|
55
|
+
lastCurrent,
|
|
56
|
+
retainedTask,
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
describe("RunAggregator routing after the current message moves", () => {
|
|
61
|
+
it("announces the moved-off message once with what is still open", () => {
|
|
62
|
+
const { aggregator, retained } = setup();
|
|
63
|
+
aggregator.announceRetained();
|
|
64
|
+
|
|
65
|
+
expect(retained).toHaveLength(1);
|
|
66
|
+
expect(retained[0]?.state).toEqual({
|
|
67
|
+
messageId: "answer",
|
|
68
|
+
streaming: true,
|
|
69
|
+
openToolCallIds: ["task"],
|
|
70
|
+
});
|
|
71
|
+
expect(aggregator.isRetained("answer")).toBe(true);
|
|
72
|
+
expect(aggregator.hasToolCall("task")).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("routes root text naming the moved-off message to it", () => {
|
|
76
|
+
const { send, lastRetained, current } = setup();
|
|
77
|
+
const emitted = current.length;
|
|
78
|
+
send({ type: "TEXT_MESSAGE_CONTENT", messageId: "answer", delta: "a2" });
|
|
79
|
+
|
|
80
|
+
expect(lastRetained()?.update.content?.[0]).toEqual({
|
|
81
|
+
type: "text",
|
|
82
|
+
text: "a1a2",
|
|
83
|
+
});
|
|
84
|
+
expect(current).toHaveLength(emitted);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("closes the text frame on TEXT_MESSAGE_END", () => {
|
|
88
|
+
const { send, lastRetained } = setup();
|
|
89
|
+
send({ type: "TEXT_MESSAGE_END", messageId: "answer" });
|
|
90
|
+
|
|
91
|
+
expect(lastRetained()?.state.streaming).toBe(true);
|
|
92
|
+
send(
|
|
93
|
+
{ type: "SUBAGENT_FINISHED", subagentRunId: "sub" },
|
|
94
|
+
{
|
|
95
|
+
type: "TOOL_CALL_RESULT",
|
|
96
|
+
messageId: "tool",
|
|
97
|
+
toolCallId: "task",
|
|
98
|
+
content: "done",
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
expect(lastRetained()?.state).toEqual({
|
|
102
|
+
messageId: "answer",
|
|
103
|
+
streaming: false,
|
|
104
|
+
openToolCallIds: [],
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("routes TOOL_CALL_START by parentMessageId, and to the current message without one", () => {
|
|
109
|
+
const { send, lastRetained, lastCurrent } = setup();
|
|
110
|
+
send(
|
|
111
|
+
{
|
|
112
|
+
type: "TOOL_CALL_START",
|
|
113
|
+
toolCallId: "late",
|
|
114
|
+
toolCallName: "late",
|
|
115
|
+
parentMessageId: "answer",
|
|
116
|
+
},
|
|
117
|
+
{ type: "TOOL_CALL_START", toolCallId: "next", toolCallName: "next" },
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
expect(lastRetained()?.state.openToolCallIds).toEqual(["task", "late"]);
|
|
121
|
+
expect(
|
|
122
|
+
lastCurrent().filter((part) => part.type === "tool-call"),
|
|
123
|
+
).toMatchObject([{ toolCallId: "next" }]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("routes TOOL_CALL_ARGS, END, and RESULT by the call they name", () => {
|
|
127
|
+
const { send, retainedTask, current } = setup();
|
|
128
|
+
const emitted = current.length;
|
|
129
|
+
send(
|
|
130
|
+
{ type: "TOOL_CALL_ARGS", toolCallId: "task", delta: '{"q":1}' },
|
|
131
|
+
{ type: "TOOL_CALL_END", toolCallId: "task" },
|
|
132
|
+
{
|
|
133
|
+
type: "TOOL_CALL_RESULT",
|
|
134
|
+
messageId: "tool",
|
|
135
|
+
toolCallId: "task",
|
|
136
|
+
content: "done",
|
|
137
|
+
},
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
expect(retainedTask()).toMatchObject({ args: { q: 1 }, result: "done" });
|
|
141
|
+
expect(current).toHaveLength(emitted);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("routes sub-agent events by run id and nests a new run under its call", () => {
|
|
145
|
+
const { send, retainedTask, current } = setup();
|
|
146
|
+
const emitted = current.length;
|
|
147
|
+
send(
|
|
148
|
+
{
|
|
149
|
+
type: "TEXT_MESSAGE_CONTENT",
|
|
150
|
+
messageId: "s1",
|
|
151
|
+
delta: "working",
|
|
152
|
+
subagentRunId: "sub",
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
type: "SUBAGENT_STARTED",
|
|
156
|
+
subagentRunId: "sub2",
|
|
157
|
+
name: "helper",
|
|
158
|
+
parentToolCallId: "task",
|
|
159
|
+
},
|
|
160
|
+
{ type: "SUBAGENT_ERROR", subagentRunId: "sub2", message: "boom" },
|
|
161
|
+
{ type: "SUBAGENT_FINISHED", subagentRunId: "sub" },
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const task = retainedTask();
|
|
165
|
+
expect(task?.type === "tool-call" && task.messages).toMatchObject([
|
|
166
|
+
{
|
|
167
|
+
id: "sub",
|
|
168
|
+
status: { type: "complete" },
|
|
169
|
+
content: [{ type: "text", text: "working" }],
|
|
170
|
+
},
|
|
171
|
+
{ id: "sub2", status: { type: "incomplete", reason: "error" } },
|
|
172
|
+
]);
|
|
173
|
+
expect(current).toHaveLength(emitted);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("routes an MCP app snapshot by its tool call", () => {
|
|
177
|
+
const { send, retainedTask } = setup();
|
|
178
|
+
send({
|
|
179
|
+
type: "ACTIVITY_SNAPSHOT",
|
|
180
|
+
messageId: "act",
|
|
181
|
+
activityType: "mcp-apps",
|
|
182
|
+
content: { toolCallId: "task", resourceUri: "ui://app/view" },
|
|
183
|
+
} as AgUiEvent);
|
|
184
|
+
|
|
185
|
+
expect(retainedTask()).toMatchObject({
|
|
186
|
+
mcp: { app: { resourceUri: "ui://app/view" } },
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("routes reasoning and activity deltas to the message that holds them", () => {
|
|
191
|
+
const { aggregator, send, lastRetained, current } = setup();
|
|
192
|
+
aggregator.rebaseMessage("answer", "other");
|
|
193
|
+
send(
|
|
194
|
+
{ type: "REASONING_MESSAGE_START", messageId: "r" },
|
|
195
|
+
{ type: "REASONING_MESSAGE_CONTENT", messageId: "r", delta: "think" },
|
|
196
|
+
{
|
|
197
|
+
type: "ACTIVITY_SNAPSHOT",
|
|
198
|
+
messageId: "act",
|
|
199
|
+
activityType: "progress",
|
|
200
|
+
content: { step: 1 },
|
|
201
|
+
} as AgUiEvent,
|
|
202
|
+
);
|
|
203
|
+
aggregator.rebaseMessage("other", "answer");
|
|
204
|
+
const emitted = current.length;
|
|
205
|
+
send({ type: "REASONING_MESSAGE_CONTENT", messageId: "r", delta: "ing" }, {
|
|
206
|
+
type: "ACTIVITY_DELTA",
|
|
207
|
+
messageId: "act",
|
|
208
|
+
activityType: "progress",
|
|
209
|
+
patch: [{ op: "replace", path: "/step", value: 2 }],
|
|
210
|
+
} as AgUiEvent);
|
|
211
|
+
|
|
212
|
+
expect(current).toHaveLength(emitted);
|
|
213
|
+
expect(lastRetained()?.update.content).toEqual(
|
|
214
|
+
expect.arrayContaining([
|
|
215
|
+
expect.objectContaining({ type: "reasoning", text: "thinking" }),
|
|
216
|
+
expect.objectContaining({ type: "data", data: { step: 2 } }),
|
|
217
|
+
]),
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("replaces an activity snapshot on the message that holds it", () => {
|
|
222
|
+
const { aggregator, send, lastRetained, current } = setup();
|
|
223
|
+
aggregator.rebaseMessage("answer", "other");
|
|
224
|
+
send({
|
|
225
|
+
type: "ACTIVITY_SNAPSHOT",
|
|
226
|
+
messageId: "act",
|
|
227
|
+
activityType: "progress",
|
|
228
|
+
content: { step: 1 },
|
|
229
|
+
} as AgUiEvent);
|
|
230
|
+
aggregator.rebaseMessage("other", "answer");
|
|
231
|
+
const emitted = current.length;
|
|
232
|
+
send({
|
|
233
|
+
type: "ACTIVITY_SNAPSHOT",
|
|
234
|
+
messageId: "act",
|
|
235
|
+
activityType: "progress",
|
|
236
|
+
content: { step: 2 },
|
|
237
|
+
} as AgUiEvent);
|
|
238
|
+
|
|
239
|
+
expect(
|
|
240
|
+
lastRetained()?.update.content?.filter((part) => part.type === "data"),
|
|
241
|
+
).toEqual([expect.objectContaining({ data: { step: 2 } })]);
|
|
242
|
+
expect(current).toHaveLength(emitted);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("keeps parsing args a snapshot started", () => {
|
|
246
|
+
const { send, retainedTask } = setup();
|
|
247
|
+
send(
|
|
248
|
+
{
|
|
249
|
+
type: "MESSAGES_SNAPSHOT",
|
|
250
|
+
messages: [
|
|
251
|
+
{
|
|
252
|
+
id: "answer",
|
|
253
|
+
role: "assistant",
|
|
254
|
+
content: "a1",
|
|
255
|
+
toolCalls: [
|
|
256
|
+
{
|
|
257
|
+
id: "task",
|
|
258
|
+
type: "function",
|
|
259
|
+
function: { name: "task", arguments: '{"q":' },
|
|
260
|
+
},
|
|
261
|
+
],
|
|
262
|
+
},
|
|
263
|
+
],
|
|
264
|
+
},
|
|
265
|
+
{ type: "TOOL_CALL_ARGS", toolCallId: "task", delta: '"x"}' },
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
expect(retainedTask()).toMatchObject({
|
|
269
|
+
argsText: '{"q":"x"}',
|
|
270
|
+
args: { q: "x" },
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("keeps root reasoning and custom events on the current message", () => {
|
|
275
|
+
const { send, lastCurrent, retained } = setup();
|
|
276
|
+
const announced = retained.length;
|
|
277
|
+
send(
|
|
278
|
+
{ type: "REASONING_MESSAGE_START", messageId: "r" },
|
|
279
|
+
{ type: "REASONING_MESSAGE_CONTENT", messageId: "r", delta: "hmm" },
|
|
280
|
+
{ type: "CUSTOM", name: "note", value: 1 },
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
expect(lastCurrent().map((part) => part.type)).toEqual([
|
|
284
|
+
"reasoning",
|
|
285
|
+
"data",
|
|
286
|
+
]);
|
|
287
|
+
expect(retained).toHaveLength(announced);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("applies MESSAGES_SNAPSHOT text and args to every message", () => {
|
|
291
|
+
const { send, retainedTask, lastRetained } = setup();
|
|
292
|
+
send(
|
|
293
|
+
{
|
|
294
|
+
type: "MESSAGES_SNAPSHOT",
|
|
295
|
+
messages: [
|
|
296
|
+
{
|
|
297
|
+
id: "answer",
|
|
298
|
+
role: "assistant",
|
|
299
|
+
content: "a1 fixed",
|
|
300
|
+
toolCalls: [
|
|
301
|
+
{
|
|
302
|
+
id: "task",
|
|
303
|
+
type: "function",
|
|
304
|
+
function: { name: "task", arguments: '{"q":2}' },
|
|
305
|
+
},
|
|
306
|
+
],
|
|
307
|
+
},
|
|
308
|
+
],
|
|
309
|
+
},
|
|
310
|
+
{ type: "TEXT_MESSAGE_CONTENT", messageId: "answer", delta: "!" },
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
expect(lastRetained()?.update.content?.[0]).toEqual({
|
|
314
|
+
type: "text",
|
|
315
|
+
text: "a1 fixed!",
|
|
316
|
+
});
|
|
317
|
+
expect(retainedTask()).toMatchObject({ argsText: '{"q":2}' });
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("settles running sub-agents on moved-off messages before the current emit", () => {
|
|
321
|
+
const { send, retained, current, retainedTask } = setup();
|
|
322
|
+
send({ type: "RUN_ERROR", message: "boom" });
|
|
323
|
+
|
|
324
|
+
const task = retainedTask();
|
|
325
|
+
expect(task?.type === "tool-call" && task.messages?.[0]?.status).toEqual({
|
|
326
|
+
type: "incomplete",
|
|
327
|
+
reason: "error",
|
|
328
|
+
error: "boom",
|
|
329
|
+
});
|
|
330
|
+
expect(retained.at(-1)?.update.status).toBeUndefined();
|
|
331
|
+
expect(current.at(-1)?.status).toMatchObject({ type: "incomplete" });
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("resumes a moved-off message that becomes current again", () => {
|
|
335
|
+
const { aggregator, send, lastCurrent } = setup();
|
|
336
|
+
aggregator.rebaseMessage("answer", "other");
|
|
337
|
+
send({ type: "TEXT_MESSAGE_CONTENT", messageId: "answer", delta: "a2" });
|
|
338
|
+
|
|
339
|
+
expect(lastCurrent()[0]).toEqual({ type: "text", text: "a1a2" });
|
|
340
|
+
expect(aggregator.isRetained("other")).toBe(true);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("forgets moved-off messages when a new run starts", () => {
|
|
344
|
+
const { aggregator, send } = setup();
|
|
345
|
+
send({ type: "RUN_STARTED", runId: "next" });
|
|
346
|
+
|
|
347
|
+
expect(aggregator.isRetained("answer")).toBe(false);
|
|
348
|
+
expect(aggregator.hasToolCall("task")).toBe(false);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("control: concat-content routes everything to the current message", () => {
|
|
352
|
+
const { send, lastCurrent, retained } = setup("concat-content");
|
|
353
|
+
const announced = retained.length;
|
|
354
|
+
send({ type: "TEXT_MESSAGE_CONTENT", messageId: "answer", delta: "a2" });
|
|
355
|
+
|
|
356
|
+
expect(retained).toHaveLength(announced);
|
|
357
|
+
expect(lastCurrent()).toContainEqual({ type: "text", text: "a2" });
|
|
358
|
+
});
|
|
359
|
+
});
|