@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.
- package/dist/AdkClient.d.ts.map +1 -1
- package/dist/AdkClient.js +13 -3
- package/dist/AdkClient.js.map +1 -1
- package/dist/AdkEventAccumulator.d.ts +2 -0
- package/dist/AdkEventAccumulator.d.ts.map +1 -1
- package/dist/AdkEventAccumulator.js +32 -10
- package/dist/AdkEventAccumulator.js.map +1 -1
- package/dist/AdkSessionAdapter.d.ts.map +1 -1
- package/dist/AdkSessionAdapter.js +2 -1
- package/dist/AdkSessionAdapter.js.map +1 -1
- package/dist/adkToolApproval.d.ts +40 -0
- package/dist/adkToolApproval.d.ts.map +1 -0
- package/dist/adkToolApproval.js +166 -0
- package/dist/adkToolApproval.js.map +1 -0
- package/dist/convertAdkMessages.d.ts +3 -1
- package/dist/convertAdkMessages.d.ts.map +1 -1
- package/dist/convertAdkMessages.js +27 -16
- package/dist/convertAdkMessages.js.map +1 -1
- package/dist/convertToAdkMessages.d.ts +49 -0
- package/dist/convertToAdkMessages.d.ts.map +1 -0
- package/dist/convertToAdkMessages.js +86 -0
- package/dist/convertToAdkMessages.js.map +1 -0
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +5 -14
- package/dist/hooks.js.map +1 -1
- package/dist/parseAdkEvent.d.ts +6 -0
- package/dist/parseAdkEvent.d.ts.map +1 -0
- package/dist/parseAdkEvent.js +16 -0
- package/dist/parseAdkEvent.js.map +1 -0
- package/dist/server/parseAdkRequest.d.ts.map +1 -1
- package/dist/server/parseAdkRequest.js +79 -13
- package/dist/server/parseAdkRequest.js.map +1 -1
- package/dist/useAdkMessages.d.ts.map +1 -1
- package/dist/useAdkMessages.js +110 -79
- package/dist/useAdkMessages.js.map +1 -1
- package/dist/useAdkRuntime.d.ts.map +1 -1
- package/dist/useAdkRuntime.js +44 -129
- package/dist/useAdkRuntime.js.map +1 -1
- package/package.json +6 -7
- package/src/AdkClient.test.ts +137 -0
- package/src/AdkClient.ts +15 -3
- package/src/AdkEventAccumulator.test.ts +161 -1
- package/src/AdkEventAccumulator.ts +44 -8
- package/src/AdkSessionAdapter.test.ts +145 -1
- package/src/AdkSessionAdapter.ts +7 -2
- package/src/adkToolApproval.test.ts +345 -0
- package/src/adkToolApproval.ts +215 -0
- package/src/convertAdkMessages.ts +72 -43
- package/src/{useAdkRuntime.test.ts → convertToAdkMessages.test.ts} +1 -1
- package/src/convertToAdkMessages.ts +139 -0
- package/src/hooks.ts +7 -19
- package/src/parseAdkEvent.ts +40 -0
- package/src/server/parseAdkRequest.test.ts +141 -0
- package/src/server/parseAdkRequest.ts +145 -13
- package/src/useAdkMessages.test.ts +313 -0
- package/src/useAdkMessages.ts +72 -25
- package/src/useAdkRuntime.ts +66 -188
- package/src/useAdkRuntimeApproval.test.tsx +344 -0
|
@@ -15,13 +15,18 @@ vi.mock("@assistant-ui/store", async (importOriginal) => ({
|
|
|
15
15
|
|
|
16
16
|
import {
|
|
17
17
|
messageToEvent,
|
|
18
|
+
messagesToEvents,
|
|
18
19
|
useAdkMessages,
|
|
19
20
|
type UseAdkMessagesOptions,
|
|
20
21
|
} from "./useAdkMessages";
|
|
22
|
+
import { projectAdkToolApprovals } from "./adkToolApproval";
|
|
23
|
+
import { createAdkStream } from "./AdkClient";
|
|
24
|
+
import { AdkEventAccumulator } from "./AdkEventAccumulator";
|
|
21
25
|
import type { AdkEvent, AdkMessage, AdkStreamCallback } from "./types";
|
|
22
26
|
|
|
23
27
|
afterEach(() => {
|
|
24
28
|
vi.restoreAllMocks();
|
|
29
|
+
vi.unstubAllGlobals();
|
|
25
30
|
});
|
|
26
31
|
|
|
27
32
|
describe("ADK runtime callbacks", () => {
|
|
@@ -125,6 +130,314 @@ describe("ADK runtime callbacks", () => {
|
|
|
125
130
|
});
|
|
126
131
|
});
|
|
127
132
|
|
|
133
|
+
describe("ADK stream lifecycle", () => {
|
|
134
|
+
it("aborts the active stream when the hook unmounts", async () => {
|
|
135
|
+
let runSignal: AbortSignal | undefined;
|
|
136
|
+
let resolveStarted!: () => void;
|
|
137
|
+
const started = new Promise<void>((resolve) => {
|
|
138
|
+
resolveStarted = resolve;
|
|
139
|
+
});
|
|
140
|
+
const stream: AdkStreamCallback = async function* (
|
|
141
|
+
_messages,
|
|
142
|
+
{ abortSignal },
|
|
143
|
+
) {
|
|
144
|
+
runSignal = abortSignal;
|
|
145
|
+
resolveStarted();
|
|
146
|
+
await new Promise<void>((resolve) => {
|
|
147
|
+
abortSignal.addEventListener("abort", () => resolve(), { once: true });
|
|
148
|
+
});
|
|
149
|
+
};
|
|
150
|
+
const { result, unmount } = renderHook(() => useAdkMessages({ stream }));
|
|
151
|
+
|
|
152
|
+
let sendPromise!: Promise<void>;
|
|
153
|
+
act(() => {
|
|
154
|
+
sendPromise = result.current.sendMessage(
|
|
155
|
+
[{ id: "user", type: "human", content: "hello" }],
|
|
156
|
+
{},
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
await started;
|
|
160
|
+
|
|
161
|
+
unmount();
|
|
162
|
+
|
|
163
|
+
expect(runSignal?.aborted).toBe(true);
|
|
164
|
+
await expect(sendPromise).resolves.toBeUndefined();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("optimistic confirmation replies", () => {
|
|
169
|
+
const confirmationCall = (id: string): AdkMessage => ({
|
|
170
|
+
id: `ai-${id}`,
|
|
171
|
+
type: "ai",
|
|
172
|
+
content: [],
|
|
173
|
+
tool_calls: [
|
|
174
|
+
{
|
|
175
|
+
id,
|
|
176
|
+
name: "adk_request_confirmation",
|
|
177
|
+
args: {
|
|
178
|
+
originalFunctionCall: { id: `orig-${id}`, name: "delete_file" },
|
|
179
|
+
toolConfirmation: { hint: "Delete?" },
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* ADK parses the text inside its client wrapper without a `try`, so a wrapper
|
|
187
|
+
* holding text that is not JSON raises and abandons the whole event.
|
|
188
|
+
*/
|
|
189
|
+
const UNREADABLE_REPLY = JSON.stringify({ response: "not json" });
|
|
190
|
+
|
|
191
|
+
const confirmationReply = (
|
|
192
|
+
id: string,
|
|
193
|
+
toolCallId: string,
|
|
194
|
+
content: string,
|
|
195
|
+
): AdkMessage => ({
|
|
196
|
+
id,
|
|
197
|
+
type: "tool",
|
|
198
|
+
tool_call_id: toolCallId,
|
|
199
|
+
name: "adk_request_confirmation",
|
|
200
|
+
content,
|
|
201
|
+
status: "success",
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const emptyStream: AdkStreamCallback = async function* () {};
|
|
205
|
+
|
|
206
|
+
const renderWithGates = async () => {
|
|
207
|
+
const { result } = renderHook(() =>
|
|
208
|
+
useAdkMessages({ stream: emptyStream }),
|
|
209
|
+
);
|
|
210
|
+
await act(async () => {
|
|
211
|
+
result.current.setMessages([
|
|
212
|
+
confirmationCall("conf-a"),
|
|
213
|
+
confirmationCall("conf-b"),
|
|
214
|
+
]);
|
|
215
|
+
});
|
|
216
|
+
return result;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
it("keeps both gates pending when one send carries an unreadable reply", async () => {
|
|
220
|
+
const result = await renderWithGates();
|
|
221
|
+
|
|
222
|
+
await act(async () => {
|
|
223
|
+
await result.current.sendMessage(
|
|
224
|
+
[
|
|
225
|
+
confirmationReply(
|
|
226
|
+
"reply-a",
|
|
227
|
+
"conf-a",
|
|
228
|
+
JSON.stringify({ confirmed: true }),
|
|
229
|
+
),
|
|
230
|
+
confirmationReply("reply-b", "conf-b", UNREADABLE_REPLY),
|
|
231
|
+
],
|
|
232
|
+
{},
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
expect([
|
|
237
|
+
// The confirmation and the call it gates share one approval object.
|
|
238
|
+
...new Set(
|
|
239
|
+
projectAdkToolApprovals(result.current.messages).approvals.values(),
|
|
240
|
+
),
|
|
241
|
+
]).toEqual([{ id: "conf-a" }, { id: "conf-b" }]);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("keeps both gates pending when an ai message sits between the replies", async () => {
|
|
245
|
+
const result = await renderWithGates();
|
|
246
|
+
|
|
247
|
+
await act(async () => {
|
|
248
|
+
await result.current.sendMessage(
|
|
249
|
+
[
|
|
250
|
+
confirmationReply(
|
|
251
|
+
"reply-a",
|
|
252
|
+
"conf-a",
|
|
253
|
+
JSON.stringify({ confirmed: true }),
|
|
254
|
+
),
|
|
255
|
+
{ id: "ai-interleaved", type: "ai", content: "thinking" },
|
|
256
|
+
confirmationReply("reply-b", "conf-b", UNREADABLE_REPLY),
|
|
257
|
+
],
|
|
258
|
+
{},
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
expect([
|
|
263
|
+
// The confirmation and the call it gates share one approval object.
|
|
264
|
+
...new Set(
|
|
265
|
+
projectAdkToolApprovals(result.current.messages).approvals.values(),
|
|
266
|
+
),
|
|
267
|
+
]).toEqual([{ id: "conf-a" }, { id: "conf-b" }]);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("settles the readable reply when the two replies were sent separately", async () => {
|
|
271
|
+
const result = await renderWithGates();
|
|
272
|
+
|
|
273
|
+
await act(async () => {
|
|
274
|
+
await result.current.sendMessage(
|
|
275
|
+
[
|
|
276
|
+
confirmationReply(
|
|
277
|
+
"reply-a",
|
|
278
|
+
"conf-a",
|
|
279
|
+
JSON.stringify({ confirmed: true }),
|
|
280
|
+
),
|
|
281
|
+
],
|
|
282
|
+
{},
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
await act(async () => {
|
|
286
|
+
await result.current.sendMessage(
|
|
287
|
+
[confirmationReply("reply-b", "conf-b", UNREADABLE_REPLY)],
|
|
288
|
+
{},
|
|
289
|
+
);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
expect([
|
|
293
|
+
// The confirmation and the call it gates share one approval object.
|
|
294
|
+
...new Set(
|
|
295
|
+
projectAdkToolApprovals(result.current.messages).approvals.values(),
|
|
296
|
+
),
|
|
297
|
+
]).toEqual([{ id: "conf-a", approved: true }, { id: "conf-b" }]);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe("messagesToEvents", () => {
|
|
302
|
+
const toolReply = (id: string, toolCallId: string): AdkMessage => ({
|
|
303
|
+
id,
|
|
304
|
+
type: "tool",
|
|
305
|
+
tool_call_id: toolCallId,
|
|
306
|
+
name: "adk_request_confirmation",
|
|
307
|
+
content: JSON.stringify({ confirmed: true }),
|
|
308
|
+
status: "success",
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("emits nothing for an empty batch", () => {
|
|
312
|
+
// `onReload` sends no messages. The transport still puts an empty user
|
|
313
|
+
// content on the wire, but projecting one here would append an empty user
|
|
314
|
+
// bubble above every regenerated turn.
|
|
315
|
+
expect(messagesToEvents([])).toEqual([]);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("merges the human/tool run across an interleaved ai message", () => {
|
|
319
|
+
const events = messagesToEvents([
|
|
320
|
+
toolReply("reply-a", "conf-a"),
|
|
321
|
+
{ id: "ai-interleaved", type: "ai", content: "thinking" },
|
|
322
|
+
toolReply("reply-b", "conf-b"),
|
|
323
|
+
]);
|
|
324
|
+
|
|
325
|
+
expect(events).toHaveLength(2);
|
|
326
|
+
expect(events[0]?.id).toBe("reply-a");
|
|
327
|
+
expect(
|
|
328
|
+
events[0]?.content?.parts?.map((p) => p.functionResponse?.id),
|
|
329
|
+
).toEqual(["conf-a", "conf-b"]);
|
|
330
|
+
expect(events[1]?.id).toBe("ai-interleaved");
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("mirrors the transport's empty user content for an ai-only batch", async () => {
|
|
334
|
+
const aiOnly: AdkMessage[] = [
|
|
335
|
+
{ id: "ai-1", type: "ai", content: "one" },
|
|
336
|
+
{ id: "ai-2", type: "ai", content: "two" },
|
|
337
|
+
];
|
|
338
|
+
|
|
339
|
+
let sentBody = "";
|
|
340
|
+
vi.stubGlobal("fetch", async (_url: string, init: RequestInit) => {
|
|
341
|
+
sentBody = init.body as string;
|
|
342
|
+
return new Response(
|
|
343
|
+
new ReadableStream<Uint8Array>({
|
|
344
|
+
start: (controller) => controller.close(),
|
|
345
|
+
}),
|
|
346
|
+
{ status: 200, headers: { "Content-Type": "text/event-stream" } },
|
|
347
|
+
);
|
|
348
|
+
});
|
|
349
|
+
const stream = createAdkStream({
|
|
350
|
+
api: "http://localhost:8000",
|
|
351
|
+
appName: "app",
|
|
352
|
+
userId: "user-1",
|
|
353
|
+
});
|
|
354
|
+
for await (const _ of await stream(aiOnly, {
|
|
355
|
+
abortSignal: new AbortController().signal,
|
|
356
|
+
initialize: async () => ({ remoteId: "r1", externalId: "s1" }),
|
|
357
|
+
})) {
|
|
358
|
+
/* drain */
|
|
359
|
+
}
|
|
360
|
+
const sent = JSON.parse(sentBody);
|
|
361
|
+
|
|
362
|
+
const events = messagesToEvents(aiOnly);
|
|
363
|
+
const userEvent = events.find((e) => e.author === "user");
|
|
364
|
+
expect(sent.newMessage.parts).toEqual([{ text: "" }]);
|
|
365
|
+
expect(userEvent?.content?.parts).toEqual(sent.newMessage.parts);
|
|
366
|
+
expect(events.map((e) => e.id)).toEqual(["ai-1", "ai-2", userEvent?.id]);
|
|
367
|
+
|
|
368
|
+
const accumulator = new AdkEventAccumulator([]);
|
|
369
|
+
let messages: AdkMessage[] = [];
|
|
370
|
+
for (const event of events) messages = accumulator.processEvent(event);
|
|
371
|
+
expect(messages.filter((m) => m.type === "human")).toEqual([
|
|
372
|
+
{ id: userEvent?.id, type: "human", content: "" },
|
|
373
|
+
]);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("emits the merged event at the position of the run it replaces", () => {
|
|
377
|
+
const events = messagesToEvents([
|
|
378
|
+
{ id: "ai-first", type: "ai", content: "thinking" },
|
|
379
|
+
{ id: "human-a", type: "human", content: "a" },
|
|
380
|
+
]);
|
|
381
|
+
|
|
382
|
+
expect(events.map((e) => e.id)).toEqual(["ai-first", "human-a"]);
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
describe("optimistic multi-message sends", () => {
|
|
387
|
+
const emptyStream: AdkStreamCallback = async function* () {};
|
|
388
|
+
|
|
389
|
+
it("does not duplicate later messages of a staged multi-human send", async () => {
|
|
390
|
+
const staged: AdkMessage[] = [
|
|
391
|
+
{ id: "human-a", type: "human", content: "a" },
|
|
392
|
+
{ id: "human-b", type: "human", content: "b" },
|
|
393
|
+
];
|
|
394
|
+
const { result } = renderHook(() =>
|
|
395
|
+
useAdkMessages({ stream: emptyStream }),
|
|
396
|
+
);
|
|
397
|
+
await act(async () => {
|
|
398
|
+
result.current.setMessages(staged);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
await act(async () => {
|
|
402
|
+
await result.current.sendMessage(staged, {});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
expect(result.current.messages).toEqual([
|
|
406
|
+
{
|
|
407
|
+
id: "human-a",
|
|
408
|
+
type: "human",
|
|
409
|
+
content: [
|
|
410
|
+
{ type: "text", text: "a" },
|
|
411
|
+
{ type: "text", text: "b" },
|
|
412
|
+
],
|
|
413
|
+
},
|
|
414
|
+
]);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("keeps messages that are not part of the send", async () => {
|
|
418
|
+
const { result } = renderHook(() =>
|
|
419
|
+
useAdkMessages({ stream: emptyStream }),
|
|
420
|
+
);
|
|
421
|
+
await act(async () => {
|
|
422
|
+
result.current.setMessages([
|
|
423
|
+
{ id: "earlier", type: "human", content: "earlier" },
|
|
424
|
+
]);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
await act(async () => {
|
|
428
|
+
await result.current.sendMessage(
|
|
429
|
+
[{ id: "human-a", type: "human", content: "a" }],
|
|
430
|
+
{},
|
|
431
|
+
);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
expect(result.current.messages.map((m) => m.id)).toEqual([
|
|
435
|
+
"earlier",
|
|
436
|
+
"human-a",
|
|
437
|
+
]);
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
|
|
128
441
|
describe("messageToEvent (contentToParts)", () => {
|
|
129
442
|
it("serializes a file content part as inlineData", () => {
|
|
130
443
|
const msg: AdkMessage = {
|
package/src/useAdkMessages.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { useState, useCallback, useRef, useMemo } from "react";
|
|
2
|
-
import {
|
|
1
|
+
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
|
2
|
+
import { generateId } from "@assistant-ui/core";
|
|
3
3
|
import { useAui } from "@assistant-ui/store";
|
|
4
|
+
import { invokeUserCallback } from "@assistant-ui/core/internal";
|
|
4
5
|
import { AdkEventAccumulator } from "./AdkEventAccumulator";
|
|
5
6
|
import { contentToParts } from "./contentToParts";
|
|
6
7
|
import type {
|
|
@@ -28,24 +29,12 @@ export type UseAdkMessagesOptions = {
|
|
|
28
29
|
|
|
29
30
|
type AdkRuntimeCallbackName = "onError" | "onCustomEvent" | "onAgentTransfer";
|
|
30
31
|
|
|
31
|
-
const
|
|
32
|
-
console.error(`[react-google-adk] ${name} callback threw an error`, error);
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
const invokeAdkRuntimeCallback = <TArgs extends unknown[]>(
|
|
32
|
+
const invokeAdkRuntimeCallback = <TArgs extends readonly unknown[]>(
|
|
36
33
|
name: AdkRuntimeCallbackName,
|
|
37
|
-
callback: ((...args: TArgs) =>
|
|
34
|
+
callback: ((...args: TArgs) => unknown) | undefined,
|
|
38
35
|
...args: TArgs
|
|
39
|
-
) => {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
try {
|
|
43
|
-
void Promise.resolve(callback(...args)).catch((error) => {
|
|
44
|
-
reportCallbackError(name, error);
|
|
45
|
-
});
|
|
46
|
-
} catch (error) {
|
|
47
|
-
reportCallbackError(name, error);
|
|
48
|
-
}
|
|
36
|
+
): void => {
|
|
37
|
+
void invokeUserCallback("react-google-adk", name, callback, ...args);
|
|
49
38
|
};
|
|
50
39
|
|
|
51
40
|
export const useAdkMessages = ({
|
|
@@ -133,12 +122,19 @@ export const useAdkMessages = ({
|
|
|
133
122
|
const sendMessage = useCallback(
|
|
134
123
|
async (newMessages: AdkMessage[], config: AdkSendMessageConfig) => {
|
|
135
124
|
const newMessagesWithId = newMessages.map((m) =>
|
|
136
|
-
m.id ? m : { ...m, id:
|
|
125
|
+
m.id ? m : { ...m, id: generateId() },
|
|
137
126
|
) as AdkMessage[];
|
|
138
127
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
128
|
+
// A staged message is already in the thread under its own id, and the
|
|
129
|
+
// merged event below re-emits the whole batch under the first one. Seeding
|
|
130
|
+
// with the originals would leave every later staged id beside the merged
|
|
131
|
+
// copy of itself.
|
|
132
|
+
const resentIds = new Set(newMessagesWithId.map((m) => m.id));
|
|
133
|
+
const accumulator = new AdkEventAccumulator(
|
|
134
|
+
messagesRef.current.filter((m) => !resentIds.has(m.id)),
|
|
135
|
+
);
|
|
136
|
+
for (const event of messagesToEvents(newMessagesWithId)) {
|
|
137
|
+
accumulator.processEvent(event);
|
|
142
138
|
}
|
|
143
139
|
setMessagesImmediate(accumulator.getMessages());
|
|
144
140
|
|
|
@@ -238,6 +234,8 @@ export const useAdkMessages = ({
|
|
|
238
234
|
}
|
|
239
235
|
}, []);
|
|
240
236
|
|
|
237
|
+
useEffect(() => cancel, [cancel]);
|
|
238
|
+
|
|
241
239
|
return {
|
|
242
240
|
messages,
|
|
243
241
|
stateDelta,
|
|
@@ -256,11 +254,60 @@ export const useAdkMessages = ({
|
|
|
256
254
|
};
|
|
257
255
|
};
|
|
258
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Transport sends every human and tool message of one `send` call as a single
|
|
259
|
+
* ADK `Content`, and ADK parses that event's function responses before running
|
|
260
|
+
* any tool, so the batch runs whole or not at all. The optimistic projection
|
|
261
|
+
* has to sit on the same boundary, so a run of those messages becomes one
|
|
262
|
+
* synthetic event whose parts come from the same per-message conversion.
|
|
263
|
+
*
|
|
264
|
+
* The transport drops `ai` messages from that `Content`, so one interleaved
|
|
265
|
+
* between two replies does not split the batch on the wire and must not split
|
|
266
|
+
* it here either. It still becomes its own event, placed after the merged one,
|
|
267
|
+
* so the optimistic projection keeps the assistant turn.
|
|
268
|
+
*
|
|
269
|
+
* @internal — exported for unit tests.
|
|
270
|
+
*/
|
|
271
|
+
export const messagesToEvents = (messages: AdkMessage[]): AdkEvent[] => {
|
|
272
|
+
// A reload sends no messages at all, and the empty user content the transport
|
|
273
|
+
// puts on the wire for it is not part of the optimistic view: projecting one
|
|
274
|
+
// would put an empty user bubble above every regenerated turn.
|
|
275
|
+
if (messages.length === 0) return [];
|
|
276
|
+
|
|
277
|
+
const events: AdkEvent[] = [];
|
|
278
|
+
const run: AdkMessage[] = [];
|
|
279
|
+
let runIndex = 0;
|
|
280
|
+
|
|
281
|
+
for (const msg of messages) {
|
|
282
|
+
if (msg.type === "ai") {
|
|
283
|
+
events.push(messageToEvent(msg));
|
|
284
|
+
} else {
|
|
285
|
+
if (run.length === 0) runIndex = events.length;
|
|
286
|
+
run.push(msg);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const parts = run.flatMap((m) => messageToEvent(m).content?.parts ?? []);
|
|
291
|
+
const human = run.find((m) => m.type === "human");
|
|
292
|
+
|
|
293
|
+
// A batch that contributes no part still reaches the wire: the transport
|
|
294
|
+
// sends an empty user `Content`, which a reload replays as an empty human
|
|
295
|
+
// message. Emitting it here keeps the optimistic view equal to that replay.
|
|
296
|
+
if (parts.length === 0) parts.push({ text: "" });
|
|
297
|
+
|
|
298
|
+
const event: AdkEvent = { id: (human ?? run[0])?.id ?? generateId() };
|
|
299
|
+
if (human || run.length === 0) event.author = "user";
|
|
300
|
+
event.content = { role: "user", parts };
|
|
301
|
+
events.splice(run.length > 0 ? runIndex : events.length, 0, event);
|
|
302
|
+
|
|
303
|
+
return events;
|
|
304
|
+
};
|
|
305
|
+
|
|
259
306
|
/** @internal — exported for unit tests. */
|
|
260
307
|
export const messageToEvent = (msg: AdkMessage): AdkEvent => {
|
|
261
308
|
if (msg.type === "human") {
|
|
262
309
|
return {
|
|
263
|
-
id: msg.id ??
|
|
310
|
+
id: msg.id ?? generateId(),
|
|
264
311
|
author: "user",
|
|
265
312
|
content: { role: "user", parts: contentToParts(msg.content) },
|
|
266
313
|
};
|
|
@@ -274,7 +321,7 @@ export const messageToEvent = (msg: AdkMessage): AdkEvent => {
|
|
|
274
321
|
response = msg.content;
|
|
275
322
|
}
|
|
276
323
|
return {
|
|
277
|
-
id: msg.id ??
|
|
324
|
+
id: msg.id ?? generateId(),
|
|
278
325
|
content: {
|
|
279
326
|
role: "user",
|
|
280
327
|
parts: [
|
|
@@ -290,7 +337,7 @@ export const messageToEvent = (msg: AdkMessage): AdkEvent => {
|
|
|
290
337
|
};
|
|
291
338
|
}
|
|
292
339
|
|
|
293
|
-
const result: AdkEvent = { id: msg.id ??
|
|
340
|
+
const result: AdkEvent = { id: msg.id ?? generateId() };
|
|
294
341
|
if (msg.author != null) result.author = msg.author;
|
|
295
342
|
result.content = {
|
|
296
343
|
role: "model",
|