@assistant-ui/react-google-adk 0.0.32 → 0.0.34
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/LICENSE +1 -1
- package/dist/AdkEventAccumulator.d.ts.map +1 -1
- package/dist/AdkEventAccumulator.js +4 -1
- package/dist/AdkEventAccumulator.js.map +1 -1
- package/dist/adkToolApproval.js +1 -1
- package/dist/adkToolApproval.js.map +1 -1
- package/dist/contentToParts.d.ts.map +1 -1
- package/dist/contentToParts.js +1 -0
- package/dist/contentToParts.js.map +1 -1
- package/dist/convertAdkMessages.d.ts.map +1 -1
- package/dist/convertAdkMessages.js +34 -19
- package/dist/convertAdkMessages.js.map +1 -1
- package/dist/convertToAdkMessages.d.ts.map +1 -1
- package/dist/convertToAdkMessages.js +2 -2
- package/dist/convertToAdkMessages.js.map +1 -1
- package/dist/sdkIdentity.js +1 -1
- package/dist/useAdkMessages.d.ts.map +1 -1
- package/dist/useAdkMessages.js +29 -11
- package/dist/useAdkMessages.js.map +1 -1
- package/dist/useAdkRuntime.d.ts.map +1 -1
- package/dist/useAdkRuntime.js +5 -4
- package/dist/useAdkRuntime.js.map +1 -1
- package/package.json +9 -6
- package/src/AdkEventAccumulator.test.ts +32 -0
- package/src/AdkEventAccumulator.ts +1 -0
- package/src/adkToolApproval.test.ts +19 -0
- package/src/adkToolApproval.ts +1 -1
- package/src/contentToParts.ts +2 -0
- package/src/convertAdkMessages.test.ts +142 -0
- package/src/convertAdkMessages.ts +24 -1
- package/src/convertToAdkMessages.test.ts +20 -0
- package/src/convertToAdkMessages.ts +4 -1
- package/src/tests/settleOutsideAct.ts +15 -0
- package/src/useAdkMessages.fast-refresh.test.tsx +114 -0
- package/src/useAdkMessages.test.ts +48 -0
- package/src/useAdkMessages.ts +40 -9
- package/src/useAdkRuntime.cancellation.test.tsx +544 -0
- package/src/useAdkRuntime.fast-refresh.test.tsx +166 -0
- package/src/useAdkRuntime.refetch.test.tsx +8 -6
- package/src/useAdkRuntime.replacement.test.tsx +4 -3
- package/src/useAdkRuntime.toolOwnership.test.tsx +4 -3
- package/src/useAdkRuntime.ts +4 -4
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { convertExternalMessages } from "@assistant-ui/core/react";
|
|
2
3
|
import { convertAdkMessage } from "./convertAdkMessages";
|
|
3
4
|
import type { AdkMessage } from "./types";
|
|
4
5
|
|
|
@@ -27,6 +28,47 @@ describe("convertAdkMessage - human messages", () => {
|
|
|
27
28
|
}
|
|
28
29
|
});
|
|
29
30
|
|
|
31
|
+
it("skips media parts without their data, url, or mime type", () => {
|
|
32
|
+
const msg = {
|
|
33
|
+
id: "m1",
|
|
34
|
+
type: "human",
|
|
35
|
+
content: [
|
|
36
|
+
{ type: "image", mimeType: "image/png" },
|
|
37
|
+
{ type: "image", data: "iVBORw0KGgo=" },
|
|
38
|
+
{ type: "image_url" },
|
|
39
|
+
{ type: "file", mimeType: "application/pdf" },
|
|
40
|
+
{ type: "file", data: "QUJD" },
|
|
41
|
+
{ type: "file_url", mimeType: "application/pdf" },
|
|
42
|
+
{ type: "text", text: "Hello" },
|
|
43
|
+
],
|
|
44
|
+
} as AdkMessage;
|
|
45
|
+
expect(convertAdkMessage(msg, {})).toMatchObject({
|
|
46
|
+
role: "user",
|
|
47
|
+
content: [{ type: "text", text: "Hello" }],
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("skips a reasoning part on a human message", () => {
|
|
52
|
+
const messages = convertExternalMessages<AdkMessage>(
|
|
53
|
+
[
|
|
54
|
+
{
|
|
55
|
+
id: "m1",
|
|
56
|
+
type: "human",
|
|
57
|
+
content: [
|
|
58
|
+
{ type: "reasoning", text: "thinking" },
|
|
59
|
+
{ type: "text", text: "Hello" },
|
|
60
|
+
],
|
|
61
|
+
} satisfies AdkMessage,
|
|
62
|
+
],
|
|
63
|
+
(message) => convertAdkMessage(message, {}),
|
|
64
|
+
false,
|
|
65
|
+
{},
|
|
66
|
+
);
|
|
67
|
+
expect(messages).toMatchObject([
|
|
68
|
+
{ role: "user", content: [{ type: "text", text: "Hello" }] },
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
|
|
30
72
|
it("converts a human message with text content parts", () => {
|
|
31
73
|
const msg: AdkMessage = {
|
|
32
74
|
id: "m1",
|
|
@@ -40,6 +82,19 @@ describe("convertAdkMessage - human messages", () => {
|
|
|
40
82
|
});
|
|
41
83
|
});
|
|
42
84
|
|
|
85
|
+
it("preserves activity messages as visible text", () => {
|
|
86
|
+
const msg: AdkMessage = {
|
|
87
|
+
id: "m1",
|
|
88
|
+
type: "human",
|
|
89
|
+
content: [{ type: "activity", message: "Working on it" }],
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
expect(convertAdkMessage(msg, {})).toMatchObject({
|
|
93
|
+
role: "user",
|
|
94
|
+
content: [{ type: "text", text: "Working on it" }],
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
43
98
|
it("restores a file_url part as a file part stamped with sourceType url", () => {
|
|
44
99
|
const msg: AdkMessage = {
|
|
45
100
|
id: "m1",
|
|
@@ -341,6 +396,76 @@ describe("convertAdkMessage - ai messages", () => {
|
|
|
341
396
|
});
|
|
342
397
|
});
|
|
343
398
|
|
|
399
|
+
it("skips null entries in tool_calls", () => {
|
|
400
|
+
const msg = {
|
|
401
|
+
id: "m1",
|
|
402
|
+
type: "ai",
|
|
403
|
+
content: [],
|
|
404
|
+
tool_calls: [null, { id: "tc-1", name: "search", args: {} }],
|
|
405
|
+
} as unknown as AdkMessage;
|
|
406
|
+
expect(convertAdkMessage(msg, {})).toMatchObject({
|
|
407
|
+
content: [{ type: "tool-call", toolCallId: "tc-1", toolName: "search" }],
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("skips a tool call without a name, so its named result does not break the thread", () => {
|
|
412
|
+
const messages = convertExternalMessages<AdkMessage>(
|
|
413
|
+
[
|
|
414
|
+
{
|
|
415
|
+
id: "m1",
|
|
416
|
+
type: "ai",
|
|
417
|
+
content: "Checking",
|
|
418
|
+
tool_calls: [{ id: "tc-1", args: {} }],
|
|
419
|
+
} as unknown as AdkMessage,
|
|
420
|
+
{
|
|
421
|
+
id: "m2",
|
|
422
|
+
type: "tool",
|
|
423
|
+
tool_call_id: "tc-1",
|
|
424
|
+
name: "search",
|
|
425
|
+
content: "{}",
|
|
426
|
+
},
|
|
427
|
+
],
|
|
428
|
+
(message) => convertAdkMessage(message, {}),
|
|
429
|
+
false,
|
|
430
|
+
{},
|
|
431
|
+
);
|
|
432
|
+
expect(messages).toMatchObject([
|
|
433
|
+
{ role: "assistant", content: [{ type: "text", text: "Checking" }] },
|
|
434
|
+
]);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it.each([
|
|
438
|
+
["an empty", ""],
|
|
439
|
+
["a non-string", 42],
|
|
440
|
+
])(
|
|
441
|
+
"skips a tool call with %s name, so its named result does not break the thread",
|
|
442
|
+
(_, name) => {
|
|
443
|
+
const messages = convertExternalMessages<AdkMessage>(
|
|
444
|
+
[
|
|
445
|
+
{
|
|
446
|
+
id: "m1",
|
|
447
|
+
type: "ai",
|
|
448
|
+
content: "Checking",
|
|
449
|
+
tool_calls: [{ id: "tc-1", name, args: {} }],
|
|
450
|
+
} as AdkMessage,
|
|
451
|
+
{
|
|
452
|
+
id: "m2",
|
|
453
|
+
type: "tool",
|
|
454
|
+
tool_call_id: "tc-1",
|
|
455
|
+
name: "search",
|
|
456
|
+
content: "{}",
|
|
457
|
+
},
|
|
458
|
+
],
|
|
459
|
+
(message) => convertAdkMessage(message, {}),
|
|
460
|
+
false,
|
|
461
|
+
{},
|
|
462
|
+
);
|
|
463
|
+
expect(messages).toMatchObject([
|
|
464
|
+
{ role: "assistant", content: [{ type: "text", text: "Checking" }] },
|
|
465
|
+
]);
|
|
466
|
+
},
|
|
467
|
+
);
|
|
468
|
+
|
|
344
469
|
it("includes status when present", () => {
|
|
345
470
|
const msg: AdkMessage = {
|
|
346
471
|
id: "m1",
|
|
@@ -414,3 +539,20 @@ describe("convertAdkMessage - tool messages", () => {
|
|
|
414
539
|
expect(result).toMatchObject({ isError: true });
|
|
415
540
|
});
|
|
416
541
|
});
|
|
542
|
+
|
|
543
|
+
describe("convertAdkMessage - unknown messages", () => {
|
|
544
|
+
it("skips a message without a type", () => {
|
|
545
|
+
const messages = convertExternalMessages<AdkMessage>(
|
|
546
|
+
[
|
|
547
|
+
{ id: "m1", type: "human", content: "Hello" },
|
|
548
|
+
{ id: "m2", content: "no type" } as unknown as AdkMessage,
|
|
549
|
+
],
|
|
550
|
+
(message) => convertAdkMessage(message, {}),
|
|
551
|
+
false,
|
|
552
|
+
{},
|
|
553
|
+
);
|
|
554
|
+
expect(messages).toMatchObject([
|
|
555
|
+
{ role: "user", content: [{ type: "text", text: "Hello" }] },
|
|
556
|
+
]);
|
|
557
|
+
});
|
|
558
|
+
});
|
|
@@ -42,18 +42,30 @@ const contentToParts = (
|
|
|
42
42
|
text: typeof part.text === "string" ? part.text : "",
|
|
43
43
|
};
|
|
44
44
|
case "reasoning":
|
|
45
|
+
if (role === "user") return null;
|
|
45
46
|
return {
|
|
46
47
|
type: "reasoning",
|
|
47
48
|
text: typeof part.text === "string" ? part.text : "",
|
|
48
49
|
};
|
|
49
50
|
case "image":
|
|
51
|
+
if (
|
|
52
|
+
typeof part.mimeType !== "string" ||
|
|
53
|
+
typeof part.data !== "string"
|
|
54
|
+
)
|
|
55
|
+
return null;
|
|
50
56
|
return {
|
|
51
57
|
type: "image",
|
|
52
58
|
image: `data:${part.mimeType};base64,${part.data}`,
|
|
53
59
|
};
|
|
54
60
|
case "image_url":
|
|
61
|
+
if (typeof part.url !== "string") return null;
|
|
55
62
|
return { type: "image", image: part.url };
|
|
56
63
|
case "file":
|
|
64
|
+
if (
|
|
65
|
+
typeof part.mimeType !== "string" ||
|
|
66
|
+
typeof part.data !== "string"
|
|
67
|
+
)
|
|
68
|
+
return null;
|
|
57
69
|
return {
|
|
58
70
|
type: "file",
|
|
59
71
|
data: part.data,
|
|
@@ -61,6 +73,7 @@ const contentToParts = (
|
|
|
61
73
|
...(part.filename != null && { filename: part.filename }),
|
|
62
74
|
};
|
|
63
75
|
case "file_url":
|
|
76
|
+
if (typeof part.url !== "string") return null;
|
|
64
77
|
if (role === "user") {
|
|
65
78
|
return {
|
|
66
79
|
type: "file",
|
|
@@ -89,6 +102,11 @@ const contentToParts = (
|
|
|
89
102
|
name: "code_execution_result",
|
|
90
103
|
data: { output: part.output, outcome: part.outcome },
|
|
91
104
|
};
|
|
105
|
+
case "activity":
|
|
106
|
+
return {
|
|
107
|
+
type: "text",
|
|
108
|
+
text: typeof part.message === "string" ? part.message : "",
|
|
109
|
+
};
|
|
92
110
|
default:
|
|
93
111
|
return null;
|
|
94
112
|
}
|
|
@@ -113,7 +131,8 @@ export const createAdkMessageConverter =
|
|
|
113
131
|
|
|
114
132
|
case "ai": {
|
|
115
133
|
const toolCallParts: ToolCallMessagePart[] =
|
|
116
|
-
message.tool_calls?.
|
|
134
|
+
message.tool_calls?.flatMap((tc) => {
|
|
135
|
+
if (typeof tc?.name !== "string" || tc.name.length === 0) return [];
|
|
117
136
|
const approval = approvals.get(tc.id);
|
|
118
137
|
return {
|
|
119
138
|
type: "tool-call",
|
|
@@ -164,6 +183,10 @@ export const createAdkMessageConverter =
|
|
|
164
183
|
isError: message.status === "error",
|
|
165
184
|
};
|
|
166
185
|
}
|
|
186
|
+
|
|
187
|
+
default:
|
|
188
|
+
message satisfies never;
|
|
189
|
+
return [];
|
|
167
190
|
}
|
|
168
191
|
};
|
|
169
192
|
|
|
@@ -81,6 +81,20 @@ describe("getPendingToolCalls", () => {
|
|
|
81
81
|
});
|
|
82
82
|
|
|
83
83
|
describe("getPendingCancellations", () => {
|
|
84
|
+
it("skips a null tool_calls entry and cancels the rest", () => {
|
|
85
|
+
const messages = [
|
|
86
|
+
{
|
|
87
|
+
id: "ai-1",
|
|
88
|
+
type: "ai",
|
|
89
|
+
content: [],
|
|
90
|
+
tool_calls: [null, { id: "tc-1", name: "tool_a", args: {} }],
|
|
91
|
+
},
|
|
92
|
+
] as unknown as AdkMessage[];
|
|
93
|
+
expect(getPendingCancellations(messages, [])).toMatchObject([
|
|
94
|
+
{ type: "tool", name: "tool_a", tool_call_id: "tc-1", status: "error" },
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
|
|
84
98
|
it("emits a {cancelled:true} tool message for every pending tool call", () => {
|
|
85
99
|
const messages: AdkMessage[] = [
|
|
86
100
|
aiWithToolCalls("ai-1", [{ id: "tc-1", name: "tool_a" }]),
|
|
@@ -156,6 +170,12 @@ describe("getPendingCancellations", () => {
|
|
|
156
170
|
});
|
|
157
171
|
|
|
158
172
|
describe("getMessageContent", () => {
|
|
173
|
+
it("sends activity messages as text parts", () => {
|
|
174
|
+
expect(
|
|
175
|
+
contentToParts([{ type: "activity", message: "Working on it" }]),
|
|
176
|
+
).toEqual([{ text: "Working on it" }]);
|
|
177
|
+
});
|
|
178
|
+
|
|
159
179
|
it("serializes data URL images as inline data", () => {
|
|
160
180
|
const content = getMessageContent(
|
|
161
181
|
makeAppendMessage([
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isRecord } from "@assistant-ui/core/internal";
|
|
1
2
|
import {
|
|
2
3
|
generateId,
|
|
3
4
|
getExternalStoreMessages,
|
|
@@ -94,7 +95,9 @@ export const getPendingToolCalls = (messages: AdkMessage[]) => {
|
|
|
94
95
|
messages,
|
|
95
96
|
(message) => {
|
|
96
97
|
if (message.type === "ai") {
|
|
97
|
-
return {
|
|
98
|
+
return {
|
|
99
|
+
toolCalls: (message.tool_calls ?? []).filter(isRecord),
|
|
100
|
+
};
|
|
98
101
|
}
|
|
99
102
|
if (message.type === "tool") {
|
|
100
103
|
return { toolCallId: message.tool_call_id };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { act, waitFor } from "@testing-library/react";
|
|
2
|
+
|
|
3
|
+
// React 18's act holds renders until its callback settles, so a task that waits on a render starts inside act and settles outside it, where React can commit what the task waits on.
|
|
4
|
+
export async function settleOutsideAct<T>(task: () => Promise<T>): Promise<T> {
|
|
5
|
+
let pending!: Promise<T>;
|
|
6
|
+
await act(async () => {
|
|
7
|
+
pending = task();
|
|
8
|
+
pending.catch(() => {});
|
|
9
|
+
});
|
|
10
|
+
await waitFor(() => pending.then(settled, settled));
|
|
11
|
+
await act(async () => {});
|
|
12
|
+
return pending;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const settled = () => {};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { act, StrictMode, useEffect } from "react";
|
|
4
|
+
import { afterAll, afterEach, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
type Family = { current: unknown };
|
|
7
|
+
type RendererInternals = {
|
|
8
|
+
setRefreshHandler: (resolve: (type: unknown) => Family | undefined) => void;
|
|
9
|
+
scheduleRefresh: (
|
|
10
|
+
root: unknown,
|
|
11
|
+
update: { staleFamilies: Set<Family>; updatedFamilies: Set<Family> },
|
|
12
|
+
) => void;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
let renderer: RendererInternals | undefined;
|
|
16
|
+
const fiberRoots = new Set<unknown>();
|
|
17
|
+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
18
|
+
vi.stubGlobal("__REACT_DEVTOOLS_GLOBAL_HOOK__", {
|
|
19
|
+
supportsFiber: true,
|
|
20
|
+
inject: (internals: RendererInternals) => {
|
|
21
|
+
renderer = internals;
|
|
22
|
+
return 1;
|
|
23
|
+
},
|
|
24
|
+
onScheduleFiberRoot: () => {},
|
|
25
|
+
onCommitFiberRoot: (_id: number, root: unknown) => fiberRoots.add(root),
|
|
26
|
+
onCommitFiberUnmount: () => {},
|
|
27
|
+
});
|
|
28
|
+
const { cleanup, render, waitFor } = await import("@testing-library/react");
|
|
29
|
+
const { useAdkMessages } = await import("./useAdkMessages");
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
cleanup();
|
|
33
|
+
renderer?.setRefreshHandler(() => undefined);
|
|
34
|
+
fiberRoots.clear();
|
|
35
|
+
});
|
|
36
|
+
afterAll(() => vi.unstubAllGlobals());
|
|
37
|
+
|
|
38
|
+
const refresh = async (Before: unknown, After: unknown) => {
|
|
39
|
+
const family: Family = { current: After };
|
|
40
|
+
renderer!.setRefreshHandler((type) =>
|
|
41
|
+
type === Before || type === After ? family : undefined,
|
|
42
|
+
);
|
|
43
|
+
await act(async () => {
|
|
44
|
+
for (const root of fiberRoots) {
|
|
45
|
+
renderer!.scheduleRefresh(root, {
|
|
46
|
+
staleFamilies: new Set(),
|
|
47
|
+
updatedFamilies: new Set([family]),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
await act(async () => {});
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
it("keeps a direct useAdkMessages stream through Fast Refresh and cancels on unmount", async () => {
|
|
55
|
+
let signal: AbortSignal | undefined;
|
|
56
|
+
let send: (() => void) | undefined;
|
|
57
|
+
let rendered: string | undefined;
|
|
58
|
+
const stream = vi.fn((_messages, config: { abortSignal: AbortSignal }) => {
|
|
59
|
+
signal = config.abortSignal;
|
|
60
|
+
return new Promise<never>(() => {});
|
|
61
|
+
});
|
|
62
|
+
const host = (name: string) => () => {
|
|
63
|
+
rendered = name;
|
|
64
|
+
const { sendMessage } = useAdkMessages({ stream: stream as never });
|
|
65
|
+
send = () => {
|
|
66
|
+
void sendMessage([{ id: "user", type: "human", content: "hello" }], {});
|
|
67
|
+
};
|
|
68
|
+
return null;
|
|
69
|
+
};
|
|
70
|
+
const Before = host("before");
|
|
71
|
+
const After = host("after");
|
|
72
|
+
const view = render(<Before />);
|
|
73
|
+
|
|
74
|
+
act(() => send!());
|
|
75
|
+
await waitFor(() => expect(stream).toHaveBeenCalledTimes(1));
|
|
76
|
+
expect(signal!.aborted).toBe(false);
|
|
77
|
+
|
|
78
|
+
await refresh(Before, After);
|
|
79
|
+
expect(rendered).toBe("after");
|
|
80
|
+
expect(signal!.aborted).toBe(false);
|
|
81
|
+
expect(stream).toHaveBeenCalledTimes(1);
|
|
82
|
+
|
|
83
|
+
view.unmount();
|
|
84
|
+
await act(async () => {});
|
|
85
|
+
expect(signal!.aborted).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("keeps a stream started during StrictMode's effect replay", async () => {
|
|
89
|
+
let signal: AbortSignal | undefined;
|
|
90
|
+
let launched = false;
|
|
91
|
+
const stream = vi.fn((_messages, config: { abortSignal: AbortSignal }) => {
|
|
92
|
+
signal = config.abortSignal;
|
|
93
|
+
return new Promise<never>(() => {});
|
|
94
|
+
});
|
|
95
|
+
const Host = () => {
|
|
96
|
+
const { sendMessage } = useAdkMessages({ stream: stream as never });
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (launched) return;
|
|
99
|
+
launched = true;
|
|
100
|
+
void sendMessage([{ id: "user", type: "human", content: "hello" }], {});
|
|
101
|
+
}, [sendMessage]);
|
|
102
|
+
return null;
|
|
103
|
+
};
|
|
104
|
+
const view = render(
|
|
105
|
+
<StrictMode>
|
|
106
|
+
<Host />
|
|
107
|
+
</StrictMode>,
|
|
108
|
+
);
|
|
109
|
+
await waitFor(() => expect(stream).toHaveBeenCalledTimes(1));
|
|
110
|
+
expect(signal!.aborted).toBe(false);
|
|
111
|
+
view.unmount();
|
|
112
|
+
await act(async () => {});
|
|
113
|
+
expect(signal!.aborted).toBe(true);
|
|
114
|
+
});
|
|
@@ -77,6 +77,40 @@ describe("optimistic tool outcomes", () => {
|
|
|
77
77
|
});
|
|
78
78
|
|
|
79
79
|
describe("ADK runtime callbacks", () => {
|
|
80
|
+
it("reports the same agent transfer again in a later run", async () => {
|
|
81
|
+
const onAgentTransfer = vi.fn();
|
|
82
|
+
const stream: AdkStreamCallback = async function* () {
|
|
83
|
+
yield {
|
|
84
|
+
id: "transfer",
|
|
85
|
+
actions: { transferToAgent: "researcher" },
|
|
86
|
+
};
|
|
87
|
+
yield {
|
|
88
|
+
id: "transfer-duplicate",
|
|
89
|
+
actions: { transferToAgent: "researcher" },
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
const { result } = renderHook(() =>
|
|
93
|
+
useAdkMessages({ stream, eventHandlers: { onAgentTransfer } }),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
await act(async () => {
|
|
97
|
+
await result.current.sendMessage(
|
|
98
|
+
[{ id: "user-1", type: "human", content: "first" }],
|
|
99
|
+
{},
|
|
100
|
+
);
|
|
101
|
+
expect(onAgentTransfer).toHaveBeenCalledTimes(1);
|
|
102
|
+
|
|
103
|
+
await result.current.sendMessage(
|
|
104
|
+
[{ id: "user-2", type: "human", content: "second" }],
|
|
105
|
+
{},
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(onAgentTransfer).toHaveBeenCalledTimes(2);
|
|
110
|
+
expect(onAgentTransfer).toHaveBeenNthCalledWith(1, "researcher");
|
|
111
|
+
expect(onAgentTransfer).toHaveBeenNthCalledWith(2, "researcher");
|
|
112
|
+
});
|
|
113
|
+
|
|
80
114
|
it.each(["onAgentTransfer", "onCustomEvent", "onError"] as const)(
|
|
81
115
|
"continues streaming when %s throws",
|
|
82
116
|
async (callbackName) => {
|
|
@@ -305,6 +339,7 @@ describe("ADK stream lifecycle", () => {
|
|
|
305
339
|
await started;
|
|
306
340
|
|
|
307
341
|
unmount();
|
|
342
|
+
await act(async () => {});
|
|
308
343
|
|
|
309
344
|
expect(runSignal?.aborted).toBe(true);
|
|
310
345
|
await expect(sendPromise).resolves.toBeUndefined();
|
|
@@ -765,6 +800,19 @@ describe("optimistic multi-message sends", () => {
|
|
|
765
800
|
});
|
|
766
801
|
|
|
767
802
|
describe("messageToEvent (contentToParts)", () => {
|
|
803
|
+
it("skips a null tool_calls entry", () => {
|
|
804
|
+
const event = messageToEvent({
|
|
805
|
+
id: "ai-1",
|
|
806
|
+
type: "ai",
|
|
807
|
+
content: [],
|
|
808
|
+
tool_calls: [null, { id: "tc-1", name: "search", args: { q: "x" } }],
|
|
809
|
+
} as unknown as AdkMessage);
|
|
810
|
+
|
|
811
|
+
expect(event.content?.parts).toEqual([
|
|
812
|
+
{ functionCall: { name: "search", id: "tc-1", args: { q: "x" } } },
|
|
813
|
+
]);
|
|
814
|
+
});
|
|
815
|
+
|
|
768
816
|
it.each([
|
|
769
817
|
["scalar", "false", { result: false }],
|
|
770
818
|
["array", "[1,2]", { results: [1, 2] }],
|
package/src/useAdkMessages.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
+
import { isRecord } from "@assistant-ui/core/internal";
|
|
1
2
|
import {
|
|
2
3
|
useState,
|
|
3
4
|
useCallback,
|
|
4
|
-
useEffect,
|
|
5
5
|
useInsertionEffect,
|
|
6
6
|
useRef,
|
|
7
7
|
useMemo,
|
|
8
8
|
} from "react";
|
|
9
9
|
import { generateId } from "@assistant-ui/core";
|
|
10
|
+
import { useReplaySafeEffect } from "@assistant-ui/store/internal";
|
|
10
11
|
import { useAui } from "@assistant-ui/store";
|
|
11
12
|
import {
|
|
12
13
|
abortableIterable,
|
|
@@ -71,7 +72,6 @@ export const useAdkMessages = ({
|
|
|
71
72
|
const [messageMetadata, setMessageMetadata] = useState<
|
|
72
73
|
Map<string, AdkMessageMetadata>
|
|
73
74
|
>(new Map());
|
|
74
|
-
const lastTransferToAgentRef = useRef<string | undefined>(undefined);
|
|
75
75
|
// setMessagesImmediate and setLongRunningToolIds are the only writers of their state and publish these refs with it, so neither ref trails a commit.
|
|
76
76
|
const messagesRef = useRef(messages);
|
|
77
77
|
const longRunningToolIdsRef = useRef(longRunningToolIds);
|
|
@@ -168,10 +168,14 @@ export const useAdkMessages = ({
|
|
|
168
168
|
for (const event of messagesToEvents(newMessagesWithId)) {
|
|
169
169
|
accumulator.processEvent(event);
|
|
170
170
|
}
|
|
171
|
-
|
|
172
|
-
|
|
171
|
+
const initialMessages = accumulator.getMessages();
|
|
172
|
+
const initialMessageIds = new Set(initialMessages.map((m) => m.id));
|
|
173
|
+
const initialLongRunningToolIds = accumulator.getLongRunningToolIds();
|
|
174
|
+
setMessagesImmediate(initialMessages);
|
|
175
|
+
setLongRunningToolIds(initialLongRunningToolIds);
|
|
173
176
|
setToolConfirmations(accumulator.getToolConfirmations());
|
|
174
177
|
setAuthRequests(accumulator.getAuthRequests());
|
|
178
|
+
let lastTransferToAgent: string | undefined;
|
|
175
179
|
|
|
176
180
|
// Google ADK replaces active runs, while React LangGraph queues sends.
|
|
177
181
|
abortControllerRef.current?.abort();
|
|
@@ -226,8 +230,8 @@ export const useAdkMessages = ({
|
|
|
226
230
|
}
|
|
227
231
|
|
|
228
232
|
const transfer = accumulator.getLastTransferToAgent();
|
|
229
|
-
if (transfer && transfer !==
|
|
230
|
-
|
|
233
|
+
if (transfer && transfer !== lastTransferToAgent) {
|
|
234
|
+
lastTransferToAgent = transfer;
|
|
231
235
|
invokeAdkRuntimeCallback(
|
|
232
236
|
"onAgentTransfer",
|
|
233
237
|
onAgentTransfer,
|
|
@@ -265,6 +269,33 @@ export const useAdkMessages = ({
|
|
|
265
269
|
}
|
|
266
270
|
} finally {
|
|
267
271
|
if (abortControllerRef.current === abortController) {
|
|
272
|
+
if (abortController.signal.aborted) {
|
|
273
|
+
setLongRunningToolIds(
|
|
274
|
+
accumulator
|
|
275
|
+
.getLongRunningToolIds()
|
|
276
|
+
.filter((id) => initialLongRunningToolIds.includes(id)),
|
|
277
|
+
);
|
|
278
|
+
const updatedMessages = messagesRef.current;
|
|
279
|
+
const lastAssistantMessage = updatedMessages.findLast(
|
|
280
|
+
(m) => m.type === "ai",
|
|
281
|
+
);
|
|
282
|
+
if (
|
|
283
|
+
lastAssistantMessage &&
|
|
284
|
+
!initialMessageIds.has(lastAssistantMessage.id) &&
|
|
285
|
+
!lastAssistantMessage.status
|
|
286
|
+
) {
|
|
287
|
+
setMessagesImmediate(
|
|
288
|
+
updatedMessages.map((m) =>
|
|
289
|
+
m === lastAssistantMessage
|
|
290
|
+
? {
|
|
291
|
+
...lastAssistantMessage,
|
|
292
|
+
status: { type: "incomplete", reason: "cancelled" },
|
|
293
|
+
}
|
|
294
|
+
: m,
|
|
295
|
+
),
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
268
299
|
abortControllerRef.current = null;
|
|
269
300
|
}
|
|
270
301
|
}
|
|
@@ -286,7 +317,7 @@ export const useAdkMessages = ({
|
|
|
286
317
|
}
|
|
287
318
|
}, []);
|
|
288
319
|
|
|
289
|
-
|
|
320
|
+
useReplaySafeEffect(() => cancel, []);
|
|
290
321
|
|
|
291
322
|
return {
|
|
292
323
|
messages,
|
|
@@ -395,9 +426,9 @@ export const messageToEvent = (msg: AdkMessage): AdkEvent => {
|
|
|
395
426
|
role: "model",
|
|
396
427
|
parts: [
|
|
397
428
|
...contentToParts(msg.content),
|
|
398
|
-
...(msg.tool_calls
|
|
429
|
+
...(msg.tool_calls ?? []).filter(isRecord).map((tc) => ({
|
|
399
430
|
functionCall: { name: tc.name, id: tc.id, args: { ...tc.args } },
|
|
400
|
-
}))
|
|
431
|
+
})),
|
|
401
432
|
],
|
|
402
433
|
};
|
|
403
434
|
return result;
|