@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.
Files changed (56) hide show
  1. package/dist/AdkClient.d.ts.map +1 -1
  2. package/dist/AdkClient.js +2 -1
  3. package/dist/AdkClient.js.map +1 -1
  4. package/dist/AdkEventAccumulator.d.ts +1 -1
  5. package/dist/AdkEventAccumulator.d.ts.map +1 -1
  6. package/dist/AdkEventAccumulator.js +33 -12
  7. package/dist/AdkEventAccumulator.js.map +1 -1
  8. package/dist/convertToAdkMessages.d.ts +10 -1
  9. package/dist/convertToAdkMessages.d.ts.map +1 -1
  10. package/dist/convertToAdkMessages.js +20 -6
  11. package/dist/convertToAdkMessages.js.map +1 -1
  12. package/dist/hooks.d.ts.map +1 -1
  13. package/dist/hooks.js +6 -8
  14. package/dist/hooks.js.map +1 -1
  15. package/dist/parseAdkEvent.d.ts.map +1 -1
  16. package/dist/parseAdkEvent.js +15 -2
  17. package/dist/parseAdkEvent.js.map +1 -1
  18. package/dist/sdkIdentity.d.ts +6 -0
  19. package/dist/sdkIdentity.d.ts.map +1 -0
  20. package/dist/sdkIdentity.js +9 -0
  21. package/dist/sdkIdentity.js.map +1 -0
  22. package/dist/server/parseAdkRequest.d.ts.map +1 -1
  23. package/dist/server/parseAdkRequest.js +2 -1
  24. package/dist/server/parseAdkRequest.js.map +1 -1
  25. package/dist/toAdkFunctionResponse.d.ts +6 -0
  26. package/dist/toAdkFunctionResponse.d.ts.map +1 -0
  27. package/dist/toAdkFunctionResponse.js +11 -0
  28. package/dist/toAdkFunctionResponse.js.map +1 -0
  29. package/dist/useAdkMessages.d.ts.map +1 -1
  30. package/dist/useAdkMessages.js +22 -10
  31. package/dist/useAdkMessages.js.map +1 -1
  32. package/dist/useAdkRuntime.d.ts.map +1 -1
  33. package/dist/useAdkRuntime.js +6 -2
  34. package/dist/useAdkRuntime.js.map +1 -1
  35. package/package.json +6 -6
  36. package/src/AdkClient.test.ts +192 -2
  37. package/src/AdkClient.ts +2 -1
  38. package/src/AdkEventAccumulator.test.ts +297 -0
  39. package/src/AdkEventAccumulator.ts +35 -5
  40. package/src/AdkSessionAdapter.test.ts +88 -0
  41. package/src/convertToAdkMessages.test.ts +70 -0
  42. package/src/convertToAdkMessages.ts +19 -4
  43. package/src/hooks.render.test.tsx +74 -0
  44. package/src/hooks.test.tsx +33 -0
  45. package/src/hooks.ts +17 -22
  46. package/src/parseAdkEvent.ts +36 -7
  47. package/src/sdkIdentity.ts +9 -0
  48. package/src/server/parseAdkRequest.test.ts +63 -0
  49. package/src/server/parseAdkRequest.ts +2 -1
  50. package/src/toAdkFunctionResponse.test.ts +46 -0
  51. package/src/toAdkFunctionResponse.ts +18 -0
  52. package/src/useAdkMessages.test.ts +247 -0
  53. package/src/useAdkMessages.ts +51 -15
  54. package/src/useAdkRuntime.replacement.test.tsx +138 -0
  55. package/src/useAdkRuntime.ts +5 -1
  56. 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((a) => a.content) ?? []),
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
- const result: Record<string, unknown> = {};
124
- for (const key of Object.keys(state)) {
125
- if (key.startsWith(prefix)) {
126
- result[key.slice(prefix.length)] = state[key];
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
- /** Returns app-level state (keys prefixed with `app:`, prefix stripped). */
133
- export const useAdkAppState = () =>
131
+ const useAdkStateByPrefix = (prefix: string) =>
134
132
  adkExtras.use(
135
- (e) => filterByPrefix(e.stateDelta, APP_PREFIX),
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);
@@ -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 { id: rawId, ...event } = value as Record<string, unknown>;
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) &&
@@ -0,0 +1,9 @@
1
+ import type { SdkIdentity } from "assistant-cloud";
2
+
3
+ export const ADK_SDK: SdkIdentity = {
4
+ name: "@assistant-ui/react-google-adk",
5
+ version:
6
+ typeof __AUI_PACKAGE_VERSION__ === "string"
7
+ ? __AUI_PACKAGE_VERSION__
8
+ : "0.0.0",
9
+ };
@@ -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
+ };