@assistant-ui/react-a2a 0.2.41 → 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 +50 -35
- 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 +6 -6
- package/src/A2AClient.test.ts +69 -0
- package/src/A2AClient.ts +13 -45
- package/src/A2AThreadRuntimeCore.test.ts +71 -18
- package/src/A2AThreadRuntimeCore.ts +74 -46
- 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) {
|
|
@@ -257,7 +284,7 @@ export class A2AThreadRuntimeCore {
|
|
|
257
284
|
};
|
|
258
285
|
});
|
|
259
286
|
if (touched) {
|
|
260
|
-
this.
|
|
287
|
+
this.publishUpdate();
|
|
261
288
|
this.persistAssistantHistory(messageId);
|
|
262
289
|
}
|
|
263
290
|
}
|
|
@@ -291,7 +318,7 @@ export class A2AThreadRuntimeCore {
|
|
|
291
318
|
this._agentCardRetryAfter = 0;
|
|
292
319
|
this._agentCardRetryDelay = INITIAL_AGENT_CARD_RETRY_DELAY_MS;
|
|
293
320
|
this._agentCardDiscoveryFailed = false;
|
|
294
|
-
this.
|
|
321
|
+
this.publishUpdate();
|
|
295
322
|
},
|
|
296
323
|
() => {
|
|
297
324
|
this._agentCardDiscoveryFailed = true;
|
|
@@ -357,10 +384,10 @@ export class A2AThreadRuntimeCore {
|
|
|
357
384
|
.finally(() => {
|
|
358
385
|
if (generation !== this._historyLoadGeneration) return;
|
|
359
386
|
this._isLoading = false;
|
|
360
|
-
this.
|
|
387
|
+
this.publishUpdate();
|
|
361
388
|
});
|
|
362
389
|
|
|
363
|
-
this.
|
|
390
|
+
this.publishUpdate();
|
|
364
391
|
return this._loadPromise;
|
|
365
392
|
}
|
|
366
393
|
|
|
@@ -380,7 +407,7 @@ export class A2AThreadRuntimeCore {
|
|
|
380
407
|
: this.session.headId;
|
|
381
408
|
this.session.addOrUpdateMessage(parentId, threadMessage);
|
|
382
409
|
this.session.switchToBranch(threadMessage.id);
|
|
383
|
-
this.
|
|
410
|
+
this.publishUpdate();
|
|
384
411
|
this.recordHistoryEntry(parentId, threadMessage);
|
|
385
412
|
|
|
386
413
|
if (!startRun) return;
|
|
@@ -391,7 +418,7 @@ export class A2AThreadRuntimeCore {
|
|
|
391
418
|
const parentId = this.session.headId;
|
|
392
419
|
this.session.addOrUpdateMessage(parentId, message);
|
|
393
420
|
this.session.switchToBranch(message.id);
|
|
394
|
-
this.
|
|
421
|
+
this.publishUpdate();
|
|
395
422
|
this.recordHistoryEntry(parentId, message);
|
|
396
423
|
}
|
|
397
424
|
|
|
@@ -422,7 +449,7 @@ export class A2AThreadRuntimeCore {
|
|
|
422
449
|
// onCancel callback synchronously, which may clear the thread and with it
|
|
423
450
|
// the task this cancellation is for, or start a new run.
|
|
424
451
|
const task = this.currentTask;
|
|
425
|
-
const
|
|
452
|
+
const lease = this.runLeases.current();
|
|
426
453
|
|
|
427
454
|
// Abort locally first so the stream stops immediately
|
|
428
455
|
this.abortController.abort();
|
|
@@ -433,11 +460,11 @@ export class A2AThreadRuntimeCore {
|
|
|
433
460
|
const updated = await this.client.cancelTask(task.id);
|
|
434
461
|
// Only apply the response while nothing newer exists. A newer snapshot
|
|
435
462
|
// or a cleared thread replaces the task object; a follow-up run that
|
|
436
|
-
// has not emitted yet keeps it, so the run
|
|
463
|
+
// has not emitted yet keeps it, so the run lease is what rules
|
|
437
464
|
// that case out.
|
|
438
|
-
if (this.currentTask === task &&
|
|
465
|
+
if (this.currentTask === task && lease.isCurrent()) {
|
|
439
466
|
this.currentTask = updated;
|
|
440
|
-
this.
|
|
467
|
+
this.publishUpdate();
|
|
441
468
|
}
|
|
442
469
|
} catch {
|
|
443
470
|
// Server cancel failed; local abort already handled
|
|
@@ -471,7 +498,7 @@ export class A2AThreadRuntimeCore {
|
|
|
471
498
|
this.currentArtifacts = [];
|
|
472
499
|
this.a2uiState = new Map();
|
|
473
500
|
this.a2uiMessageIds.clear();
|
|
474
|
-
this.
|
|
501
|
+
this.publishUpdate();
|
|
475
502
|
}
|
|
476
503
|
|
|
477
504
|
applyExternalMessages(messages: readonly ThreadMessage[]): void {
|
|
@@ -510,11 +537,17 @@ export class A2AThreadRuntimeCore {
|
|
|
510
537
|
// --- Run logic ---
|
|
511
538
|
|
|
512
539
|
private async startRun(userThreadMessage: ThreadMessage): Promise<void> {
|
|
513
|
-
this.
|
|
514
|
-
|
|
515
|
-
// Cancel any in-progress run before starting a new one
|
|
516
|
-
|
|
517
|
-
|
|
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;
|
|
518
551
|
this.abortController = null;
|
|
519
552
|
}
|
|
520
553
|
|
|
@@ -708,7 +741,7 @@ export class A2AThreadRuntimeCore {
|
|
|
708
741
|
const status = taskStateToMessageStatus(event.status.state);
|
|
709
742
|
this.updateAssistantStatus(assistantId, status);
|
|
710
743
|
|
|
711
|
-
this.
|
|
744
|
+
this.publishUpdate();
|
|
712
745
|
}
|
|
713
746
|
|
|
714
747
|
private handleArtifactUpdate(
|
|
@@ -757,7 +790,7 @@ export class A2AThreadRuntimeCore {
|
|
|
757
790
|
);
|
|
758
791
|
}
|
|
759
792
|
|
|
760
|
-
this.
|
|
793
|
+
this.publishUpdate();
|
|
761
794
|
}
|
|
762
795
|
|
|
763
796
|
private handleMessage(assistantId: string, message: A2AMessage) {
|
|
@@ -766,7 +799,7 @@ export class A2AThreadRuntimeCore {
|
|
|
766
799
|
this.applyA2uiMessage(message);
|
|
767
800
|
const content = a2aMessageToContent(message);
|
|
768
801
|
this.updateAssistantContent(assistantId, content);
|
|
769
|
-
this.
|
|
802
|
+
this.publishUpdate();
|
|
770
803
|
}
|
|
771
804
|
|
|
772
805
|
private handleTaskSnapshot(assistantId: string, task: A2ATask) {
|
|
@@ -833,7 +866,7 @@ export class A2AThreadRuntimeCore {
|
|
|
833
866
|
const status = taskStateToMessageStatus(task.status.state);
|
|
834
867
|
this.updateAssistantStatus(assistantId, status);
|
|
835
868
|
|
|
836
|
-
this.
|
|
869
|
+
this.publishUpdate();
|
|
837
870
|
}
|
|
838
871
|
|
|
839
872
|
// --- Message helpers ---
|
|
@@ -856,7 +889,7 @@ export class A2AThreadRuntimeCore {
|
|
|
856
889
|
};
|
|
857
890
|
this.session.addOrUpdateMessage(parentId, assistant);
|
|
858
891
|
this.session.switchToBranch(id);
|
|
859
|
-
this.
|
|
892
|
+
this.publishUpdate();
|
|
860
893
|
return id;
|
|
861
894
|
}
|
|
862
895
|
|
|
@@ -890,16 +923,11 @@ export class A2AThreadRuntimeCore {
|
|
|
890
923
|
private a2uiSurfaceParts(): ThreadAssistantMessagePart[] {
|
|
891
924
|
const parts: ThreadAssistantMessagePart[] = [];
|
|
892
925
|
for (const [surfaceId, surface] of this.a2uiState) {
|
|
893
|
-
const {
|
|
894
|
-
if (!
|
|
926
|
+
const { toolCall } = surfaceToPresentToolCall(surfaceId, surface);
|
|
927
|
+
if (!toolCall) continue;
|
|
895
928
|
parts.push({
|
|
896
929
|
type: "tool-call",
|
|
897
|
-
|
|
898
|
-
toolName: "present",
|
|
899
|
-
args: spec as unknown as ReadonlyJSONObject,
|
|
900
|
-
argsText: JSON.stringify(spec),
|
|
901
|
-
result: {},
|
|
902
|
-
artifact: { a2ui: surfaceToOperations(surface) },
|
|
930
|
+
...toolCall,
|
|
903
931
|
});
|
|
904
932
|
}
|
|
905
933
|
return parts;
|
|
@@ -959,7 +987,7 @@ export class A2AThreadRuntimeCore {
|
|
|
959
987
|
content: this.withA2uiSurfaces(message.content),
|
|
960
988
|
};
|
|
961
989
|
});
|
|
962
|
-
if (touched) this.
|
|
990
|
+
if (touched) this.publishUpdate();
|
|
963
991
|
}
|
|
964
992
|
|
|
965
993
|
private updateAssistantArtifacts(messageId: string) {
|
|
@@ -981,7 +1009,7 @@ export class A2AThreadRuntimeCore {
|
|
|
981
1009
|
},
|
|
982
1010
|
};
|
|
983
1011
|
});
|
|
984
|
-
if (touched) this.
|
|
1012
|
+
if (touched) this.publishUpdate();
|
|
985
1013
|
}
|
|
986
1014
|
|
|
987
1015
|
private updateAssistantStatus(messageId: string, status: MessageStatus) {
|
|
@@ -990,7 +1018,7 @@ export class A2AThreadRuntimeCore {
|
|
|
990
1018
|
return { ...message, status };
|
|
991
1019
|
});
|
|
992
1020
|
if (touched) {
|
|
993
|
-
this.
|
|
1021
|
+
this.publishUpdate();
|
|
994
1022
|
if (this.isPersistableAssistantStatus(status)) {
|
|
995
1023
|
this.persistAssistantHistory(messageId);
|
|
996
1024
|
}
|
|
@@ -1015,7 +1043,7 @@ export class A2AThreadRuntimeCore {
|
|
|
1015
1043
|
|
|
1016
1044
|
private setRunning(running: boolean) {
|
|
1017
1045
|
this.isRunningFlag = running;
|
|
1018
|
-
this.
|
|
1046
|
+
this.publishUpdate();
|
|
1019
1047
|
}
|
|
1020
1048
|
|
|
1021
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
|
|