@assistant-ui/react-ag-ui 0.1.3 → 0.1.4

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.
@@ -300,22 +300,35 @@ const replaceTextParts = (
300
300
  return result;
301
301
  };
302
302
 
303
+ /**
304
+ * Settles nested sub-agent messages with `status`. Without `runSubagentIds`,
305
+ * only running messages settle. With it, only that run's sub-agent messages
306
+ * settle, and an incomplete `status` also settles one still waiting on its own
307
+ * tool call: the run that could answer it has ended.
308
+ */
303
309
  const settleNestedContent = (
304
310
  content: ThreadAssistantMessage["content"],
305
311
  status: MessageStatus,
312
+ runSubagentIds?: ReadonlySet<string>,
306
313
  ) =>
307
- mapToolCallPartsDeep(content, (part) =>
308
- part.messages
309
- ? {
310
- ...part,
311
- messages: part.messages.map((message) =>
312
- message.role === "assistant" && message.status?.type === "running"
313
- ? { ...message, status }
314
- : message,
315
- ),
316
- }
317
- : part,
318
- ).content;
314
+ mapToolCallPartsDeep(content, (part) => {
315
+ if (!part.messages) return part;
316
+ let changed = false;
317
+ const messages = part.messages.map((message) => {
318
+ if (message.role !== "assistant") return message;
319
+ const settles = !runSubagentIds
320
+ ? message.status?.type === "running"
321
+ : runSubagentIds.has(message.id) &&
322
+ (message.status?.type === "running" ||
323
+ (status.type === "incomplete" &&
324
+ message.status?.type === "requires-action" &&
325
+ message.status.reason === "tool-calls"));
326
+ if (!settles) return message;
327
+ changed = true;
328
+ return { ...message, status };
329
+ });
330
+ return changed ? { ...part, messages } : part;
331
+ }).content;
319
332
 
320
333
  type AgUiRuntimeCallbackName = "onError" | "onCancel";
321
334
 
