@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.
Files changed (42) hide show
  1. package/LICENSE +1 -1
  2. package/dist/AdkEventAccumulator.d.ts.map +1 -1
  3. package/dist/AdkEventAccumulator.js +4 -1
  4. package/dist/AdkEventAccumulator.js.map +1 -1
  5. package/dist/adkToolApproval.js +1 -1
  6. package/dist/adkToolApproval.js.map +1 -1
  7. package/dist/contentToParts.d.ts.map +1 -1
  8. package/dist/contentToParts.js +1 -0
  9. package/dist/contentToParts.js.map +1 -1
  10. package/dist/convertAdkMessages.d.ts.map +1 -1
  11. package/dist/convertAdkMessages.js +34 -19
  12. package/dist/convertAdkMessages.js.map +1 -1
  13. package/dist/convertToAdkMessages.d.ts.map +1 -1
  14. package/dist/convertToAdkMessages.js +2 -2
  15. package/dist/convertToAdkMessages.js.map +1 -1
  16. package/dist/sdkIdentity.js +1 -1
  17. package/dist/useAdkMessages.d.ts.map +1 -1
  18. package/dist/useAdkMessages.js +29 -11
  19. package/dist/useAdkMessages.js.map +1 -1
  20. package/dist/useAdkRuntime.d.ts.map +1 -1
  21. package/dist/useAdkRuntime.js +5 -4
  22. package/dist/useAdkRuntime.js.map +1 -1
  23. package/package.json +9 -6
  24. package/src/AdkEventAccumulator.test.ts +32 -0
  25. package/src/AdkEventAccumulator.ts +1 -0
  26. package/src/adkToolApproval.test.ts +19 -0
  27. package/src/adkToolApproval.ts +1 -1
  28. package/src/contentToParts.ts +2 -0
  29. package/src/convertAdkMessages.test.ts +142 -0
  30. package/src/convertAdkMessages.ts +24 -1
  31. package/src/convertToAdkMessages.test.ts +20 -0
  32. package/src/convertToAdkMessages.ts +4 -1
  33. package/src/tests/settleOutsideAct.ts +15 -0
  34. package/src/useAdkMessages.fast-refresh.test.tsx +114 -0
  35. package/src/useAdkMessages.test.ts +48 -0
  36. package/src/useAdkMessages.ts +40 -9
  37. package/src/useAdkRuntime.cancellation.test.tsx +544 -0
  38. package/src/useAdkRuntime.fast-refresh.test.tsx +166 -0
  39. package/src/useAdkRuntime.refetch.test.tsx +8 -6
  40. package/src/useAdkRuntime.replacement.test.tsx +4 -3
  41. package/src/useAdkRuntime.toolOwnership.test.tsx +4 -3
  42. 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?.map((tc) => {
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 { toolCalls: message.tool_calls ?? [] };
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] }],
@@ -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
- setMessagesImmediate(accumulator.getMessages());
172
- setLongRunningToolIds(accumulator.getLongRunningToolIds());
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 !== lastTransferToAgentRef.current) {
230
- lastTransferToAgentRef.current = transfer;
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
- useEffect(() => cancel, [cancel]);
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?.map((tc) => ({
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;