@assistant-ui/react-google-adk 0.0.28 → 0.0.30
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/AdkClient.d.ts.map +1 -1
- package/dist/AdkClient.js +2 -1
- package/dist/AdkClient.js.map +1 -1
- package/dist/AdkEventAccumulator.d.ts +1 -1
- package/dist/AdkEventAccumulator.d.ts.map +1 -1
- package/dist/AdkEventAccumulator.js +33 -12
- package/dist/AdkEventAccumulator.js.map +1 -1
- package/dist/convertToAdkMessages.d.ts +10 -1
- package/dist/convertToAdkMessages.d.ts.map +1 -1
- package/dist/convertToAdkMessages.js +20 -6
- package/dist/convertToAdkMessages.js.map +1 -1
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +6 -8
- package/dist/hooks.js.map +1 -1
- package/dist/parseAdkEvent.d.ts.map +1 -1
- package/dist/parseAdkEvent.js +15 -2
- package/dist/parseAdkEvent.js.map +1 -1
- package/dist/sdkIdentity.d.ts +6 -0
- package/dist/sdkIdentity.d.ts.map +1 -0
- package/dist/sdkIdentity.js +9 -0
- package/dist/sdkIdentity.js.map +1 -0
- package/dist/server/parseAdkRequest.d.ts.map +1 -1
- package/dist/server/parseAdkRequest.js +2 -1
- package/dist/server/parseAdkRequest.js.map +1 -1
- package/dist/toAdkFunctionResponse.d.ts +6 -0
- package/dist/toAdkFunctionResponse.d.ts.map +1 -0
- package/dist/toAdkFunctionResponse.js +11 -0
- package/dist/toAdkFunctionResponse.js.map +1 -0
- package/dist/useAdkMessages.d.ts.map +1 -1
- package/dist/useAdkMessages.js +22 -10
- package/dist/useAdkMessages.js.map +1 -1
- package/dist/useAdkRuntime.d.ts.map +1 -1
- package/dist/useAdkRuntime.js +6 -2
- package/dist/useAdkRuntime.js.map +1 -1
- package/package.json +6 -6
- package/src/AdkClient.test.ts +192 -2
- package/src/AdkClient.ts +2 -1
- package/src/AdkEventAccumulator.test.ts +297 -0
- package/src/AdkEventAccumulator.ts +35 -5
- package/src/AdkSessionAdapter.test.ts +88 -0
- package/src/convertToAdkMessages.test.ts +70 -0
- package/src/convertToAdkMessages.ts +19 -4
- package/src/hooks.render.test.tsx +74 -0
- package/src/hooks.test.tsx +33 -0
- package/src/hooks.ts +17 -22
- package/src/parseAdkEvent.ts +36 -7
- package/src/sdkIdentity.ts +9 -0
- package/src/server/parseAdkRequest.test.ts +63 -0
- package/src/server/parseAdkRequest.ts +2 -1
- package/src/toAdkFunctionResponse.test.ts +46 -0
- package/src/toAdkFunctionResponse.ts +18 -0
- package/src/useAdkMessages.test.ts +247 -0
- package/src/useAdkMessages.ts +51 -15
- package/src/useAdkRuntime.replacement.test.tsx +138 -0
- package/src/useAdkRuntime.ts +5 -1
- package/src/useAdkRuntimeApproval.test.tsx +0 -1
|
@@ -294,6 +294,87 @@ describe("createAdkSessionAdapter - fetch", () => {
|
|
|
294
294
|
// ── load() ──
|
|
295
295
|
|
|
296
296
|
describe("createAdkSessionAdapter - load", () => {
|
|
297
|
+
it("restores tool failures from stored function responses", async () => {
|
|
298
|
+
mockFetch.mockResolvedValueOnce(
|
|
299
|
+
new Response(
|
|
300
|
+
JSON.stringify({
|
|
301
|
+
id: "s1",
|
|
302
|
+
events: [
|
|
303
|
+
{
|
|
304
|
+
id: "failed",
|
|
305
|
+
author: "user",
|
|
306
|
+
content: {
|
|
307
|
+
parts: [
|
|
308
|
+
{
|
|
309
|
+
functionResponse: {
|
|
310
|
+
id: "tc-1",
|
|
311
|
+
name: "search",
|
|
312
|
+
response: { error: "denied" },
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
],
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
],
|
|
319
|
+
}),
|
|
320
|
+
{ status: 200 },
|
|
321
|
+
),
|
|
322
|
+
);
|
|
323
|
+
const { load } = createAdkSessionAdapter(baseOptions);
|
|
324
|
+
const result = await load("s1");
|
|
325
|
+
expect(result.messages).toMatchObject([
|
|
326
|
+
{
|
|
327
|
+
type: "tool",
|
|
328
|
+
tool_call_id: "tc-1",
|
|
329
|
+
status: "error",
|
|
330
|
+
content: JSON.stringify({ error: "denied" }),
|
|
331
|
+
},
|
|
332
|
+
]);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("restores snake_case image and file parts from session history", async () => {
|
|
336
|
+
mockFetch.mockResolvedValueOnce(
|
|
337
|
+
new Response(
|
|
338
|
+
JSON.stringify({
|
|
339
|
+
id: "s1",
|
|
340
|
+
events: [
|
|
341
|
+
{
|
|
342
|
+
id: "media",
|
|
343
|
+
author: "user",
|
|
344
|
+
content: {
|
|
345
|
+
parts: [
|
|
346
|
+
{ inline_data: { mime_type: "image/png", data: "aGVsbG8=" } },
|
|
347
|
+
{
|
|
348
|
+
file_data: {
|
|
349
|
+
mime_type: "application/pdf",
|
|
350
|
+
file_uri: "https://example.test/report.pdf",
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
],
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
],
|
|
357
|
+
}),
|
|
358
|
+
{ status: 200 },
|
|
359
|
+
),
|
|
360
|
+
);
|
|
361
|
+
const { load } = createAdkSessionAdapter(baseOptions);
|
|
362
|
+
const result = await load("s1");
|
|
363
|
+
expect(result.messages).toMatchObject([
|
|
364
|
+
{
|
|
365
|
+
type: "human",
|
|
366
|
+
content: [
|
|
367
|
+
{ type: "image", mimeType: "image/png", data: "aGVsbG8=" },
|
|
368
|
+
{
|
|
369
|
+
type: "file_url",
|
|
370
|
+
mimeType: "application/pdf",
|
|
371
|
+
url: "https://example.test/report.pdf",
|
|
372
|
+
},
|
|
373
|
+
],
|
|
374
|
+
},
|
|
375
|
+
]);
|
|
376
|
+
});
|
|
377
|
+
|
|
297
378
|
it("returns the per-turn state the events imply, not just the messages", async () => {
|
|
298
379
|
const session = {
|
|
299
380
|
id: "s1",
|
|
@@ -543,10 +624,17 @@ describe("createAdkSessionAdapter - load replays tool confirmations", () => {
|
|
|
543
624
|
const result = await load("s1");
|
|
544
625
|
return {
|
|
545
626
|
messages: result.messages,
|
|
627
|
+
longRunningToolIds: result.longRunningToolIds,
|
|
546
628
|
approvals: projectAdkToolApprovals(result.messages).approvals,
|
|
547
629
|
};
|
|
548
630
|
};
|
|
549
631
|
|
|
632
|
+
it("settles an answered long-running tool on replay", async () => {
|
|
633
|
+
const { longRunningToolIds } = await loadApprovals({ confirmed: true });
|
|
634
|
+
|
|
635
|
+
expect(longRunningToolIds).toEqual([]);
|
|
636
|
+
});
|
|
637
|
+
|
|
550
638
|
it("keeps a user-authored confirmation reply as a tool message", async () => {
|
|
551
639
|
const { messages } = await loadApprovals({ confirmed: true });
|
|
552
640
|
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
import { convertAdkMessage } from "./convertAdkMessages";
|
|
8
8
|
import type { AppendMessage } from "@assistant-ui/core";
|
|
9
9
|
import type { AdkMessage } from "./types";
|
|
10
|
+
import { contentToParts } from "./contentToParts";
|
|
10
11
|
|
|
11
12
|
const makeAppendMessage = (content: AppendMessage["content"]): AppendMessage =>
|
|
12
13
|
({
|
|
@@ -155,6 +156,75 @@ describe("getPendingCancellations", () => {
|
|
|
155
156
|
});
|
|
156
157
|
|
|
157
158
|
describe("getMessageContent", () => {
|
|
159
|
+
it("serializes data URL images as inline data", () => {
|
|
160
|
+
const content = getMessageContent(
|
|
161
|
+
makeAppendMessage([
|
|
162
|
+
{ type: "image", image: "data:image/png;base64,AAAA" },
|
|
163
|
+
]),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
expect(contentToParts(content)).toEqual([
|
|
167
|
+
{ inlineData: { mimeType: "image/png", data: "AAAA" } },
|
|
168
|
+
]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("infers an image MIME type when the data URL declares a generic type", () => {
|
|
172
|
+
const content = getMessageContent(
|
|
173
|
+
makeAppendMessage([
|
|
174
|
+
{
|
|
175
|
+
type: "image",
|
|
176
|
+
image: "data:application/octet-stream;base64,iVBORw0KGgo=",
|
|
177
|
+
},
|
|
178
|
+
]),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
expect(contentToParts(content)).toEqual([
|
|
182
|
+
{ inlineData: { mimeType: "image/png", data: "iVBORw0KGgo=" } },
|
|
183
|
+
]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("prefers an attachment's declared image MIME type", () => {
|
|
187
|
+
const message = makeAppendMessage([]);
|
|
188
|
+
const content = getMessageContent({
|
|
189
|
+
...message,
|
|
190
|
+
attachments: [
|
|
191
|
+
{
|
|
192
|
+
id: "attachment-1",
|
|
193
|
+
type: "image",
|
|
194
|
+
name: "photo.webp",
|
|
195
|
+
contentType: "image/webp",
|
|
196
|
+
status: { type: "complete" },
|
|
197
|
+
content: [{ type: "image", image: "data:image/png;base64,AAAA" }],
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(contentToParts(content)).toEqual([
|
|
203
|
+
{ inlineData: { mimeType: "image/webp", data: "AAAA" } },
|
|
204
|
+
]);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("resolves wildcard attachment MIME types to a concrete image type", () => {
|
|
208
|
+
const message = makeAppendMessage([]);
|
|
209
|
+
const content = getMessageContent({
|
|
210
|
+
...message,
|
|
211
|
+
attachments: [
|
|
212
|
+
{
|
|
213
|
+
id: "attachment-1",
|
|
214
|
+
type: "image",
|
|
215
|
+
name: "photo.jpg",
|
|
216
|
+
contentType: "image/*",
|
|
217
|
+
status: { type: "complete" },
|
|
218
|
+
content: [{ type: "image", image: "data:image/jpeg;base64,AAAA" }],
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
expect(contentToParts(content)).toEqual([
|
|
224
|
+
{ inlineData: { mimeType: "image/jpeg", data: "AAAA" } },
|
|
225
|
+
]);
|
|
226
|
+
});
|
|
227
|
+
|
|
158
228
|
it("preserves file part data and mimeType end-to-end", () => {
|
|
159
229
|
const result = getMessageContent(
|
|
160
230
|
makeAppendMessage([
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
createToolCallCancellationStub,
|
|
9
9
|
parseDataUrl,
|
|
10
10
|
resolveFilePartSource,
|
|
11
|
+
resolveImageMediaType,
|
|
11
12
|
scanPendingToolCalls,
|
|
12
13
|
} from "@assistant-ui/core/internal";
|
|
13
14
|
import type { AdkMessage } from "./types";
|
|
@@ -15,16 +16,30 @@ import type { AdkMessage } from "./types";
|
|
|
15
16
|
/** Exported for unit tests. */
|
|
16
17
|
export const getMessageContent = (msg: AppendMessage) => {
|
|
17
18
|
const allContent = [
|
|
18
|
-
...msg.content,
|
|
19
|
-
...(msg.attachments?.flatMap((
|
|
19
|
+
...msg.content.map((part) => ({ part, contentType: undefined })),
|
|
20
|
+
...(msg.attachments?.flatMap((attachment) =>
|
|
21
|
+
attachment.content.map((part) => ({
|
|
22
|
+
part,
|
|
23
|
+
contentType: attachment.contentType,
|
|
24
|
+
})),
|
|
25
|
+
) ?? []),
|
|
20
26
|
];
|
|
21
|
-
const content = allContent.flatMap((part) => {
|
|
27
|
+
const content = allContent.flatMap(({ part, contentType }) => {
|
|
22
28
|
const type = part.type;
|
|
23
29
|
switch (type) {
|
|
24
30
|
case "text":
|
|
25
31
|
return { type: "text" as const, text: part.text };
|
|
26
|
-
case "image":
|
|
32
|
+
case "image": {
|
|
33
|
+
const parsed = parseDataUrl(part.image);
|
|
34
|
+
if (parsed) {
|
|
35
|
+
return {
|
|
36
|
+
type: "image" as const,
|
|
37
|
+
mimeType: resolveImageMediaType(part.image, contentType),
|
|
38
|
+
data: parsed.data,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
27
41
|
return { type: "image_url" as const, url: part.image };
|
|
42
|
+
}
|
|
28
43
|
case "file": {
|
|
29
44
|
const source = resolveFilePartSource(part);
|
|
30
45
|
if (source.kind === "url") {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { act, render, waitFor } from "@testing-library/react";
|
|
4
|
+
import type { AssistantRuntime } from "@assistant-ui/core";
|
|
5
|
+
import { AssistantRuntimeProvider } from "@assistant-ui/core/react";
|
|
6
|
+
import { describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { useAdkAppState } from "./hooks";
|
|
8
|
+
import type { AdkEvent } from "./types";
|
|
9
|
+
import { useAdkRuntime } from "./useAdkRuntime";
|
|
10
|
+
|
|
11
|
+
describe("ADK state hook rendering", () => {
|
|
12
|
+
it("keeps app state stable across unrelated store updates", async () => {
|
|
13
|
+
const deltas = [
|
|
14
|
+
{
|
|
15
|
+
"app:visible": 1,
|
|
16
|
+
"app:__proto__": { source: "provider" },
|
|
17
|
+
},
|
|
18
|
+
{ unrelated: true },
|
|
19
|
+
{ "app:visible": 2 },
|
|
20
|
+
];
|
|
21
|
+
const stream = vi.fn(async function* () {
|
|
22
|
+
const call = stream.mock.calls.length - 1;
|
|
23
|
+
yield {
|
|
24
|
+
id: `event-${call}`,
|
|
25
|
+
author: "agent",
|
|
26
|
+
actions: { stateDelta: deltas[call] },
|
|
27
|
+
turnComplete: true,
|
|
28
|
+
} satisfies AdkEvent;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
let runtime: AssistantRuntime | undefined;
|
|
32
|
+
let appState: Record<string, unknown> | undefined;
|
|
33
|
+
|
|
34
|
+
const Probe = () => {
|
|
35
|
+
appState = useAdkAppState();
|
|
36
|
+
return null;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const App = () => {
|
|
40
|
+
runtime = useAdkRuntime({
|
|
41
|
+
stream,
|
|
42
|
+
create: async () => ({ externalId: "thread-1" }),
|
|
43
|
+
});
|
|
44
|
+
return (
|
|
45
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
46
|
+
<Probe />
|
|
47
|
+
</AssistantRuntimeProvider>
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
render(<App />);
|
|
52
|
+
const send = (text: string) =>
|
|
53
|
+
act(async () => {
|
|
54
|
+
await runtime!.thread.append({
|
|
55
|
+
role: "user",
|
|
56
|
+
content: [{ type: "text", text }],
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
await send("first");
|
|
61
|
+
await waitFor(() => expect(appState?.visible).toBe(1));
|
|
62
|
+
|
|
63
|
+
const initial = appState;
|
|
64
|
+
expect(Object.hasOwn(initial!, "__proto__")).toBe(true);
|
|
65
|
+
expect(initial?.["__proto__"]).toEqual({ source: "provider" });
|
|
66
|
+
|
|
67
|
+
await send("second");
|
|
68
|
+
expect(appState).toBe(initial);
|
|
69
|
+
|
|
70
|
+
await send("third");
|
|
71
|
+
await waitFor(() => expect(appState?.visible).toBe(2));
|
|
72
|
+
expect(appState).not.toBe(initial);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { renderHook } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const mocks = vi.hoisted(() => ({
|
|
5
|
+
stateDelta: {} as Record<string, unknown>,
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
vi.mock("./adkExtras", async (importOriginal) => ({
|
|
9
|
+
...(await importOriginal<typeof import("./adkExtras")>()),
|
|
10
|
+
adkExtras: {
|
|
11
|
+
use: (
|
|
12
|
+
selector: (extras: { stateDelta: Record<string, unknown> }) => unknown,
|
|
13
|
+
) => selector({ stateDelta: mocks.stateDelta }),
|
|
14
|
+
},
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
import { useAdkAppState } from "./hooks";
|
|
18
|
+
|
|
19
|
+
describe("ADK state hooks", () => {
|
|
20
|
+
it("preserves a prototype-named state key", () => {
|
|
21
|
+
mocks.stateDelta = Object.fromEntries([
|
|
22
|
+
["app:__proto__", { source: "provider" }],
|
|
23
|
+
["app:visible", true],
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const { result } = renderHook(() => useAdkAppState());
|
|
27
|
+
|
|
28
|
+
expect(Object.getPrototypeOf(result.current)).toBe(Object.prototype);
|
|
29
|
+
expect(Object.hasOwn(result.current, "__proto__")).toBe(true);
|
|
30
|
+
expect(result.current["__proto__"]).toEqual({ source: "provider" });
|
|
31
|
+
expect(result.current.visible).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
});
|
package/src/hooks.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { generateId } from "@assistant-ui/core";
|
|
2
2
|
import { useAui } from "@assistant-ui/store";
|
|
3
|
+
import { useShallowSelector } from "@assistant-ui/store/internal";
|
|
3
4
|
import type { ReadonlyJSONValue } from "assistant-stream/utils";
|
|
4
5
|
import { adkExtras } from "./adkExtras";
|
|
5
6
|
import { toAdkConfirmationReply } from "./adkToolApproval";
|
|
@@ -10,6 +11,7 @@ import type {
|
|
|
10
11
|
AdkAuthCredential,
|
|
11
12
|
AdkAuthRequest,
|
|
12
13
|
AdkMessageMetadata,
|
|
14
|
+
AdkRuntimeExtras,
|
|
13
15
|
} from "./types";
|
|
14
16
|
|
|
15
17
|
const EMPTY_STATE_DELTA: Record<string, unknown> = {};
|
|
@@ -119,33 +121,26 @@ const TEMP_PREFIX = "temp:";
|
|
|
119
121
|
const filterByPrefix = (
|
|
120
122
|
state: Record<string, unknown>,
|
|
121
123
|
prefix: string,
|
|
122
|
-
): Record<string, unknown> =>
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
return result;
|
|
130
|
-
};
|
|
124
|
+
): Record<string, unknown> =>
|
|
125
|
+
Object.fromEntries(
|
|
126
|
+
Object.entries(state)
|
|
127
|
+
.filter(([key]) => key.startsWith(prefix))
|
|
128
|
+
.map(([key, value]) => [key.slice(prefix.length), value]),
|
|
129
|
+
);
|
|
131
130
|
|
|
132
|
-
|
|
133
|
-
export const useAdkAppState = () =>
|
|
131
|
+
const useAdkStateByPrefix = (prefix: string) =>
|
|
134
132
|
adkExtras.use(
|
|
135
|
-
(e) =>
|
|
133
|
+
useShallowSelector((e: AdkRuntimeExtras) =>
|
|
134
|
+
filterByPrefix(e.stateDelta, prefix),
|
|
135
|
+
),
|
|
136
136
|
EMPTY_STATE_DELTA,
|
|
137
137
|
);
|
|
138
138
|
|
|
139
|
+
/** Returns app-level state (keys prefixed with `app:`, prefix stripped). */
|
|
140
|
+
export const useAdkAppState = () => useAdkStateByPrefix(APP_PREFIX);
|
|
141
|
+
|
|
139
142
|
/** Returns user-level state (keys prefixed with `user:`, prefix stripped). */
|
|
140
|
-
export const useAdkUserState = () =>
|
|
141
|
-
adkExtras.use(
|
|
142
|
-
(e) => filterByPrefix(e.stateDelta, USER_PREFIX),
|
|
143
|
-
EMPTY_STATE_DELTA,
|
|
144
|
-
);
|
|
143
|
+
export const useAdkUserState = () => useAdkStateByPrefix(USER_PREFIX);
|
|
145
144
|
|
|
146
145
|
/** Returns temp state (keys prefixed with `temp:`, prefix stripped). Not persisted. */
|
|
147
|
-
export const useAdkTempState = () =>
|
|
148
|
-
adkExtras.use(
|
|
149
|
-
(e) => filterByPrefix(e.stateDelta, TEMP_PREFIX),
|
|
150
|
-
EMPTY_STATE_DELTA,
|
|
151
|
-
);
|
|
146
|
+
export const useAdkTempState = () => useAdkStateByPrefix(TEMP_PREFIX);
|
package/src/parseAdkEvent.ts
CHANGED
|
@@ -1,19 +1,26 @@
|
|
|
1
|
+
import { isRecord } from "@assistant-ui/core/internal";
|
|
1
2
|
import type { AdkEvent } from "./types";
|
|
2
3
|
|
|
4
|
+
const invalidField = (
|
|
5
|
+
errorPrefix: string,
|
|
6
|
+
field: string,
|
|
7
|
+
expectation: string,
|
|
8
|
+
): Error =>
|
|
9
|
+
new Error(`${errorPrefix}: expected "${field}" to be ${expectation}.`);
|
|
10
|
+
|
|
3
11
|
export function parseAdkEventValue(
|
|
4
12
|
value: unknown,
|
|
5
13
|
errorPrefix: string,
|
|
6
14
|
): AdkEvent {
|
|
7
|
-
if (
|
|
8
|
-
typeof value !== "object" ||
|
|
9
|
-
value === null ||
|
|
10
|
-
Array.isArray(value) ||
|
|
11
|
-
Object.keys(value).length === 0
|
|
12
|
-
) {
|
|
15
|
+
if (!isRecord(value) || Object.keys(value).length === 0) {
|
|
13
16
|
throw new Error(`${errorPrefix}: expected a non-empty object.`);
|
|
14
17
|
}
|
|
15
18
|
|
|
16
|
-
const {
|
|
19
|
+
const {
|
|
20
|
+
id: rawId,
|
|
21
|
+
content: rawContent,
|
|
22
|
+
...event
|
|
23
|
+
} = value as Record<string, unknown>;
|
|
17
24
|
if (
|
|
18
25
|
rawId != null &&
|
|
19
26
|
typeof rawId !== "string" &&
|
|
@@ -24,12 +31,34 @@ export function parseAdkEventValue(
|
|
|
24
31
|
);
|
|
25
32
|
}
|
|
26
33
|
|
|
34
|
+
let content: Record<string, unknown> | undefined;
|
|
35
|
+
if (rawContent != null) {
|
|
36
|
+
if (!isRecord(rawContent)) {
|
|
37
|
+
throw invalidField(errorPrefix, "content", "an object when present");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const { parts, ...contentFields } = rawContent;
|
|
41
|
+
if (parts != null && (!Array.isArray(parts) || !parts.every(isRecord))) {
|
|
42
|
+
throw invalidField(
|
|
43
|
+
errorPrefix,
|
|
44
|
+
"content.parts",
|
|
45
|
+
"an array of objects when present",
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
content = {
|
|
50
|
+
...contentFields,
|
|
51
|
+
...(parts != null && { parts }),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
27
55
|
const errorMessage =
|
|
28
56
|
"error" in event && typeof event.error === "string"
|
|
29
57
|
? event.error
|
|
30
58
|
: undefined;
|
|
31
59
|
return {
|
|
32
60
|
...event,
|
|
61
|
+
...(content !== undefined && { content }),
|
|
33
62
|
...(rawId != null && { id: String(rawId) }),
|
|
34
63
|
...(errorMessage !== undefined &&
|
|
35
64
|
!("errorMessage" in event) &&
|
|
@@ -239,6 +239,41 @@ describe("parseAdkRequest", () => {
|
|
|
239
239
|
});
|
|
240
240
|
|
|
241
241
|
describe("toAdkContent", () => {
|
|
242
|
+
it.each([
|
|
243
|
+
["permission denied", { error: "permission denied" }],
|
|
244
|
+
[
|
|
245
|
+
{ message: "permission denied" },
|
|
246
|
+
{ error: { message: "permission denied" } },
|
|
247
|
+
],
|
|
248
|
+
[["denied"], { error: ["denied"] }],
|
|
249
|
+
[null, { error: null }],
|
|
250
|
+
[false, { error: false }],
|
|
251
|
+
[0, { error: 0 }],
|
|
252
|
+
["", { error: "" }],
|
|
253
|
+
[
|
|
254
|
+
{ error: "denied", output: "partial" },
|
|
255
|
+
{ error: "denied", output: "partial" },
|
|
256
|
+
],
|
|
257
|
+
])(
|
|
258
|
+
"preserves explicit tool failure %j in the function response",
|
|
259
|
+
async (result, response) => {
|
|
260
|
+
const parsed = await parseAdkRequest(
|
|
261
|
+
makeRequest({
|
|
262
|
+
type: "tool-result",
|
|
263
|
+
toolCallId: "tc-1",
|
|
264
|
+
toolName: "search",
|
|
265
|
+
result,
|
|
266
|
+
isError: true,
|
|
267
|
+
}),
|
|
268
|
+
);
|
|
269
|
+
expect(toAdkContent(parsed).parts).toEqual([
|
|
270
|
+
{
|
|
271
|
+
functionResponse: { name: "search", id: "tc-1", response },
|
|
272
|
+
},
|
|
273
|
+
]);
|
|
274
|
+
},
|
|
275
|
+
);
|
|
276
|
+
|
|
242
277
|
it("converts a text message to user content with text part", () => {
|
|
243
278
|
const content = toAdkContent({
|
|
244
279
|
type: "message",
|
|
@@ -292,4 +327,32 @@ describe("toAdkContent", () => {
|
|
|
292
327
|
],
|
|
293
328
|
});
|
|
294
329
|
});
|
|
330
|
+
|
|
331
|
+
it.each([
|
|
332
|
+
[false, { result: false }],
|
|
333
|
+
[0, { result: 0 }],
|
|
334
|
+
[null, { result: null }],
|
|
335
|
+
["done", { result: "done" }],
|
|
336
|
+
[[1, 2], { results: [1, 2] }],
|
|
337
|
+
])(
|
|
338
|
+
"wraps scalar or array tool result %j in a function response object",
|
|
339
|
+
(result, response) => {
|
|
340
|
+
const content = toAdkContent({
|
|
341
|
+
type: "tool-result",
|
|
342
|
+
toolCallId: "tc-1",
|
|
343
|
+
toolName: "search",
|
|
344
|
+
result,
|
|
345
|
+
isError: false,
|
|
346
|
+
config: {},
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
expect(content.parts[0]).toEqual({
|
|
350
|
+
functionResponse: {
|
|
351
|
+
name: "search",
|
|
352
|
+
id: "tc-1",
|
|
353
|
+
response,
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
},
|
|
357
|
+
);
|
|
295
358
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isRecord } from "@assistant-ui/core/internal";
|
|
2
2
|
import type { AdkSendMessageConfig } from "../types";
|
|
3
|
+
import { toAdkFunctionResponse } from "../toAdkFunctionResponse";
|
|
3
4
|
|
|
4
5
|
type ParsedAdkRequest =
|
|
5
6
|
| {
|
|
@@ -240,7 +241,7 @@ export const toAdkContent = (
|
|
|
240
241
|
functionResponse: {
|
|
241
242
|
name: parsed.toolName,
|
|
242
243
|
id: parsed.toolCallId,
|
|
243
|
-
response: parsed.result,
|
|
244
|
+
response: toAdkFunctionResponse(parsed.result, parsed.isError),
|
|
244
245
|
},
|
|
245
246
|
},
|
|
246
247
|
],
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
isAdkFunctionError,
|
|
4
|
+
toAdkFunctionResponse,
|
|
5
|
+
} from "./toAdkFunctionResponse";
|
|
6
|
+
|
|
7
|
+
describe("ADK function response errors", () => {
|
|
8
|
+
it("retains an existing error envelope without mutating or nesting it", () => {
|
|
9
|
+
const response = Object.freeze({ error: "denied", output: "partial" });
|
|
10
|
+
expect(toAdkFunctionResponse(response, true)).toBe(response);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("preserves a failure with no details through JSON serialization", () => {
|
|
14
|
+
const response = JSON.parse(
|
|
15
|
+
JSON.stringify(toAdkFunctionResponse(undefined, true)),
|
|
16
|
+
);
|
|
17
|
+
expect(response).toEqual({ error: null });
|
|
18
|
+
expect(isAdkFunctionError(response)).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("does not classify an undefined error field as an on-wire error", () => {
|
|
22
|
+
expect(isAdkFunctionError({ error: undefined })).toBe(false);
|
|
23
|
+
expect(
|
|
24
|
+
isAdkFunctionError(JSON.parse(JSON.stringify({ error: undefined }))),
|
|
25
|
+
).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("does not classify inherited error fields as an on-wire error", () => {
|
|
29
|
+
const response = Object.create({ error: "inherited" });
|
|
30
|
+
response.output = "done";
|
|
31
|
+
expect(isAdkFunctionError(response)).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it.each([
|
|
35
|
+
["done", { result: "done" }],
|
|
36
|
+
[[1, 2], { results: [1, 2] }],
|
|
37
|
+
[
|
|
38
|
+
{ output: { error: "application data" } },
|
|
39
|
+
{ output: { error: "application data" } },
|
|
40
|
+
],
|
|
41
|
+
[null, { result: null }],
|
|
42
|
+
])("leaves successful result %j unchanged", (result, expected) => {
|
|
43
|
+
expect(toAdkFunctionResponse(result)).toEqual(expected);
|
|
44
|
+
expect(isAdkFunctionError(toAdkFunctionResponse(result))).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { isRecord } from "@assistant-ui/core/internal";
|
|
2
|
+
|
|
3
|
+
export const isAdkFunctionError = (response: unknown): boolean =>
|
|
4
|
+
isRecord(response) &&
|
|
5
|
+
Object.hasOwn(response, "error") &&
|
|
6
|
+
response.error !== undefined;
|
|
7
|
+
|
|
8
|
+
export const toAdkFunctionResponse = (
|
|
9
|
+
result: unknown,
|
|
10
|
+
isError = false,
|
|
11
|
+
): Record<string, unknown> => {
|
|
12
|
+
if (isError && !isAdkFunctionError(result)) return { error: result ?? null };
|
|
13
|
+
return Array.isArray(result)
|
|
14
|
+
? { results: result }
|
|
15
|
+
: isRecord(result)
|
|
16
|
+
? result
|
|
17
|
+
: { result };
|
|
18
|
+
};
|