@assistant-ui/react-a2a 0.2.40 → 0.2.42
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.
- package/dist/A2AClient.d.ts +6 -0
- package/dist/A2AClient.d.ts.map +1 -1
- package/dist/A2AClient.js +9 -38
- package/dist/A2AClient.js.map +1 -1
- package/dist/A2AThreadRuntimeCore.d.ts +14 -4
- package/dist/A2AThreadRuntimeCore.d.ts.map +1 -1
- package/dist/A2AThreadRuntimeCore.js +54 -38
- package/dist/A2AThreadRuntimeCore.js.map +1 -1
- package/dist/conversions.d.ts.map +1 -1
- package/dist/conversions.js +2 -2
- package/dist/conversions.js.map +1 -1
- package/dist/useA2ARuntime.d.ts.map +1 -1
- package/dist/useA2ARuntime.js +40 -28
- package/dist/useA2ARuntime.js.map +1 -1
- package/package.json +7 -6
- package/src/A2AClient.test.ts +69 -0
- package/src/A2AClient.ts +13 -45
- package/src/A2AThreadRuntimeCore.test.ts +130 -18
- package/src/A2AThreadRuntimeCore.ts +80 -49
- package/src/conversions.test.ts +21 -0
- package/src/conversions.ts +2 -1
- package/src/useA2ARuntime.test.tsx +104 -0
- package/src/useA2ARuntime.ts +62 -35
|
@@ -17,14 +17,13 @@ import {
|
|
|
17
17
|
appendToolInteraction,
|
|
18
18
|
createMessageRepositorySession,
|
|
19
19
|
invokeUserCallback,
|
|
20
|
+
RunLeases,
|
|
20
21
|
} from "@assistant-ui/core/internal";
|
|
21
|
-
import type { ReadonlyJSONObject } from "assistant-stream/utils";
|
|
22
22
|
import {
|
|
23
23
|
applyA2uiOperations,
|
|
24
|
-
|
|
25
|
-
surfaceToOperations,
|
|
24
|
+
surfaceToPresentToolCall,
|
|
26
25
|
type A2uiState,
|
|
27
|
-
} from "@assistant-ui/
|
|
26
|
+
} from "@assistant-ui/generative-ui/a2ui";
|
|
28
27
|
import type { A2AClient } from "./A2AClient";
|
|
29
28
|
import type {
|
|
30
29
|
A2AArtifact,
|
|
@@ -64,7 +63,6 @@ export type A2AThreadRuntimeCoreOptions = {
|
|
|
64
63
|
onCancel?: (() => void) | undefined;
|
|
65
64
|
onArtifactComplete?: ((artifact: A2AArtifact) => void) | undefined;
|
|
66
65
|
history?: ThreadHistoryAdapter | undefined;
|
|
67
|
-
notifyUpdate: () => void;
|
|
68
66
|
};
|
|
69
67
|
|
|
70
68
|
const FALLBACK_USER_STATUS = {
|
|
@@ -97,13 +95,23 @@ export class A2AThreadRuntimeCore {
|
|
|
97
95
|
private onCancel: (() => void) | undefined;
|
|
98
96
|
private onArtifactComplete: ((artifact: A2AArtifact) => void) | undefined;
|
|
99
97
|
private history: ThreadHistoryAdapter | undefined;
|
|
100
|
-
private readonly
|
|
98
|
+
private readonly listeners = new Set<() => void>();
|
|
99
|
+
private snapshot:
|
|
100
|
+
| {
|
|
101
|
+
isLoading: boolean;
|
|
102
|
+
isRunning: boolean;
|
|
103
|
+
messageRepository: ExportedMessageRepository;
|
|
104
|
+
task: A2ATask | undefined;
|
|
105
|
+
artifacts: readonly A2AArtifact[];
|
|
106
|
+
agentCard: A2AAgentCard | undefined;
|
|
107
|
+
}
|
|
108
|
+
| undefined;
|
|
101
109
|
|
|
102
110
|
private runtime: AssistantRuntime | undefined;
|
|
103
111
|
private readonly session = createMessageRepositorySession();
|
|
104
112
|
private isRunningFlag = false;
|
|
105
113
|
private abortController: AbortController | null = null;
|
|
106
|
-
private
|
|
114
|
+
private readonly runLeases = new RunLeases();
|
|
107
115
|
private pendingError: Error | null = null;
|
|
108
116
|
|
|
109
117
|
// A2A-specific state
|
|
@@ -137,15 +145,32 @@ export class A2AThreadRuntimeCore {
|
|
|
137
145
|
this.onCancel = options.onCancel;
|
|
138
146
|
this.onArtifactComplete = options.onArtifactComplete;
|
|
139
147
|
this.history = options.history;
|
|
140
|
-
this.notifyUpdate = options.notifyUpdate;
|
|
141
148
|
}
|
|
142
149
|
|
|
143
|
-
|
|
150
|
+
subscribe = (listener: () => void): (() => void) => {
|
|
151
|
+
this.listeners.add(listener);
|
|
152
|
+
return () => this.listeners.delete(listener);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
getSnapshot = () =>
|
|
156
|
+
(this.snapshot ??= {
|
|
157
|
+
isLoading: this._isLoading,
|
|
158
|
+
isRunning: this.isRunningFlag,
|
|
159
|
+
messageRepository: this.session.export(),
|
|
160
|
+
task: this.currentTask,
|
|
161
|
+
artifacts: this.currentArtifacts,
|
|
162
|
+
agentCard: this.agentCardValue,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
private publishUpdate(): void {
|
|
166
|
+
this.snapshot = undefined;
|
|
167
|
+
for (const listener of this.listeners) listener();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
updateOptions(options: A2AThreadRuntimeCoreOptions) {
|
|
144
171
|
this.client = options.client;
|
|
145
|
-
// The
|
|
146
|
-
//
|
|
147
|
-
// re-render with the same value must not clobber a server-assigned
|
|
148
|
-
// contextId learned from the stream.
|
|
172
|
+
// The option only seeds the context; a re-render with the same value must
|
|
173
|
+
// not clobber a server-assigned contextId learned from the stream.
|
|
149
174
|
if (options.contextId !== this.lastOptionsContextId) {
|
|
150
175
|
this.contextId = options.contextId;
|
|
151
176
|
this.lastOptionsContextId = options.contextId;
|
|
@@ -172,6 +197,7 @@ export class A2AThreadRuntimeCore {
|
|
|
172
197
|
* switches, deletes, and cancel resyncs, which must keep the live context. */
|
|
173
198
|
resetContext(): void {
|
|
174
199
|
this._historyLoadGeneration++;
|
|
200
|
+
const wasLoading = this._isLoading;
|
|
175
201
|
this._isLoading = false;
|
|
176
202
|
// Restore the seed before aborting: an onCancel callback that starts a
|
|
177
203
|
// new run must not pick up the old thread's context, and its controller
|
|
@@ -184,6 +210,7 @@ export class A2AThreadRuntimeCore {
|
|
|
184
210
|
this.abortController = null;
|
|
185
211
|
}
|
|
186
212
|
}
|
|
213
|
+
if (wasLoading) this.publishUpdate();
|
|
187
214
|
}
|
|
188
215
|
|
|
189
216
|
attachRuntime(runtime: AssistantRuntime) {
|
|
@@ -193,9 +220,12 @@ export class A2AThreadRuntimeCore {
|
|
|
193
220
|
detachRuntime() {
|
|
194
221
|
this.runtime = undefined;
|
|
195
222
|
// Abort in-flight requests on unmount
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
223
|
+
const controller = this.abortController;
|
|
224
|
+
if (controller) {
|
|
225
|
+
controller.abort();
|
|
226
|
+
if (this.abortController === controller) {
|
|
227
|
+
this.abortController = null;
|
|
228
|
+
}
|
|
199
229
|
}
|
|
200
230
|
}
|
|
201
231
|
|
|
@@ -254,7 +284,7 @@ export class A2AThreadRuntimeCore {
|
|
|
254
284
|
};
|
|
255
285
|
});
|
|
256
286
|
if (touched) {
|
|
257
|
-
this.
|
|
287
|
+
this.publishUpdate();
|
|
258
288
|
this.persistAssistantHistory(messageId);
|
|
259
289
|
}
|
|
260
290
|
}
|
|
@@ -288,7 +318,7 @@ export class A2AThreadRuntimeCore {
|
|
|
288
318
|
this._agentCardRetryAfter = 0;
|
|
289
319
|
this._agentCardRetryDelay = INITIAL_AGENT_CARD_RETRY_DELAY_MS;
|
|
290
320
|
this._agentCardDiscoveryFailed = false;
|
|
291
|
-
this.
|
|
321
|
+
this.publishUpdate();
|
|
292
322
|
},
|
|
293
323
|
() => {
|
|
294
324
|
this._agentCardDiscoveryFailed = true;
|
|
@@ -354,10 +384,10 @@ export class A2AThreadRuntimeCore {
|
|
|
354
384
|
.finally(() => {
|
|
355
385
|
if (generation !== this._historyLoadGeneration) return;
|
|
356
386
|
this._isLoading = false;
|
|
357
|
-
this.
|
|
387
|
+
this.publishUpdate();
|
|
358
388
|
});
|
|
359
389
|
|
|
360
|
-
this.
|
|
390
|
+
this.publishUpdate();
|
|
361
391
|
return this._loadPromise;
|
|
362
392
|
}
|
|
363
393
|
|
|
@@ -377,7 +407,7 @@ export class A2AThreadRuntimeCore {
|
|
|
377
407
|
: this.session.headId;
|
|
378
408
|
this.session.addOrUpdateMessage(parentId, threadMessage);
|
|
379
409
|
this.session.switchToBranch(threadMessage.id);
|
|
380
|
-
this.
|
|
410
|
+
this.publishUpdate();
|
|
381
411
|
this.recordHistoryEntry(parentId, threadMessage);
|
|
382
412
|
|
|
383
413
|
if (!startRun) return;
|
|
@@ -388,7 +418,7 @@ export class A2AThreadRuntimeCore {
|
|
|
388
418
|
const parentId = this.session.headId;
|
|
389
419
|
this.session.addOrUpdateMessage(parentId, message);
|
|
390
420
|
this.session.switchToBranch(message.id);
|
|
391
|
-
this.
|
|
421
|
+
this.publishUpdate();
|
|
392
422
|
this.recordHistoryEntry(parentId, message);
|
|
393
423
|
}
|
|
394
424
|
|
|
@@ -419,7 +449,7 @@ export class A2AThreadRuntimeCore {
|
|
|
419
449
|
// onCancel callback synchronously, which may clear the thread and with it
|
|
420
450
|
// the task this cancellation is for, or start a new run.
|
|
421
451
|
const task = this.currentTask;
|
|
422
|
-
const
|
|
452
|
+
const lease = this.runLeases.current();
|
|
423
453
|
|
|
424
454
|
// Abort locally first so the stream stops immediately
|
|
425
455
|
this.abortController.abort();
|
|
@@ -430,11 +460,11 @@ export class A2AThreadRuntimeCore {
|
|
|
430
460
|
const updated = await this.client.cancelTask(task.id);
|
|
431
461
|
// Only apply the response while nothing newer exists. A newer snapshot
|
|
432
462
|
// or a cleared thread replaces the task object; a follow-up run that
|
|
433
|
-
// has not emitted yet keeps it, so the run
|
|
463
|
+
// has not emitted yet keeps it, so the run lease is what rules
|
|
434
464
|
// that case out.
|
|
435
|
-
if (this.currentTask === task &&
|
|
465
|
+
if (this.currentTask === task && lease.isCurrent()) {
|
|
436
466
|
this.currentTask = updated;
|
|
437
|
-
this.
|
|
467
|
+
this.publishUpdate();
|
|
438
468
|
}
|
|
439
469
|
} catch {
|
|
440
470
|
// Server cancel failed; local abort already handled
|
|
@@ -468,7 +498,7 @@ export class A2AThreadRuntimeCore {
|
|
|
468
498
|
this.currentArtifacts = [];
|
|
469
499
|
this.a2uiState = new Map();
|
|
470
500
|
this.a2uiMessageIds.clear();
|
|
471
|
-
this.
|
|
501
|
+
this.publishUpdate();
|
|
472
502
|
}
|
|
473
503
|
|
|
474
504
|
applyExternalMessages(messages: readonly ThreadMessage[]): void {
|
|
@@ -507,11 +537,17 @@ export class A2AThreadRuntimeCore {
|
|
|
507
537
|
// --- Run logic ---
|
|
508
538
|
|
|
509
539
|
private async startRun(userThreadMessage: ThreadMessage): Promise<void> {
|
|
510
|
-
this.
|
|
511
|
-
|
|
512
|
-
// Cancel any in-progress run before starting a new one
|
|
513
|
-
|
|
514
|
-
|
|
540
|
+
this.runLeases.begin();
|
|
541
|
+
|
|
542
|
+
// Cancel any in-progress run before starting a new one. Its abort runs
|
|
543
|
+
// onCancel synchronously, and a run that callback starts keeps the thread.
|
|
544
|
+
// A listener that throws before finishRun leaves `previous` installed, so
|
|
545
|
+
// only a different, non-null controller counts as a replacement run.
|
|
546
|
+
const previous = this.abortController;
|
|
547
|
+
if (previous) {
|
|
548
|
+
previous.abort();
|
|
549
|
+
if (this.abortController !== previous && this.abortController !== null)
|
|
550
|
+
return;
|
|
515
551
|
this.abortController = null;
|
|
516
552
|
}
|
|
517
553
|
|
|
@@ -705,7 +741,7 @@ export class A2AThreadRuntimeCore {
|
|
|
705
741
|
const status = taskStateToMessageStatus(event.status.state);
|
|
706
742
|
this.updateAssistantStatus(assistantId, status);
|
|
707
743
|
|
|
708
|
-
this.
|
|
744
|
+
this.publishUpdate();
|
|
709
745
|
}
|
|
710
746
|
|
|
711
747
|
private handleArtifactUpdate(
|
|
@@ -754,7 +790,7 @@ export class A2AThreadRuntimeCore {
|
|
|
754
790
|
);
|
|
755
791
|
}
|
|
756
792
|
|
|
757
|
-
this.
|
|
793
|
+
this.publishUpdate();
|
|
758
794
|
}
|
|
759
795
|
|
|
760
796
|
private handleMessage(assistantId: string, message: A2AMessage) {
|
|
@@ -763,7 +799,7 @@ export class A2AThreadRuntimeCore {
|
|
|
763
799
|
this.applyA2uiMessage(message);
|
|
764
800
|
const content = a2aMessageToContent(message);
|
|
765
801
|
this.updateAssistantContent(assistantId, content);
|
|
766
|
-
this.
|
|
802
|
+
this.publishUpdate();
|
|
767
803
|
}
|
|
768
804
|
|
|
769
805
|
private handleTaskSnapshot(assistantId: string, task: A2ATask) {
|
|
@@ -830,7 +866,7 @@ export class A2AThreadRuntimeCore {
|
|
|
830
866
|
const status = taskStateToMessageStatus(task.status.state);
|
|
831
867
|
this.updateAssistantStatus(assistantId, status);
|
|
832
868
|
|
|
833
|
-
this.
|
|
869
|
+
this.publishUpdate();
|
|
834
870
|
}
|
|
835
871
|
|
|
836
872
|
// --- Message helpers ---
|
|
@@ -853,7 +889,7 @@ export class A2AThreadRuntimeCore {
|
|
|
853
889
|
};
|
|
854
890
|
this.session.addOrUpdateMessage(parentId, assistant);
|
|
855
891
|
this.session.switchToBranch(id);
|
|
856
|
-
this.
|
|
892
|
+
this.publishUpdate();
|
|
857
893
|
return id;
|
|
858
894
|
}
|
|
859
895
|
|
|
@@ -887,16 +923,11 @@ export class A2AThreadRuntimeCore {
|
|
|
887
923
|
private a2uiSurfaceParts(): ThreadAssistantMessagePart[] {
|
|
888
924
|
const parts: ThreadAssistantMessagePart[] = [];
|
|
889
925
|
for (const [surfaceId, surface] of this.a2uiState) {
|
|
890
|
-
const {
|
|
891
|
-
if (!
|
|
926
|
+
const { toolCall } = surfaceToPresentToolCall(surfaceId, surface);
|
|
927
|
+
if (!toolCall) continue;
|
|
892
928
|
parts.push({
|
|
893
929
|
type: "tool-call",
|
|
894
|
-
|
|
895
|
-
toolName: "present",
|
|
896
|
-
args: spec as unknown as ReadonlyJSONObject,
|
|
897
|
-
argsText: JSON.stringify(spec),
|
|
898
|
-
result: {},
|
|
899
|
-
artifact: { a2ui: surfaceToOperations(surface) },
|
|
930
|
+
...toolCall,
|
|
900
931
|
});
|
|
901
932
|
}
|
|
902
933
|
return parts;
|
|
@@ -956,7 +987,7 @@ export class A2AThreadRuntimeCore {
|
|
|
956
987
|
content: this.withA2uiSurfaces(message.content),
|
|
957
988
|
};
|
|
958
989
|
});
|
|
959
|
-
if (touched) this.
|
|
990
|
+
if (touched) this.publishUpdate();
|
|
960
991
|
}
|
|
961
992
|
|
|
962
993
|
private updateAssistantArtifacts(messageId: string) {
|
|
@@ -978,7 +1009,7 @@ export class A2AThreadRuntimeCore {
|
|
|
978
1009
|
},
|
|
979
1010
|
};
|
|
980
1011
|
});
|
|
981
|
-
if (touched) this.
|
|
1012
|
+
if (touched) this.publishUpdate();
|
|
982
1013
|
}
|
|
983
1014
|
|
|
984
1015
|
private updateAssistantStatus(messageId: string, status: MessageStatus) {
|
|
@@ -987,7 +1018,7 @@ export class A2AThreadRuntimeCore {
|
|
|
987
1018
|
return { ...message, status };
|
|
988
1019
|
});
|
|
989
1020
|
if (touched) {
|
|
990
|
-
this.
|
|
1021
|
+
this.publishUpdate();
|
|
991
1022
|
if (this.isPersistableAssistantStatus(status)) {
|
|
992
1023
|
this.persistAssistantHistory(messageId);
|
|
993
1024
|
}
|
|
@@ -1012,7 +1043,7 @@ export class A2AThreadRuntimeCore {
|
|
|
1012
1043
|
|
|
1013
1044
|
private setRunning(running: boolean) {
|
|
1014
1045
|
this.isRunningFlag = running;
|
|
1015
|
-
this.
|
|
1046
|
+
this.publishUpdate();
|
|
1016
1047
|
}
|
|
1017
1048
|
|
|
1018
1049
|
private finishRun(controller: AbortController | null) {
|
package/src/conversions.test.ts
CHANGED
|
@@ -475,6 +475,19 @@ describe("isInterruptedTaskState", () => {
|
|
|
475
475
|
});
|
|
476
476
|
|
|
477
477
|
describe("contentPartsToA2AParts", () => {
|
|
478
|
+
it.each(["application/pdf", ""])(
|
|
479
|
+
"unwraps a media-less data URL while retaining the adapter's %j MIME fallback",
|
|
480
|
+
(mimeType) => {
|
|
481
|
+
expect(
|
|
482
|
+
contentPartsToA2AParts([
|
|
483
|
+
{ type: "file", data: "data:;base64,SGVsbG8=", mimeType },
|
|
484
|
+
]),
|
|
485
|
+
).toEqual([
|
|
486
|
+
{ raw: "SGVsbG8=", mediaType: mimeType || "application/octet-stream" },
|
|
487
|
+
]);
|
|
488
|
+
},
|
|
489
|
+
);
|
|
490
|
+
|
|
478
491
|
it("converts text parts", () => {
|
|
479
492
|
const result = contentPartsToA2AParts([{ type: "text", text: "hi" }]);
|
|
480
493
|
expect(result).toEqual([{ text: "hi" }]);
|
|
@@ -525,6 +538,14 @@ describe("contentPartsToA2AParts", () => {
|
|
|
525
538
|
expect(result).toEqual([{ raw: "aGVsbG8=", mediaType: "image/png" }]);
|
|
526
539
|
});
|
|
527
540
|
|
|
541
|
+
it("sniffs media-less image data URLs before sending their raw bytes", () => {
|
|
542
|
+
expect(
|
|
543
|
+
contentPartsToA2AParts([
|
|
544
|
+
{ type: "image", image: "data:;base64,iVBORw0KGgo=" },
|
|
545
|
+
]),
|
|
546
|
+
).toEqual([{ raw: "iVBORw0KGgo=", mediaType: "image/png" }]);
|
|
547
|
+
});
|
|
548
|
+
|
|
528
549
|
it("propagates image filenames", () => {
|
|
529
550
|
const result = contentPartsToA2AParts([
|
|
530
551
|
{
|
package/src/conversions.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
import {
|
|
9
9
|
parseDataUrl,
|
|
10
10
|
resolveFilePartSource,
|
|
11
|
+
resolveImageMediaType,
|
|
11
12
|
} from "@assistant-ui/core/internal";
|
|
12
13
|
import type { A2AMessage, A2APart, A2ATaskState } from "./types";
|
|
13
14
|
|
|
@@ -176,7 +177,7 @@ export function contentPartsToA2AParts(
|
|
|
176
177
|
if (parsed) {
|
|
177
178
|
return {
|
|
178
179
|
raw: parsed.data,
|
|
179
|
-
mediaType:
|
|
180
|
+
mediaType: resolveImageMediaType(part.image, fallbackMimeType),
|
|
180
181
|
...(part.filename && { filename: part.filename }),
|
|
181
182
|
};
|
|
182
183
|
}
|
|
@@ -140,6 +140,42 @@ describe("useA2ARuntime", () => {
|
|
|
140
140
|
append: vi.fn().mockResolvedValue(undefined),
|
|
141
141
|
});
|
|
142
142
|
|
|
143
|
+
it("re-renders when the core publishes loaded history", async () => {
|
|
144
|
+
const { client, getAgentCard } = createMockClient();
|
|
145
|
+
getAgentCard.mockImplementation(() => new Promise(() => {}));
|
|
146
|
+
let resolveHistory!: (repo: ExportedMessageRepository) => void;
|
|
147
|
+
const pendingHistory = new Promise<ExportedMessageRepository>((resolve) => {
|
|
148
|
+
resolveHistory = resolve;
|
|
149
|
+
});
|
|
150
|
+
const history = {
|
|
151
|
+
load: vi.fn(() => pendingHistory),
|
|
152
|
+
append: async () => {},
|
|
153
|
+
};
|
|
154
|
+
const render = vi.fn();
|
|
155
|
+
const { result } = renderHook(() => {
|
|
156
|
+
render();
|
|
157
|
+
return useA2ARuntime({ client, adapters: { history } });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
await waitFor(() => expect(history.load).toHaveBeenCalledOnce());
|
|
161
|
+
const rendersBeforeHistory = render.mock.calls.length;
|
|
162
|
+
|
|
163
|
+
await act(async () => {
|
|
164
|
+
resolveHistory({
|
|
165
|
+
headId: "restored",
|
|
166
|
+
messages: [
|
|
167
|
+
{ parentId: null, message: createThreadMessage("restored") },
|
|
168
|
+
],
|
|
169
|
+
});
|
|
170
|
+
await pendingHistory;
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
expect(render.mock.calls.length).toBeGreaterThan(rendersBeforeHistory);
|
|
174
|
+
expect(result.current.thread.getState().messages.map((m) => m.id)).toEqual([
|
|
175
|
+
"restored",
|
|
176
|
+
]);
|
|
177
|
+
});
|
|
178
|
+
|
|
143
179
|
it("loads a history adapter that arrives on a later render", async () => {
|
|
144
180
|
const { client } = createMockClient();
|
|
145
181
|
const history = createHistory();
|
|
@@ -466,6 +502,74 @@ describe("useA2ARuntime", () => {
|
|
|
466
502
|
expect(result.current.thread.export().messages).toEqual([]);
|
|
467
503
|
});
|
|
468
504
|
|
|
505
|
+
it.each(["existing", "new"] as const)(
|
|
506
|
+
"drops a send started by onCancel while switching to the %s thread",
|
|
507
|
+
async (destination) => {
|
|
508
|
+
const { client, streamMessage } = createMockClient(true);
|
|
509
|
+
let resolveSwitch!: () => void;
|
|
510
|
+
const pendingSwitch = new Promise<void>((resolve) => {
|
|
511
|
+
resolveSwitch = resolve;
|
|
512
|
+
});
|
|
513
|
+
let result!: { current: ReturnType<typeof useA2ARuntime> };
|
|
514
|
+
let cancelled = false;
|
|
515
|
+
({ result } = renderHook(() => {
|
|
516
|
+
const [threadId, setThreadId] = useState("initial");
|
|
517
|
+
return useA2ARuntime({
|
|
518
|
+
client,
|
|
519
|
+
onCancel: () => {
|
|
520
|
+
if (cancelled) return;
|
|
521
|
+
cancelled = true;
|
|
522
|
+
void result.current.thread.append("started by onCancel");
|
|
523
|
+
},
|
|
524
|
+
adapters: {
|
|
525
|
+
threadList: {
|
|
526
|
+
threadId,
|
|
527
|
+
onSwitchToThread: async (id) => {
|
|
528
|
+
setThreadId(id);
|
|
529
|
+
await pendingSwitch;
|
|
530
|
+
return { messages: [createThreadMessage("loaded")] };
|
|
531
|
+
},
|
|
532
|
+
onSwitchToNewThread: async () => {
|
|
533
|
+
setThreadId("thread-new");
|
|
534
|
+
await pendingSwitch;
|
|
535
|
+
},
|
|
536
|
+
},
|
|
537
|
+
},
|
|
538
|
+
});
|
|
539
|
+
}));
|
|
540
|
+
|
|
541
|
+
act(() => {
|
|
542
|
+
void result.current.thread.append("old prompt");
|
|
543
|
+
});
|
|
544
|
+
await waitFor(() => expect(streamMessage).toHaveBeenCalledOnce());
|
|
545
|
+
|
|
546
|
+
let switching!: Promise<void>;
|
|
547
|
+
act(() => {
|
|
548
|
+
switching =
|
|
549
|
+
destination === "existing"
|
|
550
|
+
? result.current.threads.switchToThread("thread-a")
|
|
551
|
+
: result.current.threads.switchToNewThread();
|
|
552
|
+
});
|
|
553
|
+
act(() => {
|
|
554
|
+
void result.current.thread.append("during switch");
|
|
555
|
+
});
|
|
556
|
+
await act(async () => {
|
|
557
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
558
|
+
});
|
|
559
|
+
await act(async () => {
|
|
560
|
+
resolveSwitch();
|
|
561
|
+
await switching;
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
expect(cancelled).toBe(true);
|
|
565
|
+
expect(streamMessage).toHaveBeenCalledOnce();
|
|
566
|
+
expect(result.current.thread.getState().isRunning).toBe(false);
|
|
567
|
+
expect(
|
|
568
|
+
result.current.thread.getState().messages.map((m) => m.id),
|
|
569
|
+
).toEqual(destination === "existing" ? ["loaded"] : []);
|
|
570
|
+
},
|
|
571
|
+
);
|
|
572
|
+
|
|
469
573
|
it("leaves a new thread empty after an active run and failed creation", async () => {
|
|
470
574
|
const { client, streamMessage } = createMockClient(true);
|
|
471
575
|
let rejectNew!: (error: Error) => void;
|
package/src/useA2ARuntime.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
useMemo,
|
|
8
8
|
useRef,
|
|
9
9
|
useState,
|
|
10
|
+
useSyncExternalStore,
|
|
10
11
|
} from "react";
|
|
11
12
|
import {
|
|
12
13
|
useExternalStoreRuntime,
|
|
@@ -43,8 +44,6 @@ const serializeManagedClientOptions = (
|
|
|
43
44
|
};
|
|
44
45
|
|
|
45
46
|
export function useA2ARuntime(options: UseA2ARuntimeOptions): AssistantRuntime {
|
|
46
|
-
const [_version, setVersion] = useState(0);
|
|
47
|
-
const notifyUpdate = useCallback(() => setVersion((v) => v + 1), []);
|
|
48
47
|
const runtimeAdapters = useRuntimeAdapters();
|
|
49
48
|
const historyAdapter = options.adapters?.history ?? runtimeAdapters?.history;
|
|
50
49
|
const threadListAdapter = options.adapters?.threadList;
|
|
@@ -118,7 +117,6 @@ export function useA2ARuntime(options: UseA2ARuntimeOptions): AssistantRuntime {
|
|
|
118
117
|
new A2AThreadRuntimeCore({
|
|
119
118
|
...coreOptionsRef.current,
|
|
120
119
|
client,
|
|
121
|
-
notifyUpdate,
|
|
122
120
|
});
|
|
123
121
|
const [pinnedCore, setPinnedCore] = useState(() => ({
|
|
124
122
|
client,
|
|
@@ -130,6 +128,11 @@ export function useA2ARuntime(options: UseA2ARuntimeOptions): AssistantRuntime {
|
|
|
130
128
|
setPinnedCore(currentCore);
|
|
131
129
|
}
|
|
132
130
|
const core = currentCore.core;
|
|
131
|
+
const snapshot = useSyncExternalStore(
|
|
132
|
+
core.subscribe,
|
|
133
|
+
core.getSnapshot,
|
|
134
|
+
core.getSnapshot,
|
|
135
|
+
);
|
|
133
136
|
|
|
134
137
|
useEffect(() => {
|
|
135
138
|
core.updateOptions(coreOptions);
|
|
@@ -137,41 +140,58 @@ export function useA2ARuntime(options: UseA2ARuntimeOptions): AssistantRuntime {
|
|
|
137
140
|
|
|
138
141
|
// Thread list
|
|
139
142
|
const threadSwitchGenerationRef = useRef(0);
|
|
143
|
+
const switchingGenerationRef = useRef<number | null>(null);
|
|
140
144
|
const threadList = useMemo(() => {
|
|
141
145
|
if (!threadListAdapter) return undefined;
|
|
142
146
|
|
|
143
147
|
const { onSwitchToNewThread, onSwitchToThread } = threadListAdapter;
|
|
144
148
|
|
|
149
|
+
const releaseSwitch = (generation: number) => {
|
|
150
|
+
if (switchingGenerationRef.current === generation) {
|
|
151
|
+
switchingGenerationRef.current = null;
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
145
155
|
return {
|
|
146
156
|
threadId: threadListAdapter.threadId,
|
|
147
157
|
onSwitchToNewThread: onSwitchToNewThread
|
|
148
158
|
? async () => {
|
|
149
159
|
const generation = ++threadSwitchGenerationRef.current;
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
160
|
+
switchingGenerationRef.current = generation;
|
|
161
|
+
try {
|
|
162
|
+
// Clear before the thread id flips, or the old messages leak
|
|
163
|
+
// into the new thread as a sibling branch.
|
|
164
|
+
core.applyExternalMessages([]);
|
|
165
|
+
core.resetContext();
|
|
166
|
+
await onSwitchToNewThread();
|
|
167
|
+
if (generation !== threadSwitchGenerationRef.current) return;
|
|
168
|
+
// Apply first so the abort inside resetContext finds an already
|
|
169
|
+
// cleared repository and cannot persist the old thread's partial
|
|
170
|
+
// assistant message.
|
|
171
|
+
core.applyExternalMessages([]);
|
|
172
|
+
core.resetContext();
|
|
173
|
+
} finally {
|
|
174
|
+
releaseSwitch(generation);
|
|
175
|
+
}
|
|
161
176
|
}
|
|
162
177
|
: undefined,
|
|
163
178
|
onSwitchToThread: onSwitchToThread
|
|
164
179
|
? async (threadId: string) => {
|
|
165
180
|
const generation = ++threadSwitchGenerationRef.current;
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
181
|
+
switchingGenerationRef.current = generation;
|
|
182
|
+
try {
|
|
183
|
+
// Clear before the thread id flips, or the old messages leak
|
|
184
|
+
// into the new thread as a sibling branch.
|
|
185
|
+
core.applyExternalMessages([]);
|
|
186
|
+
core.resetContext();
|
|
187
|
+
const result = await onSwitchToThread(threadId);
|
|
188
|
+
if (generation !== threadSwitchGenerationRef.current) return;
|
|
189
|
+
core.applyExternalMessages([]);
|
|
190
|
+
core.applyExternalMessages(result.messages);
|
|
191
|
+
core.resetContext();
|
|
192
|
+
} finally {
|
|
193
|
+
releaseSwitch(generation);
|
|
194
|
+
}
|
|
175
195
|
}
|
|
176
196
|
: undefined,
|
|
177
197
|
};
|
|
@@ -194,24 +214,31 @@ export function useA2ARuntime(options: UseA2ARuntimeOptions): AssistantRuntime {
|
|
|
194
214
|
// Build store adapter
|
|
195
215
|
const shared = useExternalStoreSharedOptions(options);
|
|
196
216
|
const store = useMemo(() => {
|
|
197
|
-
void _version;
|
|
198
|
-
|
|
199
217
|
return {
|
|
200
218
|
...shared,
|
|
201
|
-
isLoading:
|
|
202
|
-
messageRepository:
|
|
203
|
-
isRunning:
|
|
219
|
+
isLoading: snapshot.isLoading,
|
|
220
|
+
messageRepository: snapshot.messageRepository,
|
|
221
|
+
isRunning: snapshot.isRunning,
|
|
204
222
|
unstable_persistsHistory: true,
|
|
205
223
|
extras: a2aExtras.provide({
|
|
206
|
-
task:
|
|
207
|
-
artifacts:
|
|
208
|
-
agentCard:
|
|
224
|
+
task: snapshot.task,
|
|
225
|
+
artifacts: snapshot.artifacts,
|
|
226
|
+
agentCard: snapshot.agentCard,
|
|
209
227
|
}),
|
|
210
|
-
onNew: (message: AppendMessage) =>
|
|
228
|
+
onNew: (message: AppendMessage) =>
|
|
229
|
+
switchingGenerationRef.current === null
|
|
230
|
+
? core.append(message)
|
|
231
|
+
: Promise.resolve(),
|
|
211
232
|
onVoiceTranscript: (message: ThreadMessage) =>
|
|
212
233
|
core.appendVoiceTranscript(message),
|
|
213
|
-
onEdit: (message: AppendMessage) =>
|
|
214
|
-
|
|
234
|
+
onEdit: (message: AppendMessage) =>
|
|
235
|
+
switchingGenerationRef.current === null
|
|
236
|
+
? core.edit(message)
|
|
237
|
+
: Promise.resolve(),
|
|
238
|
+
onReload: (parentId: string | null) =>
|
|
239
|
+
switchingGenerationRef.current === null
|
|
240
|
+
? core.reload(parentId)
|
|
241
|
+
: Promise.resolve(),
|
|
215
242
|
onCancel: () => core.cancel(),
|
|
216
243
|
unstable_onRecordToolInteraction: (options) =>
|
|
217
244
|
core.recordToolInteraction(options),
|
|
@@ -221,7 +248,7 @@ export function useA2ARuntime(options: UseA2ARuntimeOptions): AssistantRuntime {
|
|
|
221
248
|
core.applyExternalMessages(messages),
|
|
222
249
|
adapters: adapterAdapters,
|
|
223
250
|
} satisfies ExternalStoreAdapter<ThreadMessage>;
|
|
224
|
-
}, [adapterAdapters, core,
|
|
251
|
+
}, [adapterAdapters, core, snapshot, shared]);
|
|
225
252
|
|
|
226
253
|
const runtime = useExternalStoreRuntime(store);
|
|
227
254
|
|