@assistant-ui/react-google-adk 0.0.25 → 0.0.27

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 (58) hide show
  1. package/dist/AdkClient.d.ts.map +1 -1
  2. package/dist/AdkClient.js +13 -3
  3. package/dist/AdkClient.js.map +1 -1
  4. package/dist/AdkEventAccumulator.d.ts +2 -0
  5. package/dist/AdkEventAccumulator.d.ts.map +1 -1
  6. package/dist/AdkEventAccumulator.js +32 -10
  7. package/dist/AdkEventAccumulator.js.map +1 -1
  8. package/dist/AdkSessionAdapter.d.ts.map +1 -1
  9. package/dist/AdkSessionAdapter.js +2 -1
  10. package/dist/AdkSessionAdapter.js.map +1 -1
  11. package/dist/adkToolApproval.d.ts +40 -0
  12. package/dist/adkToolApproval.d.ts.map +1 -0
  13. package/dist/adkToolApproval.js +166 -0
  14. package/dist/adkToolApproval.js.map +1 -0
  15. package/dist/convertAdkMessages.d.ts +3 -1
  16. package/dist/convertAdkMessages.d.ts.map +1 -1
  17. package/dist/convertAdkMessages.js +27 -16
  18. package/dist/convertAdkMessages.js.map +1 -1
  19. package/dist/convertToAdkMessages.d.ts +49 -0
  20. package/dist/convertToAdkMessages.d.ts.map +1 -0
  21. package/dist/convertToAdkMessages.js +86 -0
  22. package/dist/convertToAdkMessages.js.map +1 -0
  23. package/dist/hooks.d.ts.map +1 -1
  24. package/dist/hooks.js +5 -14
  25. package/dist/hooks.js.map +1 -1
  26. package/dist/parseAdkEvent.d.ts +6 -0
  27. package/dist/parseAdkEvent.d.ts.map +1 -0
  28. package/dist/parseAdkEvent.js +16 -0
  29. package/dist/parseAdkEvent.js.map +1 -0
  30. package/dist/server/parseAdkRequest.d.ts.map +1 -1
  31. package/dist/server/parseAdkRequest.js +79 -13
  32. package/dist/server/parseAdkRequest.js.map +1 -1
  33. package/dist/useAdkMessages.d.ts.map +1 -1
  34. package/dist/useAdkMessages.js +110 -79
  35. package/dist/useAdkMessages.js.map +1 -1
  36. package/dist/useAdkRuntime.d.ts.map +1 -1
  37. package/dist/useAdkRuntime.js +44 -129
  38. package/dist/useAdkRuntime.js.map +1 -1
  39. package/package.json +6 -7
  40. package/src/AdkClient.test.ts +137 -0
  41. package/src/AdkClient.ts +15 -3
  42. package/src/AdkEventAccumulator.test.ts +161 -1
  43. package/src/AdkEventAccumulator.ts +44 -8
  44. package/src/AdkSessionAdapter.test.ts +145 -1
  45. package/src/AdkSessionAdapter.ts +7 -2
  46. package/src/adkToolApproval.test.ts +345 -0
  47. package/src/adkToolApproval.ts +215 -0
  48. package/src/convertAdkMessages.ts +72 -43
  49. package/src/{useAdkRuntime.test.ts → convertToAdkMessages.test.ts} +1 -1
  50. package/src/convertToAdkMessages.ts +139 -0
  51. package/src/hooks.ts +7 -19
  52. package/src/parseAdkEvent.ts +40 -0
  53. package/src/server/parseAdkRequest.test.ts +141 -0
  54. package/src/server/parseAdkRequest.ts +145 -13
  55. package/src/useAdkMessages.test.ts +313 -0
  56. package/src/useAdkMessages.ts +72 -25
  57. package/src/useAdkRuntime.ts +66 -188
  58. package/src/useAdkRuntimeApproval.test.tsx +344 -0
@@ -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({
@@ -240,6 +240,29 @@ describe("AdkEventAccumulator - function responses", () => {
240
240
  });
241
241
  });
242
242
 
243
+ it("skips a user function response that answers no call", () => {
244
+ const acc = new AdkEventAccumulator();
245
+ const msgs = acc.processEvent(
246
+ makeEvent({
247
+ author: "user",
248
+ content: {
249
+ role: "user",
250
+ parts: [
251
+ {
252
+ functionResponse: {
253
+ name: "adk_request_confirmation",
254
+ response: { confirmed: true },
255
+ },
256
+ },
257
+ ],
258
+ },
259
+ }),
260
+ );
261
+ // Without an id it answers no call: core drops it as an orphan, and
262
+ // keeping it would let it settle the confirmation batch it grouped into.
263
+ expect(msgs.filter((m) => m.type === "tool")).toEqual([]);
264
+ });
265
+
243
266
  // A session load replays the stored events through a fresh accumulator.
