@assistant-ui/react-ag-ui 0.0.34 → 0.0.36

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.
Files changed (31) hide show
  1. package/dist/index.d.ts +2 -1
  2. package/dist/index.js +2 -1
  3. package/dist/runtime/AgUiThreadRuntimeCore.d.ts +8 -2
  4. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
  5. package/dist/runtime/AgUiThreadRuntimeCore.js +158 -52
  6. package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
  7. package/dist/runtime/adapter/conversions.d.ts +12 -4
  8. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  9. package/dist/runtime/adapter/conversions.js +65 -30
  10. package/dist/runtime/adapter/conversions.js.map +1 -1
  11. package/dist/runtime/adapter/run-aggregator.d.ts +1 -0
  12. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  13. package/dist/runtime/adapter/run-aggregator.js +17 -0
  14. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  15. package/dist/runtime/adapter/subscriber.d.ts +3 -0
  16. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  17. package/dist/runtime/adapter/subscriber.js +1 -0
  18. package/dist/runtime/adapter/subscriber.js.map +1 -1
  19. package/dist/runtime/event-parser.js +9 -0
  20. package/dist/runtime/event-parser.js.map +1 -1
  21. package/dist/runtime/types.d.ts +4 -0
  22. package/dist/runtime/types.d.ts.map +1 -1
  23. package/dist/useAgUiRuntime.js.map +1 -1
  24. package/package.json +9 -8
  25. package/src/index.ts +2 -0
  26. package/src/runtime/AgUiThreadRuntimeCore.ts +252 -87
  27. package/src/runtime/adapter/conversions.ts +87 -47
  28. package/src/runtime/adapter/run-aggregator.ts +30 -5
  29. package/src/runtime/adapter/subscriber.ts +3 -0
  30. package/src/runtime/event-parser.ts +9 -0
  31. package/src/runtime/types.ts +5 -0
@@ -5,13 +5,16 @@ import type {
5
5
  AddToolResultOptions,
6
6
  AppendMessage,
7
7
  AssistantRuntime,
8
+ ChatModelRunOptions,
8
9
  ChatModelRunResult,
9
10
  MessageStatus,
10
11
  ThreadAssistantMessage,
11
12
  ThreadHistoryAdapter,
12
13
  ThreadMessage,
14
+ ToolCallMessagePart,
13
15
  } from "@assistant-ui/core";
14
16
  import type { HttpAgent } from "@ag-ui/client";
17
+ import jsonpatch, { type Operation } from "fast-json-patch";
15
18
  import type { Logger } from "./logger";
16
19
  import type { AgUiEvent, AgUiInterrupt, AgUiResumeEntry } from "./types";
17
20
  import type { ReadonlyJSONValue } from "assistant-stream/utils";
@@ -34,11 +37,14 @@ const isOptimisticId = (id: string) => id.startsWith(optimisticPrefix);
34
37
  const symbolResumeShim = Symbol("agui-resume-shim");
35
38
 
36
39
  type RunConfig = NonNullable<AppendMessage["runConfig"]>;
40
+ type ResumeStream = (
41
+ options: ChatModelRunOptions,
42
+ ) => AsyncGenerator<ChatModelRunResult, void, unknown>;
37
43
  type ResumeRunConfig = {
38
44
  parentId: string | null;
39
45
  sourceId: string | null;
40
46
  runConfig: RunConfig;
41
- stream?: unknown;
47
+ stream?: ResumeStream;
42
48
  };
43
49
 
