@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.
@@ -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 = new Set(
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
- current?.role === "assistant" &&
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
- applyUpdate({
2533
- status,
2534
- content: settleNestedContent(current.content, status),
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
- if (
3448
- activeAssistantId !== nextActiveAssistantId &&
3449
- activeAssistantId
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(activeAssistantId)?.message;
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(activeAssistantId, {
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
- if (activeAssistantId && joinStrategy !== "concat-content") {
3468
- aggregator.rebaseMessage(activeAssistantId);
3469
- }
3470
- if (hasNewPendingToolCallAnswer && head) {
3471
- this.updateAssistantMessage(head.id, {
3472
- status: { type: "running" },
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
+ });