@ai-sdk/google 3.0.66 → 3.0.68

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.
@@ -0,0 +1,47 @@
1
+ import type { JSONObject, LanguageModelV3Usage } from '@ai-sdk/provider';
2
+ import type { GoogleInteractionsUsage } from './google-interactions-api';
3
+
4
+ export function convertGoogleInteractionsUsage(
5
+ usage: GoogleInteractionsUsage | undefined | null,
6
+ ): LanguageModelV3Usage {
7
+ if (usage == null) {
8
+ return {
9
+ inputTokens: {
10
+ total: undefined,
11
+ noCache: undefined,
12
+ cacheRead: undefined,
13
+ cacheWrite: undefined,
14
+ },
15
+ outputTokens: {
16
+ total: undefined,
17
+ text: undefined,
18
+ reasoning: undefined,
19
+ },
20
+ raw: undefined,
21
+ };
22
+ }
23
+
24
+ const totalInput = usage.total_input_tokens ?? 0;
25
+ const totalOutput = usage.total_output_tokens ?? 0;
26
+ const totalThought = usage.total_thought_tokens ?? 0;
27
+ const totalCached = usage.total_cached_tokens ?? 0;
28
+
29
+ return {
30
+ inputTokens: {
31
+ total: usage.total_input_tokens ?? undefined,
32
+ noCache:
33
+ usage.total_input_tokens == null ? undefined : totalInput - totalCached,
34
+ cacheRead: usage.total_cached_tokens ?? undefined,
35
+ cacheWrite: undefined,
36
+ },
37
+ outputTokens: {
38
+ total:
39
+ usage.total_output_tokens == null && usage.total_thought_tokens == null
40
+ ? undefined
41
+ : totalOutput + totalThought,
42
+ text: usage.total_output_tokens ?? undefined,
43
+ reasoning: usage.total_thought_tokens ?? undefined,
44
+ },
45
+ raw: usage as unknown as JSONObject,
46
+ };
47
+ }
@@ -0,0 +1,630 @@
1
+ import type {
2
+ LanguageModelV3FilePart,
3
+ LanguageModelV3Prompt,
4
+ LanguageModelV3ToolResultOutput,
5
+ SharedV3Warning,
6
+ } from '@ai-sdk/provider';
7
+ import { convertToBase64 } from '@ai-sdk/provider-utils';
8
+ import type {
9
+ GoogleInteractionsContent,
10
+ GoogleInteractionsFunctionResultContent,
11
+ GoogleInteractionsImageContent,
12
+ GoogleInteractionsInput,
13
+ GoogleInteractionsTextContent,
14
+ GoogleInteractionsTurn,
15
+ } from './google-interactions-prompt';
16
+
17
+ function getTopLevelMediaType(mediaType: string): string {
18
+ const slashIndex = mediaType.indexOf('/');
19
+ return slashIndex === -1 ? mediaType : mediaType.substring(0, slashIndex);
20
+ }
21
+
22
+ function isFullMediaType(mediaType: string): boolean {
23
+ const slashIndex = mediaType.indexOf('/');
24
+ if (slashIndex === -1) {
25
+ return false;
26
+ }
27
+ const subtype = mediaType.substring(slashIndex + 1);
28
+ return subtype.length > 0 && subtype !== '*';
29
+ }
30
+
31
+ export type GoogleInteractionsMediaResolution =
32
+ | 'low'
33
+ | 'medium'
34
+ | 'high'
35
+ | 'ultra_high';
36
+
37
+ export type ConvertToGoogleInteractionsInputResult = {
38
+ input: GoogleInteractionsInput;
39
+ systemInstruction: string | undefined;
40
+ warnings: Array<SharedV3Warning>;
41
+ };
42
+
43
+ /**
44
+ * Converts an AI SDK `LanguageModelV3Prompt` into the Gemini Interactions
45
+ * request shape (`{ input, system_instruction }`).
46
+ *
47
+ * Handles text parts, file parts (image / audio / document / video, all four
48
+ * `data.type` shapes), tool-call/tool-result round-tripping, per-block
49
+ * `signature` round-tripping (`thought.signature`, `function_call.signature`),
50
+ * and statefulness compaction (drop assistant/tool turns whose
51
+ * `providerOptions.google.interactionId === previousInteractionId`).
52
+ *
53
+ * NOTE on PRD Open Q3 (empty-text-with-signature carrier hack from the
54
+ * `:generateContent` provider): unnecessary on Interactions because
55
+ * `thought.signature` and `function_call.signature` are explicit fields on
56
+ * the wire (verified against `googleapis/js-genai`
57
+ * `src/interactions/resources/interactions.ts` `ThoughtContent` /
58
+ * `FunctionCallContent`). When an input reasoning part has empty text + a
59
+ * signature, the converter emits a `thought` block with `signature` and an
60
+ * omitted `summary` — no synthetic empty-text carrier needed.
61
+ */
62
+ export function convertToGoogleInteractionsInput({
63
+ prompt,
64
+ previousInteractionId,
65
+ store,
66
+ mediaResolution,
67
+ }: {
68
+ prompt: LanguageModelV3Prompt;
69
+ previousInteractionId?: string;
70
+ store?: boolean;
71
+ /**
72
+ * Per-block media resolution applied to every image / video input block
73
+ * (the Interactions wire format places `resolution` on the block, not at
74
+ * the top level). See js-genai
75
+ * `src/interactions/resources/interactions.ts` `ImageContent.resolution`
76
+ * and `VideoContent.resolution`.
77
+ */
78
+ mediaResolution?: GoogleInteractionsMediaResolution;
79
+ }): ConvertToGoogleInteractionsInputResult {
80
+ const warnings: Array<SharedV3Warning> = [];
81
+
82
+ /*
83
+ * Behavior matrix per PRD § "Public-API contracts" → "Configurable behavior
84
+ * matrix":
85
+ *
86
+ * - `previousInteractionId` set + `store !== false` → compact history (drop
87
+ * assistant/tool turns whose `providerMetadata.google.interactionId`
88
+ * matches), emit `previous_interaction_id`.
89
+ * - `previousInteractionId` set + `store === false` → emit warning
90
+ * (incoherent combo), still send full history (NO compaction).
91
+ * - `store === false`, no `previousInteractionId` → no compaction.
92
+ * - Default → no compaction.
93
+ *
94
+ * The actual `previous_interaction_id` / `store` body fields are emitted in
95
+ * the language model's `getArgs`; this converter only handles the history
96
+ * shape and the warning.
97
+ */
98
+ const incoherentCombo = previousInteractionId != null && store === false;
99
+ const shouldCompact = previousInteractionId != null && store !== false;
100
+ if (incoherentCombo) {
101
+ warnings.push({
102
+ type: 'other',
103
+ message:
104
+ 'google.interactions: providerOptions.google.previousInteractionId was set together with store: false. These are incoherent (the prior interaction cannot be referenced when nothing was stored on the server); the full history will be sent and previous_interaction_id will still be emitted.',
105
+ });
106
+ }
107
+
108
+ const compactedPrompt = shouldCompact
109
+ ? compactPromptForPreviousInteraction({
110
+ prompt,
111
+ previousInteractionId,
112
+ })
113
+ : prompt;
114
+
115
+ const systemTexts: Array<string> = [];
116
+ const turns: Array<GoogleInteractionsTurn> = [];
117
+
118
+ for (const message of compactedPrompt) {
119
+ switch (message.role) {
120
+ case 'system': {
121
+ systemTexts.push(message.content);
122
+ break;
123
+ }
124
+ case 'user': {
125
+ const content: Array<GoogleInteractionsContent> = [];
126
+ for (const part of message.content) {
127
+ if (part.type === 'text') {
128
+ const block: GoogleInteractionsTextContent = {
129
+ type: 'text',
130
+ text: part.text,
131
+ };
132
+ content.push(block);
133
+ } else if (part.type === 'file') {
134
+ const fileBlock = convertFilePartToContent({
135
+ part,
136
+ warnings,
137
+ mediaResolution,
138
+ });
139
+ if (fileBlock != null) {
140
+ content.push(fileBlock);
141
+ }
142
+ }
143
+ }
144
+ const merged = mergeAdjacentTextContent(content);
145
+ if (merged.length > 0) {
146
+ turns.push({ role: 'user', content: merged });
147
+ }
148
+ break;
149
+ }
150
+ case 'assistant': {
151
+ const content: Array<GoogleInteractionsContent> = [];
152
+ for (const part of message.content) {
153
+ if (part.type === 'text') {
154
+ content.push({ type: 'text', text: part.text });
155
+ } else if (part.type === 'reasoning') {
156
+ const signature = part.providerOptions?.google?.signature as
157
+ | string
158
+ | undefined;
159
+ content.push({
160
+ type: 'thought',
161
+ ...(signature != null ? { signature } : {}),
162
+ summary:
163
+ part.text.length > 0
164
+ ? [{ type: 'text', text: part.text }]
165
+ : undefined,
166
+ });
167
+ } else if (part.type === 'tool-call') {
168
+ const signature = part.providerOptions?.google?.signature as
169
+ | string
170
+ | undefined;
171
+ const args =
172
+ typeof part.input === 'string'
173
+ ? safeParseToolArgs(part.input)
174
+ : ((part.input ?? {}) as Record<string, unknown>);
175
+ content.push({
176
+ type: 'function_call',
177
+ id: part.toolCallId,
178
+ name: part.toolName,
179
+ arguments: args,
180
+ ...(signature != null ? { signature } : {}),
181
+ });
182
+ } else {
183
+ warnings.push({
184
+ type: 'other',
185
+ message: `google.interactions: unsupported assistant content part type "${part.type}"; part dropped.`,
186
+ });
187
+ }
188
+ }
189
+ if (content.length > 0) {
190
+ turns.push({ role: 'model', content });
191
+ }
192
+ break;
193
+ }
194
+ case 'tool': {
195
+ /*
196
+ * Tool-result messages are emitted as a `user` turn whose content
197
+ * holds one `function_result` block per tool-result part. Wire shape
198
+ * (verified against `googleapis/js-genai`
199
+ * `samples/interactions_function_calling_client_state.ts` and
200
+ * `src/interactions/resources/interactions.ts` `FunctionResultContent`
201
+ * around line 979 — RESOLVES PRD Open Q2):
202
+ *
203
+ * {
204
+ * role: 'user',
205
+ * content: [
206
+ * {
207
+ * type: 'function_result',
208
+ * call_id: <id from the matching function_call block>,
209
+ * name: <tool name>,
210
+ * result: <string | unknown | Array<TextContent|ImageContent>>,
211
+ * is_error?: boolean,
212
+ * signature?: string,
213
+ * },
214
+ * ],
215
+ * }
216
+ *
217
+ * The `result` field is a discriminated union: a plain string for
218
+ * text-only results, or an array of `text` / `image` content blocks
219
+ * for mixed text/image results. Our converter takes the AI SDK
220
+ * canonical `LanguageModelV3ToolResultOutput` and maps:
221
+ * - `{ type: 'text', value }` → `result: <string>`
222
+ * - `{ type: 'json', value }` → `result: <stringified JSON>`
223
+ * - `{ type: 'error-text', value }` → `result: <string>` + `is_error: true`
224
+ * - `{ type: 'error-json', value }` → `result: <stringified JSON>` + `is_error: true`
225
+ * - `{ type: 'execution-denied', reason }` → `result: <reason>` + `is_error: true`
226
+ * - `{ type: 'content', value: [...] }` → `result: Array<text|image>`
227
+ * where each AI SDK `file` part with `mediaType: image/*` becomes
228
+ * an Interactions `image` block (file-data path matches
229
+ * `convertFilePartToContent` for top-level user images), and `text`
230
+ * parts pass through. Non-image file parts fall back to a warning
231
+ * because `FunctionResultContent.result` only accepts text/image.
232
+ */
233
+ const content: Array<GoogleInteractionsContent> = [];
234
+ for (const part of message.content) {
235
+ if (part.type !== 'tool-result') {
236
+ warnings.push({
237
+ type: 'other',
238
+ message: `google.interactions: unsupported tool message part type "${part.type}"; part dropped.`,
239
+ });
240
+ continue;
241
+ }
242
+ const block = convertToolResultPart({
243
+ toolCallId: part.toolCallId,
244
+ toolName: part.toolName,
245
+ output: part.output,
246
+ signature: part.providerOptions?.google?.signature as
247
+ | string
248
+ | undefined,
249
+ warnings,
250
+ });
251
+ content.push(block);
252
+ }
253
+ if (content.length > 0) {
254
+ turns.push({ role: 'user', content });
255
+ }
256
+ break;
257
+ }
258
+ }
259
+ }
260
+
261
+ const systemInstruction =
262
+ systemTexts.length > 0 ? systemTexts.join('\n\n') : undefined;
263
+
264
+ let input: GoogleInteractionsInput;
265
+ if (turns.length === 0) {
266
+ input = '';
267
+ } else if (
268
+ turns.length === 1 &&
269
+ turns[0].role === 'user' &&
270
+ Array.isArray(turns[0].content)
271
+ ) {
272
+ /*
273
+ * Single-turn user prompt: send the bare `Array<Content>` shape per the
274
+ * Interactions API's preferred single-turn format.
275
+ */
276
+ input = turns[0].content;
277
+ } else {
278
+ input = turns;
279
+ }
280
+
281
+ return { input, systemInstruction, warnings };
282
+ }
283
+
284
+ /**
285
+ * Maps a single AI SDK `LanguageModelV3FilePart` to a Gemini Interactions
286
+ * content block (`image` / `audio` / `document` / `video`).
287
+ *
288
+ * Rules for the V3 `data` shapes:
289
+ * - `Uint8Array` / `string` (base64) → block with inline `data` (base64) +
290
+ * `mime_type`.
291
+ * - `URL` → block with `uri` set to the URL string verbatim. Files API URIs
292
+ * (e.g. `https://generativelanguage.googleapis.com/v1beta/files/<id>`) and
293
+ * YouTube URLs are passed through the same way.
294
+ */
295
+ function convertFilePartToContent({
296
+ part,
297
+ warnings,
298
+ mediaResolution,
299
+ }: {
300
+ part: LanguageModelV3FilePart;
301
+ warnings: Array<SharedV3Warning>;
302
+ mediaResolution?: GoogleInteractionsMediaResolution;
303
+ }): GoogleInteractionsContent | undefined {
304
+ const topLevel = getTopLevelMediaType(part.mediaType);
305
+ let kind: 'image' | 'audio' | 'video' | 'document' | undefined;
306
+ switch (topLevel) {
307
+ case 'image':
308
+ kind = 'image';
309
+ break;
310
+ case 'audio':
311
+ kind = 'audio';
312
+ break;
313
+ case 'video':
314
+ kind = 'video';
315
+ break;
316
+ case 'application':
317
+ kind = 'document';
318
+ break;
319
+ default:
320
+ kind = undefined;
321
+ }
322
+
323
+ if (kind == null) {
324
+ warnings.push({
325
+ type: 'other',
326
+ message: `google.interactions: unsupported file media type "${part.mediaType}"; part dropped.`,
327
+ });
328
+ return undefined;
329
+ }
330
+
331
+ /*
332
+ * `resolution` is per-block on the wire (`ImageContent.resolution`,
333
+ * `VideoContent.resolution`); only image and video carry it (see
334
+ * `googleapis/js-genai` `src/interactions/resources/interactions.ts`).
335
+ * Audio / document blocks ignore the option silently.
336
+ */
337
+ const resolutionField =
338
+ mediaResolution != null && (kind === 'image' || kind === 'video')
339
+ ? { resolution: mediaResolution }
340
+ : {};
341
+
342
+ if (part.data instanceof URL) {
343
+ return {
344
+ type: kind,
345
+ uri: part.data.toString(),
346
+ ...(isFullMediaType(part.mediaType) ? { mime_type: part.mediaType } : {}),
347
+ ...resolutionField,
348
+ };
349
+ }
350
+
351
+ if (!isFullMediaType(part.mediaType)) {
352
+ warnings.push({
353
+ type: 'other',
354
+ message: `google.interactions: inline file data requires a full IANA media type (e.g. "image/png"), got "${part.mediaType}"; part dropped.`,
355
+ });
356
+ return undefined;
357
+ }
358
+
359
+ return {
360
+ type: kind,
361
+ data: convertToBase64(part.data),
362
+ mime_type: part.mediaType,
363
+ ...resolutionField,
364
+ };
365
+ }
366
+
367
+ /*
368
+ * Drops assistant turns that were part of the linked interaction
369
+ * (`previousInteractionId`) so the API doesn't see them re-sent on top of its
370
+ * server-side state. Also drops any subsequent `tool` (tool-result) message
371
+ * whose `tool-result.toolCallId` matches a `tool-call.toolCallId` from the
372
+ * dropped assistant turn — server-state already has the matching tool result
373
+ * baked in, and re-sending it without its paired call would be malformed.
374
+ *
375
+ * An assistant message is considered "part of the linked interaction" if any
376
+ * of its content parts carry `providerOptions.google.interactionId ===
377
+ * previousInteractionId`. This is stamped by `parseGoogleInteractionsOutputs`
378
+ * (and the stream transformer) on every output content part.
379
+ *
380
+ * User messages are always kept regardless of where they fell in the prior
381
+ * conversation — only assistant model output and its tool plumbing live on the
382
+ * server. (Note that the AI SDK does not stamp `interactionId` onto user
383
+ * messages, so even if it did, this function would not have a way to identify
384
+ * which user message belongs to which interaction.)
385
+ */
386
+ function compactPromptForPreviousInteraction({
387
+ prompt,
388
+ previousInteractionId,
389
+ }: {
390
+ prompt: LanguageModelV3Prompt;
391
+ previousInteractionId: string;
392
+ }): LanguageModelV3Prompt {
393
+ const out: LanguageModelV3Prompt = [];
394
+ const droppedToolCallIds = new Set<string>();
395
+
396
+ for (const message of prompt) {
397
+ if (message.role === 'assistant') {
398
+ const matchesLinkedInteraction = message.content.some(part => {
399
+ const partInteractionId = (
400
+ part as { providerOptions?: { google?: { interactionId?: string } } }
401
+ ).providerOptions?.google?.interactionId;
402
+ return partInteractionId === previousInteractionId;
403
+ });
404
+ if (matchesLinkedInteraction) {
405
+ for (const part of message.content) {
406
+ if (part.type === 'tool-call') {
407
+ droppedToolCallIds.add(part.toolCallId);
408
+ }
409
+ }
410
+ continue;
411
+ }
412
+ out.push(message);
413
+ continue;
414
+ }
415
+ if (message.role === 'tool') {
416
+ const remaining = message.content.filter(part => {
417
+ if (part.type !== 'tool-result') {
418
+ return true;
419
+ }
420
+ return !droppedToolCallIds.has(part.toolCallId);
421
+ });
422
+ if (remaining.length === 0) {
423
+ continue;
424
+ }
425
+ out.push({
426
+ ...message,
427
+ content: remaining as typeof message.content,
428
+ });
429
+ continue;
430
+ }
431
+ out.push(message);
432
+ }
433
+
434
+ return out;
435
+ }
436
+
437
+ function safeParseToolArgs(input: string): Record<string, unknown> {
438
+ try {
439
+ const parsed = JSON.parse(input);
440
+ if (
441
+ parsed != null &&
442
+ typeof parsed === 'object' &&
443
+ !Array.isArray(parsed)
444
+ ) {
445
+ return parsed as Record<string, unknown>;
446
+ }
447
+ return { value: parsed };
448
+ } catch {
449
+ return { value: input };
450
+ }
451
+ }
452
+
453
+ function convertToolResultPart({
454
+ toolCallId,
455
+ toolName,
456
+ output,
457
+ signature,
458
+ warnings,
459
+ }: {
460
+ toolCallId: string;
461
+ toolName: string;
462
+ output: LanguageModelV3ToolResultOutput;
463
+ signature: string | undefined;
464
+ warnings: Array<SharedV3Warning>;
465
+ }): GoogleInteractionsFunctionResultContent {
466
+ const base = {
467
+ type: 'function_result' as const,
468
+ call_id: toolCallId,
469
+ name: toolName,
470
+ ...(signature != null ? { signature } : {}),
471
+ };
472
+
473
+ switch (output.type) {
474
+ case 'text':
475
+ return { ...base, result: output.value };
476
+ case 'json':
477
+ return { ...base, result: JSON.stringify(output.value) };
478
+ case 'error-text':
479
+ return { ...base, is_error: true, result: output.value };
480
+ case 'error-json':
481
+ return { ...base, is_error: true, result: JSON.stringify(output.value) };
482
+ case 'execution-denied':
483
+ return {
484
+ ...base,
485
+ is_error: true,
486
+ result: output.reason ?? 'Tool execution denied by user.',
487
+ };
488
+ case 'content': {
489
+ const blocks: Array<
490
+ GoogleInteractionsTextContent | GoogleInteractionsImageContent
491
+ > = [];
492
+ for (const item of output.value) {
493
+ if (item.type === 'text') {
494
+ blocks.push({ type: 'text', text: item.text });
495
+ } else if (item.type === 'image-data') {
496
+ const imageBlock = filePartToImageBlock({
497
+ part: {
498
+ type: 'file',
499
+ mediaType: item.mediaType,
500
+ data: item.data,
501
+ },
502
+ warnings,
503
+ });
504
+ if (imageBlock != null) {
505
+ blocks.push(imageBlock);
506
+ }
507
+ } else if (item.type === 'image-url') {
508
+ const imageBlock = filePartToImageBlock({
509
+ part: {
510
+ type: 'file',
511
+ mediaType: 'image/*',
512
+ data: new URL(item.url),
513
+ },
514
+ warnings,
515
+ });
516
+ if (imageBlock != null) {
517
+ blocks.push(imageBlock);
518
+ }
519
+ } else if (item.type === 'file-data' || item.type === 'file-url') {
520
+ const mediaType =
521
+ item.type === 'file-data' ? item.mediaType : 'application/*';
522
+ const topLevel = getTopLevelMediaType(mediaType);
523
+ if (topLevel !== 'image') {
524
+ warnings.push({
525
+ type: 'other',
526
+ message: `google.interactions: tool-result file with mediaType "${mediaType}" is not supported (Interactions \`function_result.result\` accepts only text and image content); part dropped.`,
527
+ });
528
+ continue;
529
+ }
530
+ const imageBlock = filePartToImageBlock({
531
+ part:
532
+ item.type === 'file-data'
533
+ ? {
534
+ type: 'file',
535
+ mediaType: item.mediaType,
536
+ data: item.data,
537
+ }
538
+ : {
539
+ type: 'file',
540
+ mediaType,
541
+ data: new URL(item.url),
542
+ },
543
+ warnings,
544
+ });
545
+ if (imageBlock != null) {
546
+ blocks.push(imageBlock);
547
+ }
548
+ } else {
549
+ warnings.push({
550
+ type: 'other',
551
+ message: `google.interactions: tool-result content part type "${(item as { type: string }).type}" is not supported; part dropped.`,
552
+ });
553
+ }
554
+ }
555
+ return { ...base, result: blocks };
556
+ }
557
+ }
558
+ }
559
+
560
+ function filePartToImageBlock({
561
+ part,
562
+ warnings,
563
+ }: {
564
+ part: {
565
+ type: 'file';
566
+ mediaType: string;
567
+ data: Uint8Array | string | URL;
568
+ filename?: string;
569
+ };
570
+ warnings: Array<SharedV3Warning>;
571
+ }): GoogleInteractionsImageContent | undefined {
572
+ if (part.data instanceof URL) {
573
+ return {
574
+ type: 'image',
575
+ uri: part.data.toString(),
576
+ ...(isFullMediaType(part.mediaType) ? { mime_type: part.mediaType } : {}),
577
+ };
578
+ }
579
+
580
+ if (!isFullMediaType(part.mediaType)) {
581
+ warnings.push({
582
+ type: 'other',
583
+ message: `google.interactions: tool-result image part requires a full IANA media type (e.g. "image/png"), got "${part.mediaType}"; part dropped.`,
584
+ });
585
+ return undefined;
586
+ }
587
+
588
+ return {
589
+ type: 'image',
590
+ data: convertToBase64(part.data),
591
+ mime_type: part.mediaType,
592
+ };
593
+ }
594
+
595
+ /*
596
+ * Collapses runs of adjacent text content blocks within a single user message
597
+ * into one combined text block, separated by a blank line. The Interactions
598
+ * API has no `text+data` shape, so a `data.type === 'text'` file part is
599
+ * already lowered to a `text` block by `convertFilePartToContent`; merging
600
+ * keeps the wire shape compact and preserves intent when an inline text file
601
+ * sits next to a regular text part. Text blocks carrying `annotations` are
602
+ * left untouched (annotations are tied to specific text spans).
603
+ */
604
+ function mergeAdjacentTextContent(
605
+ content: Array<GoogleInteractionsContent>,
606
+ ): Array<GoogleInteractionsContent> {
607
+ if (content.length < 2) {
608
+ return content;
609
+ }
610
+ const result: Array<GoogleInteractionsContent> = [];
611
+ for (const block of content) {
612
+ const last = result[result.length - 1];
613
+ if (
614
+ block.type === 'text' &&
615
+ last != null &&
616
+ last.type === 'text' &&
617
+ (last as GoogleInteractionsTextContent).annotations == null &&
618
+ (block as GoogleInteractionsTextContent).annotations == null
619
+ ) {
620
+ const merged: GoogleInteractionsTextContent = {
621
+ type: 'text',
622
+ text: `${(last as GoogleInteractionsTextContent).text}\n\n${(block as GoogleInteractionsTextContent).text}`,
623
+ };
624
+ result[result.length - 1] = merged;
625
+ continue;
626
+ }
627
+ result.push(block);
628
+ }
629
+ return result;
630
+ }