@assistant-ui/react-ag-ui 0.1.2 → 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.
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi } from "vitest";
2
- import type { AbstractAgent } from "@ag-ui/client";
2
+ import { HttpAgent, type AbstractAgent } from "@ag-ui/client";
3
3
  import type {
4
4
  AppendMessage,
5
5
  ThreadAssistantMessage,
@@ -540,6 +540,25 @@ describe("AgUiThreadController activity deltas", () => {
540
540
  });
541
541
 
542
542
  describe("AgUiThreadController run state", () => {
543
+ const createStateAgent = (sentStates: unknown[]) =>
544
+ new HttpAgent({
545
+ url: "http://agent.test/agent",
546
+ initialState: { fromInitialState: true },
547
+ fetch: async (_url, init) => {
548
+ const body = JSON.parse(String(init?.body));
549
+ sentStates.push(body.state);
550
+ const events = [
551
+ { type: "RUN_STARTED", threadId: body.threadId, runId: body.runId },
552
+ { type: "STATE_SNAPSHOT", snapshot: { a: 1 } },
553
+ { type: "RUN_FINISHED", threadId: body.threadId, runId: body.runId },
554
+ ];
555
+ return new Response(
556
+ events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""),
557
+ { headers: { "content-type": "text/event-stream" } },
558
+ );
559
+ },
560
+ });
561
+
543
562
  type Subscriber = {
544
563
  onStateSnapshotEvent?: (payload: {
545
564
  event: { type: "STATE_SNAPSHOT"; snapshot: { count: number } };
@@ -577,6 +596,119 @@ describe("AgUiThreadController run state", () => {
577
596
  expect(runAgent.mock.calls[2]?.[0].state).toBeNull();
578
597
  expect(agent.state).toBeNull();
579
598
  });
599
+
600
+ it("isolates mounted threads sharing one agent through runs and setState", async () => {
601
+ const sentStates: unknown[] = [];
602
+ const agent = createStateAgent(sentStates);
603
+ const { core: threadA } = createCore(undefined, agent);
604
+ const { core: threadB } = createCore(undefined, agent);
605
+
606
+ await threadA.append({ ...userMessage("A"), startRun: true });
607
+ expect(threadA.getState()).toEqual({ a: 1 });
608
+ expect.soft(threadB.getState()).toEqual({ fromInitialState: true });
609
+
610
+ threadA.setState({ x: 1 });
611
+ await flush();
612
+ expect(threadA.getState()).toEqual({ x: 1 });
613
+ expect.soft(threadB.getState()).toEqual({ fromInitialState: true });
614
+
615
+ await threadB.append({ ...userMessage("B"), startRun: true });
616
+ expect
617
+ .soft(sentStates)
618
+ .toEqual([{ fromInitialState: true }, { fromInitialState: true }]);
619
+ expect.soft(threadA.getState()).toEqual({ x: 1 });
620
+ });
621
+
622
+ it("starts a later thread from the shared agent's initial state after a run", async () => {
623
+ const sentStates: unknown[] = [];
624
+ const agent = createStateAgent(sentStates);
625
+ const { core: threadA } = createCore(undefined, agent);
626
+
627
+ await threadA.append({ ...userMessage("A"), startRun: true });
628
+ expect(agent.state).toEqual({ a: 1 });
629
+
630
+ const { core: threadB } = createCore(undefined, agent);
631
+ expect.soft(threadB.getState()).toEqual({ fromInitialState: true });
632
+ await threadB.append({ ...userMessage("B"), startRun: true });
633
+ expect(sentStates).toEqual([
634
+ { fromInitialState: true },
635
+ { fromInitialState: true },
636
+ ]);
637
+ });
638
+
639
+ it("copies the initial state before the agent state is mutated", () => {
640
+ const agent = new HttpAgent({
641
+ url: "http://agent.test/agent",
642
+ initialState: { nested: { count: 0 } },
643
+ });
644
+ const { core } = createCore(undefined, agent);
645
+
646
+ agent.state.nested.count = 1;
647
+ expect(core.getState()).toEqual({ nested: { count: 0 } });
648
+
649
+ const { core: laterThread } = createCore(undefined, agent);
650
+ expect(laterThread.getState()).toEqual({ nested: { count: 0 } });
651
+ });
652
+
653
+ it("records an undefined initial state without adopting later agent state", () => {
654
+ const agent = {} as AbstractAgent;
655
+ createCore(undefined, agent);
656
+ agent.state = { fromPreviousThread: true };
657
+
658
+ const { core } = createCore(undefined, agent);
659
+ expect(core.getState()).toBeUndefined();
660
+ });
661
+
662
+ it("leaves the state undefined for an agent without initialState", () => {
663
+ const agent = new HttpAgent({ url: "http://agent.test/agent" });
664
+ const { core } = createCore(undefined, agent);
665
+
666
+ expect(agent.state).toEqual({});
667
+ expect(core.getState()).toBeUndefined();
668
+
669
+ const swapped = new HttpAgent({ url: "http://agent.test/other" });
670
+ core.updateOptions({
671
+ agent: swapped,
672
+ logger: makeLogger(),
673
+ showThinking: true,
674
+ });
675
+ expect(core.getState()).toBeUndefined();
676
+ });
677
+
678
+ it("seeds a new agent's initial state on swap when the thread has no snapshot", () => {
679
+ const { core } = createCore();
680
+ const agent = new HttpAgent({
681
+ url: "http://agent.test/agent",
682
+ initialState: { a: 1 },
683
+ });
684
+ const listener = vi.fn();
685
+ core.getSnapshot();
686
+ core.subscribe(listener);
687
+
688
+ core.updateOptions({ agent, logger: makeLogger(), showThinking: true });
689
+
690
+ expect(agent.state).toEqual({ a: 1 });
691
+ expect(core.getState()).toEqual({ a: 1 });
692
+ expect(core.getSnapshot().state).toEqual({ a: 1 });
693
+ expect(listener).toHaveBeenCalledOnce();
694
+ });
695
+
696
+ it("preserves the thread snapshot on swap and records the new agent's initial state", async () => {
697
+ const { core } = createCore();
698
+ core.setState({ fromThread: true });
699
+ const sentStates: unknown[] = [];
700
+ const agent = createStateAgent(sentStates);
701
+
702
+ core.updateOptions({ agent, logger: makeLogger(), showThinking: true });
703
+
704
+ expect(agent.state).toEqual({ fromInitialState: true });
705
+ expect(core.getState()).toEqual({ fromThread: true });
706
+ await core.append({ ...userMessage("after swap"), startRun: true });
707
+ expect(sentStates).toEqual([{ fromThread: true }]);
708
+
709
+ const { core: laterThread } = createCore(undefined, agent);
710
+ expect(laterThread.getState()).toEqual({ fromInitialState: true });
711
+ });
580
712
  });
581
713
 
582
714
  describe("AgUiThreadController setState", () => {
@@ -82,6 +82,26 @@ import {
82
82
  withToolApprovalDecision,
83
83
  } from "./adapter/tool-approval";
84
84
 
85
+ // The SDK does not retain initialState.
86
+ const agentInitialStates = new WeakMap<
87
+ AbstractAgent,
88
+ ReadonlyJSONValue | undefined
89
+ >();
90
+
91
+ const getAgentInitialState = (agent: AbstractAgent) => {
92
+ if (!agentInitialStates.has(agent)) {
93
+ const state = agent.state;
94
+ agentInitialStates.set(
95
+ agent,
96
+ state === undefined ||
97
+ (isPlainObject(state) && Object.keys(state).length === 0)
98
+ ? undefined
99
+ : (structuredClone(state) as ReadonlyJSONValue),
100
+ );
101
+ }
102
+ return agentInitialStates.get(agent);
103
+ };
104
+
85
105
  // AbstractAgent.runAgent declares two parameters. HttpAgent ignores a third and
86
106
  // is cancelled through agent.abortRun(); the run options stay for subclasses
87
107
  // that inherit the base no-op abortRun and have no other cancellation hook.
@@ -280,22 +300,35 @@ const replaceTextParts = (
280
300
  return result;
281
301
  };
282
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
+ */
283
309
  const settleNestedContent = (
284
310
  content: ThreadAssistantMessage["content"],
285
311
  status: MessageStatus,
312
+ runSubagentIds?: ReadonlySet<string>,
286
313
  ) =>
287
- mapToolCallPartsDeep(content, (part) =>
288
- part.messages
289
- ? {
290
- ...part,
291
- messages: part.messages.map((message) =>
292
- message.role === "assistant" && message.status?.type === "running"
293
- ? { ...message, status }
294
- : message,
295
- ),
296
- }
297
- : part,
298
- ).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;
299
332
 
300
333
  type AgUiRuntimeCallbackName = "onError" | "onCancel";
301
334
 
@@ -372,6 +405,8 @@ export class AgUiThreadController {
372
405
  // The agent that started the active run. updateOptions can swap this.agent
373
406
  // mid-run, and cancelling has to reach the agent holding the live request.
374
407
  private activeRunAgent: AbstractAgent | null = null;
408
+ // Counts runs started, so a finished run can tell a newer one began.
409
+ private runCount = 0;
375
410
  private history: ThreadHistoryAdapter | undefined;
376
411
  private readonly assistantHistoryParents = new Map<string, string | null>();
377
412
  private readonly subagentParentToolCallIds = new Map<
@@ -380,12 +415,22 @@ export class AgUiThreadController {
380
415
  >();
381
416
  private readonly snapshotHistoryIds = new Set<string>();
382
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>();
383
421
  private readonly historyWrites = createHistoryWriteChain();
384
422
  private _historyImportPromise: Promise<void> | undefined;
385
423
  private _loadPromise: Promise<void> | undefined;
386
424
 
387
425
  constructor(options: CoreOptions) {
388
426
  this.agent = options.agent;
427
+ const initialState = getAgentInitialState(this.agent);
428
+ if (initialState !== undefined) {
429
+ this.dispatch({
430
+ type: "stateSnapshot",
431
+ snapshot: initialState,
432
+ });
433
+ }
389
434
  this.logger = options.logger;
390
435
  this.showThinking = options.showThinking;
391
436
  this.joinStrategy = options.joinStrategy;
@@ -421,7 +466,18 @@ export class AgUiThreadController {
421
466
  }
422
467
 
423
468
  updateOptions(options: CoreOptions) {
469
+ const agentChanged = this.agent !== options.agent;
424
470
  this.agent = options.agent;
471
+ if (agentChanged) {
472
+ const initialState = getAgentInitialState(this.agent);
473
+ if (
474
+ this.state.stateSnapshot === undefined &&
475
+ initialState !== undefined
476
+ ) {
477
+ this.dispatch({ type: "stateSnapshot", snapshot: initialState });
478
+ this.publishUpdate();
479
+ }
480
+ }
425
481
  this.logger = options.logger;
426
482
  this.showThinking = options.showThinking;
427
483
  this.joinStrategy = options.joinStrategy;
@@ -487,6 +543,7 @@ export class AgUiThreadController {
487
543
  this.subagentParentToolCallIds.clear();
488
544
  this.snapshotHistoryIds.clear();
489
545
  this.persistedHistoryIds.clear();
546
+ this.unappendedHistoryIds.clear();
490
547
  for (const { message } of repo.messages) {
491
548
  this.persistedHistoryIds.add(message.id);
492
549
  }
@@ -1683,6 +1740,8 @@ export class AgUiThreadController {
1683
1740
  const assistantParentId = parent ? parentId : this.session.headId;
1684
1741
  let assistantMessageId: string | undefined;
1685
1742
  let snapshotAssistantId: string | undefined;
1743
+ const importedRunAssistants = new Set<string>();
1744
+ const runSubagentIds = new Set<string>();
1686
1745
  let replayInterruptMessageId: string | undefined;
1687
1746
  // A snapshot the preserve gate declines still evicts the in-flight
1688
1747
  // assistant; recreating under the cached id on the next content-bearing
@@ -2225,9 +2284,38 @@ export class AgUiThreadController {
2225
2284
  adoptServerMessageId(serverId, true, frameOpenIds);
2226
2285
  },
2227
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
+ };
2228
2314
  let runFinished = false;
2229
2315
  const dispatch = (event: AgUiEvent) => {
2230
2316
  if (this.abortController !== abortController) return;
2317
+ if (event.type === "SUBAGENT_STARTED")
2318
+ runSubagentIds.add(event.subagentRunId);
2231
2319
  if (event.type === "RUN_FINISHED") {
2232
2320
  runFinished = true;
2233
2321
  if (event.outcome?.type === "interrupt")
@@ -2250,6 +2338,7 @@ export class AgUiThreadController {
2250
2338
  event.type === "RUN_FINISHED" ||
2251
2339
  event.type === "RUN_ERROR" ||
2252
2340
  event.type === "RUN_CANCELLED";
2341
+ if (settlingRun) persistRunAssistants(true);
2253
2342
  const beforeSnapshot =
2254
2343
  event.type === "MESSAGES_SNAPSHOT" ? this.session.getMessages() : [];
2255
2344
  try {
@@ -2263,6 +2352,31 @@ export class AgUiThreadController {
2263
2352
  candidateContexts.size ? reconcileReplaySnapshot : undefined,
2264
2353
  );
2265
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
+ }
2266
2380
  for (const message of beforeSnapshot) {
2267
2381
  const context = replayContexts.get(message.id);
2268
2382
  const current = this.session.tryGetMessage(message.id)?.message;
@@ -2294,6 +2408,16 @@ export class AgUiThreadController {
2294
2408
  tail?.role === "assistant" ? tail.id : undefined;
2295
2409
  }
2296
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);
2297
2421
  } finally {
2298
2422
  settlingRun = false;
2299
2423
  }
@@ -2302,6 +2426,7 @@ export class AgUiThreadController {
2302
2426
  this.abortController = abortController;
2303
2427
  const runAgentInstance = this.agent;
2304
2428
  this.activeRunAgent = runAgentInstance;
2429
+ const runNumber = ++this.runCount;
2305
2430
 
2306
2431
  let cancelRun = () => dispatch({ type: "RUN_CANCELLED" });
2307
2432
  abortSignal.addEventListener(
@@ -2385,6 +2510,7 @@ export class AgUiThreadController {
2385
2510
  current?.role === "assistant" &&
2386
2511
  current.status?.type === "running"
2387
2512
  ) {
2513
+ persistRunAssistants(true);
2388
2514
  // Finalization is transport cleanup, not proof of a terminal event.
2389
2515
  const status: MessageStatus = {
2390
2516
  type: "incomplete",
@@ -2397,6 +2523,25 @@ export class AgUiThreadController {
2397
2523
  content: settleNestedContent(current.content, status),
2398
2524
  });
2399
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);
2400
2545
  this.finishRun(abortController);
2401
2546
  }
2402
2547
 
@@ -2735,11 +2880,14 @@ export class AgUiThreadController {
2735
2880
  oldId,
2736
2881
  collidesWithExisting ? undefined : newId,
2737
2882
  );
2883
+ if (this.unappendedHistoryIds.delete(oldId) && !collidesWithExisting)
2884
+ this.unappendedHistoryIds.add(newId);
2738
2885
  if (pendingWrite && !collidesWithExisting) {
2739
2886
  void pendingWrite.then(
2740
2887
  () => {
2741
2888
  this.persistedHistoryIds.delete(oldId);
2742
2889
  this.persistedHistoryIds.add(newId);
2890
+ this.unappendedHistoryIds.delete(newId);
2743
2891
  },
2744
2892
  () => {},
2745
2893
  );
@@ -2775,7 +2923,7 @@ export class AgUiThreadController {
2775
2923
  update.content as ThreadAssistantMessage["content"],
2776
2924
  )
2777
2925
  : assistant.content;
2778
- const calls = replay ? Array.from(iterateToolCallParts(content)) : [];
2926
+ const calls = Array.from(iterateToolCallParts(content));
2779
2927
  // A retained historical call needs existing or current stream authority.
2780
2928
  const pending =
2781
2929
  pendingToolCallIds ??
@@ -2789,7 +2937,11 @@ export class AgUiThreadController {
2789
2937
  : undefined);
2790
2938
  if (replay && latestStatus?.type !== "running" && pending)
2791
2939
  replay.admittedToolCallIds = pending;
2792
- if (replay) {
2940
+ if (
2941
+ replay ||
2942
+ (latestStatus?.type === "requires-action" &&
2943
+ latestStatus.reason === "interrupt")
2944
+ ) {
2793
2945
  // Project gates against the content the user can actually answer.
2794
2946
  const interrupts =
2795
2947
  latestStatus?.type === "requires-action" &&
@@ -2805,10 +2957,10 @@ export class AgUiThreadController {
2805
2957
  new Set(calls.map((part) => part.toolCallId)),
2806
2958
  );
2807
2959
  const originalCalls = new Map(
2808
- Array.from(iterateToolCallParts(replay.originalContent), (part) => [
2809
- part.toolCallId,
2810
- part,
2811
- ]),
2960
+ Array.from(
2961
+ iterateToolCallParts(replay?.originalContent ?? assistant.content),
2962
+ (part) => [part.toolCallId, part],
2963
+ ),
2812
2964
  );
2813
2965
  content = mapToolCallPartsDeep(content, (part) => {
2814
2966
  const approval = approvals.get(part.toolCallId);
@@ -3301,6 +3453,9 @@ export class AgUiThreadController {
3301
3453
  }
3302
3454
  }
3303
3455
  activeAssistantId = nextActiveAssistantId;
3456
+ if (activeAssistantId && joinStrategy !== "concat-content") {
3457
+ aggregator.rebaseMessage(activeAssistantId);
3458
+ }
3304
3459
  if (hasNewPendingToolCallAnswer && head) {
3305
3460
  this.updateAssistantMessage(head.id, {
3306
3461
  status: { type: "running" },
@@ -3592,10 +3747,28 @@ export class AgUiThreadController {
3592
3747
  previous?.status?.type === "running" &&
3593
3748
  snapshot.status?.type === "requires-action" &&
3594
3749
  snapshot.status.reason === "tool-calls";
3750
+ const previousToolCallIds = new Set(
3751
+ Array.from(
3752
+ iterateToolCallParts(previous?.content ?? []),
3753
+ (call) => call.toolCallId,
3754
+ ),
3755
+ );
3756
+ const preservesSettledStatus =
3757
+ convertedMessage.id !== activeAssistantId &&
3758
+ this.isTerminalStatus(previous?.status) &&
3759
+ snapshot.status?.type === "requires-action" &&
3760
+ snapshot.status.reason === "tool-calls" &&
3761
+ Array.from(iterateToolCallParts(snapshot.content)).every(
3762
+ (call) =>
3763
+ isResolvedToolCall(call) ||
3764
+ previousToolCallIds.has(call.toolCallId),
3765
+ );
3595
3766
  converted[index] = previous
3596
3767
  ? {
3597
3768
  ...snapshot,
3598
- ...(preservesRunningStatus ? { status: previous.status } : {}),
3769
+ ...(preservesRunningStatus || preservesSettledStatus
3770
+ ? { status: previous.status }
3771
+ : {}),
3599
3772
  content: this.mergeSnapshotAssistantContent(
3600
3773
  previous.content,
3601
3774
  snapshot.content,
@@ -3796,7 +3969,9 @@ export class AgUiThreadController {
3796
3969
 
3797
3970
  if (shouldUpdate) {
3798
3971
  const write = this.historyWrites.chain(messageId, () =>
3799
- update.call(history, { parentId, message }),
3972
+ this.unappendedHistoryIds.has(messageId)
3973
+ ? this.appendOnce(history, messageId, { parentId, message })
3974
+ : update.call(history, { parentId, message }),
3800
3975
  );
3801
3976
  this.assistantHistoryParents.delete(messageId);
3802
3977
  void write.then(
@@ -3834,6 +4009,15 @@ export class AgUiThreadController {
3834
4009
  );
3835
4010
  }
3836
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
+
3837
4021
  private appendHistoryItem(
3838
4022
  parentId: string | null,
3839
4023
  message: ThreadMessage,
@@ -3842,9 +4026,10 @@ export class AgUiThreadController {
3842
4026
  const pending = this.historyWrites.pending(message.id);
3843
4027
  if (pending) return pending;
3844
4028
 
3845
- const append = this.history.append.bind(this.history);
4029
+ const history = this.history;
4030
+ this.unappendedHistoryIds.add(message.id);
3846
4031
  const write = this.historyWrites.chain(message.id, () =>
3847
- append({ parentId, message }),
4032
+ this.appendOnce(history, message.id, { parentId, message }),
3848
4033
  );
3849
4034
  void write.then(
3850
4035
  () => {
@@ -267,6 +267,17 @@ export class RunAggregator {
267
267
  return this.toolCalls.has(toolCallId);
268
268
  }
269
269
 
270
+ rebaseMessage(messageId: string): void {
271
+ const snapshotText = this.pendingSnapshotText.get(messageId);
272
+ this.resetMessageParts();
273
+ this.lastTextMessageId = messageId;
274
+ this.reportedServerMessageId = messageId;
275
+ if (snapshotText !== undefined) {
276
+ this.pendingSnapshotText.set(messageId, snapshotText);
277
+ }
278
+ this.startTextMessage(ROOT_SCOPE, messageId);
279
+ }
280
+
270
281
  handle(event: AgUiEvent): void {
271
282
  switch (event.type) {
272
283
  case "RUN_STARTED": {