@effect-uai/google 0.14.0 → 0.16.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.
Files changed (44) hide show
  1. package/README.md +11 -2
  2. package/dist/Gemini.d.mts +1 -1
  3. package/dist/GeminiEmbedding.d.mts +1 -1
  4. package/dist/GeminiImageGenerator.d.mts +1 -1
  5. package/dist/GeminiLiveSession.d.mts +17 -0
  6. package/dist/GeminiLiveSession.d.mts.map +1 -0
  7. package/dist/GeminiLiveSession.mjs +26 -0
  8. package/dist/GeminiLiveSession.mjs.map +1 -0
  9. package/dist/GeminiSynthesizer.d.mts +1 -1
  10. package/dist/GeminiSynthesizer.test.mjs +1 -1
  11. package/dist/GoogleDeepResearch.d.mts +1 -1
  12. package/dist/GoogleDeepResearch.test.mjs +1 -1
  13. package/dist/LyriaGenerator.d.mts +1 -1
  14. package/dist/LyriaGenerator.test.mjs +1 -1
  15. package/dist/codec.d.mts +5 -2
  16. package/dist/codec.d.mts.map +1 -1
  17. package/dist/codec.mjs +4 -1
  18. package/dist/codec.mjs.map +1 -1
  19. package/dist/geminiSpeechCodec.test.mjs +1 -1
  20. package/dist/geminiTools.test.mjs +1 -1
  21. package/dist/index.d.mts +3 -2
  22. package/dist/index.mjs +2 -1
  23. package/dist/models-BVSKJwCx.d.mts +87 -0
  24. package/dist/models-BVSKJwCx.d.mts.map +1 -0
  25. package/dist/models.d.mts +2 -75
  26. package/dist/realtimeSession-7HSIjWh4.d.mts +62 -0
  27. package/dist/realtimeSession-7HSIjWh4.d.mts.map +1 -0
  28. package/dist/realtimeSession.d.mts +2 -0
  29. package/dist/realtimeSession.mjs +406 -0
  30. package/dist/realtimeSession.mjs.map +1 -0
  31. package/dist/realtimeSession.test.d.mts +1 -0
  32. package/dist/realtimeSession.test.mjs +505 -0
  33. package/dist/realtimeSession.test.mjs.map +1 -0
  34. package/dist/{test.DNmyFkvJ-N3QqMRwZ.mjs → test.DNmyFkvJ-BPz3G1HS.mjs} +531 -22
  35. package/dist/test.DNmyFkvJ-BPz3G1HS.mjs.map +1 -0
  36. package/package.json +6 -2
  37. package/src/GeminiLiveSession.ts +45 -0
  38. package/src/codec.ts +3 -2
  39. package/src/index.ts +1 -0
  40. package/src/models.ts +19 -0
  41. package/src/realtimeSession.test.ts +415 -0
  42. package/src/realtimeSession.ts +807 -0
  43. package/dist/models.d.mts.map +0 -1
  44. package/dist/test.DNmyFkvJ-N3QqMRwZ.mjs.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-uai/google",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Google Gemini provider for @effect-uai/core.",
5
5
  "keywords": [
6
6
  "ai",
@@ -46,6 +46,10 @@
46
46
  "types": "./dist/GeminiImageGenerator.d.mts",
47
47
  "import": "./dist/GeminiImageGenerator.mjs"
48
48
  },
