@assistant-ui/react-a2a 0.2.35 → 0.2.37
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 +45 -48
- package/dist/A2AClient.d.ts.map +1 -1
- package/dist/A2AClient.js +43 -7
- package/dist/A2AClient.js.map +1 -1
- package/dist/A2AThreadRuntimeCore.d.ts +100 -87
- package/dist/A2AThreadRuntimeCore.d.ts.map +1 -1
- package/dist/A2AThreadRuntimeCore.js +218 -20
- package/dist/A2AThreadRuntimeCore.js.map +1 -1
- package/dist/a2aExtras.d.ts +2 -5
- package/dist/a2aExtras.d.ts.map +1 -1
- package/dist/conversions.d.ts +26 -26
- package/dist/conversions.d.ts.map +1 -1
- package/dist/conversions.js +24 -2
- package/dist/conversions.js.map +1 -1
- package/dist/hooks.d.ts +3 -7
- package/dist/hooks.d.ts.map +1 -1
- package/dist/index.d.ts +9 -6
- package/dist/index.d.ts.map +1 -0
- package/dist/types.d.ts +276 -279
- package/dist/types.d.ts.map +1 -1
- package/dist/useA2ARuntime.d.ts +3 -6
- package/dist/useA2ARuntime.d.ts.map +1 -1
- package/dist/useA2ARuntime.js +8 -0
- package/dist/useA2ARuntime.js.map +1 -1
- package/package.json +12 -10
- package/src/A2AClient.test.ts +41 -0
- package/src/A2AClient.ts +50 -6
- package/src/A2AThreadRuntimeCore.test.ts +1134 -10
- package/src/A2AThreadRuntimeCore.ts +372 -21
- package/src/conversions.test.ts +116 -0
- package/src/conversions.ts +48 -1
- package/src/useA2ARuntime.test.tsx +336 -1
- package/src/useA2ARuntime.ts +14 -0
- package/src/useA2ARuntime.voice.test.tsx +370 -0
package/src/conversions.test.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
a2aPartToContent,
|
|
4
4
|
a2aPartsToContent,
|
|
5
5
|
a2aMessageToContent,
|
|
6
|
+
isA2uiDataPart,
|
|
6
7
|
taskStateToMessageStatus,
|
|
7
8
|
contentPartsToA2AParts,
|
|
8
9
|
isTerminalTaskState,
|
|
@@ -157,6 +158,82 @@ describe("a2aPartToContent", () => {
|
|
|
157
158
|
});
|
|
158
159
|
});
|
|
159
160
|
|
|
161
|
+
describe("A2UI data parts", () => {
|
|
162
|
+
it.each([
|
|
163
|
+
[
|
|
164
|
+
"media type",
|
|
165
|
+
{
|
|
166
|
+
mediaType: "application/vnd.A2UI+json",
|
|
167
|
+
data: { value: "from media type" },
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
[
|
|
171
|
+
"metadata",
|
|
172
|
+
{
|
|
173
|
+
metadata: { mimeType: "application/a2ui+json" },
|
|
174
|
+
data: { value: "from metadata" },
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
[
|
|
178
|
+
"metadata media type",
|
|
179
|
+
{
|
|
180
|
+
metadata: { mediaType: "application/a2ui+json" },
|
|
181
|
+
data: { value: "from metadata media type" },
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
[
|
|
185
|
+
"operation shape",
|
|
186
|
+
{
|
|
187
|
+
data: {
|
|
188
|
+
version: "v0.9",
|
|
189
|
+
createSurface: { surfaceId: "surface" },
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
[
|
|
194
|
+
"operation array",
|
|
195
|
+
{
|
|
196
|
+
data: [
|
|
197
|
+
{
|
|
198
|
+
version: "v0.9",
|
|
199
|
+
createSurface: { surfaceId: "surface" },
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
version: "v0.9",
|
|
203
|
+
deleteSurface: { surfaceId: "surface" },
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
] as const)("detects A2UI by %s", (_source, part) => {
|
|
209
|
+
expect(isA2uiDataPart(part)).toBe(true);
|
|
210
|
+
expect(a2aPartsToContent([part])).toEqual([]);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("does not detect an operation array containing a non-operation", () => {
|
|
214
|
+
const part = {
|
|
215
|
+
data: [
|
|
216
|
+
{
|
|
217
|
+
version: "v0.9",
|
|
218
|
+
createSurface: { surfaceId: "surface" },
|
|
219
|
+
},
|
|
220
|
+
{ value: "not an operation" },
|
|
221
|
+
],
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
expect(isA2uiDataPart(part)).toBe(false);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("keeps ordinary data parts as JSON text", () => {
|
|
228
|
+
expect(a2aPartsToContent([{ data: { value: "plain" } }])).toEqual([
|
|
229
|
+
{
|
|
230
|
+
type: "text",
|
|
231
|
+
text: '{\n "value": "plain"\n}',
|
|
232
|
+
},
|
|
233
|
+
]);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
160
237
|
describe("inbound file part round trip", () => {
|
|
161
238
|
const restoreFilePart = (part: A2APart) => {
|
|
162
239
|
const restored = a2aPartToContent(part);
|
|
@@ -690,4 +767,43 @@ describe("threadMessageToA2AMessage", () => {
|
|
|
690
767
|
expect(result.contextId).toBeUndefined();
|
|
691
768
|
expect(result.taskId).toBeUndefined();
|
|
692
769
|
});
|
|
770
|
+
|
|
771
|
+
it("keeps tool interactions out of outbound messages", () => {
|
|
772
|
+
const result = threadMessageToA2AMessage({
|
|
773
|
+
...userMessage,
|
|
774
|
+
role: "user",
|
|
775
|
+
attachments: [],
|
|
776
|
+
content: [
|
|
777
|
+
{ type: "text", text: "hello" },
|
|
778
|
+
{
|
|
779
|
+
type: "file",
|
|
780
|
+
data: "ZmlsZQ==",
|
|
781
|
+
mimeType: "text/plain",
|
|
782
|
+
filename: "file.txt",
|
|
783
|
+
},
|
|
784
|
+
{
|
|
785
|
+
type: "tool-call",
|
|
786
|
+
toolCallId: "tool-1",
|
|
787
|
+
toolName: "present",
|
|
788
|
+
args: {},
|
|
789
|
+
argsText: "{}",
|
|
790
|
+
result: {},
|
|
791
|
+
unstable_interactions: {
|
|
792
|
+
entries: [
|
|
793
|
+
{
|
|
794
|
+
type: "action",
|
|
795
|
+
occurredAt: 1_700_000_000_000,
|
|
796
|
+
payload: { value: "selected" },
|
|
797
|
+
},
|
|
798
|
+
],
|
|
799
|
+
},
|
|
800
|
+
},
|
|
801
|
+
],
|
|
802
|
+
} as any);
|
|
803
|
+
|
|
804
|
+
expect(result.parts).toEqual([
|
|
805
|
+
{ text: "hello" },
|
|
806
|
+
{ raw: "ZmlsZQ==", mediaType: "text/plain", filename: "file.txt" },
|
|
807
|
+
]);
|
|
808
|
+
});
|
|
693
809
|
});
|
package/src/conversions.ts
CHANGED
|
@@ -15,6 +15,51 @@ function isImageMediaType(mediaType?: string): boolean {
|
|
|
15
15
|
return !!mediaType && mediaType.startsWith("image/");
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
const A2UI_OPERATION_KEYS = [
|
|
19
|
+
"createSurface",
|
|
20
|
+
"updateComponents",
|
|
21
|
+
"updateDataModel",
|
|
22
|
+
"deleteSurface",
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
25
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
26
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
27
|
+
|
|
28
|
+
const isA2uiOperation = (value: unknown): boolean =>
|
|
29
|
+
isRecord(value) &&
|
|
30
|
+
typeof value.version === "string" &&
|
|
31
|
+
A2UI_OPERATION_KEYS.some((key) => Object.hasOwn(value, key));
|
|
32
|
+
|
|
33
|
+
const hasA2uiMediaType = (value: unknown): boolean =>
|
|
34
|
+
typeof value === "string" && value.toLowerCase().includes("a2ui");
|
|
35
|
+
|
|
36
|
+
export function isA2uiDataPart(part: A2APart): boolean {
|
|
37
|
+
if (part.data === undefined) return false;
|
|
38
|
+
|
|
39
|
+
const metadata = isRecord(part.metadata) ? part.metadata : {};
|
|
40
|
+
return (
|
|
41
|
+
hasA2uiMediaType(part.mediaType) ||
|
|
42
|
+
hasA2uiMediaType(metadata.mimeType) ||
|
|
43
|
+
hasA2uiMediaType(metadata.mediaType) ||
|
|
44
|
+
isA2uiOperation(part.data) ||
|
|
45
|
+
(Array.isArray(part.data) &&
|
|
46
|
+
part.data.length > 0 &&
|
|
47
|
+
part.data.every(isA2uiOperation))
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function a2uiPartToOperations(part: A2APart): readonly unknown[] {
|
|
52
|
+
if (!isA2uiDataPart(part)) return [];
|
|
53
|
+
return Array.isArray(part.data) ? part.data : [part.data];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function a2uiPartsToOperations(
|
|
57
|
+
parts: readonly A2APart[],
|
|
58
|
+
): readonly unknown[] {
|
|
59
|
+
if (!Array.isArray(parts)) return [];
|
|
60
|
+
return parts.flatMap(a2uiPartToOperations);
|
|
61
|
+
}
|
|
62
|
+
|
|
18
63
|
export function a2aPartToContent(
|
|
19
64
|
part: A2APart,
|
|
20
65
|
): ThreadAssistantMessage["content"][number] {
|
|
@@ -61,7 +106,9 @@ export function a2aPartToContent(
|
|
|
61
106
|
export function a2aPartsToContent(
|
|
62
107
|
parts: A2APart[],
|
|
63
108
|
): ThreadAssistantMessage["content"] {
|
|
64
|
-
return (Array.isArray(parts) ? parts : [])
|
|
109
|
+
return (Array.isArray(parts) ? parts : [])
|
|
110
|
+
.filter((part) => !isA2uiDataPart(part))
|
|
111
|
+
.map(a2aPartToContent);
|
|
65
112
|
}
|
|
66
113
|
|
|
67
114
|
const TERMINAL_STATES = new Set<A2ATaskState>([
|
|
@@ -8,7 +8,10 @@ import {
|
|
|
8
8
|
type PropsWithChildren,
|
|
9
9
|
} from "react";
|
|
10
10
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
11
|
-
import type {
|
|
11
|
+
import type {
|
|
12
|
+
ExportedMessageRepository,
|
|
13
|
+
ThreadMessage,
|
|
14
|
+
} from "@assistant-ui/core";
|
|
12
15
|
import type { A2AClient } from "./A2AClient";
|
|
13
16
|
import type { A2AStreamEvent } from "./types";
|
|
14
17
|
import { useA2ARuntime } from "./useA2ARuntime";
|
|
@@ -357,6 +360,338 @@ describe("useA2ARuntime", () => {
|
|
|
357
360
|
]);
|
|
358
361
|
});
|
|
359
362
|
|
|
363
|
+
it("does not keep the previous thread as a sibling branch after a switch", async () => {
|
|
364
|
+
const { client } = createMockClient();
|
|
365
|
+
let resolveNext!: (value: { messages: ThreadMessage[] }) => void;
|
|
366
|
+
let pending = new Promise<{ messages: ThreadMessage[] }>((resolve) => {
|
|
367
|
+
resolveNext = resolve;
|
|
368
|
+
});
|
|
369
|
+
const { result } = renderHook(() => {
|
|
370
|
+
const [threadId, setThreadId] = useState("initial");
|
|
371
|
+
return useA2ARuntime({
|
|
372
|
+
client,
|
|
373
|
+
adapters: {
|
|
374
|
+
threadList: {
|
|
375
|
+
threadId,
|
|
376
|
+
onSwitchToThread: async (nextThreadId) => {
|
|
377
|
+
setThreadId(nextThreadId);
|
|
378
|
+
return pending;
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
await act(async () => {
|
|
386
|
+
const switchA = result.current.threads.switchToThread("thread-a");
|
|
387
|
+
resolveNext({ messages: [createThreadMessage("thread-a")] });
|
|
388
|
+
await switchA;
|
|
389
|
+
});
|
|
390
|
+
expect(
|
|
391
|
+
result.current.thread.export().messages.map((m) => m.message.id),
|
|
392
|
+
).toEqual(["thread-a"]);
|
|
393
|
+
|
|
394
|
+
pending = new Promise<{ messages: ThreadMessage[] }>((resolve) => {
|
|
395
|
+
resolveNext = resolve;
|
|
396
|
+
});
|
|
397
|
+
let switchB!: Promise<void>;
|
|
398
|
+
act(() => {
|
|
399
|
+
switchB = result.current.threads.switchToThread("thread-b");
|
|
400
|
+
});
|
|
401
|
+
expect(result.current.thread.export().messages).toEqual([]);
|
|
402
|
+
|
|
403
|
+
await act(async () => {
|
|
404
|
+
resolveNext({ messages: [createThreadMessage("thread-b")] });
|
|
405
|
+
await switchB;
|
|
406
|
+
});
|
|
407
|
+
expect(
|
|
408
|
+
result.current.thread.export().messages.map((m) => m.message.id),
|
|
409
|
+
).toEqual(["thread-b"]);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("does not keep the previous thread as a sibling branch after switching to a new thread", async () => {
|
|
413
|
+
const { client, streamMessage } = createMockClient(true);
|
|
414
|
+
let resolveNext!: (value: { messages: ThreadMessage[] }) => void;
|
|
415
|
+
let pending = new Promise<{ messages: ThreadMessage[] }>((resolve) => {
|
|
416
|
+
resolveNext = resolve;
|
|
417
|
+
});
|
|
418
|
+
let resolveNew!: () => void;
|
|
419
|
+
const pendingNew = new Promise<void>((resolve) => {
|
|
420
|
+
resolveNew = resolve;
|
|
421
|
+
});
|
|
422
|
+
const { result } = renderHook(() => {
|
|
423
|
+
const [threadId, setThreadId] = useState("initial");
|
|
424
|
+
return useA2ARuntime({
|
|
425
|
+
client,
|
|
426
|
+
adapters: {
|
|
427
|
+
threadList: {
|
|
428
|
+
threadId,
|
|
429
|
+
onSwitchToThread: async (nextThreadId) => {
|
|
430
|
+
setThreadId(nextThreadId);
|
|
431
|
+
return pending;
|
|
432
|
+
},
|
|
433
|
+
onSwitchToNewThread: async () => {
|
|
434
|
+
setThreadId("thread-new");
|
|
435
|
+
await pendingNew;
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
},
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
await act(async () => {
|
|
443
|
+
const switchA = result.current.threads.switchToThread("thread-a");
|
|
444
|
+
resolveNext({ messages: [createThreadMessage("thread-a")] });
|
|
445
|
+
await switchA;
|
|
446
|
+
});
|
|
447
|
+
expect(
|
|
448
|
+
result.current.thread.export().messages.map((m) => m.message.id),
|
|
449
|
+
).toEqual(["thread-a"]);
|
|
450
|
+
|
|
451
|
+
act(() => {
|
|
452
|
+
void result.current.thread.append("still running");
|
|
453
|
+
});
|
|
454
|
+
await waitFor(() => expect(streamMessage).toHaveBeenCalledOnce());
|
|
455
|
+
|
|
456
|
+
let switchNew!: Promise<void>;
|
|
457
|
+
act(() => {
|
|
458
|
+
switchNew = result.current.threads.switchToNewThread();
|
|
459
|
+
});
|
|
460
|
+
expect(result.current.thread.export().messages).toEqual([]);
|
|
461
|
+
|
|
462
|
+
await act(async () => {
|
|
463
|
+
resolveNew();
|
|
464
|
+
await switchNew;
|
|
465
|
+
});
|
|
466
|
+
expect(result.current.thread.export().messages).toEqual([]);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it("leaves a new thread empty after an active run and failed creation", async () => {
|
|
470
|
+
const { client, streamMessage } = createMockClient(true);
|
|
471
|
+
let rejectNew!: (error: Error) => void;
|
|
472
|
+
const pendingNew = new Promise<void>((_, reject) => {
|
|
473
|
+
rejectNew = reject;
|
|
474
|
+
});
|
|
475
|
+
const { result } = renderHook(() => {
|
|
476
|
+
const [threadId, setThreadId] = useState("initial");
|
|
477
|
+
return useA2ARuntime({
|
|
478
|
+
client,
|
|
479
|
+
adapters: {
|
|
480
|
+
threadList: {
|
|
481
|
+
threadId,
|
|
482
|
+
onSwitchToNewThread: async () => {
|
|
483
|
+
setThreadId("thread-new");
|
|
484
|
+
await pendingNew;
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
act(() => {
|
|
492
|
+
void result.current.thread.append("still running");
|
|
493
|
+
});
|
|
494
|
+
await waitFor(() => expect(streamMessage).toHaveBeenCalledOnce());
|
|
495
|
+
|
|
496
|
+
let switchNew!: Promise<void>;
|
|
497
|
+
act(() => {
|
|
498
|
+
switchNew = result.current.threads.switchToNewThread();
|
|
499
|
+
});
|
|
500
|
+
await waitFor(() =>
|
|
501
|
+
expect(result.current.threads.getState().mainThreadId).toBe("thread-new"),
|
|
502
|
+
);
|
|
503
|
+
rejectNew(new Error("create failed"));
|
|
504
|
+
await expect(switchNew).rejects.toThrow("create failed");
|
|
505
|
+
|
|
506
|
+
expect(result.current.thread.export()).toEqual({
|
|
507
|
+
headId: null,
|
|
508
|
+
messages: [],
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("does not clear a newer thread when an older creation finishes", async () => {
|
|
513
|
+
const { client } = createMockClient();
|
|
514
|
+
let resolveNew!: () => void;
|
|
515
|
+
const pendingNew = new Promise<void>((resolve) => {
|
|
516
|
+
resolveNew = resolve;
|
|
517
|
+
});
|
|
518
|
+
const { result } = renderHook(() => {
|
|
519
|
+
const [threadId, setThreadId] = useState("initial");
|
|
520
|
+
return useA2ARuntime({
|
|
521
|
+
client,
|
|
522
|
+
adapters: {
|
|
523
|
+
threadList: {
|
|
524
|
+
threadId,
|
|
525
|
+
onSwitchToThread: async (nextThreadId) => {
|
|
526
|
+
setThreadId(nextThreadId);
|
|
527
|
+
return { messages: [createThreadMessage(nextThreadId)] };
|
|
528
|
+
},
|
|
529
|
+
onSwitchToNewThread: async () => {
|
|
530
|
+
setThreadId("thread-new");
|
|
531
|
+
await pendingNew;
|
|
532
|
+
},
|
|
533
|
+
},
|
|
534
|
+
},
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
let switchNew!: Promise<void>;
|
|
539
|
+
act(() => {
|
|
540
|
+
switchNew = result.current.threads.switchToNewThread();
|
|
541
|
+
});
|
|
542
|
+
await act(async () => {
|
|
543
|
+
await result.current.threads.switchToThread("thread-b");
|
|
544
|
+
});
|
|
545
|
+
await act(async () => {
|
|
546
|
+
resolveNew();
|
|
547
|
+
await switchNew;
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
expect(result.current.threads.getState().mainThreadId).toBe("thread-b");
|
|
551
|
+
expect(result.current.thread.getState().messages.map((m) => m.id)).toEqual([
|
|
552
|
+
"thread-b",
|
|
553
|
+
]);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it("does not restore initial history next to the switched thread", async () => {
|
|
557
|
+
const { client } = createMockClient();
|
|
558
|
+
let resolveHistory!: (repo: ExportedMessageRepository) => void;
|
|
559
|
+
const pendingHistory = new Promise<ExportedMessageRepository>((resolve) => {
|
|
560
|
+
resolveHistory = resolve;
|
|
561
|
+
});
|
|
562
|
+
let resolveSwitch!: (value: { messages: ThreadMessage[] }) => void;
|
|
563
|
+
const pendingSwitch = new Promise<{ messages: ThreadMessage[] }>(
|
|
564
|
+
(resolve) => {
|
|
565
|
+
resolveSwitch = resolve;
|
|
566
|
+
},
|
|
567
|
+
);
|
|
568
|
+
const { result } = renderHook(() => {
|
|
569
|
+
const [threadId, setThreadId] = useState("initial");
|
|
570
|
+
return useA2ARuntime({
|
|
571
|
+
client,
|
|
572
|
+
adapters: {
|
|
573
|
+
history: { load: () => pendingHistory, append: async () => {} },
|
|
574
|
+
threadList: {
|
|
575
|
+
threadId,
|
|
576
|
+
onSwitchToThread: async (nextThreadId) => {
|
|
577
|
+
setThreadId(nextThreadId);
|
|
578
|
+
return pendingSwitch;
|
|
579
|
+
},
|
|
580
|
+
},
|
|
581
|
+
},
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
let switchB!: Promise<void>;
|
|
586
|
+
act(() => {
|
|
587
|
+
switchB = result.current.threads.switchToThread("thread-b");
|
|
588
|
+
});
|
|
589
|
+
await act(async () => {
|
|
590
|
+
resolveHistory({
|
|
591
|
+
headId: "history-a",
|
|
592
|
+
messages: [
|
|
593
|
+
{ parentId: null, message: createThreadMessage("history-a") },
|
|
594
|
+
],
|
|
595
|
+
});
|
|
596
|
+
await pendingHistory;
|
|
597
|
+
});
|
|
598
|
+
await act(async () => {
|
|
599
|
+
resolveSwitch({ messages: [createThreadMessage("thread-b")] });
|
|
600
|
+
await switchB;
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
expect(
|
|
604
|
+
result.current.thread.export().messages.map((m) => m.message.id),
|
|
605
|
+
).toEqual(["thread-b"]);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it("keeps pending history when a run is cancelled in the same thread", async () => {
|
|
609
|
+
const { client, streamMessage } = createMockClient(true);
|
|
610
|
+
let resolve!: (repo: ExportedMessageRepository) => void;
|
|
611
|
+
const pending = new Promise<ExportedMessageRepository>((res) => {
|
|
612
|
+
resolve = res;
|
|
613
|
+
});
|
|
614
|
+
const { result } = renderHook(() =>
|
|
615
|
+
useA2ARuntime({
|
|
616
|
+
client,
|
|
617
|
+
adapters: { history: { load: () => pending, append: async () => {} } },
|
|
618
|
+
}),
|
|
619
|
+
);
|
|
620
|
+
act(() => {
|
|
621
|
+
result.current.thread.append("Hello");
|
|
622
|
+
});
|
|
623
|
+
await waitFor(() => expect(streamMessage).toHaveBeenCalledOnce());
|
|
624
|
+
await act(async () => {
|
|
625
|
+
result.current.thread.cancelRun();
|
|
626
|
+
await new Promise((done) => setTimeout(done, 0));
|
|
627
|
+
});
|
|
628
|
+
const wasLoading = result.current.thread.getState().isLoading;
|
|
629
|
+
const restored = createThreadMessage("restored");
|
|
630
|
+
await act(async () => {
|
|
631
|
+
resolve({
|
|
632
|
+
headId: restored.id,
|
|
633
|
+
messages: [{ parentId: null, message: restored }],
|
|
634
|
+
});
|
|
635
|
+
await pending;
|
|
636
|
+
});
|
|
637
|
+
expect(wasLoading).toBe(true);
|
|
638
|
+
expect(result.current.thread.getState().messages).toEqual([restored]);
|
|
639
|
+
expect(result.current.thread.getState().isLoading).toBe(false);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it.each(["existing", "new"])(
|
|
643
|
+
"keeps the selected %s thread when initial history finishes later",
|
|
644
|
+
async (target) => {
|
|
645
|
+
const { client } = createMockClient();
|
|
646
|
+
let resolveHistory!: (repo: ExportedMessageRepository) => void;
|
|
647
|
+
const pending = new Promise<ExportedMessageRepository>((resolve) => {
|
|
648
|
+
resolveHistory = resolve;
|
|
649
|
+
});
|
|
650
|
+
const history = { load: () => pending, append: async () => {} };
|
|
651
|
+
const { result } = renderHook(() => {
|
|
652
|
+
const [threadId, setThreadId] = useState("thread-a");
|
|
653
|
+
return useA2ARuntime({
|
|
654
|
+
client,
|
|
655
|
+
adapters: {
|
|
656
|
+
history,
|
|
657
|
+
threadList: {
|
|
658
|
+
threadId,
|
|
659
|
+
onSwitchToThread: async (id) => {
|
|
660
|
+
setThreadId(id);
|
|
661
|
+
return { messages: [createThreadMessage("message-b")] };
|
|
662
|
+
},
|
|
663
|
+
onSwitchToNewThread: async () => {
|
|
664
|
+
setThreadId("thread-new");
|
|
665
|
+
},
|
|
666
|
+
},
|
|
667
|
+
},
|
|
668
|
+
});
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
await act(async () => {
|
|
672
|
+
if (target === "existing")
|
|
673
|
+
await result.current.threads.switchToThread("thread-b");
|
|
674
|
+
else await result.current.threads.switchToNewThread();
|
|
675
|
+
});
|
|
676
|
+
const selectedId = result.current.threads.getState().mainThreadId;
|
|
677
|
+
const selectedMessages = result.current.thread.getState().messages;
|
|
678
|
+
await act(async () => {
|
|
679
|
+
resolveHistory({
|
|
680
|
+
headId: "message-a",
|
|
681
|
+
messages: [
|
|
682
|
+
{ parentId: null, message: createThreadMessage("message-a") },
|
|
683
|
+
],
|
|
684
|
+
});
|
|
685
|
+
await pending;
|
|
686
|
+
});
|
|
687
|
+
expect(result.current.threads.getState().mainThreadId).toBe(selectedId);
|
|
688
|
+
expect(result.current.thread.getState().messages).toEqual(
|
|
689
|
+
selectedMessages,
|
|
690
|
+
);
|
|
691
|
+
expect(result.current.thread.getState().isLoading).toBe(false);
|
|
692
|
+
},
|
|
693
|
+
);
|
|
694
|
+
|
|
360
695
|
it("ignores a thread load superseded by a new thread", async () => {
|
|
361
696
|
const { client } = createMockClient();
|
|
362
697
|
let resolveLoad!: (value: { messages: ThreadMessage[] }) => void;
|
package/src/useA2ARuntime.ts
CHANGED
|
@@ -121,6 +121,10 @@ export function useA2ARuntime(options: UseA2ARuntimeOptions): AssistantRuntime {
|
|
|
121
121
|
onSwitchToNewThread: onSwitchToNewThread
|
|
122
122
|
? async () => {
|
|
123
123
|
const generation = ++threadSwitchGenerationRef.current;
|
|
124
|
+
// Clear before the thread id flips, or the old messages leak
|
|
125
|
+
// into the new thread as a sibling branch.
|
|
126
|
+
core.applyExternalMessages([]);
|
|
127
|
+
core.resetContext();
|
|
124
128
|
await onSwitchToNewThread();
|
|
125
129
|
if (generation !== threadSwitchGenerationRef.current) return;
|
|
126
130
|
// Apply first so the abort inside resetContext finds an already
|
|
@@ -133,8 +137,13 @@ export function useA2ARuntime(options: UseA2ARuntimeOptions): AssistantRuntime {
|
|
|
133
137
|
onSwitchToThread: onSwitchToThread
|
|
134
138
|
? async (threadId: string) => {
|
|
135
139
|
const generation = ++threadSwitchGenerationRef.current;
|
|
140
|
+
// Clear before the thread id flips, or the old messages leak
|
|
141
|
+
// into the new thread as a sibling branch.
|
|
142
|
+
core.applyExternalMessages([]);
|
|
143
|
+
core.resetContext();
|
|
136
144
|
const result = await onSwitchToThread(threadId);
|
|
137
145
|
if (generation !== threadSwitchGenerationRef.current) return;
|
|
146
|
+
core.applyExternalMessages([]);
|
|
138
147
|
core.applyExternalMessages(result.messages);
|
|
139
148
|
core.resetContext();
|
|
140
149
|
}
|
|
@@ -166,15 +175,20 @@ export function useA2ARuntime(options: UseA2ARuntimeOptions): AssistantRuntime {
|
|
|
166
175
|
isLoading: core.isLoading,
|
|
167
176
|
messageRepository: core.getMessageRepository(),
|
|
168
177
|
isRunning: core.isRunning(),
|
|
178
|
+
unstable_persistsHistory: true,
|
|
169
179
|
extras: a2aExtras.provide({
|
|
170
180
|
task: core.getTask(),
|
|
171
181
|
artifacts: core.getArtifacts(),
|
|
172
182
|
agentCard: core.getAgentCard(),
|
|
173
183
|
}),
|
|
174
184
|
onNew: (message: AppendMessage) => core.append(message),
|
|
185
|
+
onVoiceTranscript: (message: ThreadMessage) =>
|
|
186
|
+
core.appendVoiceTranscript(message),
|
|
175
187
|
onEdit: (message: AppendMessage) => core.edit(message),
|
|
176
188
|
onReload: (parentId: string | null) => core.reload(parentId),
|
|
177
189
|
onCancel: () => core.cancel(),
|
|
190
|
+
unstable_onRecordToolInteraction: (options) =>
|
|
191
|
+
core.recordToolInteraction(options),
|
|
178
192
|
setMessages: (messages: readonly ThreadMessage[]) =>
|
|
179
193
|
core.applyExternalMessages(messages),
|
|
180
194
|
onImport: (messages: readonly ThreadMessage[]) =>
|