@@ -392,6 +405,8 @@ export class AgUiThreadController {
392
405
  // The agent that started the active run. updateOptions can swap this.agent
393
406
  // mid-run, and cancelling has to reach the agent holding the live request.
394
407
  private activeRunAgent: AbstractAgent | null = null;
408
+ // Counts runs started, so a finished run can tell a newer one began.
409
+ private runCount = 0;
395
410
  private history: ThreadHistoryAdapter | undefined;
396
411
  private readonly assistantHistoryParents = new Map<string, string | null>();
397
412
  private readonly subagentParentToolCallIds = new Map<
@@ -400,6 +415,9 @@ export class AgUiThreadController {
400
415
  >();
401
416
  private readonly snapshotHistoryIds = new Set<string>();
402
417
  private readonly persistedHistoryIds = new Set<string>();
418
+ // Ids whose append has not succeeded yet. A write queued behind that append
419
+ // runs as an append too, since an update may not move the stored head.
420
+ private readonly unappendedHistoryIds = new Set<string>();
403
421
  private readonly historyWrites = createHistoryWriteChain();
404
422
  private _historyImportPromise: Promise<void> | undefined;
405
423
  private _loadPromise: Promise<void> | undefined;
@@ -525,6 +543,7 @@ export class AgUiThreadController {
525
543
  this.subagentParentToolCallIds.clear();
526
544
  this.snapshotHistoryIds.clear();
527
545
  this.persistedHistoryIds.clear();
546
+ this.unappendedHistoryIds.clear();
528
547
  for (const { message } of repo.messages) {
529
548
  this.persistedHistoryIds.add(message.id);
530
549
  }
@@ -1721,6 +1740,8 @@ export class AgUiThreadController {
1721
1740
  const assistantParentId = parent ? parentId : this.session.headId;
1722
1741
  let assistantMessageId: string | undefined;
1723
1742
  let snapshotAssistantId: string | undefined;
1743
+ const importedRunAssistants = new Set<string>();
1744
+ const runSubagentIds = new Set<string>();
1724
1745
  let replayInterruptMessageId: string | undefined;
1725
1746
  // A snapshot the preserve gate declines still evicts the in-flight
1726
1747
  // assistant; recreating under the cached id on the next content-bearing
@@ -2263,9 +2284,38 @@ export class AgUiThreadController {
2263
2284
  adoptServerMessageId(serverId, true, frameOpenIds);
2264
2285
  },
2265
2286
  });
2287
+ const settleRunSubagents = (status: MessageStatus) => {
2288
+ if (runSubagentIds.size === 0) return;
2289
+ for (const message of this.session.getMessages()) {
2290
+ if (message.role !== "assistant") continue;
2291
+ const content = settleNestedContent(
2292
+ message.content,
2293
+ status,
2294
+ runSubagentIds,
2295
+ );
2296
+ if (content === message.content) continue;
2297
+ this.updateAssistantMessage(message.id, { content });
2298
+ this.persistSettledAssistant(message.id);
2299
+ }
2300
+ };
2301
+ // Writes this run's other assistants in branch order: before the answer
2302
+ // settles, the ones above it; after, the ones below. An adapter that
2303
+ // moves its head on each first write then ends on the branch's tail.
2304
+ const persistRunAssistants = (aboveAnswer: boolean) => {
2305
+ for (const message of this.session.getMessages()) {
2306
+ if (message.id === assistantMessageId) {
2307
+ if (aboveAnswer) return;
2308
+ continue;
2309
+ }
2310
+ if (message.role === "assistant" && !runStartMessageIds.has(message.id))
2311
+ this.persistAssistantHistory(message.id);
2312
+ }
2313
+ };
2266
2314
  let runFinished = false;
2267
2315
  const dispatch = (event: AgUiEvent) => {
2268
2316
  if (this.abortController !== abortController) return;
2317
+ if (event.type === "SUBAGENT_STARTED")
2318
+ runSubagentIds.add(event.subagentRunId);
2269
2319
  if (event.type === "RUN_FINISHED") {
2270
2320
  runFinished = true;
2271
2321
  if (event.outcome?.type === "interrupt")
@@ -2288,6 +2338,7 @@ export class AgUiThreadController {
2288
2338
  event.type === "RUN_FINISHED" ||
2289
2339
  event.type === "RUN_ERROR" ||
2290
2340
  event.type === "RUN_CANCELLED";
2341
+ if (settlingRun) persistRunAssistants(true);
2291
2342
  const beforeSnapshot =
2292
2343
  event.type === "MESSAGES_SNAPSHOT" ? this.session.getMessages() : [];
2293
2344
  try {
@@ -2301,6 +2352,31 @@ export class AgUiThreadController {
2301
2352
  candidateContexts.size ? reconcileReplaySnapshot : undefined,
2302
2353
  );
2303
2354
  if (event.type === "MESSAGES_SNAPSHOT") {
2355
+ // Assistants a snapshot adds belong to the run's history even when
2356
+ // the run never streams into them. Every import resets the pending
2357
+ // marks, so each snapshot marks them again. The import also routes
2358
+ // every message to update; a run assistant this client never wrote
2359
+ // gets an append first, which is what moves the stored head.
2360
+ const previousIds = new Set(beforeSnapshot.map(({ id }) => id));
2361
+ for (const message of this.session.getMessages()) {
2362
+ if (
2363
+ message.role !== "assistant" ||
2364
+ runStartMessageIds.has(message.id)
2365
+ )
2366
+ continue;
2367
+ if (!previousIds.has(message.id))
2368
+ importedRunAssistants.add(message.id);
2369
+ if (
2370
+ !this.persistedHistoryIds.has(message.id) &&
2371
+ this.historyWrites.pending(message.id) === undefined
2372
+ )
2373
+ this.snapshotHistoryIds.delete(message.id);
2374
+ }
2375
+ for (const messageId of importedRunAssistants) {
2376
+ const item = this.session.tryGetMessage(messageId);
2377
+ if (item)
2378
+ this.markPendingAssistantHistory(messageId, item.parentId);
2379
+ }
2304
2380
  for (const message of beforeSnapshot) {
2305
2381
  const context = replayContexts.get(message.id);
2306
2382
  const current = this.session.tryGetMessage(message.id)?.message;
@@ -2332,6 +2408,16 @@ export class AgUiThreadController {
2332
2408
  tail?.role === "assistant" ? tail.id : undefined;
2333
2409
  }
2334
2410
  }
2411
+ if (event.type === "RUN_ERROR") {
2412
+ settleRunSubagents({
2413
+ type: "incomplete",
2414
+ reason: "error",
2415
+ ...(event.message !== undefined ? { error: event.message } : {}),
2416
+ });
2417
+ } else if (event.type === "RUN_CANCELLED") {
2418
+ settleRunSubagents({ type: "incomplete", reason: "cancelled" });
2419
+ }
2420
+ if (settlingRun) persistRunAssistants(false);
2335
2421
  } finally {
2336
2422
  settlingRun = false;
2337
2423
  }
@@ -2340,6 +2426,7 @@ export class AgUiThreadController {
2340
2426
  this.abortController = abortController;
2341
2427
  const runAgentInstance = this.agent;
2342
2428
  this.activeRunAgent = runAgentInstance;
2429
+ const runNumber = ++this.runCount;
2343
2430
 
2344
2431
  let cancelRun = () => dispatch({ type: "RUN_CANCELLED" });
2345
2432
  abortSignal.addEventListener(
@@ -2423,6 +2510,7 @@ export class AgUiThreadController {
2423
2510
  current?.role === "assistant" &&
2424
2511
  current.status?.type === "running"
2425
2512
  ) {
2513
+ persistRunAssistants(true);
2426
2514
  // Finalization is transport cleanup, not proof of a terminal event.
2427
2515
  const status: MessageStatus = {
2428
2516
  type: "incomplete",
@@ -2435,6 +2523,25 @@ export class AgUiThreadController {
2435
2523
  content: settleNestedContent(current.content, status),
2436
2524
  });
2437
2525
  }
2526
+ if (this.abortController === abortController && !runFinished) {
2527
+ // The run ended without RUN_FINISHED, so no sub-agent call it left
2528
+ // open can still be answered.
2529
+ const settled = assistantMessageId
2530
+ ? this.session.tryGetMessage(assistantMessageId)?.message
2531
+ : undefined;
2532
+ settleRunSubagents(
2533
+ settled?.status?.type === "incomplete"
2534
+ ? settled.status
2535
+ : {
2536
+ type: "incomplete",
2537
+ reason: "error",
2538
+ error: "The connection ended before the answer finished.",
2539
+ },
2540
+ );
2541
+ }
2542
+ // Stop may have finished this run already; a newer run owns its own
2543
+ // writes.
2544
+ if (this.runCount === runNumber) persistRunAssistants(false);
2438
2545
  this.finishRun(abortController);
2439
2546
  }
2440
2547
 
@@ -2773,11 +2880,14 @@ export class AgUiThreadController {
2773
2880
  oldId,
2774
2881
  collidesWithExisting ? undefined : newId,
2775
2882
  );
2883
+ if (this.unappendedHistoryIds.delete(oldId) && !collidesWithExisting)
2884
+ this.unappendedHistoryIds.add(newId);
2776
2885
  if (pendingWrite && !collidesWithExisting) {
2777
2886
  void pendingWrite.then(
2778
2887
  () => {
2779
2888
  this.persistedHistoryIds.delete(oldId);
2780
2889
  this.persistedHistoryIds.add(newId);
2890
+ this.unappendedHistoryIds.delete(newId);
2781
2891
  },
2782
2892
  () => {},
2783
2893
  );
@@ -3859,7 +3969,9 @@ export class AgUiThreadController {
3859
3969
 
3860
3970
  if (shouldUpdate) {
3861
3971
  const write = this.historyWrites.chain(messageId, () =>
3862
- update.call(history, { parentId, message }),
3972
+ this.unappendedHistoryIds.has(messageId)
3973
+ ? this.appendOnce(history, messageId, { parentId, message })
3974
+ : update.call(history, { parentId, message }),
3863
3975
  );
3864
3976
  this.assistantHistoryParents.delete(messageId);
3865
3977
  void write.then(
@@ -3897,6 +4009,15 @@ export class AgUiThreadController {
3897
4009
  );
3898
4010
  }
3899
4011
 
4012
+ private async appendOnce(
4013
+ history: ThreadHistoryAdapter,
4014
+ messageId: string,
4015
+ item: { parentId: string | null; message: ThreadMessage },
4016
+ ): Promise<void> {
4017
+ await history.append(item);
4018
+ this.unappendedHistoryIds.delete(messageId);
4019
+ }
4020
+
3900
4021
  private appendHistoryItem(
3901
4022
  parentId: string | null,
3902
4023
  message: ThreadMessage,
@@ -3905,9 +4026,10 @@ export class AgUiThreadController {
3905
4026
  const pending = this.historyWrites.pending(message.id);
3906
4027
  if (pending) return pending;
3907
4028
 
3908
- const append = this.history.append.bind(this.history);
4029
+ const history = this.history;
4030
+ this.unappendedHistoryIds.add(message.id);
3909
4031
  const write = this.historyWrites.chain(message.id, () =>
3910
- append({ parentId, message }),
4032
+ this.appendOnce(history, message.id, { parentId, message }),
3911
4033
  );
3912
4034
  void write.then(
3913
4035
  () => {