49
+ "./GeminiLiveSession": {
50
+ "types": "./dist/GeminiLiveSession.d.mts",
51
+ "import": "./dist/GeminiLiveSession.mjs"
52
+ },
49
53
  "./GeminiSynthesizer": {
50
54
  "types": "./dist/GeminiSynthesizer.d.mts",
51
55
  "import": "./dist/GeminiSynthesizer.mjs"
@@ -65,7 +69,7 @@
65
69
  "devDependencies": {
66
70
  "effect": "4.0.0-rc.111",
67
71
  "typescript": "^7.0.2",
68
- "@effect-uai/core": "0.14.0"
72
+ "@effect-uai/core": "0.16.0"
69
73
  },
70
74
  "peerDependencies": {
71
75
  "@effect-uai/core": ">=0.2.0 <1",
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Gemini Live speech-to-speech Layer. Registers the typed `GeminiLiveSession`
3
+ * tag, the generic `RealtimeSession` tag, and the `RealtimeVideoInput` marker,
4
+ * which makes `sendVideoFrame` compile against this provider and not against
5
+ * one without video.
6
+ *
7
+ * Auth rides on the URL, so this runs anywhere a `WebSocket` global exists:
8
+ * Node, Bun, Deno and the browser, with no peer dependency.
9
+ */
10
+ import { Context, Effect, Layer, type Scope } from "effect"
11
+ import type * as AiError from "@effect-uai/core/AiError"
12
+ import type { CommonSessionRequest } from "@effect-uai/core/Realtime"
13
+ import {
14
+ RealtimeSession,
15
+ type RealtimeSessionHandle,
16
+ type RealtimeSessionService,
17
+ RealtimeVideoInput,
18
+ } from "@effect-uai/core/RealtimeSession"
19
+ import { type Config, type GeminiLiveRequest, openSession } from "./realtimeSession.js"
20
+
21
+ export type { Config, GeminiLiveRequest } from "./realtimeSession.js"
22
+
23
+ export type GeminiLiveSessionService = {
24
+ readonly open: (
25
+ request: GeminiLiveRequest,
26
+ ) => Effect.Effect<RealtimeSessionHandle, AiError.AiError, Scope.Scope>
27
+ }
28
+
29
+ export class GeminiLiveSession extends Context.Service<
30
+ GeminiLiveSession,
31
+ GeminiLiveSessionService
32
+ >()("@betalyra/effect-uai/providers/google/GeminiLiveSession") {}
33
+
34
+ export const make = (cfg: Config): GeminiLiveSessionService => ({ open: openSession(cfg) })
35
+
36
+ export const layer = (
37
+ cfg: Config,
38
+ ): Layer.Layer<GeminiLiveSession | RealtimeSession | RealtimeVideoInput> =>
39
+ Layer.mergeAll(
40
+ Layer.succeed(GeminiLiveSession, make(cfg)),
41
+ Layer.succeed(RealtimeSession, {
42
+ open: (request: CommonSessionRequest) => make(cfg).open(request as GeminiLiveRequest),
43
+ } satisfies RealtimeSessionService),
44
+ Layer.succeed(RealtimeVideoInput, undefined),
45
+ )
package/src/codec.ts CHANGED
@@ -317,7 +317,8 @@ const parsedJsonObject =
317
317
  )
318
318
 
319
319
  const parsedArgs = parsedJsonObject(() => ({}))
320
- const parsedResponse = parsedJsonObject((raw) => ({ output: raw }))
320
+ /** A `ToolCallOutput.output` string as the object `functionResponse` wants. */
321
+ export const parsedResponse = parsedJsonObject((raw) => ({ output: raw }))
321
322
 
322
323
  /**
323
324
  * `ToolCallOutput` only carries `call_id`; Gemini's `functionResponse`
@@ -460,7 +461,7 @@ const sanitizeSchema = (schema: unknown): unknown => {
460
461
  )
461
462
  }
462
463
 
463
- const toolDescriptorsToTools = (
464
+ export const toolDescriptorsToTools = (
464
465
  tools: ReadonlyArray<ToolDescriptor>,
465
466
  ): ReadonlyArray<RequestTool> =>
466
467
  tools.length === 0
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ export * as Gemini from "./Gemini.js"
2
2
  export * as GeminiTools from "./GeminiTools.js"
3
3
  export * as GeminiEmbedding from "./GeminiEmbedding.js"
4
4
  export * as GeminiImageGenerator from "./GeminiImageGenerator.js"
5
+ export * as GeminiLiveSession from "./GeminiLiveSession.js"
5
6
  export * as GeminiSynthesizer from "./GeminiSynthesizer.js"
6
7
  export * as GoogleDeepResearch from "./GoogleDeepResearch.js"
7
8
  export * as LyriaGenerator from "./LyriaGenerator.js"
package/src/models.ts CHANGED
@@ -83,6 +83,25 @@ export type GeminiTtsModel =
83
83
  // eslint-disable-next-line @typescript-eslint/ban-types
84
84
  | (string & {})
85
85
 
86
+ /**
87
+ * Live API models, for `BidiGenerateContent` speech-to-speech sessions. Every
88
+ * current one is native audio: output is audio only, and text comes from
89
+ * output transcription.
90
+ *
91
+ * `gemini-3.8-live` is the default for a voice agent. Its
92
+ * `-extended-thinking` sibling reasons in the background during a live
93
+ * exchange, which costs the latency a voice turn is most sensitive to.
94
+ *
95
+ * Reference: https://ai.google.dev/gemini-api/docs/live-api
96
+ */
97
+ export type GeminiLiveModel =
98
+ | "gemini-3.8-live"
99
+ | "gemini-3.8-live-extended-thinking"
100
+ | "gemini-3.1-flash-live-preview"
101
+ | "gemini-2.5-flash-native-audio-preview-12-2025"
102
+ // eslint-disable-next-line @typescript-eslint/ban-types
103
+ | (string & {})
104
+
86
105
  /**
87
106
  * 30 prebuilt voice names for Gemini TTS. No custom-voice / cloning path
88
107
  * is exposed on this surface, so the tail is stock-only (no
@@ -0,0 +1,415 @@
1
+ import { describe, it } from "@effect/vitest"
2
+ import { Duration, Effect, Encoding, Fiber, Logger, Redacted, Ref, Stream } from "effect"
3
+ import { expect } from "vitest"
4
+ import type { RealtimeEvent } from "@effect-uai/core/Realtime"
5
+ import { RealtimeInput } from "@effect-uai/core/Realtime"
6
+ import type { RealtimeSessionHandle } from "@effect-uai/core/RealtimeSession"
7
+ import * as FakeWebSocket from "@effect-uai/core/testing/FakeWebSocket"
8
+ import { type GeminiLiveRequest, openSession } from "./realtimeSession.js"
9
+
10
+ const request: GeminiLiveRequest = {
11
+ model: "gemini-3.1-flash-live-preview",
12
+ instructions: "be terse",
13
+ voiceId: "Kore",
14
+ inputFormat: { container: "raw", encoding: "pcm_s16le", sampleRate: 16000, channels: 1 },
15
+ outputFormat: { container: "raw", encoding: "pcm_s16le", sampleRate: 24000, channels: 1 },
16
+ }
17
+
18
+ const cfg = { apiKey: Redacted.make("test-key") }
19
+
20
+ /** The client speaks first here, so the handshake answers `setup`. */
21
+ const gemini = FakeWebSocket.make({
22
+ reply: (frame) =>
23
+ Effect.succeed(Object.hasOwn(frame as object, "setup") ? [{ setupComplete: {} }] : []),
24
+ })
25
+
26
+ /** Let the socket's own turn and the adapter's fibers run. */
27
+ const tick = Effect.sleep("5 millis")
28
+
29
+ /**
30
+ * Events are recorded as they arrive rather than collected at the end, so a
31
+ * run that ends in a failure still says what the caller saw before it.
32
+ */
33
+ const transcript = (
34
+ server: FakeWebSocket.FakeWebSocketServer,
35
+ script: (handle: RealtimeSessionHandle) => Effect.Effect<void, unknown>,
36
+ options?: { readonly closeCode?: number; readonly request?: GeminiLiveRequest },
37
+ ) =>
38
+ Effect.gen(function* () {
39
+ const handle = yield* openSession({ ...cfg, webSocket: server.connect })(
40
+ options?.request ?? request,
41
+ )
42
+ const seen = yield* Ref.make<ReadonlyArray<RealtimeEvent>>([])
43
+ const collector = yield* Effect.forkChild(
44
+ Effect.exit(Stream.runForEach(handle.events, (e) => Ref.update(seen, (xs) => [...xs, e]))),
45
+ )
46
+ yield* script(handle)
47
+ yield* tick
48
+ yield* server.close(options?.closeCode ?? 1000)
49
+ const exit = yield* Fiber.join(collector)
50
+ return { exit, events: yield* Ref.get(seen) }
51
+ })
52
+
53
+ const tags = (events: ReadonlyArray<RealtimeEvent>) => events.map((e) => e._tag)
54
+
55
+ const audioPart = (bytes: ReadonlyArray<number>) => ({
56
+ inlineData: {
57
+ mimeType: "audio/pcm;rate=24000",
58
+ data: Encoding.encodeBase64(new Uint8Array(bytes)),
59
+ },
60
+ })
61
+
62
+ describe("Gemini live session turns", () => {
63
+ it.live("mints one turn id for a whole answer, and reports its usage at the end", () =>
64
+ Effect.gen(function* () {
65
+ const server = yield* gemini
66
+ const { events } = yield* transcript(server, () =>
67
+ Effect.gen(function* () {
68
+ // 3.1 packs several parts, and their transcription, into one message.
69
+ yield* server.push({
70
+ serverContent: {
71
+ modelTurn: { parts: [audioPart([1, 2, 3]), { thought: true, text: "planning" }] },
72
+ outputTranscription: { text: "hi" },
73
+ },
74
+ })
75
+ yield* server.push({
76
+ serverContent: { modelTurn: { parts: [audioPart([4, 5])] } },
77
+ usageMetadata: { promptTokenCount: 10, responseTokenCount: 20, totalTokenCount: 30 },
78
+ })
79
+ yield* server.push({ serverContent: { turnComplete: true } })
80
+ }),
81
+ )
82
+
83
+ // The thought part is the model reasoning, not something to speak.
84
+ expect(tags(events)).toEqual([
85
+ "ResponseStarted",
86
+ "AudioDelta",
87
+ "OutputTranscriptDelta",
88
+ "AudioDelta",
89
+ "ResponseDone",
90
+ ])
91
+ // The wire carries no response id, so every event of the turn shares one.
92
+ expect(new Set(events.map((e) => ("responseId" in e ? e.responseId : "")))).toEqual(
93
+ new Set(["turn_1"]),
94
+ )
95
+ const done = events[4]
96
+ expect(done?._tag === "ResponseDone" && done.reason).toBe("complete")
97
+ expect(done?._tag === "ResponseDone" && done.usage?.total_tokens).toBe(30)
98
+ }).pipe(Effect.scoped),
99
+ )
100
+
101
+ it.live("ends an interrupted turn once, and numbers the next one after it", () =>
102
+ Effect.gen(function* () {
103
+ const server = yield* gemini
104
+ const { events } = yield* transcript(server, () =>
105
+ Effect.gen(function* () {
106
+ yield* server.push({ serverContent: { modelTurn: { parts: [audioPart([1])] } } })
107
+ yield* server.push({ serverContent: { interrupted: true } })
108
+ // The server follows an interruption with `turnComplete`, which
109
+ // would otherwise end the same turn a second time.
110
+ yield* server.push({ serverContent: { turnComplete: true } })
111
+ yield* server.push({ serverContent: { modelTurn: { parts: [audioPart([2])] } } })
112
+ yield* server.push({ serverContent: { turnComplete: true } })
113
+ }),
114
+ )
115
+
116
+ expect(tags(events)).toEqual([
117
+ "ResponseStarted",
118
+ "AudioDelta",
119
+ "Interrupted",
120
+ "ResponseDone",
121
+ "ResponseStarted",
122
+ "AudioDelta",
123
+ "ResponseDone",
124
+ ])
125
+ const interrupted = events[3]
126
+ expect(interrupted?._tag === "ResponseDone" && interrupted.reason).toBe("interrupted")
127
+ const next = events[4]
128
+ expect(next?._tag === "ResponseStarted" && next.responseId).toBe("turn_2")
129
+ }).pipe(Effect.scoped),
130
+ )
131
+
132
+ it.live("marks the user's words interim until the utterance is transcribed", () =>
133
+ Effect.gen(function* () {
134
+ const server = yield* gemini
135
+ const { events } = yield* transcript(server, () =>
136
+ Effect.gen(function* () {
137
+ yield* server.push({ serverContent: { interimInputTranscription: { text: "weather" } } })
138
+ yield* server.push({
139
+ serverContent: { inputTranscription: { text: "weather in Lisbon" } },
140
+ })
141
+ }),
142
+ )
143
+
144
+ // A transcript belongs to no turn, so none is started for it.
145
+ expect(events).toEqual([
146
+ { _tag: "InputTranscript", text: "weather", final: false },
147
+ { _tag: "InputTranscript", text: "weather in Lisbon", final: true },
148
+ ])
149
+ }).pipe(Effect.scoped),
150
+ )
151
+
152
+ it.live("ends the stream as an incomplete turn when a turn is still running", () =>
153
+ Effect.gen(function* () {
154
+ const server = yield* gemini
155
+ const { exit } = yield* transcript(server, () =>
156
+ server.push({ serverContent: { modelTurn: { parts: [audioPart([1])] } } }),
157
+ )
158
+
159
+ // A close must never look like a finished turn.
160
+ expect(exit._tag).toBe("Failure")
161
+ expect(JSON.stringify(exit)).toContain("IncompleteTurn")
162
+ }).pipe(Effect.scoped),
163
+ )
164
+
165
+ it.live("fails when the socket closes before the configuration is accepted", () =>
166
+ Effect.gen(function* () {
167
+ const mute = yield* FakeWebSocket.make()
168
+ const opened = yield* Effect.forkChild(
169
+ Effect.exit(openSession({ ...cfg, webSocket: mute.connect })(request)),
170
+ )
171
+ yield* tick
172
+ yield* mute.close(1000)
173
+
174
+ const exit = yield* Fiber.join(opened)
175
+ expect(exit._tag).toBe("Failure")
176
+ expect(JSON.stringify(exit)).toContain("Unavailable")
177
+ }).pipe(Effect.scoped),
178
+ )
179
+ })
180
+
181
+ describe("Gemini live session tools", () => {
182
+ it.live("answers a call with the name the server gave it", () =>
183
+ Effect.gen(function* () {
184
+ const server = yield* gemini
185
+ const { events } = yield* transcript(server, (handle) =>
186
+ Effect.gen(function* () {
187
+ yield* server.push({
188
+ toolCall: {
189
+ functionCalls: [{ id: "fc_1", name: "get_weather", args: { city: "Lisbon" } }],
190
+ },
191
+ })
192
+ yield* tick
193
+ yield* handle.send(
194
+ RealtimeInput.ToolResult({
195
+ output: {
196
+ type: "function_call_output",
197
+ call_id: "fc_1",
198
+ output: '{"celsius":32}',
199
+ providerData: undefined,
200
+ },
201
+ }),
202
+ )
203
+ yield* server.push({ serverContent: { turnComplete: true } })
204
+ }),
205
+ )
206
+
207
+ const call = events.find((e) => e._tag === "ToolCall")
208
+ expect(call?._tag === "ToolCall" && call.call.arguments).toBe('{"city":"Lisbon"}')
209
+ // A call arrives with no turn open, so it gets one of its own.
210
+ expect(call?._tag === "ToolCall" && call.responseId).toBe("turn_1")
211
+
212
+ const sent = yield* server.sent
213
+ const response = sent.find((f: any) => f.toolResponse !== undefined) as any
214
+ // The name comes from the call, and the payload has to be an object.
215
+ // `WHEN_IDLE` by default: a model that kept talking while the tool ran
216
+ // finishes the sentence rather than cutting itself off to report.
217
+ expect(response.toolResponse.functionResponses).toEqual([
218
+ {
219
+ id: "fc_1",
220
+ name: "get_weather",
221
+ response: { celsius: 32 },
222
+ scheduling: "WHEN_IDLE",
223
+ },
224
+ ])
225
+ }).pipe(Effect.scoped),
226
+ )
227
+
228
+ it.live("reports a result at once when the caller asks it to interrupt", () =>
229
+ Effect.gen(function* () {
230
+ const server = yield* gemini
231
+ yield* transcript(
232
+ server,
233
+ (handle) =>
234
+ Effect.gen(function* () {
235
+ yield* server.push({
236
+ toolCall: { functionCalls: [{ id: "fc_1", name: "get_weather", args: {} }] },
237
+ })
238
+ yield* tick
239
+ yield* handle.send(
240
+ RealtimeInput.ToolResult({
241
+ output: {
242
+ type: "function_call_output",
243
+ call_id: "fc_1",
244
+ output: "{}",
245
+ providerData: undefined,
246
+ },
247
+ }),
248
+ )
249
+ }),
250
+ { request: { ...request, toolScheduling: "interrupt" } },
251
+ )
252
+
253
+ const sent = yield* server.sent
254
+ const response = sent.find((f: any) => f.toolResponse !== undefined) as any
255
+ expect(response.toolResponse.functionResponses[0].scheduling).toBe("INTERRUPT")
256
+ }).pipe(Effect.scoped),
257
+ )
258
+
259
+ it.live("rejects a result for a call this session never made", () =>
260
+ Effect.gen(function* () {
261
+ const server = yield* gemini
262
+ const handle = yield* openSession({ ...cfg, webSocket: server.connect })(request)
263
+ const failure = yield* Effect.exit(
264
+ handle.send(
265
+ RealtimeInput.ToolResult({
266
+ output: {
267
+ type: "function_call_output",
268
+ call_id: "fc_unknown",
269
+ output: "{}",
270
+ providerData: undefined,
271
+ },
272
+ }),
273
+ ),
274
+ )
275
+
276
+ expect(failure._tag).toBe("Failure")
277
+ expect(JSON.stringify(failure)).toContain("InvalidRequest")
278
+ expect((yield* server.sent).some((f: any) => f.toolResponse !== undefined)).toBe(false)
279
+ }).pipe(Effect.scoped),
280
+ )
281
+ })
282
+
283
+ describe("Gemini live session lifetime", () => {
284
+ it.live("keeps only a resumable handle, and reads the closing warning as a duration", () =>
285
+ Effect.gen(function* () {
286
+ const server = yield* gemini
287
+ const { events, exit } = yield* transcript(server, () =>
288
+ Effect.gen(function* () {
289
+ // Sent while a tool runs: no handle, and the stored one must stand.
290
+ yield* server.push({ sessionResumptionUpdate: { newHandle: "", resumable: false } })
291
+ yield* server.push({ sessionResumptionUpdate: { newHandle: "h1", resumable: true } })
292
+ yield* server.push({ goAway: { timeLeft: "60s" } })
293
+ }),
294
+ )
295
+
296
+ expect(tags(events)).toEqual(["ResumptionHandle", "SessionEnding"])
297
+ const handle = events[0]
298
+ expect(handle?._tag === "ResumptionHandle" && handle.handle).toBe("h1")
299
+ const ending = events[1]
300
+ // A proto duration string, not a number.
301
+ expect(ending?._tag === "SessionEnding" && ending.timeLeft).toEqual(Duration.seconds(60))
302
+ // The close `goAway` announced is the lifetime cap, not a clean end, so
303
+ // a caller can tell "reconnect" from "we are done here".
304
+ expect(JSON.stringify(exit)).toContain("SessionExpired")
305
+ }).pipe(Effect.scoped),
306
+ )
307
+
308
+ it.live("ends cleanly when the close was not announced", () =>
309
+ Effect.gen(function* () {
310
+ const server = yield* gemini
311
+ const { exit } = yield* transcript(server, () =>
312
+ server.push({ serverContent: { turnComplete: true } }),
313
+ )
314
+
315
+ expect(exit._tag).toBe("Success")
316
+ }).pipe(Effect.scoped),
317
+ )
318
+ })
319
+
320
+ /** The `CapabilityWarning` payloads a run produced, in order. */
321
+ const warningCollector = () => {
322
+ const seen: Array<{ readonly field: string; readonly value?: unknown }> = []
323
+ const logger = Logger.make((options) => {
324
+ const [label, warning] = options.message as ReadonlyArray<any>
325
+ if (label === "Capability dropped") seen.push(warning)
326
+ })
327
+ return { seen, layer: Logger.layer([logger]) }
328
+ }
329
+
330
+ const userText = (text: string) =>
331
+ ({
332
+ type: "message",
333
+ role: "user",
334
+ content: [{ type: "input_text", text, providerData: undefined }],
335
+ providerData: undefined,
336
+ }) as const
337
+
338
+ const call = (id: string) =>
339
+ ({
340
+ type: "function_call",
341
+ call_id: id,
342
+ name: "get_time",
343
+ arguments: "{}",
344
+ providerData: undefined,
345
+ }) as const
346
+
347
+ describe("Gemini live session history", () => {
348
+ it.live("names each kind of item the text-only seed cannot carry, once", () =>
349
+ Effect.gen(function* () {
350
+ const server = yield* gemini
351
+ const warnings = warningCollector()
352
+
353
+ yield* openSession({ ...cfg, webSocket: server.connect })({
354
+ ...request,
355
+ history: [
356
+ userText("what time is it"),
357
+ call("fc_1"),
358
+ call("fc_2"),
359
+ {
360
+ type: "function_call_output",
361
+ call_id: "fc_1",
362
+ output: '{"now":"12:00"}',
363
+ providerData: undefined,
364
+ },
365
+ ],
366
+ }).pipe(Effect.provide(warnings.layer))
367
+ yield* tick
368
+
369
+ // Two calls, one kind: a long tool history is not a wall of warnings.
370
+ expect(warnings.seen.map((w) => w.value)).toEqual(["function_call", "function_call_output"])
371
+ expect(warnings.seen.every((w) => w.field === "history")).toBe(true)
372
+
373
+ const sent = yield* server.sent
374
+ const seed = sent.find((f: any) => f.clientContent !== undefined) as any
375
+ expect(seed.clientContent.turns).toEqual([
376
+ { role: "user", parts: [{ text: "what time is it" }] },
377
+ ])
378
+ }).pipe(Effect.scoped),
379
+ )
380
+ })
381
+
382
+ describe("Gemini live session video", () => {
383
+ it.live("sends frame bytes inline, and refuses a frame it would have to fetch", () =>
384
+ Effect.gen(function* () {
385
+ const server = yield* gemini
386
+ yield* transcript(server, (handle) =>
387
+ Effect.gen(function* () {
388
+ yield* handle.send(
389
+ RealtimeInput.VideoFrame({
390
+ frame: { _tag: "bytes", bytes: new Uint8Array([1, 2]), mimeType: "image/jpeg" },
391
+ }),
392
+ )
393
+ // A URL would need an upload through the Files API first.
394
+ const exit = yield* Effect.exit(
395
+ handle.send(
396
+ RealtimeInput.VideoFrame({
397
+ frame: { _tag: "url", url: "https://example.com/frame.jpg" },
398
+ }),
399
+ ),
400
+ )
401
+ expect(JSON.stringify(exit)).toContain("Unsupported")
402
+ }),
403
+ )
404
+
405
+ const sent = yield* server.sent
406
+ expect(sent.filter((f: any) => f.realtimeInput?.video !== undefined)).toEqual([
407
+ {
408
+ realtimeInput: {
409
+ video: { data: Encoding.encodeBase64(new Uint8Array([1, 2])), mimeType: "image/jpeg" },
410
+ },
411
+ },
412
+ ])
413
+ }).pipe(Effect.scoped),
414
+ )
415
+ })