@assistant-ui/react-google-adk 0.0.26 → 0.0.28

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.
@@ -117,7 +117,7 @@ describe("AdkEventAccumulator - function calls", () => {
117
117
  });
118
118
  });
119
119
 
120
- it("generates a UUID for functionCall without an id", () => {
120
+ it("generates an ID for functionCall without an id", () => {
121
121
  const acc = new AdkEventAccumulator();
122
122
  const msgs = acc.processEvent(
123
123
  makeEvent({
@@ -1477,3 +1477,86 @@ describe("AdkEventAccumulator - user message handling", () => {
1477
1477
  });
1478
1478
  });
1479
1479
  });
1480
+
1481
+ describe("AdkEventAccumulator - multiple parts in one event", () => {
1482
+ it("keeps every text part of a single non-partial event in order", () => {
1483
+ const acc = new AdkEventAccumulator();
1484
+ const msgs = acc.processEvent(
1485
+ makeEvent({
1486
+ author: "agent",
1487
+ content: { role: "model", parts: [{ text: "A" }, { text: "B" }] },
1488
+ }),
1489
+ );
1490
+
1491
+ expect(msgs[0]?.content).toEqual([
1492
+ { type: "text", text: "A" },
1493
+ { type: "text", text: "B" },
1494
+ ]);
1495
+ });
1496
+
1497
+ it("preserves the Gemini code-execution shape", () => {
1498
+ const acc = new AdkEventAccumulator();
1499
+ const msgs = acc.processEvent(
1500
+ makeEvent({
1501
+ author: "agent",
1502
+ content: {
1503
+ role: "model",
1504
+ parts: [
1505
+ { text: "Here is the code:" },
1506
+ { executableCode: { code: "print(1)", language: "python" } },
1507
+ { codeExecutionResult: { outcome: "OUTCOME_OK", output: "1" } },
1508
+ { text: "The result is 1." },
1509
+ ],
1510
+ },
1511
+ }),
1512
+ );
1513
+
1514
+ expect(msgs[0]?.content).toEqual([
1515
+ { type: "text", text: "Here is the code:" },
1516
+ { type: "code", code: "print(1)", language: "python" },
1517
+ { type: "code_result", output: "1", outcome: "OUTCOME_OK" },
1518
+ { type: "text", text: "The result is 1." },
1519
+ ]);
1520
+ });
1521
+
1522
+ it("keeps every reasoning part of a single non-partial event", () => {
1523
+ const acc = new AdkEventAccumulator();
1524
+ const msgs = acc.processEvent(
1525
+ makeEvent({
1526
+ author: "agent",
1527
+ content: {
1528
+ role: "model",
1529
+ parts: [
1530
+ { text: "First thought", thought: true },
1531
+ { text: "Second thought", thought: true },
1532
+ ],
1533
+ },
1534
+ }),
1535
+ );
1536
+
1537
+ expect(msgs[0]?.content).toEqual([
1538
+ { type: "reasoning", text: "First thought" },
1539
+ { type: "reasoning", text: "Second thought" },
1540
+ ]);
1541
+ });
1542
+
1543
+ it("still replaces the streamed buffer with the final text", () => {
1544
+ const acc = new AdkEventAccumulator();
1545
+ acc.processEvent(makeTextEvent("Hel", true));
1546
+ acc.processEvent(makeTextEvent("lo", true));
1547
+ const msgs = acc.processEvent(
1548
+ makeEvent({
1549
+ author: "agent",
1550
+ content: {
1551
+ role: "model",
1552
+ parts: [{ text: "Hello" }, { text: "Again" }],
1553
+ },
1554
+ }),
1555
+ );
1556
+
1557
+ expect(msgs[0]?.content).toEqual([
1558
+ { type: "text", text: "Hello" },
1559
+ { type: "text", text: "Again" },
1560
+ ]);
1561
+ });
1562
+ });
@@ -1,4 +1,4 @@
1
- import { v4 as uuidv4 } from "uuid";
1
+ import { generateId } from "@assistant-ui/core";
2
2
  import type { MessageStatus } from "@assistant-ui/core";
3
3
  import type {
4
4
  AdkEvent,
@@ -26,10 +26,10 @@ type InProgressMessage = AdkMessage & { type: "ai" };
26
26
  * event has already opened.
27
27
  */
28
28
  const toolMessageId = (event: AdkEvent, partIndex: number): string =>
29
- event.id ? `${event.id}:${partIndex}` : uuidv4();
29
+ event.id ? `${event.id}:${partIndex}` : generateId();
30
30
 
31
31
  const aiMessageId = (event: AdkEvent, ordinal: number): string =>
32
- event.id ? `${event.id}:ai${ordinal === 0 ? "" : ordinal}` : uuidv4();
32
+ event.id ? `${event.id}:ai${ordinal === 0 ? "" : ordinal}` : generateId();
33
33
 
34
34
  const ADK_REQUEST_CONFIRMATION = "adk_request_confirmation";
35
35
  const ADK_REQUEST_CREDENTIAL = "adk_request_credential";
@@ -191,6 +191,8 @@ export class AdkEventAccumulator {
191
191
  private messagesMap = new Map<string, AdkMessage>();
192
192
  private currentMessageId: string | null = null;
193
193
  private partialTextBuffer = "";
194
+ private finalTextReplacedThisEvent = false;
195
+ private finalReasoningReplacedThisEvent = false;
194
196
  private partialReasoningBuffer = "";
195
197
  private accumulatedStateDelta: Record<string, unknown> = {};
196
198
  private accumulatedArtifactDelta: Record<string, number> = {};
@@ -368,7 +370,7 @@ export class AdkEventAccumulator {
368
370
  this.messagesMap.set(toolMsg.id, toolMsg);
369
371
  }
370
372
  if (humanParts.length > 0) {
371
- const id = event.id ?? uuidv4();
373
+ const id = event.id ?? generateId();
372
374
  const first = humanParts[0];
373
375
  const content: string | AdkMessageContentPart[] =
374
376
  humanParts.length === 1 && first?.type === "text"
@@ -387,6 +389,11 @@ export class AdkEventAccumulator {
387
389
  }
388
390
  }
389
391
 
392
+ // Replace-semantics close out the streamed partial buffer, which only
393
+ // the first final text/reasoning part of an event may do; later parts
394
+ // of the same event are distinct content and append.
395
+ this.finalTextReplacedThisEvent = false;
396
+ this.finalReasoningReplacedThisEvent = false;
390
397
  for (const [index, part] of parts.entries()) {
391
398
  this.processPart(part, event, index);
392
399
  }
@@ -472,9 +479,12 @@ export class AdkEventAccumulator {
472
479
  if (event.partial) {
473
480
  this.partialReasoningBuffer += part.text;
474
481
  this.replaceLastReasoningContent(msg, this.partialReasoningBuffer);
475
- } else {
482
+ } else if (!this.finalReasoningReplacedThisEvent) {
483
+ this.finalReasoningReplacedThisEvent = true;
476
484
  this.partialReasoningBuffer = "";
477
485
  this.replaceLastReasoningContent(msg, part.text);
486
+ } else {
487
+ this.appendContent(msg, { type: "reasoning", text: part.text });
478
488
  }
479
489
  return;
480
490
  }
@@ -485,9 +495,12 @@ export class AdkEventAccumulator {
485
495
  if (event.partial) {
486
496
  this.partialTextBuffer += part.text;
487
497
  this.replaceLastTextContent(msg, this.partialTextBuffer);
488
- } else {
498
+ } else if (!this.finalTextReplacedThisEvent) {
499
+ this.finalTextReplacedThisEvent = true;
489
500
  this.partialTextBuffer = "";
490
501
  this.replaceLastTextContent(msg, part.text);
502
+ } else {
503
+ this.appendContent(msg, { type: "text", text: part.text });
491
504
  }
492
505
  return;
493
506
  }
@@ -497,7 +510,7 @@ export class AdkEventAccumulator {
497
510
  if (event.partial) return;
498
511
  const msg = this.getOrCreateAiMessage(event);
499
512
  const toolCall: AdkToolCall = {
500
- id: part.functionCall.id ?? uuidv4(),
513
+ id: part.functionCall.id ?? generateId(),
501
514
  name: part.functionCall.name,
502
515
  args: part.functionCall.args as ReadonlyJSONObject,
503
516
  argsText: JSON.stringify(part.functionCall.args),
@@ -1,9 +1,9 @@
1
+ import { generateId } from "@assistant-ui/core";
1
2
  import type {
2
3
  RespondToToolApprovalOptions,
3
4
  ToolCallMessagePart,
4
5
  } from "@assistant-ui/core";
5
6
  import type { ReadonlyJSONValue } from "assistant-stream/utils";
6
- import { v4 as uuidv4 } from "uuid";
7
7
  import type { AdkMessage } from "./types";
8
8
 
9
9
  export type AdkToolApproval = NonNullable<ToolCallMessagePart["approval"]>;
@@ -185,7 +185,7 @@ export const toAdkConfirmationReply = (
185
185
  confirmed: boolean,
186
186
  payload?: ReadonlyJSONValue,
187
187
  ): AdkMessage & { type: "tool" } => ({
188
- id: uuidv4(),
188
+ id: generateId(),
189
189
  type: "tool",
190
190
  tool_call_id: toolCallId,
191
191
  name: ADK_REQUEST_CONFIRMATION,
@@ -3,7 +3,7 @@ import {
3
3
  getMessageContent,
4
4
  getPendingCancellations,
5
5
  getPendingToolCalls,
6
- } from "./useAdkRuntime";
6
+ } from "./convertToAdkMessages";
7
7
  import { convertAdkMessage } from "./convertAdkMessages";
8
8
  import type { AppendMessage } from "@assistant-ui/core";
9
9
  import type { AdkMessage } from "./types";
@@ -0,0 +1,139 @@
1
+ import {
2
+ generateId,
3
+ getExternalStoreMessages,
4
+ type AppendMessage,
5
+ type ThreadMessage,
6
+ } from "@assistant-ui/core";
7
+ import {
8
+ createToolCallCancellationStub,
9
+ parseDataUrl,
10
+ resolveFilePartSource,
11
+ scanPendingToolCalls,
12
+ } from "@assistant-ui/core/internal";
13
+ import type { AdkMessage } from "./types";
14
+
15
+ /** Exported for unit tests. */
16
+ export const getMessageContent = (msg: AppendMessage) => {
17
+ const allContent = [
18
+ ...msg.content,
19
+ ...(msg.attachments?.flatMap((a) => a.content) ?? []),
20
+ ];
21
+ const content = allContent.flatMap((part) => {
22
+ const type = part.type;
23
+ switch (type) {
24
+ case "text":
25
+ return { type: "text" as const, text: part.text };
26
+ case "image":
27
+ return { type: "image_url" as const, url: part.image };
28
+ case "file": {
29
+ const source = resolveFilePartSource(part);
30
+ if (source.kind === "url") {
31
+ return {
32
+ type: "file_url" as const,
33
+ url: source.url,
34
+ mimeType: part.mimeType,
35
+ };
36
+ }
37
+ return {
38
+ type: "file" as const,
39
+ mimeType: part.mimeType,
40
+ // Lands in Gemini `inlineData.data`, which takes bare base64, so a
41
+ // data URL envelope is stripped rather than forwarded.
42
+ data: source.data,
43
+ ...(part.filename != null && { filename: part.filename }),
44
+ };
45
+ }
46
+ case "audio": {
47
+ const parsed = parseDataUrl(part.audio.data);
48
+ return {
49
+ type: "file" as const,
50
+ mimeType: `audio/${part.audio.format}`,
51
+ data: parsed?.data ?? part.audio.data,
52
+ };
53
+ }
54
+ case "data":
55
+ return [];
56
+
57
+ case "tool-call":
58
+ throw new Error("Tool call appends are not supported.");
59
+
60
+ default: {
61
+ const _exhaustiveCheck: "reasoning" | "source" | "generative-ui" = type;
62
+ throw new Error(
63
+ `Unsupported append message part type: ${_exhaustiveCheck}`,
64
+ );
65
+ }
66
+ }
67
+ });
68
+
69
+ if (content.length === 1 && content[0]?.type === "text") {
70
+ return content[0].text ?? "";
71
+ }
72
+
73
+ return content;
74
+ };
75
+
76
+ /** Exported for unit tests. */
77
+ export const getPendingToolCalls = (messages: AdkMessage[]) => {
78
+ return scanPendingToolCalls(
79
+ messages,
80
+ (message) => {
81
+ if (message.type === "ai") {
82
+ return { toolCalls: message.tool_calls ?? [] };
83
+ }
84
+ if (message.type === "tool") {
85
+ return { toolCallId: message.tool_call_id };
86
+ }
87
+ return undefined;
88
+ },
89
+ (toolCall) => toolCall.id,
90
+ );
91
+ };
92
+
93
+ /**
94
+ * Exported for unit tests.
95
+ *
96
+ * Returns `{cancelled: true}` tool responses for pending tool calls when the
97
+ * user sends a new turn, EXCEPT for HITL interrupts marked via
98
+ * `long_running_tool_ids` (`adk_request_input`, `adk_request_confirmation`,
99
+ * `adk_request_credential`). Those must be answered through a dedicated tool
100
+ * UI + submit helper, not auto-cancelled.
101
+ */
102
+ export const getPendingCancellations = (
103
+ messages: AdkMessage[],
104
+ longRunningToolIds: readonly string[],
105
+ ): Array<AdkMessage & { type: "tool" }> => {
106
+ const longRunningSet = new Set(longRunningToolIds);
107
+ return getPendingToolCalls(messages)
108
+ .filter((t) => !longRunningSet.has(t.id))
109
+ .map(
110
+ (t) =>
111
+ ({
112
+ id: generateId(),
113
+ ...createToolCallCancellationStub(t),
114
+ }) satisfies AdkMessage & { type: "tool" },
115
+ );
116
+ };
117
+
118
+ export const truncateAdkMessages = (
119
+ threadMessages: readonly ThreadMessage[],
120
+ parentId: string | null,
121
+ ): AdkMessage[] => {
122
+ if (parentId === null) return [];
123
+ const parentIndex = threadMessages.findIndex((m) => m.id === parentId);
124
+ if (parentIndex === -1) return [];
125
+ const truncated: AdkMessage[] = [];
126
+ for (let i = 0; i <= parentIndex && i < threadMessages.length; i++) {
127
+ truncated.push(...getExternalStoreMessages<AdkMessage>(threadMessages[i]!));
128
+ }
129
+ return truncated;
130
+ };
131
+
132
+ export const toAdkUserMessage = (
133
+ msg: AppendMessage,
134
+ id = generateId(),
135
+ ): AdkMessage & { type: "human"; id: string } => ({
136
+ id,
137
+ type: "human",
138
+ content: getMessageContent(msg),
139
+ });
package/src/hooks.ts CHANGED
@@ -1,5 +1,5 @@
1
+ import { generateId } from "@assistant-ui/core";
1
2
  import { useAui } from "@assistant-ui/store";
2
- import { v4 as uuidv4 } from "uuid";
3
3
  import type { ReadonlyJSONValue } from "assistant-stream/utils";
4
4
  import { adkExtras } from "./adkExtras";
5
5
  import { toAdkConfirmationReply } from "./adkToolApproval";
@@ -79,7 +79,7 @@ export const useAdkSubmitAuth = () => {
79
79
  adkExtras.get(aui).send(
80
80
  [
81
81
  {
82
- id: uuidv4(),
82
+ id: generateId(),
83
83
  type: "tool",
84
84
  tool_call_id: toolCallId,
85
85
  name: "adk_request_credential",
@@ -98,7 +98,7 @@ export const useAdkSubmitInput = () => {
98
98
  adkExtras.get(aui).send(
99
99
  [
100
100
  {
101
- id: uuidv4(),
101
+ id: generateId(),
102
102
  type: "tool",
103
103
  tool_call_id: toolCallId,
104
104
  name: "adk_request_input",
@@ -1,6 +1,14 @@
1
- import { useState, useCallback, useEffect, useRef, useMemo } from "react";
2
- import { v4 as uuidv4 } from "uuid";
1
+ import {
2
+ useState,
3
+ useCallback,
4
+ useEffect,
5
+ useInsertionEffect,
6
+ useRef,
7
+ useMemo,
8
+ } from "react";
9
+ import { generateId } from "@assistant-ui/core";
3
10
  import { useAui } from "@assistant-ui/store";
11
+ import { invokeUserCallback } from "@assistant-ui/core/internal";
4
12
  import { AdkEventAccumulator } from "./AdkEventAccumulator";
5
13
  import { contentToParts } from "./contentToParts";
6
14
  import type {
@@ -28,24 +36,12 @@ export type UseAdkMessagesOptions = {
28
36
 
29
37
  type AdkRuntimeCallbackName = "onError" | "onCustomEvent" | "onAgentTransfer";
30
38
 
31
- const reportCallbackError = (name: AdkRuntimeCallbackName, error: unknown) => {
32
- console.error(`[react-google-adk] ${name} callback threw an error`, error);
33
- };
34
-
35
- const invokeAdkRuntimeCallback = <TArgs extends unknown[]>(
39
+ const invokeAdkRuntimeCallback = <TArgs extends readonly unknown[]>(
36
40
  name: AdkRuntimeCallbackName,
37
- callback: ((...args: TArgs) => void | Promise<void>) | undefined,
41
+ callback: ((...args: TArgs) => unknown) | undefined,
38
42
  ...args: TArgs
39
- ) => {
40
- if (!callback) return;
41
-
42
- try {
43
- void Promise.resolve(callback(...args)).catch((error) => {
44
- reportCallbackError(name, error);
45
- });
46
- } catch (error) {
47
- reportCallbackError(name, error);
48
- }
43
+ ): void => {
44
+ void invokeUserCallback("react-google-adk", name, callback, ...args);
49
45
  };
50
46
 
51
47
  export const useAdkMessages = ({
@@ -71,14 +67,21 @@ export const useAdkMessages = ({
71
67
  Map<string, AdkMessageMetadata>
72
68
  >(new Map());
73
69
  const lastTransferToAgentRef = useRef<string | undefined>(undefined);
70
+ // setMessagesImmediate is the only writer of the messages state and publishes
71
+ // this ref with it, so the ref never trails a commit.
74
72
  const messagesRef = useRef(messages);
75
- messagesRef.current = messages;
76
73
  const stateDeltaRef = useRef(stateDelta);
77
- stateDeltaRef.current = stateDelta;
74
+ useInsertionEffect(() => {
75
+ stateDeltaRef.current = stateDelta;
76
+ }, [stateDelta]);
78
77
  const artifactDeltaRef = useRef(artifactDelta);
79
- artifactDeltaRef.current = artifactDelta;
78
+ useInsertionEffect(() => {
79
+ artifactDeltaRef.current = artifactDelta;
80
+ }, [artifactDelta]);
80
81
  const messageMetadataRef = useRef(messageMetadata);
81
- messageMetadataRef.current = messageMetadata;
82
+ useInsertionEffect(() => {
83
+ messageMetadataRef.current = messageMetadata;
84
+ }, [messageMetadata]);
82
85
 
83
86
  const setMessagesImmediate = useCallback((msgs: AdkMessage[]) => {
84
87
  messagesRef.current = msgs;
@@ -133,7 +136,7 @@ export const useAdkMessages = ({
133
136
  const sendMessage = useCallback(
134
137
  async (newMessages: AdkMessage[], config: AdkSendMessageConfig) => {
135
138
  const newMessagesWithId = newMessages.map((m) =>
136
- m.id ? m : { ...m, id: uuidv4() },
139
+ m.id ? m : { ...m, id: generateId() },
137
140
  ) as AdkMessage[];
138
141
 
139
142
  // A staged message is already in the thread under its own id, and the
@@ -306,7 +309,7 @@ export const messagesToEvents = (messages: AdkMessage[]): AdkEvent[] => {
306
309
  // message. Emitting it here keeps the optimistic view equal to that replay.
307
310
  if (parts.length === 0) parts.push({ text: "" });
308
311
 
309
- const event: AdkEvent = { id: (human ?? run[0])?.id ?? uuidv4() };
312
+ const event: AdkEvent = { id: (human ?? run[0])?.id ?? generateId() };
310
313
  if (human || run.length === 0) event.author = "user";
311
314
  event.content = { role: "user", parts };
312
315
  events.splice(run.length > 0 ? runIndex : events.length, 0, event);
@@ -318,7 +321,7 @@ export const messagesToEvents = (messages: AdkMessage[]): AdkEvent[] => {
318
321
  export const messageToEvent = (msg: AdkMessage): AdkEvent => {
319
322
  if (msg.type === "human") {
320
323
  return {
321
- id: msg.id ?? uuidv4(),
324
+ id: msg.id ?? generateId(),
322
325
  author: "user",
323
326
  content: { role: "user", parts: contentToParts(msg.content) },
324
327
  };
@@ -332,7 +335,7 @@ export const messageToEvent = (msg: AdkMessage): AdkEvent => {
332
335
  response = msg.content;
333
336
  }
334
337
  return {
335
- id: msg.id ?? uuidv4(),
338
+ id: msg.id ?? generateId(),
336
339
  content: {
337
340
  role: "user",
338
341
  parts: [
@@ -348,7 +351,7 @@ export const messageToEvent = (msg: AdkMessage): AdkEvent => {
348
351
  };
349
352
  }
350
353
 
351
- const result: AdkEvent = { id: msg.id ?? uuidv4() };
354
+ const result: AdkEvent = { id: msg.id ?? generateId() };
352
355
  if (msg.author != null) result.author = msg.author;
353
356
  result.content = {
354
357
  role: "model",