@assistant-ui/react-google-adk 0.0.19 → 0.0.21
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.js +15 -2
- package/dist/AdkClient.js.map +1 -1
- package/dist/AdkEventAccumulator.d.ts +1 -0
- package/dist/AdkEventAccumulator.d.ts.map +1 -1
- package/dist/AdkEventAccumulator.js +20 -4
- package/dist/AdkEventAccumulator.js.map +1 -1
- package/dist/AdkSessionAdapter.d.ts +4 -4
- package/dist/AdkSessionAdapter.d.ts.map +1 -1
- package/dist/AdkSessionAdapter.js +16 -3
- package/dist/AdkSessionAdapter.js.map +1 -1
- package/dist/adkExtras.d.ts +1 -1
- package/dist/adkExtras.d.ts.map +1 -1
- package/dist/adkExtras.js +1 -1
- package/dist/adkExtras.js.map +1 -1
- package/dist/convertAdkMessages.d.ts.map +1 -1
- package/dist/convertAdkMessages.js +18 -11
- package/dist/convertAdkMessages.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/types.d.ts +22 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/useAdkMessages.d.ts +2 -1
- package/dist/useAdkMessages.d.ts.map +1 -1
- package/dist/useAdkMessages.js +19 -1
- package/dist/useAdkMessages.js.map +1 -1
- package/dist/useAdkRuntime.d.ts +10 -4
- package/dist/useAdkRuntime.d.ts.map +1 -1
- package/dist/useAdkRuntime.js +79 -30
- package/dist/useAdkRuntime.js.map +1 -1
- package/package.json +7 -4
- package/src/AdkClient.test.ts +55 -0
- package/src/AdkClient.ts +16 -2
- package/src/AdkEventAccumulator.test.ts +127 -0
- package/src/AdkEventAccumulator.ts +31 -5
- package/src/AdkSessionAdapter.test.ts +43 -0
- package/src/AdkSessionAdapter.ts +21 -4
- package/src/adkExtras.ts +1 -1
- package/src/convertAdkMessages.test.ts +113 -0
- package/src/convertAdkMessages.ts +24 -4
- package/src/index.ts +1 -0
- package/src/types.ts +21 -0
- package/src/useAdkMessages.ts +24 -1
- package/src/useAdkRuntime.refetch.test.tsx +336 -0
- package/src/useAdkRuntime.test.ts +222 -0
- package/src/useAdkRuntime.ts +139 -34
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { act, render, renderHook, waitFor } from "@testing-library/react";
|
|
4
|
+
import { type FC, type ReactNode } from "react";
|
|
5
|
+
import { describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { AssistantRuntimeProvider } from "@assistant-ui/core/react";
|
|
7
|
+
import type {
|
|
8
|
+
AssistantRuntime,
|
|
9
|
+
RemoteThreadListAdapter,
|
|
10
|
+
} from "@assistant-ui/core";
|
|
11
|
+
import { useAui } from "@assistant-ui/store";
|
|
12
|
+
import { useAdkRuntime } from "./useAdkRuntime";
|
|
13
|
+
import type { AdkMessage, AdkThreadSnapshot } from "./types";
|
|
14
|
+
|
|
15
|
+
const deferred = <T,>() => {
|
|
16
|
+
let resolve!: (value: T) => void;
|
|
17
|
+
let reject!: (reason: unknown) => void;
|
|
18
|
+
const promise = new Promise<T>((res, rej) => {
|
|
19
|
+
resolve = res;
|
|
20
|
+
reject = rej;
|
|
21
|
+
});
|
|
22
|
+
return { promise, resolve, reject };
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const makeThreadListAdapter = (): RemoteThreadListAdapter => ({
|
|
26
|
+
list: vi.fn(async () => ({
|
|
27
|
+
threads: [
|
|
28
|
+
{
|
|
29
|
+
status: "regular" as const,
|
|
30
|
+
remoteId: "adk-1",
|
|
31
|
+
externalId: "adk-1",
|
|
32
|
+
title: "Existing ADK session",
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
})),
|
|
36
|
+
initialize: vi.fn(async () => ({
|
|
37
|
+
remoteId: "adk-1",
|
|
38
|
+
externalId: "adk-1",
|
|
39
|
+
})),
|
|
40
|
+
rename: vi.fn(async () => {}),
|
|
41
|
+
archive: vi.fn(async () => {}),
|
|
42
|
+
unarchive: vi.fn(async () => {}),
|
|
43
|
+
delete: vi.fn(async () => {}),
|
|
44
|
+
generateTitle: vi.fn(async () => new ReadableStream() as never),
|
|
45
|
+
fetch: vi.fn(async () => ({
|
|
46
|
+
status: "regular" as const,
|
|
47
|
+
remoteId: "adk-1",
|
|
48
|
+
externalId: "adk-1",
|
|
49
|
+
title: "Existing ADK session",
|
|
50
|
+
})),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const wrapperFactory = (runtime: AssistantRuntime) => {
|
|
54
|
+
const Wrapper = ({ children }: { children: ReactNode }) => (
|
|
55
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
56
|
+
{children}
|
|
57
|
+
</AssistantRuntimeProvider>
|
|
58
|
+
);
|
|
59
|
+
Wrapper.displayName = "TestWrapper";
|
|
60
|
+
return Wrapper;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const aiMessage = (id: string, text: string): AdkMessage => ({
|
|
64
|
+
id,
|
|
65
|
+
type: "ai",
|
|
66
|
+
content: [{ type: "text", text }],
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const renderAdk = async (
|
|
70
|
+
load?: (
|
|
71
|
+
threadId: string,
|
|
72
|
+
options?: { signal?: AbortSignal | undefined },
|
|
73
|
+
) => Promise<AdkThreadSnapshot>,
|
|
74
|
+
) => {
|
|
75
|
+
const streamMock = vi.fn(async function* () {});
|
|
76
|
+
const capture: { runtime: AssistantRuntime | null } = { runtime: null };
|
|
77
|
+
|
|
78
|
+
// the runtime hook's binder mounts inside the provider, so the provider has
|
|
79
|
+
// to be in the same tree as the hook for a thread switch to settle
|
|
80
|
+
const Inner: FC = () => {
|
|
81
|
+
const runtime = useAdkRuntime({
|
|
82
|
+
stream: streamMock as never,
|
|
83
|
+
...(load ? { load } : {}),
|
|
84
|
+
sessionAdapter: makeThreadListAdapter(),
|
|
85
|
+
});
|
|
86
|
+
capture.runtime = runtime;
|
|
87
|
+
return (
|
|
88
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
89
|
+
{null}
|
|
90
|
+
</AssistantRuntimeProvider>
|
|
91
|
+
);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
let unmount!: () => void;
|
|
95
|
+
await act(async () => {
|
|
96
|
+
({ unmount } = render(<Inner />));
|
|
97
|
+
});
|
|
98
|
+
await waitFor(() => expect(capture.runtime).not.toBeNull());
|
|
99
|
+
|
|
100
|
+
await act(async () => {
|
|
101
|
+
await capture.runtime!.threads.switchToThread("adk-1");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return { capture, streamMock, unmount };
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
describe("useAdkRuntime refetch", () => {
|
|
108
|
+
it("declares the refetch capability only when a load is supplied", async () => {
|
|
109
|
+
const withLoad = await renderAdk(async () => ({ messages: [] }));
|
|
110
|
+
expect(
|
|
111
|
+
withLoad.capture.runtime!.thread.getState().capabilities.refetchThread,
|
|
112
|
+
).toBe(true);
|
|
113
|
+
|
|
114
|
+
const withoutLoad = await renderAdk();
|
|
115
|
+
expect(
|
|
116
|
+
withoutLoad.capture.runtime!.thread.getState().capabilities.refetchThread,
|
|
117
|
+
).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("refetches in place, keeping the composer draft and the runtime", async () => {
|
|
121
|
+
let call = 0;
|
|
122
|
+
const load = vi.fn(async () => {
|
|
123
|
+
call++;
|
|
124
|
+
return {
|
|
125
|
+
messages: [aiMessage(`m-${call}`, `load ${call}`)],
|
|
126
|
+
} satisfies AdkThreadSnapshot;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const { capture } = await renderAdk(load);
|
|
130
|
+
const wrapper = wrapperFactory(capture.runtime!);
|
|
131
|
+
const { result: auiResult } = renderHook(() => useAui(), { wrapper });
|
|
132
|
+
|
|
133
|
+
await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
|
|
134
|
+
await waitFor(() =>
|
|
135
|
+
expect(
|
|
136
|
+
JSON.stringify(capture.runtime!.thread.getState().messages),
|
|
137
|
+
).toContain("load 1"),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
await act(async () => {
|
|
141
|
+
auiResult.current.composer.setText("draft that must survive");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
await act(async () => {
|
|
145
|
+
await capture.runtime!.threads.reloadMainThread();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(load).toHaveBeenCalledTimes(2);
|
|
149
|
+
await waitFor(() =>
|
|
150
|
+
expect(
|
|
151
|
+
JSON.stringify(capture.runtime!.thread.getState().messages),
|
|
152
|
+
).toContain("load 2"),
|
|
153
|
+
);
|
|
154
|
+
expect(auiResult.current.composer.getState().text).toBe(
|
|
155
|
+
"draft that must survive",
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("swaps the per-turn state over with the messages", async () => {
|
|
160
|
+
let call = 0;
|
|
161
|
+
const load = vi.fn(async () => {
|
|
162
|
+
call++;
|
|
163
|
+
return call === 1
|
|
164
|
+
? {
|
|
165
|
+
messages: [aiMessage("m-1", "first")],
|
|
166
|
+
toolConfirmations: [
|
|
167
|
+
{ toolCallId: "tc-1", toolName: "search", hint: "before" },
|
|
168
|
+
],
|
|
169
|
+
}
|
|
170
|
+
: {
|
|
171
|
+
messages: [aiMessage("m-2", "second")],
|
|
172
|
+
toolConfirmations: [
|
|
173
|
+
{ toolCallId: "tc-2", toolName: "search", hint: "after" },
|
|
174
|
+
],
|
|
175
|
+
};
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const { capture } = await renderAdk(load as never);
|
|
179
|
+
await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
|
|
180
|
+
|
|
181
|
+
await act(async () => {
|
|
182
|
+
await capture.runtime!.threads.reloadMainThread();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
await waitFor(() =>
|
|
186
|
+
expect(
|
|
187
|
+
JSON.stringify(capture.runtime!.thread.getState().messages),
|
|
188
|
+
).toContain("second"),
|
|
189
|
+
);
|
|
190
|
+
// the confirmation from the fresh snapshot replaced the earlier one rather
|
|
191
|
+
// than the thread passing through a cleared state
|
|
192
|
+
const extras = capture.runtime!.thread.getState().extras as {
|
|
193
|
+
toolConfirmations: { hint: string }[];
|
|
194
|
+
};
|
|
195
|
+
expect(extras.toolConfirmations).toHaveLength(1);
|
|
196
|
+
expect(extras.toolConfirmations[0]!.hint).toBe("after");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("leaves the thread untouched when the refetch fails, and rejects", async () => {
|
|
200
|
+
let call = 0;
|
|
201
|
+
const load = vi.fn(async () => {
|
|
202
|
+
call++;
|
|
203
|
+
if (call === 1) return { messages: [aiMessage("m-1", "first")] };
|
|
204
|
+
throw new Error("refetch failed");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const { capture } = await renderAdk(load as never);
|
|
208
|
+
await waitFor(() =>
|
|
209
|
+
expect(
|
|
210
|
+
JSON.stringify(capture.runtime!.thread.getState().messages),
|
|
211
|
+
).toContain("first"),
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
await act(async () => {
|
|
215
|
+
await expect(capture.runtime!.threads.reloadMainThread()).rejects.toThrow(
|
|
216
|
+
"refetch failed",
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
expect(
|
|
221
|
+
JSON.stringify(capture.runtime!.thread.getState().messages),
|
|
222
|
+
).toContain("first");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("defers to an initial load still in flight rather than taking it over", async () => {
|
|
226
|
+
const pending = deferred<AdkThreadSnapshot>();
|
|
227
|
+
let call = 0;
|
|
228
|
+
const load = vi.fn(async () => {
|
|
229
|
+
call++;
|
|
230
|
+
return call === 1
|
|
231
|
+
? pending.promise
|
|
232
|
+
: { messages: [aiMessage("m-2", "second")] };
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const streamMock = vi.fn(async function* () {});
|
|
236
|
+
const capture: { runtime: AssistantRuntime | null } = { runtime: null };
|
|
237
|
+
const Inner: FC = () => {
|
|
238
|
+
const runtime = useAdkRuntime({
|
|
239
|
+
stream: streamMock as never,
|
|
240
|
+
load: load as never,
|
|
241
|
+
sessionAdapter: makeThreadListAdapter(),
|
|
242
|
+
});
|
|
243
|
+
capture.runtime = runtime;
|
|
244
|
+
return (
|
|
245
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
246
|
+
{null}
|
|
247
|
+
</AssistantRuntimeProvider>
|
|
248
|
+
);
|
|
249
|
+
};
|
|
250
|
+
await act(async () => {
|
|
251
|
+
render(<Inner />);
|
|
252
|
+
});
|
|
253
|
+
await act(async () => {
|
|
254
|
+
await capture.runtime!.threads.switchToThread("adk-1");
|
|
255
|
+
});
|
|
256
|
+
await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
|
|
257
|
+
|
|
258
|
+
let settled = false;
|
|
259
|
+
act(() => {
|
|
260
|
+
capture.runtime!.threads.reloadMainThread().then(() => {
|
|
261
|
+
settled = true;
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// the refetch waits on the initial load instead of starting a second one
|
|
266
|
+
expect(load).toHaveBeenCalledTimes(1);
|
|
267
|
+
expect(settled).toBe(false);
|
|
268
|
+
|
|
269
|
+
await act(async () => {
|
|
270
|
+
pending.resolve({ messages: [aiMessage("m-1", "first")] });
|
|
271
|
+
});
|
|
272
|
+
await waitFor(() => expect(settled).toBe(true));
|
|
273
|
+
expect(
|
|
274
|
+
JSON.stringify(capture.runtime!.thread.getState().messages),
|
|
275
|
+
).toContain("first");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("does not report an aborted load as a failure", async () => {
|
|
279
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
280
|
+
const pending = deferred<AdkThreadSnapshot>();
|
|
281
|
+
let call = 0;
|
|
282
|
+
const load = vi.fn(
|
|
283
|
+
async (_id: string, options?: { signal?: AbortSignal }) => {
|
|
284
|
+
call++;
|
|
285
|
+
if (call === 1) {
|
|
286
|
+
options?.signal?.addEventListener("abort", () =>
|
|
287
|
+
pending.reject(
|
|
288
|
+
Object.assign(new Error("aborted"), { name: "AbortError" }),
|
|
289
|
+
),
|
|
290
|
+
);
|
|
291
|
+
return pending.promise;
|
|
292
|
+
}
|
|
293
|
+
return { messages: [] };
|
|
294
|
+
},
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
const { unmount } = await renderAdk(load as never);
|
|
298
|
+
await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
|
|
299
|
+
|
|
300
|
+
unmount();
|
|
301
|
+
await act(async () => {
|
|
302
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
expect(warn).not.toHaveBeenCalled();
|
|
306
|
+
warn.mockRestore();
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("aborts a refetch still in flight when the thread unmounts", async () => {
|
|
310
|
+
const pending = deferred<AdkThreadSnapshot>();
|
|
311
|
+
let call = 0;
|
|
312
|
+
const signals: (AbortSignal | undefined)[] = [];
|
|
313
|
+
const load = vi.fn(
|
|
314
|
+
async (_id: string, options?: { signal?: AbortSignal }) => {
|
|
315
|
+
call++;
|
|
316
|
+
signals.push(options?.signal);
|
|
317
|
+
if (call === 1) return { messages: [] };
|
|
318
|
+
return pending.promise;
|
|
319
|
+
},
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
const { capture, unmount } = await renderAdk(load as never);
|
|
323
|
+
await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
|
|
324
|
+
|
|
325
|
+
act(() => {
|
|
326
|
+
capture.runtime!.threads.reloadMainThread().catch(() => {});
|
|
327
|
+
});
|
|
328
|
+
await waitFor(() => expect(load).toHaveBeenCalledTimes(2));
|
|
329
|
+
expect(signals[1]?.aborted).toBe(false);
|
|
330
|
+
|
|
331
|
+
unmount();
|
|
332
|
+
|
|
333
|
+
expect(signals[1]?.aborted).toBe(true);
|
|
334
|
+
pending.resolve({ messages: [] });
|
|
335
|
+
});
|
|
336
|
+
});
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
getPendingCancellations,
|
|
5
5
|
getPendingToolCalls,
|
|
6
6
|
} from "./useAdkRuntime";
|
|
7
|
+
import { convertAdkMessage } from "./convertAdkMessages";
|
|
7
8
|
import type { AppendMessage } from "@assistant-ui/core";
|
|
8
9
|
import type { AdkMessage } from "./types";
|
|
9
10
|
|
|
@@ -208,4 +209,225 @@ describe("getMessageContent", () => {
|
|
|
208
209
|
{ type: "file", mimeType: "application/pdf", data: "AAAA" },
|
|
209
210
|
]);
|
|
210
211
|
});
|
|
212
|
+
|
|
213
|
+
it("emits a file_url part for file parts with sourceType url", () => {
|
|
214
|
+
const result = getMessageContent(
|
|
215
|
+
makeAppendMessage([
|
|
216
|
+
{
|
|
217
|
+
type: "file",
|
|
218
|
+
mimeType: "application/pdf",
|
|
219
|
+
data: "gs://bucket/report.pdf",
|
|
220
|
+
filename: "report.pdf",
|
|
221
|
+
sourceType: "url",
|
|
222
|
+
},
|
|
223
|
+
]),
|
|
224
|
+
);
|
|
225
|
+
expect(result).toEqual([
|
|
226
|
+
{
|
|
227
|
+
type: "file_url",
|
|
228
|
+
url: "gs://bucket/report.pdf",
|
|
229
|
+
mimeType: "application/pdf",
|
|
230
|
+
},
|
|
231
|
+
]);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("keeps file parts inline without sourceType", () => {
|
|
235
|
+
const result = getMessageContent(
|
|
236
|
+
makeAppendMessage([
|
|
237
|
+
{
|
|
238
|
+
type: "file",
|
|
239
|
+
mimeType: "application/pdf",
|
|
240
|
+
data: "gs://bucket/report.pdf",
|
|
241
|
+
},
|
|
242
|
+
]),
|
|
243
|
+
);
|
|
244
|
+
expect(result).toEqual([
|
|
245
|
+
{
|
|
246
|
+
type: "file",
|
|
247
|
+
mimeType: "application/pdf",
|
|
248
|
+
data: "gs://bucket/report.pdf",
|
|
249
|
+
},
|
|
250
|
+
]);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("round-trips a file_url part through convert and edit-resend", () => {
|
|
254
|
+
const converted = convertAdkMessage(
|
|
255
|
+
{
|
|
256
|
+
id: "m1",
|
|
257
|
+
type: "human",
|
|
258
|
+
content: [
|
|
259
|
+
{
|
|
260
|
+
type: "file_url",
|
|
261
|
+
url: "gs://bucket/report.pdf",
|
|
262
|
+
mimeType: "application/pdf",
|
|
263
|
+
},
|
|
264
|
+
],
|
|
265
|
+
},
|
|
266
|
+
{},
|
|
267
|
+
);
|
|
268
|
+
const content = (converted as { content: AppendMessage["content"] })
|
|
269
|
+
.content;
|
|
270
|
+
const result = getMessageContent(makeAppendMessage(content));
|
|
271
|
+
expect(result).toEqual([
|
|
272
|
+
{
|
|
273
|
+
type: "file_url",
|
|
274
|
+
url: "gs://bucket/report.pdf",
|
|
275
|
+
mimeType: "application/pdf",
|
|
276
|
+
},
|
|
277
|
+
]);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("ignores sourceType id on file parts", () => {
|
|
281
|
+
const result = getMessageContent(
|
|
282
|
+
makeAppendMessage([
|
|
283
|
+
{
|
|
284
|
+
type: "file",
|
|
285
|
+
mimeType: "application/pdf",
|
|
286
|
+
data: "file-abc123",
|
|
287
|
+
sourceType: "id",
|
|
288
|
+
},
|
|
289
|
+
]),
|
|
290
|
+
);
|
|
291
|
+
expect(result).toEqual([
|
|
292
|
+
{ type: "file", mimeType: "application/pdf", data: "file-abc123" },
|
|
293
|
+
]);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("forwards an audio part as a file block with the format-derived mime type", () => {
|
|
297
|
+
const result = getMessageContent(
|
|
298
|
+
makeAppendMessage([
|
|
299
|
+
{ type: "audio", audio: { data: "QUJD", format: "mp3" } },
|
|
300
|
+
]),
|
|
301
|
+
);
|
|
302
|
+
expect(result).toEqual([
|
|
303
|
+
{ type: "file", mimeType: "audio/mp3", data: "QUJD" },
|
|
304
|
+
]);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("forwards a wav audio part with the audio/wav mime type", () => {
|
|
308
|
+
const result = getMessageContent(
|
|
309
|
+
makeAppendMessage([
|
|
310
|
+
{ type: "audio", audio: { data: "QUJD", format: "wav" } },
|
|
311
|
+
]),
|
|
312
|
+
);
|
|
313
|
+
expect(result).toEqual([
|
|
314
|
+
{ type: "file", mimeType: "audio/wav", data: "QUJD" },
|
|
315
|
+
]);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("strips a data URL envelope from audio data", () => {
|
|
319
|
+
const result = getMessageContent(
|
|
320
|
+
makeAppendMessage([
|
|
321
|
+
{
|
|
322
|
+
type: "audio",
|
|
323
|
+
audio: { data: "data:audio/mp3;base64,QUJD", format: "mp3" },
|
|
324
|
+
},
|
|
325
|
+
]),
|
|
326
|
+
);
|
|
327
|
+
expect(result).toEqual([
|
|
328
|
+
{ type: "file", mimeType: "audio/mp3", data: "QUJD" },
|
|
329
|
+
]);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("strips a data URL envelope from file data", () => {
|
|
333
|
+
const result = getMessageContent(
|
|
334
|
+
makeAppendMessage([
|
|
335
|
+
{
|
|
336
|
+
type: "file",
|
|
337
|
+
data: "data:application/pdf;base64,QUJD",
|
|
338
|
+
mimeType: "application/pdf",
|
|
339
|
+
filename: "a.pdf",
|
|
340
|
+
},
|
|
341
|
+
]),
|
|
342
|
+
);
|
|
343
|
+
expect(result).toEqual([
|
|
344
|
+
{
|
|
345
|
+
type: "file",
|
|
346
|
+
mimeType: "application/pdf",
|
|
347
|
+
data: "QUJD",
|
|
348
|
+
filename: "a.pdf",
|
|
349
|
+
},
|
|
350
|
+
]);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("emits a file_url part for an unmarked http source", () => {
|
|
354
|
+
const result = getMessageContent(
|
|
355
|
+
makeAppendMessage([
|
|
356
|
+
{
|
|
357
|
+
type: "file",
|
|
358
|
+
data: "https://cdn.example.com/a.pdf",
|
|
359
|
+
mimeType: "application/pdf",
|
|
360
|
+
},
|
|
361
|
+
]),
|
|
362
|
+
);
|
|
363
|
+
expect(result).toEqual([
|
|
364
|
+
{
|
|
365
|
+
type: "file_url",
|
|
366
|
+
url: "https://cdn.example.com/a.pdf",
|
|
367
|
+
mimeType: "application/pdf",
|
|
368
|
+
},
|
|
369
|
+
]);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("leaves bare base64 file data untouched", () => {
|
|
373
|
+
const result = getMessageContent(
|
|
374
|
+
makeAppendMessage([
|
|
375
|
+
{ type: "file", data: "QUJD", mimeType: "application/pdf" },
|
|
376
|
+
]),
|
|
377
|
+
);
|
|
378
|
+
expect(result).toEqual([
|
|
379
|
+
{ type: "file", mimeType: "application/pdf", data: "QUJD" },
|
|
380
|
+
]);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("round-trips an audio file part through both converters", () => {
|
|
384
|
+
const outbound = getMessageContent(
|
|
385
|
+
makeAppendMessage([
|
|
386
|
+
{
|
|
387
|
+
type: "file",
|
|
388
|
+
data: "data:audio/mp3;base64,QUJD",
|
|
389
|
+
mimeType: "audio/mp3",
|
|
390
|
+
},
|
|
391
|
+
]),
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
expect(outbound).toEqual([
|
|
395
|
+
{ type: "file", mimeType: "audio/mp3", data: "QUJD" },
|
|
396
|
+
]);
|
|
397
|
+
|
|
398
|
+
const inbound = convertAdkMessage(
|
|
399
|
+
{ id: "m1", type: "human", content: outbound } as never,
|
|
400
|
+
{},
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
expect(inbound).toMatchObject({
|
|
404
|
+
role: "user",
|
|
405
|
+
content: [{ type: "file", data: "QUJD", mimeType: "audio/mp3" }],
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("skips data parts while keeping surrounding text", () => {
|
|
410
|
+
const result = getMessageContent(
|
|
411
|
+
makeAppendMessage([
|
|
412
|
+
{ type: "text", text: "hi" },
|
|
413
|
+
{ type: "data", name: "chart", data: { x: 1 } },
|
|
414
|
+
]),
|
|
415
|
+
);
|
|
416
|
+
expect(result).toBe("hi");
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("returns empty content for a data-only message", () => {
|
|
420
|
+
const result = getMessageContent(
|
|
421
|
+
makeAppendMessage([{ type: "data", name: "chart", data: { x: 1 } }]),
|
|
422
|
+
);
|
|
423
|
+
expect(result).toEqual([]);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("still throws on assistant-only part types", () => {
|
|
427
|
+
expect(() =>
|
|
428
|
+
getMessageContent(
|
|
429
|
+
makeAppendMessage([{ type: "reasoning", text: "thinking" }]),
|
|
430
|
+
),
|
|
431
|
+
).toThrow("Unsupported append message part type: reasoning");
|
|
432
|
+
});
|
|
211
433
|
});
|