@effect-uai/google 0.11.0 → 0.12.0
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/Gemini.d.mts +8 -0
- package/dist/Gemini.d.mts.map +1 -1
- package/dist/GeminiSynthesizer.d.mts +14 -4
- package/dist/GeminiSynthesizer.d.mts.map +1 -1
- package/dist/GeminiTools.d.mts +9 -3
- package/dist/GeminiTools.d.mts.map +1 -1
- package/dist/GoogleDeepResearch.d.mts +56 -4
- package/dist/GoogleDeepResearch.d.mts.map +1 -1
- package/dist/GoogleDeepResearch.mjs +70 -19
- package/dist/GoogleDeepResearch.mjs.map +1 -1
- package/dist/GoogleDeepResearch.test.d.mts +1 -0
- package/dist/GoogleDeepResearch.test.mjs +66 -0
- package/dist/GoogleDeepResearch.test.mjs.map +1 -0
- package/dist/codec.d.mts +24 -4
- package/dist/codec.d.mts.map +1 -1
- package/dist/codec.mjs +25 -2
- package/dist/codec.mjs.map +1 -1
- package/dist/geminiTools.test.mjs +27 -8
- package/dist/geminiTools.test.mjs.map +1 -1
- package/package.json +2 -2
- package/src/GoogleDeepResearch.test.ts +80 -0
- package/src/GoogleDeepResearch.ts +115 -16
- package/src/codec.ts +89 -1
- package/src/geminiTools.test.ts +21 -3
|
@@ -1,6 +1,20 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Array as Arr,
|
|
3
|
+
Context,
|
|
4
|
+
Effect,
|
|
5
|
+
Layer,
|
|
6
|
+
Match,
|
|
7
|
+
Option,
|
|
8
|
+
Redacted,
|
|
9
|
+
Ref,
|
|
10
|
+
Result,
|
|
11
|
+
Schema,
|
|
12
|
+
Stream,
|
|
13
|
+
pipe,
|
|
14
|
+
} from "effect"
|
|
2
15
|
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
|
3
16
|
import * as AiError from "@effect-uai/core/AiError"
|
|
17
|
+
import type { Source } from "@effect-uai/core/Citation"
|
|
4
18
|
import {
|
|
5
19
|
DeepResearch,
|
|
6
20
|
type DeepResearchService,
|
|
@@ -13,6 +27,7 @@ import * as Job from "@effect-uai/core/Job"
|
|
|
13
27
|
import type { ResearchJobRef, ResearchRequest, ResearchState } from "@effect-uai/core/Research"
|
|
14
28
|
import * as SSE from "@effect-uai/core/SSE"
|
|
15
29
|
import { type Turn, TurnEvent } from "@effect-uai/core/Turn"
|
|
30
|
+
import { GroundingMetadata, groundingToAnnotations } from "./codec.js"
|
|
16
31
|
import type { GoogleDeepResearchModel } from "./models.js"
|
|
17
32
|
|
|
18
33
|
// ---------------------------------------------------------------------------
|
|
@@ -61,23 +76,28 @@ const decodeSubmit = Schema.decodeUnknownEffect(WireSubmit)
|
|
|
61
76
|
|
|
62
77
|
// A completed interaction's text lives at `outputs[].text` (SDK `outputs`) or,
|
|
63
78
|
// in some doc examples, `steps[].content[].text`. Decode both defensively and
|
|
64
|
-
// extract from whichever is populated.
|
|
65
|
-
// preview surface
|
|
66
|
-
//
|
|
79
|
+
// extract from whichever is populated. Grounding sources ride Google's
|
|
80
|
+
// `groundingMetadata.groundingChunks[]`; this preview surface is undocumented,
|
|
81
|
+
// so decode it at every plausible level (interaction / output / step) and map
|
|
82
|
+
// whichever is populated onto `url_citation` annotations. The report text also
|
|
83
|
+
// carries the sources as an inline `[n]` / `**Sources:**` list.
|
|
67
84
|
const WireTextPart = Schema.Struct({
|
|
68
85
|
type: Schema.optional(Schema.NullOr(Schema.String)),
|
|
69
86
|
text: Schema.optional(Schema.NullOr(Schema.String)),
|
|
87
|
+
groundingMetadata: Schema.optional(Schema.NullOr(GroundingMetadata)),
|
|
70
88
|
})
|
|
71
89
|
const WireStep = Schema.Struct({
|
|
72
90
|
type: Schema.optional(Schema.NullOr(Schema.String)),
|
|
73
91
|
text: Schema.optional(Schema.NullOr(Schema.String)),
|
|
74
92
|
content: Schema.optional(Schema.NullOr(Schema.Array(WireTextPart))),
|
|
93
|
+
groundingMetadata: Schema.optional(Schema.NullOr(GroundingMetadata)),
|
|
75
94
|
})
|
|
76
95
|
export const WireInteraction = Schema.Struct({
|
|
77
96
|
id: Schema.optional(Schema.NullOr(Schema.String)),
|
|
78
97
|
status: Schema.optional(Schema.NullOr(Schema.String)),
|
|
79
98
|
outputs: Schema.optional(Schema.NullOr(Schema.Array(WireTextPart))),
|
|
80
99
|
steps: Schema.optional(Schema.NullOr(Schema.Array(WireStep))),
|
|
100
|
+
groundingMetadata: Schema.optional(Schema.NullOr(GroundingMetadata)),
|
|
81
101
|
error: Schema.optional(
|
|
82
102
|
Schema.NullOr(
|
|
83
103
|
Schema.Struct({
|
|
@@ -104,18 +124,94 @@ const reportText = (wire: WireInteraction): string => {
|
|
|
104
124
|
return fromStep.length > 0 ? fromStep : (lastStep?.text ?? "")
|
|
105
125
|
}
|
|
106
126
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
127
|
+
/**
|
|
128
|
+
* The subset of the domain `Citation.Source` this provider can populate.
|
|
129
|
+
* Declared here rather than derived from the wire schema, so the exported
|
|
130
|
+
* shape is ours and a wire change cannot silently alter it.
|
|
131
|
+
*/
|
|
132
|
+
const SourceSchema = Schema.Struct({
|
|
133
|
+
url: Schema.String,
|
|
134
|
+
title: Schema.optional(Schema.String),
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The research trace: what the model did at each step and which sources it
|
|
139
|
+
* consulted there. The `Turn` keeps only the final report and the deduped
|
|
140
|
+
* union of all sources, so the per-step breakdown lives here or nowhere.
|
|
141
|
+
*
|
|
142
|
+
* Lands on `providerData.gemini`; read it with {@link researchDataOf}.
|
|
143
|
+
*/
|
|
144
|
+
export const GeminiResearchData = Schema.Struct({
|
|
145
|
+
steps: Schema.Array(
|
|
146
|
+
Schema.Struct({
|
|
147
|
+
text: Schema.String,
|
|
148
|
+
sources: Schema.Array(SourceSchema),
|
|
149
|
+
}),
|
|
150
|
+
),
|
|
118
151
|
})
|
|
152
|
+
export type GeminiResearchData = typeof GeminiResearchData.Type
|
|
153
|
+
|
|
154
|
+
const decodeResearchData = Schema.decodeUnknownResult(Schema.Struct({ gemini: GeminiResearchData }))
|
|
155
|
+
|
|
156
|
+
/** Read this provider's data off an item, if it is there and ours. */
|
|
157
|
+
export const researchDataOf = (item: Items.HistoryItem): Option.Option<GeminiResearchData> =>
|
|
158
|
+
pipe(
|
|
159
|
+
decodeResearchData(item.providerData),
|
|
160
|
+
Result.match({
|
|
161
|
+
onSuccess: (d) => Option.some(d.gemini),
|
|
162
|
+
onFailure: () => Option.none<GeminiResearchData>(),
|
|
163
|
+
}),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
const sourcesOf = (meta: GroundingMetadata | null | undefined): ReadonlyArray<Source> =>
|
|
167
|
+
(meta?.groundingChunks ?? []).flatMap((chunk) =>
|
|
168
|
+
chunk.web?.uri == null
|
|
169
|
+
? []
|
|
170
|
+
: [{ url: chunk.web.uri, ...(chunk.web.title != null && { title: chunk.web.title }) }],
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
// A step contributes to the trace if it said something or consulted something.
|
|
174
|
+
const researchSteps = (wire: WireInteraction): GeminiResearchData["steps"] =>
|
|
175
|
+
(wire.steps ?? []).flatMap((step) => {
|
|
176
|
+
const text = textOfParts(step.content) || (step.text ?? "")
|
|
177
|
+
const sources = [
|
|
178
|
+
...sourcesOf(step.groundingMetadata),
|
|
179
|
+
...(step.content ?? []).flatMap((p) => sourcesOf(p.groundingMetadata)),
|
|
180
|
+
]
|
|
181
|
+
return text.length === 0 && sources.length === 0 ? [] : [{ text, sources }]
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
// Grounding can live at any level of the interaction envelope; gather it from
|
|
185
|
+
// all three (interaction / output part / step) and let `groundingToAnnotations`
|
|
186
|
+
// de-dupe by url.
|
|
187
|
+
const interactionAnnotations = (wire: WireInteraction): ReadonlyArray<Items.Annotation> =>
|
|
188
|
+
groundingToAnnotations(
|
|
189
|
+
wire.groundingMetadata,
|
|
190
|
+
...Arr.map(wire.outputs ?? [], (o) => o.groundingMetadata),
|
|
191
|
+
...Arr.map(wire.steps ?? [], (s) => s.groundingMetadata),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
const turnFromInteraction = (wire: WireInteraction): Turn => {
|
|
195
|
+
const annotations = interactionAnnotations(wire)
|
|
196
|
+
return {
|
|
197
|
+
items: [
|
|
198
|
+
{
|
|
199
|
+
type: "message",
|
|
200
|
+
role: "assistant",
|
|
201
|
+
content: [
|
|
202
|
+
{
|
|
203
|
+
type: "output_text",
|
|
204
|
+
text: reportText(wire),
|
|
205
|
+
...(annotations.length > 0 && { annotations }),
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
providerData: { gemini: { steps: researchSteps(wire) } },
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
usage: {},
|
|
212
|
+
stop_reason: "stop",
|
|
213
|
+
}
|
|
214
|
+
}
|
|
119
215
|
|
|
120
216
|
// Map the interaction status onto the generic `JobState`.
|
|
121
217
|
export const jobStateOf = (wire: WireInteraction): ResearchState =>
|
|
@@ -226,6 +322,9 @@ const pollJob = (
|
|
|
226
322
|
return yield* httpStatusError(response.status, text)
|
|
227
323
|
}
|
|
228
324
|
const json = yield* response.json.pipe(Effect.mapError(transportFailure))
|
|
325
|
+
// Preview surface: log the raw interaction so the (undocumented) grounding
|
|
326
|
+
// path can be confirmed against a live completed job at LOG_LEVEL=Debug.
|
|
327
|
+
yield* Effect.logDebug("google interactions poll response", { interaction: json })
|
|
229
328
|
const wire = yield* decodeInteraction(json).pipe(Effect.mapError(transportFailure))
|
|
230
329
|
return jobStateOf(wire)
|
|
231
330
|
})
|
|
@@ -284,7 +383,7 @@ const completedTurn = (wire: WireInteraction | null | undefined, streamed: strin
|
|
|
284
383
|
type: "message",
|
|
285
384
|
role: "assistant",
|
|
286
385
|
content: [{ type: "output_text", text: streamed }],
|
|
287
|
-
...(wire != null && { providerData: wire }),
|
|
386
|
+
...(wire != null && { providerData: { gemini: { steps: researchSteps(wire) } } }),
|
|
288
387
|
},
|
|
289
388
|
],
|
|
290
389
|
usage: {},
|
package/src/codec.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
InputImage,
|
|
6
6
|
HistoryItem,
|
|
7
7
|
Message,
|
|
8
|
+
Annotation,
|
|
8
9
|
} from "@effect-uai/core/Items"
|
|
9
10
|
import type { ToolDescriptor } from "@effect-uai/core/Tool"
|
|
10
11
|
import type { Turn } from "@effect-uai/core/Turn"
|
|
@@ -50,8 +51,55 @@ const Content = Schema.Struct({
|
|
|
50
51
|
parts: Schema.optional(Schema.Array(Part)),
|
|
51
52
|
})
|
|
52
53
|
|
|
54
|
+
// Google grounding metadata. On a grounded response the candidate carries
|
|
55
|
+
// `groundingChunks[].web.{uri,title}` - the structured form of the inline `[n]`
|
|
56
|
+
// / trailing `**Sources:**` list the model renders into the answer text.
|
|
57
|
+
// Decoded defensively (every field optional) as it is absent on ungrounded
|
|
58
|
+
// turns. Shared with the Interactions deep-research surface via `codec.ts`.
|
|
59
|
+
const GroundingChunk = Schema.Struct({
|
|
60
|
+
web: Schema.optional(
|
|
61
|
+
Schema.NullOr(
|
|
62
|
+
Schema.Struct({
|
|
63
|
+
uri: Schema.optional(Schema.NullOr(Schema.String)),
|
|
64
|
+
title: Schema.optional(Schema.NullOr(Schema.String)),
|
|
65
|
+
}),
|
|
66
|
+
),
|
|
67
|
+
),
|
|
68
|
+
})
|
|
69
|
+
export const GroundingMetadata = Schema.Struct({
|
|
70
|
+
groundingChunks: Schema.optional(Schema.NullOr(Schema.Array(GroundingChunk))),
|
|
71
|
+
})
|
|
72
|
+
export type GroundingMetadata = typeof GroundingMetadata.Type
|
|
73
|
+
|
|
74
|
+
// Two citations are the same source when they point at the same url.
|
|
75
|
+
const sameUrlCitation = (a: Annotation, b: Annotation): boolean =>
|
|
76
|
+
a.type === "url_citation" && b.type === "url_citation" && a.url === b.url
|
|
77
|
+
|
|
78
|
+
// Grounding chunks → `url_citation` annotations, de-duped by url across every
|
|
79
|
+
// metadata block passed. `web.uri` is a `grounding-api-redirect` URL and
|
|
80
|
+
// `web.title` the bare domain; both are kept as-is (the redirect resolves to
|
|
81
|
+
// the real page).
|
|
82
|
+
export const groundingToAnnotations = (
|
|
83
|
+
...metas: ReadonlyArray<GroundingMetadata | null | undefined>
|
|
84
|
+
): ReadonlyArray<Annotation> =>
|
|
85
|
+
pipe(
|
|
86
|
+
metas,
|
|
87
|
+
Arr.flatMap((meta) => meta?.groundingChunks ?? []),
|
|
88
|
+
Arr.filterMap((chunk) =>
|
|
89
|
+
chunk.web?.uri == null
|
|
90
|
+
? Result.failVoid
|
|
91
|
+
: Result.succeed<Annotation>({
|
|
92
|
+
type: "url_citation",
|
|
93
|
+
url: chunk.web.uri,
|
|
94
|
+
title: chunk.web.title ?? chunk.web.uri,
|
|
95
|
+
}),
|
|
96
|
+
),
|
|
97
|
+
Arr.dedupeWith(sameUrlCitation),
|
|
98
|
+
)
|
|
99
|
+
|
|
53
100
|
const Candidate = Schema.Struct({
|
|
54
101
|
content: Schema.optional(Content),
|
|
102
|
+
groundingMetadata: Schema.optional(GroundingMetadata),
|
|
55
103
|
finishReason: Schema.optional(Schema.String),
|
|
56
104
|
index: Schema.optional(Schema.Number),
|
|
57
105
|
})
|
|
@@ -87,6 +135,7 @@ type RequestPart =
|
|
|
87
135
|
}
|
|
88
136
|
| {
|
|
89
137
|
readonly functionResponse: {
|
|
138
|
+
readonly id?: string
|
|
90
139
|
readonly name: string
|
|
91
140
|
readonly response: Record<string, unknown>
|
|
92
141
|
}
|
|
@@ -292,6 +341,20 @@ const geminiField = (
|
|
|
292
341
|
)
|
|
293
342
|
|
|
294
343
|
const providerIdFor = (item: ToolCall): Option.Option<string> => geminiField(item, (g) => g.id)
|
|
344
|
+
|
|
345
|
+
// The originating call's Gemini id, looked up from history by our `call_id`.
|
|
346
|
+
// Gemini 3 maps a `functionResponse` back to its call by this id, so parallel
|
|
347
|
+
// calls to the same function are mis-paired if we drop it.
|
|
348
|
+
const providerIdForCallId = (
|
|
349
|
+
history: ReadonlyArray<HistoryItem>,
|
|
350
|
+
call_id: string,
|
|
351
|
+
): Option.Option<string> =>
|
|
352
|
+
pipe(
|
|
353
|
+
history,
|
|
354
|
+
Arr.findFirst((item): item is ToolCall => isFunctionCallItem(item) && item.call_id === call_id),
|
|
355
|
+
Option.flatMap(providerIdFor),
|
|
356
|
+
)
|
|
357
|
+
|
|
295
358
|
const signatureFor = (item: ToolCall): Option.Option<string> =>
|
|
296
359
|
geminiField(item, (g) => g.thoughtSignature)
|
|
297
360
|
|
|
@@ -327,6 +390,10 @@ const itemToContent =
|
|
|
327
390
|
parts: [
|
|
328
391
|
{
|
|
329
392
|
functionResponse: {
|
|
393
|
+
...Option.match(providerIdForCallId(history, o.call_id), {
|
|
394
|
+
onSome: (id) => ({ id }),
|
|
395
|
+
onNone: () => ({}),
|
|
396
|
+
}),
|
|
330
397
|
name: Option.getOrElse(nameForCallId(history, o.call_id), () => o.call_id),
|
|
331
398
|
response: parsedResponse(o.output),
|
|
332
399
|
},
|
|
@@ -466,6 +533,8 @@ export type Accumulator = {
|
|
|
466
533
|
readonly text: string
|
|
467
534
|
readonly reasoning: string
|
|
468
535
|
readonly functionCalls: ReadonlyArray<AccumulatedFunctionCall>
|
|
536
|
+
/** Grounding citations, accumulated (de-duped by url) across chunks. */
|
|
537
|
+
readonly annotations: ReadonlyArray<Annotation>
|
|
469
538
|
readonly finishReason: Option.Option<string>
|
|
470
539
|
readonly usage: {
|
|
471
540
|
readonly input_tokens?: number
|
|
@@ -480,6 +549,7 @@ export const emptyAccumulator: Accumulator = {
|
|
|
480
549
|
text: "",
|
|
481
550
|
reasoning: "",
|
|
482
551
|
functionCalls: [],
|
|
552
|
+
annotations: [],
|
|
483
553
|
finishReason: Option.none(),
|
|
484
554
|
usage: {},
|
|
485
555
|
}
|
|
@@ -598,6 +668,14 @@ const appendFunctionCalls = (
|
|
|
598
668
|
prior,
|
|
599
669
|
)
|
|
600
670
|
|
|
671
|
+
// Grounding usually arrives on the final chunk, but merge across chunks and
|
|
672
|
+
// de-dupe by url so a mid-stream metadata block is not lost or double-counted.
|
|
673
|
+
const mergeAnnotations = (
|
|
674
|
+
prev: ReadonlyArray<Annotation>,
|
|
675
|
+
next: ReadonlyArray<Annotation>,
|
|
676
|
+
): ReadonlyArray<Annotation> =>
|
|
677
|
+
next.length === 0 ? prev : Arr.dedupeWith(Arr.appendAll(prev, next), sameUrlCitation)
|
|
678
|
+
|
|
601
679
|
export const ingestChunk = (acc: Accumulator, chunk: WireChunk): ChunkResult => {
|
|
602
680
|
const parts = chunkParts(chunk)
|
|
603
681
|
const finishReason = Option.fromNullishOr(chunk.candidates?.[0]?.finishReason)
|
|
@@ -608,6 +686,10 @@ export const ingestChunk = (acc: Accumulator, chunk: WireChunk): ChunkResult =>
|
|
|
608
686
|
text: acc.text + sumStrings(parts, "text"),
|
|
609
687
|
reasoning: acc.reasoning + sumStrings(parts, "reasoning"),
|
|
610
688
|
functionCalls: appendFunctionCalls(acc.functionCalls, collectFunctionCalls(parts)),
|
|
689
|
+
annotations: mergeAnnotations(
|
|
690
|
+
acc.annotations,
|
|
691
|
+
groundingToAnnotations(chunk.candidates?.[0]?.groundingMetadata),
|
|
692
|
+
),
|
|
611
693
|
finishReason: Option.orElse(finishReason, () => acc.finishReason),
|
|
612
694
|
usage: mergeUsage(acc.usage, chunk.usageMetadata),
|
|
613
695
|
},
|
|
@@ -624,7 +706,13 @@ const assistantMessageItems = (acc: Accumulator): ReadonlyArray<HistoryItem> =>
|
|
|
624
706
|
{
|
|
625
707
|
type: "message",
|
|
626
708
|
role: "assistant",
|
|
627
|
-
content: [
|
|
709
|
+
content: [
|
|
710
|
+
{
|
|
711
|
+
type: "output_text",
|
|
712
|
+
text: acc.text,
|
|
713
|
+
...(acc.annotations.length > 0 && { annotations: acc.annotations }),
|
|
714
|
+
},
|
|
715
|
+
],
|
|
628
716
|
},
|
|
629
717
|
]
|
|
630
718
|
|
package/src/geminiTools.test.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { Effect, Option, Result, Schema } from "effect"
|
|
2
2
|
import * as Tool from "@effect-uai/core/Tool"
|
|
3
3
|
import { descriptorsOf, providerToolsOf } from "@effect-uai/core/Tool"
|
|
4
|
+
import * as Turn from "@effect-uai/core/Turn"
|
|
4
5
|
import { describe, expect, it } from "vitest"
|
|
5
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
WireChunk,
|
|
8
|
+
accumulatorToTurn,
|
|
9
|
+
buildRequestBody,
|
|
10
|
+
emptyAccumulator,
|
|
11
|
+
ingestChunk,
|
|
12
|
+
} from "./codec.js"
|
|
6
13
|
import { codeExecutionTool, googleSearchTool, renderProviderTools } from "./GeminiTools.js"
|
|
7
14
|
|
|
8
15
|
const localTool = Tool.make({
|
|
@@ -69,14 +76,19 @@ describe("Gemini provider tools", () => {
|
|
|
69
76
|
expect(Result.isFailure(result)).toBe(true)
|
|
70
77
|
})
|
|
71
78
|
|
|
72
|
-
it("
|
|
79
|
+
it("maps groundingMetadata chunks to url_citation annotations on the turn", () => {
|
|
73
80
|
const grounded = {
|
|
74
81
|
candidates: [
|
|
75
82
|
{
|
|
76
83
|
content: { role: "model", parts: [{ text: "Grounded answer." }] },
|
|
77
84
|
groundingMetadata: {
|
|
78
85
|
webSearchQueries: ["effect ts"],
|
|
79
|
-
groundingChunks: [
|
|
86
|
+
groundingChunks: [
|
|
87
|
+
{ web: { uri: "https://example.com", title: "Example" } },
|
|
88
|
+
// Duplicate uri (de-duped) and a bare-domain title fallback.
|
|
89
|
+
{ web: { uri: "https://example.com", title: "Example" } },
|
|
90
|
+
{ web: { uri: "https://effect.website" } },
|
|
91
|
+
],
|
|
80
92
|
},
|
|
81
93
|
finishReason: "STOP",
|
|
82
94
|
},
|
|
@@ -84,5 +96,11 @@ describe("Gemini provider tools", () => {
|
|
|
84
96
|
}
|
|
85
97
|
const chunk = Schema.decodeUnknownSync(WireChunk)(grounded)
|
|
86
98
|
expect(chunk.candidates?.[0]?.content?.parts?.[0]).toEqual({ text: "Grounded answer." })
|
|
99
|
+
|
|
100
|
+
const turn = accumulatorToTurn(ingestChunk(emptyAccumulator, chunk).accumulator)
|
|
101
|
+
expect(Turn.citations(turn)).toEqual([
|
|
102
|
+
{ type: "url_citation", url: "https://example.com", title: "Example" },
|
|
103
|
+
{ type: "url_citation", url: "https://effect.website", title: "https://effect.website" },
|
|
104
|
+
])
|
|
87
105
|
})
|
|
88
106
|
})
|