@assistant-ui/react-a2a 0.2.47 → 0.2.49

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.
@@ -15,15 +15,12 @@ import type {
15
15
  } from "@assistant-ui/core";
16
16
  import {
17
17
  appendToolInteraction,
18
+ createHistoryWriteChain,
18
19
  createMessageRepositorySession,
19
20
  invokeUserCallback,
20
21
  RunLeases,
21
22
  } from "@assistant-ui/core/internal";
22
- import {
23
- applyA2uiOperations,
24
- surfaceToPresentToolCall,
25
- type A2uiState,
26
- } from "@assistant-ui/generative-ui/a2ui";
23
+ import { surfaceToPresentToolCall } from "@assistant-ui/generative-ui/a2ui";
27
24
  import type { A2AClient } from "./A2AClient";
28
25
  import type {
29
26
  A2AArtifact,
@@ -38,11 +35,16 @@ import type {
38
35
 
39
36
  import {
40
37
  a2aMessageToContent,
41
- a2uiPartsToOperations,
42
38
  isTerminalTaskState,
43
39
  threadMessageToA2AMessage,
44
40
  taskStateToMessageStatus,
45
41
  } from "./conversions";
42
+ import {
43
+ createA2AThreadState,
44
+ reduceA2AThreadState,
45
+ type A2AThreadAction,
46
+ type A2AThreadState,
47
+ } from "./a2aThreadState";
46
48
 
47
49
  const INITIAL_AGENT_CARD_RETRY_DELAY_MS = 5_000;
48
50
  const MAX_AGENT_CARD_RETRY_DELAY_MS = 5 * 60_000;
@@ -55,7 +57,7 @@ const storedArtifact = (artifact: A2AArtifact): A2AArtifact => ({
55
57
  parts: artifact.parts.map(({ raw: _raw, ...part }) => part),
56
58
  });
57
59
 
58
- export type A2AThreadRuntimeCoreOptions = {
60
+ export type A2AThreadControllerOptions = {
59
61
  client: A2AClient;
60
62
  contextId?: string | undefined;
61
63
  configuration?: A2ASendMessageConfiguration | undefined;
@@ -80,16 +82,9 @@ const invokeRuntimeCallback = <TArgs extends readonly unknown[]>(
80
82
  void invokeUserCallback("react-a2a", name, callback, ...args);
81
83
  };
82
84
 
83
- function normalizeArtifact(artifact: A2AArtifact): A2AArtifact {
84
- return {
85
- ...artifact,
86
- parts: Array.isArray(artifact.parts) ? artifact.parts : [],
87
- };
88
- }
89
-
90
- export class A2AThreadRuntimeCore {
85
+ export class A2AThreadController {
91
86
  private client: A2AClient;
92
- private contextId: string | undefined;
87
+ private state: A2AThreadState;
93
88
  private configuration: A2ASendMessageConfiguration | undefined;
94
89
  private onError: ((error: Error) => void) | undefined;
95
90
  private onCancel: (() => void) | undefined;
@@ -109,26 +104,16 @@ export class A2AThreadRuntimeCore {
109
104
 
110
105
  private runtime: AssistantRuntime | undefined;
111
106
  private readonly session = createMessageRepositorySession();
112
- private isRunningFlag = false;
113
107
  private abortController: AbortController | null = null;
114
108
  private readonly runLeases = new RunLeases();
115
109
  private pendingError: Error | null = null;
116
110
 
117
- // A2A-specific state
118
- private currentTask: A2ATask | undefined;
119
- private currentArtifacts: A2AArtifact[] = [];
120
- private a2uiState: A2uiState = new Map();
121
- private readonly a2uiMessageIds = new Set<string>();
122
- private agentCardValue: A2AAgentCard | undefined;
123
-
124
111
  // History tracking
125
112
  private readonly assistantHistoryParents = new Map<string, string | null>();
126
113
  private readonly recordedHistoryIds = new Set<string>();
127
- private readonly historyWrites = new Map<string, Promise<void>>();
128
- private _isLoading = false;
114
+ private readonly historyWrites = createHistoryWriteChain();
129
115
  private _loadPromise: Promise<void> | undefined;
130
116
  private _historyLoadGeneration = 0;
131
- private _loadRequested = false;
132
117
  private _agentCardPromise: Promise<void> | undefined;
133
118
  private _agentCardRetryAfter = 0;
134
119
  private _agentCardRetryDelay = INITIAL_AGENT_CARD_RETRY_DELAY_MS;
@@ -136,9 +121,9 @@ export class A2AThreadRuntimeCore {
136
121
 
137
122
  private lastOptionsContextId: string | undefined;
138
123
 
139
- constructor(options: A2AThreadRuntimeCoreOptions) {
124
+ constructor(options: A2AThreadControllerOptions) {
140
125
  this.client = options.client;
141
- this.contextId = options.contextId;
126
+ this.state = createA2AThreadState(options.contextId);
142
127
  this.lastOptionsContextId = options.contextId;
143
128
  this.configuration = options.configuration;
144
129
  this.onError = options.onError;
@@ -154,25 +139,32 @@ export class A2AThreadRuntimeCore {
154
139
 
155
140
  getSnapshot = () =>
156
141
  (this.snapshot ??= {
157
- isLoading: this._isLoading,
158
- isRunning: this.isRunningFlag,
142
+ isLoading: this.state.isLoading,
143
+ isRunning: this.state.isRunning,
159
144
  messageRepository: this.session.export(),
160
- task: this.currentTask,
161
- artifacts: this.currentArtifacts,
162
- agentCard: this.agentCardValue,
145
+ task: this.state.currentTask,
146
+ artifacts: this.state.currentArtifacts,
147
+ agentCard: this.state.agentCard,
163
148
  });
164
149
 
150
+ private dispatch(action: A2AThreadAction): void {
151
+ this.state = reduceA2AThreadState(this.state, action);
152
+ }
153
+
165
154
  private publishUpdate(): void {
166
155
  this.snapshot = undefined;
167
156
  for (const listener of this.listeners) listener();
168
157
  }
169
158
 
170
- updateOptions(options: A2AThreadRuntimeCoreOptions) {
159
+ updateOptions(options: A2AThreadControllerOptions) {
171
160
  this.client = options.client;
172
161
  // The option only seeds the context; a re-render with the same value must
173
162
  // not clobber a server-assigned contextId learned from the stream.
174
163
  if (options.contextId !== this.lastOptionsContextId) {
175
- this.contextId = options.contextId;
164
+ this.dispatch({
165
+ type: "contextLearned",
166
+ contextId: options.contextId,
167
+ });
176
168
  this.lastOptionsContextId = options.contextId;
177
169
  }
178
170
  this.configuration = options.configuration;
@@ -183,7 +175,7 @@ export class A2AThreadRuntimeCore {
183
175
  this.history = options.history;
184
176
 
185
177
  if (
186
- this._loadRequested &&
178
+ this.state.loadRequested &&
187
179
  !this._loadPromise &&
188
180
  !previousHistory &&
189
181
  options.history &&
@@ -197,13 +189,15 @@ export class A2AThreadRuntimeCore {
197
189
  * switches, deletes, and cancel resyncs, which must keep the live context. */
198
190
  resetContext(): void {
199
191
  this._historyLoadGeneration++;
200
- const wasLoading = this._isLoading;
201
- this._isLoading = false;
192
+ const wasLoading = this.state.isLoading;
202
193
  // Restore the seed before aborting: an onCancel callback that starts a
203
194
  // new run must not pick up the old thread's context, and its controller
204
195
  // must not be discarded.
205
196
  const controller = this.abortController;
206
- this.contextId = this.lastOptionsContextId;
197
+ this.dispatch({
198
+ type: "resetContext",
199
+ contextId: this.lastOptionsContextId,
200
+ });
207
201
  if (controller) {
208
202
  controller.abort();
209
203
  if (this.abortController === controller) {
@@ -290,23 +284,23 @@ export class A2AThreadRuntimeCore {
290
284
  }
291
285
 
292
286
  getTask(): A2ATask | undefined {
293
- return this.currentTask;
287
+ return this.state.currentTask;
294
288
  }
295
289
 
296
290
  getArtifacts(): readonly A2AArtifact[] {
297
- return this.currentArtifacts;
291
+ return this.state.currentArtifacts;
298
292
  }
299
293
 
300
294
  getAgentCard(): A2AAgentCard | undefined {
301
- return this.agentCardValue;
295
+ return this.state.agentCard;
302
296
  }
303
297
 
304
298
  isRunning(): boolean {
305
- return this.isRunningFlag;
299
+ return this.state.isRunning;
306
300
  }
307
301
 
308
302
  get isLoading(): boolean {
309
- return this._isLoading;
303
+ return this.state.isLoading;
310
304
  }
311
305
 
312
306
  private loadAgentCard(): Promise<void> {
@@ -314,7 +308,10 @@ export class A2AThreadRuntimeCore {
314
308
 
315
309
  this._agentCardPromise ??= this.client.getAgentCard().then(
316
310
  (agentCard) => {
317
- this.agentCardValue = agentCard;
311
+ this.dispatch({
312
+ type: "agentCardLoaded",
313
+ agentCard,
314
+ });
318
315
  this._agentCardRetryAfter = 0;
319
316
  this._agentCardRetryDelay = INITIAL_AGENT_CARD_RETRY_DELAY_MS;
320
317
  this._agentCardDiscoveryFailed = false;
@@ -354,13 +351,13 @@ export class A2AThreadRuntimeCore {
354
351
  }
355
352
 
356
353
  __internal_load(): Promise<void> {
357
- this._loadRequested = true;
354
+ this.dispatch({ type: "loadRequested" });
358
355
  const agentCardPromise = this.loadAgentCard();
359
356
 
360
357
  if (this._loadPromise) return this._loadPromise;
361
358
  if (!this.history) return agentCardPromise;
362
359
 
363
- this._isLoading = true;
360
+ this.dispatch({ type: "loadStarted" });
364
361
 
365
362
  const generation = this._historyLoadGeneration;
366
363
  const historyPromise = this.history.load();
@@ -383,7 +380,7 @@ export class A2AThreadRuntimeCore {
383
380
  })
384
381
  .finally(() => {
385
382
  if (generation !== this._historyLoadGeneration) return;
386
- this._isLoading = false;
383
+ this.dispatch({ type: "loadFinished" });
387
384
  this.publishUpdate();
388
385
  });
389
386
 
@@ -448,7 +445,7 @@ export class A2AThreadRuntimeCore {
448
445
  // Read the server target before aborting: the abort listener runs the
449
446
  // onCancel callback synchronously, which may clear the thread and with it
450
447
  // the task this cancellation is for, or start a new run.
451
- const task = this.currentTask;
448
+ const task = this.state.currentTask;
452
449
  const lease = this.runLeases.current();
453
450
 
454
451
  // Abort locally first so the stream stops immediately
@@ -462,8 +459,11 @@ export class A2AThreadRuntimeCore {
462
459
  // or a cleared thread replaces the task object; a follow-up run that
463
460
  // has not emitted yet keeps it, so the run lease is what rules
464
461
  // that case out.
465
- if (this.currentTask === task && lease.isCurrent()) {
466
- this.currentTask = updated;
462
+ if (this.state.currentTask === task && lease.isCurrent()) {
463
+ this.dispatch({
464
+ type: "taskReplaced",
465
+ task: updated,
466
+ });
467
467
  this.publishUpdate();
468
468
  }
469
469
  } catch {
@@ -494,10 +494,7 @@ export class A2AThreadRuntimeCore {
494
494
  for (const { message } of this.getMessageRepository().messages) {
495
495
  this.recordedHistoryIds.add(message.id);
496
496
  }
497
- this.currentTask = undefined;
498
- this.currentArtifacts = [];
499
- this.a2uiState = new Map();
500
- this.a2uiMessageIds.clear();
497
+ this.dispatch({ type: "reset" });
501
498
  this.publishUpdate();
502
499
  }
503
500
 
@@ -552,25 +549,15 @@ export class A2AThreadRuntimeCore {
552
549
  }
553
550
 
554
551
  const a2aMessage = threadMessageToA2AMessage(userThreadMessage, {
555
- contextId: this.contextId,
552
+ contextId: this.state.contextId,
556
553
  taskId:
557
- this.currentTask?.id &&
558
- !isTerminalTaskState(this.currentTask.status.state)
559
- ? this.currentTask.id
554
+ this.state.currentTask?.id &&
555
+ !isTerminalTaskState(this.state.currentTask.status.state)
556
+ ? this.state.currentTask.id
560
557
  : undefined,
561
558
  });
562
559
 
563
- // Clear task if previous task reached terminal state
564
- if (
565
- this.currentTask &&
566
- isTerminalTaskState(this.currentTask.status.state)
567
- ) {
568
- this.currentTask = undefined;
569
- }
570
-
571
- this.currentArtifacts = [];
572
- this.a2uiState = new Map();
573
- this.a2uiMessageIds.clear();
560
+ this.dispatch({ type: "prepareRun" });
574
561
 
575
562
  const assistantParentId = userThreadMessage.id;
576
563
  const assistantId = this.insertAssistantPlaceholder(assistantParentId);
@@ -598,7 +585,7 @@ export class A2AThreadRuntimeCore {
598
585
  if (!(await this.waitForAgentCard(abortController.signal))) return;
599
586
 
600
587
  const supportsStreaming =
601
- this.agentCardValue?.capabilities?.streaming !== false;
588
+ this.state.agentCard?.capabilities?.streaming !== false;
602
589
  if (supportsStreaming) {
603
590
  await this.runStreaming(a2aMessage, assistantId, abortController);
604
591
  } else {
@@ -718,22 +705,12 @@ export class A2AThreadRuntimeCore {
718
705
  assistantId: string,
719
706
  event: A2ATaskStatusUpdateEvent,
720
707
  ) {
721
- if (!this.currentTask) {
722
- this.currentTask = {
723
- id: event.taskId,
724
- contextId: event.contextId,
725
- status: event.status,
726
- };
727
- } else {
728
- this.currentTask = { ...this.currentTask, status: event.status };
729
- }
730
-
731
- if (event.contextId) {
732
- this.contextId = event.contextId;
733
- }
708
+ this.dispatch({
709
+ type: "statusUpdate",
710
+ event,
711
+ });
734
712
 
735
713
  if (event.status.message) {
736
- this.applyA2uiMessage(event.status.message);
737
714
  const content = a2aMessageToContent(event.status.message);
738
715
  this.updateAssistantContent(assistantId, content);
739
716
  }
@@ -748,41 +725,17 @@ export class A2AThreadRuntimeCore {
748
725
  assistantId: string,
749
726
  event: A2ATaskArtifactUpdateEvent,
750
727
  ) {
751
- const { append, lastChunk } = event;
752
- const artifact = normalizeArtifact(event.artifact);
753
- const existingIdx = this.currentArtifacts.findIndex(
754
- (a) => a.artifactId === artifact.artifactId,
755
- );
756
-
757
- let updated: A2AArtifact;
758
- if (existingIdx >= 0 && append) {
759
- const existing = this.currentArtifacts[existingIdx]!;
760
- updated = {
761
- ...existing,
762
- parts: [...existing.parts, ...artifact.parts],
763
- };
764
- this.currentArtifacts = [
765
- ...this.currentArtifacts.slice(0, existingIdx),
766
- updated,
767
- ...this.currentArtifacts.slice(existingIdx + 1),
768
- ];
769
- } else if (existingIdx >= 0) {
770
- updated = artifact;
771
- this.currentArtifacts = [
772
- ...this.currentArtifacts.slice(0, existingIdx),
773
- updated,
774
- ...this.currentArtifacts.slice(existingIdx + 1),
775
- ];
776
- } else {
777
- updated = artifact;
778
- this.currentArtifacts = [...this.currentArtifacts, updated];
779
- }
780
-
781
- this.applyA2uiParts(artifact.parts);
728
+ this.dispatch({
729
+ type: "artifactUpdate",
730
+ event,
731
+ });
732
+ const updated = this.state.currentArtifacts.find(
733
+ ({ artifactId }) => artifactId === event.artifact.artifactId,
734
+ )!;
782
735
  this.updateAssistantArtifacts(assistantId);
783
736
  this.rebuildAssistantA2uiSurfaces(assistantId);
784
737
 
785
- if (lastChunk) {
738
+ if (event.lastChunk) {
786
739
  invokeRuntimeCallback(
787
740
  "onArtifactComplete",
788
741
  this.onArtifactComplete,
@@ -796,63 +749,26 @@ export class A2AThreadRuntimeCore {
796
749
  private handleMessage(assistantId: string, message: A2AMessage) {
797
750
  if (message.role !== "agent") return;
798
751
 
799
- this.applyA2uiMessage(message);
752
+ this.dispatch({
753
+ type: "a2uiMessage",
754
+ message,
755
+ });
800
756
  const content = a2aMessageToContent(message);
801
757
  this.updateAssistantContent(assistantId, content);
802
758
  this.publishUpdate();
803
759
  }
804
760
 
805
761
  private handleTaskSnapshot(assistantId: string, task: A2ATask) {
806
- const artifacts =
807
- task.artifacts === undefined
808
- ? undefined
809
- : Array.isArray(task.artifacts)
810
- ? task.artifacts.map(normalizeArtifact)
811
- : [];
812
- const history =
813
- task.history === undefined
814
- ? undefined
815
- : Array.isArray(task.history)
816
- ? task.history
817
- : [];
818
- this.currentTask = {
819
- ...task,
820
- ...(artifacts === undefined ? {} : { artifacts }),
821
- ...(history === undefined ? {} : { history }),
822
- };
823
-
824
- if (task.contextId) {
825
- this.contextId = task.contextId;
826
- }
827
- const isCompleteSnapshot = history !== undefined && artifacts !== undefined;
828
- const artifactsToApply = isCompleteSnapshot
829
- ? artifacts
830
- : artifacts?.filter(
831
- (artifact) =>
832
- !this.currentArtifacts.some(
833
- ({ artifactId }) => artifactId === artifact.artifactId,
834
- ),
835
- );
836
-
837
- if (isCompleteSnapshot) {
838
- this.currentArtifacts = artifacts;
839
- this.a2uiState = new Map();
840
- this.a2uiMessageIds.clear();
841
- } else if (artifactsToApply) {
842
- this.currentArtifacts = [...this.currentArtifacts, ...artifactsToApply];
843
- }
844
-
845
- for (const message of history ?? []) {
846
- if (message.role === "agent") this.applyA2uiMessage(message, true);
847
- }
848
- for (const artifact of artifactsToApply ?? []) {
849
- this.applyA2uiParts(artifact.parts);
850
- }
851
- if (task.status.message) {
852
- this.applyA2uiMessage(task.status.message, true);
853
- }
762
+ const previousArtifacts = this.state.currentArtifacts;
763
+ this.dispatch({
764
+ type: "taskSnapshot",
765
+ task,
766
+ });
854
767
 
855
- if (isCompleteSnapshot || artifactsToApply?.length) {
768
+ if (
769
+ (task.history !== undefined && task.artifacts !== undefined) ||
770
+ this.state.currentArtifacts.length > previousArtifacts.length
771
+ ) {
856
772
  this.updateAssistantArtifacts(assistantId);
857
773
  }
858
774
 
@@ -906,23 +822,9 @@ export class A2AThreadRuntimeCore {
906
822
  });
907
823
  }
908
824
 
909
- private applyA2uiMessage(message: A2AMessage, replay = false) {
910
- if (message.messageId) {
911
- if (replay && this.a2uiMessageIds.has(message.messageId)) return;
912
- this.a2uiMessageIds.add(message.messageId);
913
- }
914
- this.applyA2uiParts(message.parts);
915
- }
916
-
917
- private applyA2uiParts(parts: readonly A2AMessage["parts"][number][]) {
918
- const operations = a2uiPartsToOperations(parts);
919
- if (operations.length === 0) return;
920
- this.a2uiState = applyA2uiOperations(this.a2uiState, operations).state;
921
- }
922
-
923
825
  private a2uiSurfaceParts(): ThreadAssistantMessagePart[] {
924
826
  const parts: ThreadAssistantMessagePart[] = [];
925
- for (const [surfaceId, surface] of this.a2uiState) {
827
+ for (const [surfaceId, surface] of this.state.a2uiState) {
926
828
  const { toolCall } = surfaceToPresentToolCall(surfaceId, surface);
927
829
  if (!toolCall) continue;
928
830
  parts.push({
@@ -1003,7 +905,7 @@ export class A2AThreadRuntimeCore {
1003
905
  ...custom,
1004
906
  a2a: {
1005
907
  ...a2a,
1006
- artifacts: this.currentArtifacts.map(storedArtifact),
908
+ artifacts: this.state.currentArtifacts.map(storedArtifact),
1007
909
  },
1008
910
  },
1009
911
  },
@@ -1042,7 +944,9 @@ export class A2AThreadRuntimeCore {
1042
944
  // --- Lifecycle helpers ---
1043
945
 
1044
946
  private setRunning(running: boolean) {
1045
- this.isRunningFlag = running;
947
+ this.dispatch({
948
+ type: running ? "runStarted" : "runFinished",
949
+ });
1046
950
  this.publishUpdate();
1047
951
  }
1048
952
 
@@ -1088,7 +992,7 @@ export class A2AThreadRuntimeCore {
1088
992
  if (this.recordedHistoryIds.has(messageId)) {
1089
993
  if (this.history.update) {
1090
994
  const update = this.history.update.bind(this.history);
1091
- const write = this.chainHistoryWrite(messageId, () =>
995
+ const write = this.historyWrites.chain(messageId, () =>
1092
996
  update({ parentId: resolvedParentId, message }),
1093
997
  );
1094
998
  if (!isPausing) {
@@ -1099,7 +1003,7 @@ export class A2AThreadRuntimeCore {
1099
1003
  this.recordedHistoryIds.add(messageId);
1100
1004
  },
1101
1005
  (error) => {
1102
- const pending = this.historyWrites.get(messageId);
1006
+ const pending = this.historyWrites.pending(messageId);
1103
1007
  if (pending === undefined || pending === write) {
1104
1008
  this.assistantHistoryParents.set(messageId, resolvedParentId);
1105
1009
  }
@@ -1119,7 +1023,7 @@ export class A2AThreadRuntimeCore {
1119
1023
  this.assistantHistoryParents.delete(messageId);
1120
1024
  }
1121
1025
  void write.catch((error) => {
1122
- const pending = this.historyWrites.get(messageId);
1026
+ const pending = this.historyWrites.pending(messageId);
1123
1027
  if (pending === undefined || pending === write) {
1124
1028
  this.assistantHistoryParents.set(messageId, resolvedParentId);
1125
1029
  }
@@ -1134,7 +1038,7 @@ export class A2AThreadRuntimeCore {
1134
1038
  if (!this.history || this.recordedHistoryIds.has(message.id)) return;
1135
1039
  this.recordedHistoryIds.add(message.id);
1136
1040
  const append = this.history.append.bind(this.history);
1137
- const write = this.chainHistoryWrite(message.id, () =>
1041
+ const write = this.historyWrites.chain(message.id, () =>
1138
1042
  append({ parentId, message }),
1139
1043
  );
1140
1044
  void write.catch(() => {
@@ -1142,35 +1046,4 @@ export class A2AThreadRuntimeCore {
1142
1046
  });
1143
1047
  return write;
1144
1048
  }
1145
-
1146
- private chainHistoryWrite(
1147
- id: string,
1148
- write: () => Promise<void>,
1149
- ): Promise<void> {
1150
- const pending = this.historyWrites.get(id);
1151
- let next: Promise<void>;
1152
- if (pending) {
1153
- next = pending.then(write, write);
1154
- } else {
1155
- try {
1156
- next = Promise.resolve(write());
1157
- } catch (error) {
1158
- next = Promise.reject(error);
1159
- }
1160
- }
1161
- this.historyWrites.set(id, next);
1162
- void next.then(
1163
- () => {
1164
- if (this.historyWrites.get(id) === next) {
1165
- this.historyWrites.delete(id);
1166
- }
1167
- },
1168
- () => {
1169
- if (this.historyWrites.get(id) === next) {
1170
- this.historyWrites.delete(id);
1171
- }
1172
- },
1173
- );
1174
- return next;
1175
- }
1176
1049
  }