244
267
  it("gives a tool message the same id on every replay of an event", () => {
245
268
  const event = makeEvent({
@@ -1091,6 +1114,60 @@ describe("AdkEventAccumulator - user message handling", () => {
1091
1114
  });
1092
1115
  });
1093
1116
 
1117
+ it("creates a tool message for a user-authored function response", () => {
1118
+ const acc = new AdkEventAccumulator();
1119
+ const msgs = acc.processEvent(
1120
+ makeEvent({
1121
+ author: "user",
1122
+ content: {
1123
+ role: "user",
1124
+ parts: [
1125
+ {
1126
+ functionResponse: {
1127
+ id: "tc-1",
1128
+ name: "adk_request_confirmation",
1129
+ response: { confirmed: true },
1130
+ },
1131
+ },
1132
+ ],
1133
+ },
1134
+ }),
1135
+ );
1136
+ expect(msgs).toHaveLength(1);
1137
+ expect(msgs[0]).toMatchObject({
1138
+ type: "tool",
1139
+ tool_call_id: "tc-1",
1140
+ name: "adk_request_confirmation",
1141
+ content: JSON.stringify({ confirmed: true }),
1142
+ status: "success",
1143
+ });
1144
+ });
1145
+
1146
+ it("orders function responses from one user event before its text", () => {
1147
+ const acc = new AdkEventAccumulator();
1148
+ const msgs = acc.processEvent(
1149
+ makeEvent({
1150
+ author: "user",
1151
+ content: {
1152
+ role: "user",
1153
+ parts: [
1154
+ { text: "go ahead" },
1155
+ {
1156
+ functionResponse: {
1157
+ id: "tc-1",
1158
+ name: "adk_request_confirmation",
1159
+ response: { confirmed: true },
1160
+ },
1161
+ },
1162
+ ],
1163
+ },
1164
+ }),
1165
+ );
1166
+ expect(msgs).toHaveLength(2);
1167
+ expect(msgs[0]).toMatchObject({ type: "tool", tool_call_id: "tc-1" });
1168
+ expect(msgs[1]).toMatchObject({ type: "human", content: "go ahead" });
1169
+ });
1170
+
1094
1171
  it("creates separate human and AI messages for a user/agent turn", () => {
1095
1172
  const acc = new AdkEventAccumulator();
1096
1173
  acc.processEvent(
@@ -1400,3 +1477,86 @@ describe("AdkEventAccumulator - user message handling", () => {
1400
1477
  });
1401
1478
  });
1402
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> = {};
@@ -331,7 +333,8 @@ export class AdkEventAccumulator {
331
333
  if (event.author === "user") {
332
334
  this.finalizeCurrentMessage();
333
335
  const humanParts: AdkMessageContentPart[] = [];
334
- for (const part of parts) {
336
+ const toolMessages: AdkMessage[] = [];
337
+ for (const [index, part] of parts.entries()) {
335
338
  if (part.text != null && !part.thought) {
336
339
  humanParts.push({ type: "text", text: part.text });
337
340
  } else if (part.inlineData) {
@@ -342,10 +345,32 @@ export class AdkEventAccumulator {
342
345
  humanParts.push(
343
346
  fileDataToPart(part.fileData.fileUri, part.fileData.mimeType),
344
347
  );
348
+ } else if (part.functionResponse?.id) {
349
+ // ADK records tool confirmation and other client-supplied tool
350
+ // results as user-authored function responses, and its request
351
+ // confirmation processors search user events for them. Dropping
352
+ // them here would replay a settled gate as pending. A response
353
+ // carrying no id answers no call: core drops it as an orphan, and
354
+ // keeping it would let it settle the batch it was grouped into.
355
+ toolMessages.push({
356
+ id: toolMessageId(event, index),
357
+ type: "tool",
358
+ tool_call_id: part.functionResponse.id,
359
+ name: part.functionResponse.name,
360
+ content: JSON.stringify(part.functionResponse.response),
361
+ status: "success",
362
+ });
345
363
  }
346
364
  }
365
+ // The replies answer the preceding assistant turn, so they are emitted
366
+ // before any user content in the same event. A human message between the
367
+ // tool call and its reply splits them into separate converted messages,
368
+ // orphaning the reply and leaving its gate unsettled.
369
+ for (const toolMsg of toolMessages) {
370
+ this.messagesMap.set(toolMsg.id, toolMsg);
371
+ }
347
372
  if (humanParts.length > 0) {
348
- const id = event.id ?? uuidv4();
373
+ const id = event.id ?? generateId();
349
374
  const first = humanParts[0];
350
375
  const content: string | AdkMessageContentPart[] =
351
376
  humanParts.length === 1 && first?.type === "text"
@@ -364,6 +389,11 @@ export class AdkEventAccumulator {
364
389
  }
365
390
  }
366
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;
367
397
  for (const [index, part] of parts.entries()) {
368
398
  this.processPart(part, event, index);
369
399
  }
@@ -449,9 +479,12 @@ export class AdkEventAccumulator {
449
479
  if (event.partial) {
450
480
  this.partialReasoningBuffer += part.text;
451
481
  this.replaceLastReasoningContent(msg, this.partialReasoningBuffer);
452
- } else {
482
+ } else if (!this.finalReasoningReplacedThisEvent) {
483
+ this.finalReasoningReplacedThisEvent = true;
453
484
  this.partialReasoningBuffer = "";
454
485
  this.replaceLastReasoningContent(msg, part.text);
486
+ } else {
487
+ this.appendContent(msg, { type: "reasoning", text: part.text });
455
488
  }
456
489
  return;
457
490
  }
@@ -462,9 +495,12 @@ export class AdkEventAccumulator {
462
495
  if (event.partial) {
463
496
  this.partialTextBuffer += part.text;
464
497
  this.replaceLastTextContent(msg, this.partialTextBuffer);
465
- } else {
498
+ } else if (!this.finalTextReplacedThisEvent) {
499
+ this.finalTextReplacedThisEvent = true;
466
500
  this.partialTextBuffer = "";
467
501
  this.replaceLastTextContent(msg, part.text);
502
+ } else {
503
+ this.appendContent(msg, { type: "text", text: part.text });
468
504
  }
469
505
  return;
470
506
  }
@@ -474,7 +510,7 @@ export class AdkEventAccumulator {
474
510
  if (event.partial) return;
475
511
  const msg = this.getOrCreateAiMessage(event);
476
512
  const toolCall: AdkToolCall = {
477
- id: part.functionCall.id ?? uuidv4(),
513
+ id: part.functionCall.id ?? generateId(),
478
514
  name: part.functionCall.name,
479
515
  args: part.functionCall.args as ReadonlyJSONObject,
480
516
  argsText: JSON.stringify(part.functionCall.args),
@@ -1,5 +1,6 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
  import { createAdkSessionAdapter } from "./AdkSessionAdapter";
3
+ import { projectAdkToolApprovals } from "./adkToolApproval";
3
4
 
4
5
  // ── Helpers ──
5
6
 
@@ -410,6 +411,45 @@ describe("createAdkSessionAdapter - load", () => {
410
411
  );
411
412
  });
412
413
 
414
+ it("rejects malformed events inside a session history", async () => {
415
+ mockFetch.mockResolvedValueOnce(
416
+ new Response(JSON.stringify({ id: "s1", events: [{}] }), {
417
+ status: 200,
418
+ }),
419
+ );
420
+
421
+ const { load } = createAdkSessionAdapter(baseOptions);
422
+
423
+ await expect(load("s1")).rejects.toThrow(
424
+ "Invalid ADK session event at index 0: expected a non-empty object.",
425
+ );
426
+ });
427
+
428
+ it("rejects partial replay when a history event is malformed", async () => {
429
+ mockFetch.mockResolvedValueOnce(
430
+ new Response(
431
+ JSON.stringify({
432
+ id: "s1",
433
+ events: [
434
+ {
435
+ id: "e1",
436
+ author: "user",
437
+ content: { role: "user", parts: [{ text: "Hello" }] },
438
+ },
439
+ {},
440
+ ],
441
+ }),
442
+ { status: 200 },
443
+ ),
444
+ );
445
+
446
+ const { load } = createAdkSessionAdapter(baseOptions);
447
+
448
+ await expect(load("s1")).rejects.toThrow(
449
+ "Invalid ADK session event at index 1: expected a non-empty object.",
450
+ );
451
+ });
452
+
413
453
  it("throws when session fetch fails", async () => {
414
454
  mockFetch.mockResolvedValueOnce(
415
455
  new Response("Server error", { status: 500 }),
@@ -448,6 +488,110 @@ describe("createAdkSessionAdapter - load", () => {
448
488
  });
449
489
  });
450
490
 
491
+ describe("createAdkSessionAdapter - load replays tool confirmations", () => {
492
+ const CONFIRMATION_CALL = "conf-1";
493
+
494
+ const confirmationSession = (response: unknown) => ({
495
+ id: "s1",
496
+ events: [
497
+ {
498
+ id: "e1",
499
+ author: "agent",
500
+ longRunningToolIds: [CONFIRMATION_CALL],
501
+ content: {
502
+ role: "model",
503
+ parts: [
504
+ {
505
+ functionCall: {
506
+ id: CONFIRMATION_CALL,
507
+ name: "adk_request_confirmation",
508
+ args: {
509
+ originalFunctionCall: { id: "tc-1", name: "delete_file" },
510
+ toolConfirmation: { hint: "Delete /tmp/a?" },
511
+ },
512
+ },
513
+ },
514
+ ],
515
+ },
516
+ },
517
+ {
518
+ id: "e2",
519
+ author: "user",
520
+ content: {
521
+ role: "user",
522
+ parts: [
523
+ {
524
+ functionResponse: {
525
+ id: CONFIRMATION_CALL,
526
+ name: "adk_request_confirmation",
527
+ response,
528
+ },
529
+ },
530
+ ],
531
+ },
532
+ },
533
+ ],
534
+ });
535
+
536
+ const loadApprovals = async (response: unknown) => {
537
+ mockFetch.mockResolvedValueOnce(
538
+ new Response(JSON.stringify(confirmationSession(response)), {
539
+ status: 200,
540
+ }),
541
+ );
542
+ const { load } = createAdkSessionAdapter(baseOptions);
543
+ const result = await load("s1");
544
+ return {
545
+ messages: result.messages,
546
+ approvals: projectAdkToolApprovals(result.messages).approvals,
547
+ };
548
+ };
549
+
550
+ it("keeps a user-authored confirmation reply as a tool message", async () => {
551
+ const { messages } = await loadApprovals({ confirmed: true });
552
+
553
+ expect(messages.map((m) => m.type)).toEqual(["ai", "tool"]);
554
+ expect(messages[1]).toMatchObject({
555
+ type: "tool",
556
+ tool_call_id: CONFIRMATION_CALL,
557
+ name: "adk_request_confirmation",
558
+ });
559
+ });
560
+
561
+ it.each([
562
+ ["direct", { confirmed: true }, { approved: true }],
563
+ ["direct denial", { confirmed: false }, { approved: false }],
564
+ [
565
+ "wrapped",
566
+ { response: JSON.stringify({ confirmed: true }) },
567
+ { approved: true },
568
+ ],
569
+ [
570
+ "wrapped denial",
571
+ { response: JSON.stringify({ confirmed: false }) },
572
+ { approved: false },
573
+ ],
574
+ ])(
575
+ "projects a settled gate from a replayed %s reply",
576
+ async (_name, response, expected) => {
577
+ const { approvals } = await loadApprovals(response);
578
+
579
+ expect(approvals.get(CONFIRMATION_CALL)).toEqual({
580
+ id: CONFIRMATION_CALL,
581
+ ...expected,
582
+ });
583
+ },
584
+ );
585
+
586
+ it("leaves the gate pending when the replayed reply is unreadable", async () => {
587
+ const { approvals } = await loadApprovals({ response: "not json" });
588
+
589
+ expect(approvals.get(CONFIRMATION_CALL)).toEqual({
590
+ id: CONFIRMATION_CALL,
591
+ });
592
+ });
593
+ });
594
+
451
595
  describe("createAdkSessionAdapter - artifacts", () => {
452
596
  it("lists names from current and legacy artifact responses", async () => {
453
597
  mockFetch.mockResolvedValueOnce(
@@ -6,7 +6,8 @@ import type {
6
6
  RemoteThreadMetadata,
7
7
  } from "@assistant-ui/core";
8
8
  import { AdkEventAccumulator } from "./AdkEventAccumulator";
9
- import type { AdkEvent, AdkMessage, AdkThreadSnapshot } from "./types";
9
+ import { parseAdkEventValue } from "./parseAdkEvent";
10
+ import type { AdkMessage, AdkThreadSnapshot } from "./types";
10
11
  import { trimTrailingSlashes } from "./trimTrailingSlashes";
11
12
 
12
13
  export type AdkSessionAdapterOptions = {
@@ -323,7 +324,11 @@ export function createAdkSessionAdapter(
323
324
  );
324
325
  }
325
326
 
326
- const events = session.events as AdkEvent[] | undefined;
327
+ // Events carry ordered state and tool transitions, so partial replay can
328
+ // produce a plausible but incorrect thread.
329
+ const events = session.events?.map((event, index) =>
330
+ parseAdkEventValue(event, `Invalid ADK session event at index ${index}`),
331
+ );
327
332
 
328
333
  if (!events?.length) {
329
334
  return { messages: [] };