@assistant-ui/react-ag-ui 0.1.4 → 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
 
@@ -702,6 +714,17 @@ export class AgUiThreadController {
702
714
  await this.startRun(parentId, config.runConfig);
703
715
  }
704
716
 
717
+ /** Deletes a message that has no replies under it, and its stored history entry. */
718
+ async deleteMessage(messageId: string): Promise<void> {
719
+ const item = this.session.tryGetMessage(messageId);
720
+ if (!item) return;
721
+ const parentId = item.parentId ?? null;
722
+ this.session.deleteMessage(messageId, parentId);
723
+ this.snapshotHistoryIds.delete(messageId);
724
+ this.publishUpdate();
725
+ await this.history?.delete?.([{ parentId, message: item.message }]);
726
+ }
727
+
705
728
  async cancel(): Promise<void> {
706
729
  this.dispatch({
707
730
  type: "historyMutationAdvanced",
@@ -1757,6 +1780,12 @@ export class AgUiThreadController {
1757
1780
  let settlingRun = false;
1758
1781
  let latestSnapshot: ThreadAssistantMessage | undefined;
1759
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
+ >();
1760
1789
  const ensureAssistant = (allowRecreate = false): string => {
1761
1790
  const cached = assistantMessageId;
1762
1791
  if (cached !== undefined && this.session.tryGetMessage(cached))
@@ -1823,6 +1852,8 @@ export class AgUiThreadController {
1823
1852
  }
1824
1853
  for (const [messageId, openToolCallIds] of heldMessages) {
1825
1854
  const existing = this.session.tryGetMessage(messageId);
1855
+ if (!canWaitForTools && this.isTerminalStatus(existing?.message.status))
1856
+ continue;
1826
1857
  if (existing)
1827
1858
  this.markPendingAssistantHistory(messageId, existing.parentId);
1828
1859
  let requiresToolResult = false;
@@ -2155,6 +2186,85 @@ export class AgUiThreadController {
2155
2186
  }
2156
2187
  };
2157
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
+
2158
2268
  const abortController = new AbortController();
2159
2269
  const abortSignal = abortController.signal;
2160
2270
  const aggregator = new RunAggregator({
@@ -2257,10 +2367,24 @@ export class AgUiThreadController {
2257
2367
  ? current
2258
2368
  : undefined;
2259
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",
2260
2376
  onServerMessageId: (serverId) => {
2261
2377
  adoptServerMessageId(serverId, false);
2262
2378
  },
2263
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;
2264
2388
  const owners = this.getTrailingAssistantTurn().filter((message) =>
2265
2389
  replayContexts.has(message.id),
2266
2390
  );
@@ -2282,6 +2406,7 @@ export class AgUiThreadController {
2282
2406
  }
2283
2407
  frameOwnerIds.clear();
2284
2408
  adoptServerMessageId(serverId, true, frameOpenIds);
2409
+ return outgoingId;
2285
2410
  },
2286
2411
  });
2287
2412
  const settleRunSubagents = (status: MessageStatus) => {
@@ -2334,6 +2459,17 @@ export class AgUiThreadController {
2334
2459
  openToolCallIds.delete(event.toolCallId);
2335
2460
  }
2336
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
+ }
2337
2473
  settlingRun =
2338
2474
  event.type === "RUN_FINISHED" ||
2339
2475
  event.type === "RUN_ERROR" ||
@@ -2349,6 +2485,7 @@ export class AgUiThreadController {
2349
2485
  joinStrategy,
2350
2486
  runStartMessageIds,
2351
2487
  hasRunUserBoundary,
2488
+ heldMessages,
2352
2489
  candidateContexts.size ? reconcileReplaySnapshot : undefined,
2353
2490
  );
2354
2491
  if (event.type === "MESSAGES_SNAPSHOT") {
@@ -2395,18 +2532,31 @@ export class AgUiThreadController {
2395
2532
  );
2396
2533
  latestSnapshot =
2397
2534
  imported?.role === "assistant" && source ? imported : undefined;
2398
- snapshotToolCallIds = new Set(
2399
- Array.isArray(source?.toolCalls)
2400
- ? source.toolCalls
2401
- .map((call: { id?: unknown }) => call.id)
2402
- .filter((id: unknown): id is string => typeof id === "string")
2403
- : [],
2404
- );
2535
+ snapshotToolCallIds = snapshotRecordToolCallIds(source);
2405
2536
  if (!replayEnded && assistantMessageId === undefined) {
2406
2537
  const tail = this.session.getMessages().at(-1);
2407
2538
  snapshotAssistantId =
2408
2539
  tail?.role === "assistant" ? tail.id : undefined;
2409
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
+ }
2410
2560
  }
2411
2561
  if (event.type === "RUN_ERROR") {
2412
2562
  settleRunSubagents({
@@ -2505,10 +2655,11 @@ export class AgUiThreadController {
2505
2655
  const current = assistantMessageId
2506
2656
  ? this.session.tryGetMessage(assistantMessageId)?.message
2507
2657
  : undefined;
2658
+ const currentRunning =
2659
+ current?.role === "assistant" && current.status?.type === "running";
2508
2660
  if (
2509
2661
  this.abortController === abortController &&
2510
- current?.role === "assistant" &&
2511
- current.status?.type === "running"
2662
+ (currentRunning || heldMessages.size > 0)
2512
2663
  ) {
2513
2664
  persistRunAssistants(true);
2514
2665
  // Finalization is transport cleanup, not proof of a terminal event.
@@ -2518,10 +2669,11 @@ export class AgUiThreadController {
2518
2669
  error: "The connection ended before the answer finished.",
2519
2670
  };
2520
2671
  settleHeldMessages(status);
2521
- applyUpdate({
2522
- status,
2523
- content: settleNestedContent(current.content, status),
2524
- });
2672
+ if (currentRunning)
2673
+ applyUpdate({
2674
+ status,
2675
+ content: settleNestedContent(current.content, status),
2676
+ });
2525
2677
  }
2526
2678
  if (this.abortController === abortController && !runFinished) {
2527
2679
  // The run ended without RUN_FINISHED, so no sub-agent call it left
@@ -3360,6 +3512,7 @@ export class AgUiThreadController {
3360
3512
  joinStrategy: UseAgUiRuntimeOptions["joinStrategy"],
3361
3513
  runStartMessageIds: ReadonlySet<string>,
3362
3514
  hasRunUserBoundary: boolean,
3515
+ heldMessages: ReadonlyMap<string, unknown>,
3363
3516
  reconcileSnapshot?: SnapshotMessageReconciler,
3364
3517
  ): string | undefined {
3365
3518
  if (
@@ -3415,6 +3568,7 @@ export class AgUiThreadController {
3415
3568
  joinStrategy,
3416
3569
  runStartMessageIds,
3417
3570
  hasRunUserBoundary,
3571
+ heldMessages,
3418
3572
  reconcileSnapshot,
3419
3573
  );
3420
3574
  const headId = this.session.headId;
@@ -3433,17 +3587,22 @@ export class AgUiThreadController {
3433
3587
  ) {
3434
3588
  const nextActiveAssistantId =
3435
3589
  head?.role === "assistant" ? (headId ?? undefined) : undefined;
3436
- if (
3437
- activeAssistantId !== nextActiveAssistantId &&
3438
- activeAssistantId
3439
- ) {
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) {
3440
3599
  const previous =
3441
- this.session.tryGetMessage(activeAssistantId)?.message;
3600
+ this.session.tryGetMessage(previousActiveId)?.message;
3442
3601
  if (
3443
3602
  previous?.role === "assistant" &&
3444
3603
  previous.status?.type === "running"
3445
3604
  ) {
3446
- this.updateAssistantMessage(activeAssistantId, {
3605
+ this.updateAssistantMessage(previousActiveId, {
3447
3606
  status: FALLBACK_USER_STATUS,
3448
3607
  content: settleNestedContent(
3449
3608
  previous.content,
@@ -3453,14 +3612,15 @@ export class AgUiThreadController {
3453
3612
  }
3454
3613
  }
3455
3614
  activeAssistantId = nextActiveAssistantId;
3456
- if (activeAssistantId && joinStrategy !== "concat-content") {
3457
- aggregator.rebaseMessage(activeAssistantId);
3458
- }
3459
- if (hasNewPendingToolCallAnswer && head) {
3460
- this.updateAssistantMessage(head.id, {
3461
- status: { type: "running" },
3462
- });
3463
- }
3615
+ }
3616
+ if (
3617
+ hasNewPendingToolCallAnswer &&
3618
+ head &&
3619
+ head.id === activeAssistantId
3620
+ ) {
3621
+ this.updateAssistantMessage(head.id, {
3622
+ status: { type: "running" },
3623
+ });
3464
3624
  }
3465
3625
  this.updateActiveAssistantState(activeAssistantId);
3466
3626
  break;
@@ -3687,6 +3847,7 @@ export class AgUiThreadController {
3687
3847
  joinStrategy: UseAgUiRuntimeOptions["joinStrategy"],
3688
3848
  runStartMessageIds: ReadonlySet<string>,
3689
3849
  hasRunUserBoundary: boolean,
3850
+ heldMessages: ReadonlyMap<string, unknown>,
3690
3851
  reconcileSnapshot?: SnapshotMessageReconciler,
3691
3852
  ) {
3692
3853
  try {
@@ -3743,7 +3904,8 @@ export class AgUiThreadController {
3743
3904
  const snapshot =
3744
3905
  reconcileSnapshot?.(convertedMessage, previous) ?? convertedMessage;
3745
3906
  const preservesRunningStatus =
3746
- convertedMessage.id === activeAssistantId &&
3907
+ (convertedMessage.id === activeAssistantId ||
3908
+ heldMessages.has(convertedMessage.id)) &&
3747
3909
  previous?.status?.type === "running" &&
3748
3910
  snapshot.status?.type === "requires-action" &&
3749
3911
  snapshot.status.reason === "tool-calls";