44
50
  type CoreOptions = {
@@ -73,6 +79,7 @@ export class AgUiThreadRuntimeCore {
73
79
  private readonly recordedHistoryIds = new Set<string>();
74
80
  private _isLoading = false;
75
81
  private _loadPromise: Promise<void> | undefined;
82
+ private pendingResumeMessageId: string | null = null;
76
83
 
77
84
  constructor(options: CoreOptions) {
78
85
  this.agent = options.agent;
@@ -135,7 +142,13 @@ export class AgUiThreadRuntimeCore {
135
142
 
136
143
  if (repo.unstable_resume) {
137
144
  const parentId = repo.headId ?? messages.at(-1)?.id ?? null;
138
- await this.startRun(parentId, this.lastRunConfig);
145
+ const resumeStream = this.history?.resume?.bind(this.history);
146
+ await this.startRun(
147
+ parentId,
148
+ this.lastRunConfig,
149
+ undefined,
150
+ resumeStream,
151
+ );
139
152
  }
140
153
  })
141
154
  .catch((error) => {
@@ -193,14 +206,11 @@ export class AgUiThreadRuntimeCore {
193
206
 
194
207
  async resume(config: ResumeRunConfig): Promise<void> {
195
208
  this.assertNoPendingInterrupts();
196
- if (config.stream) {
197
- this.logger.debug?.(
198
- "[agui] resume stream is not supported, falling back to regular run",
199
- );
200
- }
201
209
  await this.startRun(
202
210
  config.parentId,
203
211
  config.runConfig ?? this.lastRunConfig,
212
+ undefined,
213
+ config.stream,
204
214
  );
205
215
  }
206
216
 
@@ -360,7 +370,6 @@ export class AgUiThreadRuntimeCore {
360
370
 
361
371
  addToolResult(options: AddToolResultOptions): void {
362
372
  let updated = false;
363
- let shouldResume = false;
364
373
  this.messages = this.messages.map((message) => {
365
374
  if (message.id !== options.messageId || message.role !== "assistant")
366
375
  return message;
@@ -379,47 +388,60 @@ export class AgUiThreadRuntimeCore {
379
388
  });
380
389
  if (!matchedToolCall) return message;
381
390
  updated = true;
382
-
383
- if (
384
- assistant.status?.type === "requires-action" &&
385
- assistant.status.reason === "tool-calls" &&
386
- content.every(
387
- (part) =>
388
- part.type !== "tool-call" ||
389
- ("result" in part && part.result !== undefined),
390
- )
391
- ) {
392
- shouldResume = true;
393
- return {
394
- ...assistant,
395
- content,
396
- status: { type: "complete" as const, reason: "unknown" as const },
397
- };
398
- }
399
-
400
- return {
401
- ...assistant,
402
- content,
403
- };
391
+ return { ...assistant, content };
404
392
  });
405
393
 
406
- if (updated) {
407
- this.notifyUpdate();
394
+ if (!updated) return;
395
+ this.notifyUpdate();
396
+ this.maybeResumeAfterToolResults(options.messageId);
397
+ }
408
398
 
409
- if (shouldResume) {
410
- this.persistAssistantHistory(options.messageId);
399
+ // Re-evaluate whether every tool call on the assistant message now has a
400
+ // result and, if so, drive the follow-up run. Centralizing this lets the
401
+ // continuation fire whether the frontend result lands before RUN_FINISHED
402
+ // (status flips to requires-action only later, while a run is still draining)
403
+ // or after it. Idempotent: once the message is marked complete it no-ops.
404
+ private maybeResumeAfterToolResults(messageId: string): void {
405
+ const message = this.messages.find((m) => m.id === messageId);
406
+ if (!message || message.role !== "assistant") return;
407
+ const assistant = message as ThreadAssistantMessage;
408
+ if (
409
+ assistant.status?.type !== "requires-action" ||
410
+ assistant.status.reason !== "tool-calls"
411
+ ) {
412
+ return;
413
+ }
414
+ const allResolved = assistant.content.every(
415
+ (part) =>
416
+ part.type !== "tool-call" ||
417
+ ("result" in part && part.result !== undefined),
418
+ );
419
+ if (!allResolved) return;
420
+
421
+ this.messages = this.messages.map((m) =>
422
+ m.id === messageId && m.role === "assistant"
423
+ ? {
424
+ ...(m as ThreadAssistantMessage),
425
+ status: { type: "complete" as const, reason: "unknown" as const },
426
+ }
427
+ : m,
428
+ );
429
+ this.notifyUpdate();
430
+ this.persistAssistantHistory(messageId);
411
431
 
412
- if (!this.isRunningFlag) {
413
- void this.startRun(options.messageId, this.lastRunConfig).catch(
414
- (error) => {
415
- this.onError?.(
416
- error instanceof Error ? error : new Error(String(error)),
417
- );
418
- },
419
- );
420
- }
421
- }
432
+ if (this.isRunningFlag) {
433
+ // A run is still draining (RUN_FINISHED arrived but the stream has not
434
+ // closed). Defer until startRun's tail so we never start two runs.
435
+ this.pendingResumeMessageId = messageId;
436
+ return;
422
437
  }
438
+ this.startResumeRun(messageId);
439
+ }
440
+
441
+ private startResumeRun(messageId: string): void {
442
+ void this.startRun(messageId, this.lastRunConfig).catch((error) => {
443
+ this.onError?.(error instanceof Error ? error : new Error(String(error)));
444
+ });
423
445
  }
424
446
 
425
447
  applyExternalMessages(messages: readonly ThreadMessage[]): void {
@@ -441,20 +463,14 @@ export class AgUiThreadRuntimeCore {
441
463
  parentId: string | null,
442
464
  runConfig?: RunConfig,
443
465
  resume?: AgUiResumeEntry[],
466
+ resumeStream?: ResumeStream,
444
467
  ): Promise<void> {
445
468
  const normalizedRunConfig = runConfig ?? {};
446
469
  this.lastRunConfig = normalizedRunConfig;
447
470
  this.resetHead(parentId);
448
471
  const historicalMessages = [...this.messages];
449
472
 
450
- const runId = generateId();
451
473
  this.pendingError = null;
452
- const input = this.buildRunInput(
453
- runId,
454
- normalizedRunConfig,
455
- historicalMessages,
456
- resume,
457
- );
458
474
  const assistantParentId = parentId ?? this.messages.at(-1)?.id ?? null;
459
475
  let assistantMessageId: string | undefined;
460
476
  const ensureAssistant = () => {
@@ -465,15 +481,17 @@ export class AgUiThreadRuntimeCore {
465
481
  return created;
466
482
  };
467
483
 
484
+ const applyUpdate = (update: ChatModelRunResult) => {
485
+ const resolved = this.updateAssistantMessage(ensureAssistant(), update);
486
+ if (resolved !== assistantMessageId) {
487
+ assistantMessageId = resolved;
488
+ }
489
+ };
490
+
468
491
  const aggregator = new RunAggregator({
469
492
  showThinking: this.showThinking,
470
493
  logger: this.logger,
471
- emit: (update) => {
472
- const resolved = this.updateAssistantMessage(ensureAssistant(), update);
473
- if (resolved !== assistantMessageId) {
474
- assistantMessageId = resolved;
475
- }
476
- },
494
+ emit: applyUpdate,
477
495
  onServerMessageId: (serverId) => {
478
496
  const placeholder = ensureAssistant();
479
497
  if (placeholder === serverId) return;
@@ -487,40 +505,63 @@ export class AgUiThreadRuntimeCore {
487
505
  const abortSignal = abortController.signal;
488
506
  this.abortController = abortController;
489
507
 
508
+ let cancelRun = () => dispatch({ type: "RUN_CANCELLED" });
490
509
  abortSignal.addEventListener(
491
510
  "abort",
492
511
  () => {
493
- dispatch({ type: "RUN_CANCELLED" });
512
+ cancelRun();
494
513
  this.finishRun(abortController);
495
514
  this.onCancel?.();
496
515
  },
497
516
  { once: true },
498
517
  );
499
518
 
500
- const subscriber = createAgUiSubscriber({
501
- dispatch,
502
- runId,
503
- logger: this.logger,
504
- onRunFailed: (error) => {
505
- this.pendingError = error;
506
- this.onError?.(error);
507
- },
508
- });
509
-
510
- aggregator.handle({ type: "RUN_STARTED", runId });
511
519
  this.setRunning(true);
512
520
 
513
521
  try {
514
- try {
515
- (this.agent as any).messages = input.messages;
516
- (this.agent as any).threadId = input.threadId;
517
- (this.agent as any).state = input.state ?? null;
518
- } catch {
519
- // ignore
522
+ if (resumeStream) {
523
+ // Cancel flips only the status; an aggregator RUN_CANCELLED would emit an empty snapshot and wipe the replayed content.
524
+ cancelRun = () =>
525
+ applyUpdate({ status: { type: "incomplete", reason: "cancelled" } });
526
+ await this.consumeResumeStream(resumeStream, {
527
+ runConfig: normalizedRunConfig,
528
+ threadId: this.agent.threadId || "main",
529
+ parentId: assistantParentId,
530
+ historicalMessages,
531
+ abortSignal,
532
+ ensureAssistant,
533
+ applyUpdate,
534
+ getAssistantMessageId: () => assistantMessageId,
535
+ });
536
+ } else {
537
+ const runId = generateId();
538
+ aggregator.handle({ type: "RUN_STARTED", runId });
539
+ const input = this.buildRunInput(
540
+ runId,
541
+ normalizedRunConfig,
542
+ historicalMessages,
543
+ resume,
544
+ );
545
+ const subscriber = createAgUiSubscriber({
546
+ dispatch,
547
+ runId,
548
+ logger: this.logger,
549
+ onRunFailed: (error) => {
550
+ this.pendingError = error;
551
+ this.onError?.(error);
552
+ },
553
+ });
554
+ try {
555
+ (this.agent as any).messages = input.messages;
556
+ (this.agent as any).threadId = input.threadId;
557
+ (this.agent as any).state = input.state ?? null;
558
+ } catch {
559
+ // ignore
560
+ }
561
+ await (this.agent as any).runAgent(input, subscriber, {
562
+ signal: abortSignal,
563
+ });
520
564
  }
521
- await (this.agent as any).runAgent(input, subscriber, {
522
- signal: abortSignal,
523
- });
524
565
  } catch (error) {
525
566
  if (!abortSignal.aborted) {
526
567
  const err = error instanceof Error ? error : new Error(String(error));
@@ -535,8 +576,77 @@ export class AgUiThreadRuntimeCore {
535
576
  if (this.pendingError) {
536
577
  const err = this.pendingError;
537
578
  this.pendingError = null;
579
+ this.pendingResumeMessageId = null;
538
580
  throw err;
539
581
  }
582
+
583
+ // A tool result that landed before the run settled deferred its
584
+ // continuation here so a second run never overlaps the first.
585
+ if (this.pendingResumeMessageId !== null) {
586
+ const resumeMessageId = this.pendingResumeMessageId;
587
+ this.pendingResumeMessageId = null;
588
+ if (!abortSignal.aborted) {
589
+ this.startResumeRun(resumeMessageId);
590
+ }
591
+ }
592
+ }
593
+
594
+ // Replays a persisted run's snapshots into the existing assistant message, bypassing agent.runAgent so it is not re-invoked.
595
+ private async consumeResumeStream(
596
+ stream: ResumeStream,
597
+ ctx: {
598
+ runConfig: RunConfig;
599
+ threadId: string;
600
+ parentId: string | null;
601
+ historicalMessages: readonly ThreadMessage[];
602
+ abortSignal: AbortSignal;
603
+ ensureAssistant: () => string;
604
+ applyUpdate: (update: ChatModelRunResult) => void;
605
+ getAssistantMessageId: () => string | undefined;
606
+ },
607
+ ): Promise<void> {
608
+ const assistantId = ctx.ensureAssistant();
609
+ const currentId = () => ctx.getAssistantMessageId() ?? assistantId;
610
+ const options: ChatModelRunOptions = {
611
+ messages: ctx.historicalMessages,
612
+ runConfig: ctx.runConfig,
613
+ abortSignal: ctx.abortSignal,
614
+ context: this.runtime?.thread.getModelContext() ?? {},
615
+ unstable_assistantMessageId: assistantId,
616
+ unstable_threadId: ctx.threadId,
617
+ unstable_parentId: ctx.parentId,
618
+ unstable_getMessage: () => {
619
+ const message = this.messages.find((m) => m.id === currentId());
620
+ if (!message) {
621
+ throw new Error(
622
+ "[agui] resume stream requested the assistant message before it existed",
623
+ );
624
+ }
625
+ return message;
626
+ },
627
+ };
628
+
629
+ try {
630
+ for await (const result of stream(options)) {
631
+ if (ctx.abortSignal.aborted) return;
632
+ ctx.applyUpdate(result);
633
+ }
634
+ } catch (error) {
635
+ if (ctx.abortSignal.aborted) return;
636
+ const err = error instanceof Error ? error : new Error(String(error));
637
+ ctx.applyUpdate({
638
+ status: { type: "incomplete", reason: "error", error: err.message },
639
+ });
640
+ this.onError?.(err);
641
+ this.pendingError = this.pendingError ?? err;
642
+ return;
643
+ }
644
+
645
+ if (ctx.abortSignal.aborted) return;
646
+ const current = this.messages.find((m) => m.id === currentId());
647
+ if (!current || current.status?.type === "running") {
648
+ ctx.applyUpdate({ status: { type: "complete", reason: "unknown" } });
649
+ }
540
650
  }
541
651
 
542
652
  private buildRunInput(
@@ -668,10 +778,16 @@ export class AgUiThreadRuntimeCore {
668
778
  ? this.mergeAssistantMetadata(assistant.metadata, update.metadata)
669
779
  : assistant.metadata;
670
780
  latestStatus = update.status ?? assistant.status;
781
+ const content =
782
+ update.content !== undefined
783
+ ? this.preserveToolResults(
784
+ assistant.content,
785
+ update.content as ThreadAssistantMessage["content"],
786
+ )
787
+ : assistant.content;
671
788
  return {
672
789
  ...assistant,
673
- content: (update.content ??
674
- assistant.content) as ThreadAssistantMessage["content"],
790
+ content,
675
791
  status: latestStatus,
676
792
  metadata,
677
793
  };
@@ -691,9 +807,47 @@ export class AgUiThreadRuntimeCore {
691
807
  if (this.isPersistableStatus(latestStatus)) {
692
808
  this.persistAssistantHistory(resolvedMessageId);
693
809
  }
810
+ this.maybeResumeAfterToolResults(resolvedMessageId);
694
811
  return resolvedMessageId;
695
812
  }
696
813
 
814
+ // The RunAggregator rebuilds the assistant content from stream events only,
815
+ // so a fresh snapshot omits results injected via addToolResult (frontend tool
816
+ // execution). Carry those results forward so the aggregator never clobbers
817
+ // them. Results are only ever added in this flow, so preserving is safe.
818
+ private preserveToolResults(
819
+ previous: ThreadAssistantMessage["content"],
820
+ next: ThreadAssistantMessage["content"],
821
+ ): ThreadAssistantMessage["content"] {
822
+ const resolved = new Map<string, ToolCallMessagePart>();
823
+ for (const part of previous) {
824
+ if (
825
+ part.type === "tool-call" &&
826
+ "result" in part &&
827
+ part.result !== undefined
828
+ ) {
829
+ resolved.set(part.toolCallId, part);
830
+ }
831
+ }
832
+ if (resolved.size === 0) return next;
833
+
834
+ let changed = false;
835
+ const merged = next.map((part) => {
836
+ if (part.type !== "tool-call") return part;
837
+ if ("result" in part && part.result !== undefined) return part;
838
+ const prior = resolved.get(part.toolCallId);
839
+ if (!prior) return part;
840
+ changed = true;
841
+ return {
842
+ ...part,
843
+ result: prior.result,
844
+ ...(prior.artifact !== undefined ? { artifact: prior.artifact } : {}),
845
+ ...(prior.isError !== undefined ? { isError: prior.isError } : {}),
846
+ };
847
+ });
848
+ return changed ? (merged as ThreadAssistantMessage["content"]) : next;
849
+ }
850
+
697
851
  private mergeAssistantMetadata(
698
852
  current: ThreadAssistantMessage["metadata"],
699
853
  incoming: NonNullable<ChatModelRunResult["metadata"]>,
@@ -730,7 +884,20 @@ export class AgUiThreadRuntimeCore {
730
884
  return;
731
885
  }
732
886
  case "STATE_DELTA": {
733
- this.logger.debug?.("[agui] state delta event ignored", event.delta);
887
+ if (event.delta.length === 0) return;
888
+ try {
889
+ const state = this.stateSnapshot ?? {};
890
+ const result = jsonpatch.applyPatch(
891
+ state,
892
+ event.delta as Operation[],
893
+ /* validateOperation */ true,
894
+ /* mutateDocument */ false,
895
+ );
896
+ this.stateSnapshot = result.newDocument as ReadonlyJSONValue;
897
+ this.notifyUpdate();
898
+ } catch (error) {
899
+ this.logger.error?.("[agui] failed to apply state delta", error);
900
+ }
734
901
  return;
735
902
  }
736
903
  case "MESSAGES_SNAPSHOT": {
@@ -744,16 +911,14 @@ export class AgUiThreadRuntimeCore {
744
911
 
745
912
  private importMessagesSnapshot(rawMessages: readonly unknown[]) {
746
913
  try {
747
- const normalized = fromAgUiMessages(rawMessages);
914
+ const normalized = fromAgUiMessages(rawMessages, {
915
+ showThinking: this.showThinking,
916
+ });
748
917
  const converted: ThreadMessage[] = [];
749
918
  for (const message of normalized) {
750
919
  try {
751
920
  converted.push(
752
- fromThreadMessageLike(
753
- message as any,
754
- generateId(),
755
- FALLBACK_USER_STATUS,
756
- ),
921
+ fromThreadMessageLike(message, generateId(), FALLBACK_USER_STATUS),
757
922
  );
758
923
  } catch (error) {
759
924
  this.logger.error?.(