@kuralle-syrinx/realtime 4.2.0 → 4.3.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.
@@ -115,6 +115,7 @@ export class RealtimeBridge implements VoicePlugin {
115
115
  private turnAssistantText = "";
116
116
  /** Concatenated streamed transcript fragments — providers that emit deltas only, no final (Gemini Live). */
117
117
  private turnAssistantDeltas = "";
118
+ private pendingSpeechEndedAtMs: number | null = null;
118
119
  private sessionAbort: AbortController | null = null;
119
120
  private inflight: AbortController | undefined;
120
121
  private delegateTask: Promise<void> | null = null;
@@ -129,6 +130,16 @@ export class RealtimeBridge implements VoicePlugin {
129
130
  private readonly opts: RealtimeBridgeOptions = {},
130
131
  ) {}
131
132
 
133
+ injectContext(text: string): void {
134
+ // Provider history differs here: OpenAI retains system items, while Gemini drops
135
+ // system/developer history, so the adapter selects the provider-safe representation.
136
+ if (this.adapter.injectContext) {
137
+ this.adapter.injectContext(text);
138
+ return;
139
+ }
140
+ console.warn("RealtimeBridge: context injection requested but the adapter does not support it");
141
+ }
142
+
132
143
  async initialize(bus: PipelineBus, _cfg: PluginConfig): Promise<void> {
133
144
  this.bus = bus;
134
145
  this.sessionAbort = new AbortController();
@@ -216,6 +227,12 @@ export class RealtimeBridge implements VoicePlugin {
216
227
  } else if (!ev.final && ev.text) {
217
228
  // Streamed fragments already carry their own leading spaces — concatenate verbatim.
218
229
  this.turnAssistantDeltas += ev.text;
230
+ bus.push(Route.Main, {
231
+ kind: "llm.delta",
232
+ contextId: this.contextId,
233
+ timestampMs: Date.now(),
234
+ text: ev.text,
235
+ });
219
236
  }
220
237
  break;
221
238
  case "tool_call":
@@ -223,10 +240,23 @@ export class RealtimeBridge implements VoicePlugin {
223
240
  break;
224
241
  case "response_done":
225
242
  this.onResponseDone(bus);
243
+ // Meter the native front — previously native turns produced no usage at all.
244
+ if (ev.usage && this.contextId) {
245
+ bus.push(Route.Background, {
246
+ kind: "usage.recorded",
247
+ contextId: this.contextId,
248
+ timestampMs: Date.now(),
249
+ stage: "llm",
250
+ ...ev.usage,
251
+ });
252
+ }
226
253
  break;
227
254
  case "speech_started":
228
255
  this.onSpeechStarted(bus);
229
256
  break;
257
+ case "speech_stopped":
258
+ this.onSpeechStopped(bus);
259
+ break;
230
260
  case "resumption_handle": {
231
261
  // G4: persist-worthy native-resume handle (Gemini). Background route —
232
262
  // a durable host stores the latest and passes it back on reconnect.
@@ -338,6 +368,25 @@ export class RealtimeBridge implements VoicePlugin {
338
368
  this.inflight = controller;
339
369
  let answer = "";
340
370
  let grounded = false;
371
+ let blockedMessage: string | undefined;
372
+ let blockedPayload: unknown;
373
+ let passStartedMs = Date.now();
374
+ let passTtftRecorded = false;
375
+ const pushConversationMetric = (name: string, value: string, timestampMs: number): void => {
376
+ bus.push(Route.Main, {
377
+ kind: "metric.conversation",
378
+ contextId,
379
+ timestampMs,
380
+ name,
381
+ value,
382
+ });
383
+ };
384
+ const recordPassTtft = (timestampMs: number): void => {
385
+ if (passTtftRecorded) return;
386
+ passTtftRecorded = true;
387
+ pushConversationMetric("llm.pass_ttft_ms", String(timestampMs - passStartedMs), timestampMs);
388
+ };
389
+ pushConversationMetric("llm.call_started", "1", passStartedMs);
341
390
 
342
391
  // G2 observability: the query is on its way to the reasoner (Background route, R4).
343
392
  const queryStartedMs = Date.now();
@@ -361,10 +410,37 @@ export class RealtimeBridge implements VoicePlugin {
361
410
  })) {
362
411
  switch (part.type) {
363
412
  case "text-delta":
413
+ recordPassTtft(Date.now());
364
414
  answer += part.text;
365
415
  break;
416
+ case "tool-call":
417
+ recordPassTtft(Date.now());
418
+ break;
366
419
  case "tool-result":
367
420
  grounded = true;
421
+ passStartedMs = Date.now();
422
+ passTtftRecorded = false;
423
+ pushConversationMetric("llm.call_started", "1", passStartedMs);
424
+ break;
425
+ case "control": {
426
+ const controlMs = Date.now();
427
+ bus.push(Route.Background, {
428
+ kind: "delegate.result",
429
+ contextId,
430
+ timestampMs: controlMs,
431
+ query: userText,
432
+ answer,
433
+ durationMs: controlMs - queryStartedMs,
434
+ grounded,
435
+ toolId: ev.toolId,
436
+ toolName: ev.toolName,
437
+ control: { name: part.name, payload: part.payload },
438
+ });
439
+ break;
440
+ }
441
+ case "blocked":
442
+ blockedMessage = part.userFacingMessage;
443
+ blockedPayload = part.payload;
368
444
  break;
369
445
  case "finish":
370
446
  if (!answer && part.text) answer = part.text;
@@ -382,6 +458,7 @@ export class RealtimeBridge implements VoicePlugin {
382
458
  this.onError(bus, part.cause, true);
383
459
  return;
384
460
  }
461
+ if (blockedMessage !== undefined) break;
385
462
  }
386
463
  } catch (err) {
387
464
  if (isAbortError(err)) return;
@@ -392,6 +469,24 @@ export class RealtimeBridge implements VoicePlugin {
392
469
  if (this.inflight === controller) this.inflight = undefined;
393
470
  }
394
471
 
472
+ if (blockedMessage !== undefined) {
473
+ const blockedMs = Date.now();
474
+ bus.push(Route.Background, {
475
+ kind: "delegate.result",
476
+ contextId,
477
+ timestampMs: blockedMs,
478
+ query: userText,
479
+ answer: blockedMessage,
480
+ durationMs: blockedMs - queryStartedMs,
481
+ grounded,
482
+ toolId: ev.toolId,
483
+ toolName: ev.toolName,
484
+ blocked: { userFacingMessage: blockedMessage, payload: blockedPayload },
485
+ });
486
+ this.adapter.injectToolResult(ev.toolId, this.formatToolResult(blockedMessage));
487
+ return;
488
+ }
489
+
395
490
  if (answer.length === 0) {
396
491
  this.onError(bus, new Error("delegate produced no output"), false);
397
492
  return;
@@ -445,6 +540,20 @@ export class RealtimeBridge implements VoicePlugin {
445
540
  bus.push(Route.Critical, packet);
446
541
  }
447
542
 
543
+ private onSpeechStopped(bus: PipelineBus): void {
544
+ const timestampMs = Date.now();
545
+ if (!this.contextId) {
546
+ // Server VAD reports speech end before response.created mints the response context.
547
+ this.pendingSpeechEndedAtMs = timestampMs;
548
+ return;
549
+ }
550
+ bus.push(Route.Main, {
551
+ kind: "vad.speech_ended",
552
+ contextId: this.contextId,
553
+ timestampMs,
554
+ });
555
+ }
556
+
448
557
  private onResponseStarted(bus: PipelineBus): void {
449
558
  const previousContextId = this.contextId;
450
559
  this.contextId = crypto.randomUUID();
@@ -453,6 +562,14 @@ export class RealtimeBridge implements VoicePlugin {
453
562
  this.turnAssistantDeltas = "";
454
563
  this.playedMs = 0;
455
564
  this.audioRemainder = new Uint8Array(0);
565
+ if (this.pendingSpeechEndedAtMs !== null) {
566
+ bus.push(Route.Main, {
567
+ kind: "vad.speech_ended",
568
+ contextId: this.contextId,
569
+ timestampMs: this.pendingSpeechEndedAtMs,
570
+ });
571
+ this.pendingSpeechEndedAtMs = null;
572
+ }
456
573
  const packet: TurnChangePacket = {
457
574
  kind: "turn.change",
458
575
  contextId: this.contextId,
@@ -558,19 +675,21 @@ export class RealtimeBridge implements VoicePlugin {
558
675
  // providers that only emit non-final deltas (Gemini Live).
559
676
  const assistantText = this.turnAssistantText.trim() || this.turnAssistantDeltas.trim();
560
677
  if (assistantText) {
561
- const delta: LlmDeltaPacket = {
562
- kind: "llm.delta",
563
- contextId: this.contextId,
564
- timestampMs,
565
- text: assistantText,
566
- };
567
678
  const done: LlmResponseDonePacket = {
568
679
  kind: "llm.done",
569
680
  contextId: this.contextId,
570
681
  timestampMs,
571
682
  text: assistantText,
572
683
  };
573
- bus.push(Route.Main, delta, done);
684
+ if (this.turnAssistantDeltas.length === 0) {
685
+ bus.push(Route.Main, {
686
+ kind: "llm.delta",
687
+ contextId: this.contextId,
688
+ timestampMs,
689
+ text: assistantText,
690
+ });
691
+ }
692
+ bus.push(Route.Main, done);
574
693
  }
575
694
  bus.push(Route.Main, turnComplete, ttsEnd);
576
695
  }
@@ -1,173 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
- //
3
- // R-14: regression gate — the realtime package must run on workerd without
4
- // Buffer, process, or node:crypto. Reintroducing any Node-only primitive in
5
- // src/ should fail this test.
6
-
7
- import { readFileSync, readdirSync } from "node:fs";
8
- import path from "node:path";
9
- import { fileURLToPath } from "node:url";
10
-
11
- import { describe, expect, it } from "vitest";
12
-
13
- import {
14
- PipelineBusImpl,
15
- Route,
16
- type TextToSpeechAudioPacket,
17
- type TurnChangePacket,
18
- } from "@kuralle-syrinx/core";
19
- import type { ManagedSocket, SocketData, SocketFactory } from "@kuralle-syrinx/ws";
20
-
21
- import { bytesToBase64, fromOpenAIRealtime } from "./from-openai-realtime.js";
22
- import { RealtimeBridge } from "./realtime-bridge.js";
23
-
24
- const srcDir = path.dirname(fileURLToPath(import.meta.url));
25
-
26
- interface EdgeMockHarness {
27
- readonly factory: SocketFactory;
28
- readonly sent: string[];
29
- inject(msg: Record<string, unknown>): void;
30
- }
31
-
32
- function createEdgeMockHarness(): EdgeMockHarness {
33
- const sent: string[] = [];
34
- let messageHandler: ((data: SocketData, isBinary: boolean) => void) | null = null;
35
-
36
- const socket: ManagedSocket = {
37
- get isOpen() {
38
- return true;
39
- },
40
- send: (data: SocketData) => {
41
- sent.push(typeof data === "string" ? data : "");
42
- },
43
- keepAlivePing: () => {},
44
- verify: async () => true,
45
- dispose: () => {},
46
- onOpen: (handler) => {
47
- queueMicrotask(() => handler());
48
- },
49
- onMessage: (handler) => {
50
- messageHandler = handler;
51
- },
52
- onClose: () => {},
53
- onError: () => {},
54
- };
55
-
56
- return {
57
- factory: () => socket,
58
- sent,
59
- inject: (msg) => messageHandler?.(JSON.stringify(msg), false),
60
- };
61
- }
62
-
63
- function pcmFromSamples(samples: readonly number[]): Uint8Array {
64
- const pcm = Int16Array.from(samples);
65
- return new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength);
66
- }
67
-
68
- async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
69
- const startedAt = Date.now();
70
- while (Date.now() - startedAt < timeoutMs) {
71
- if (predicate()) return;
72
- await new Promise((resolve) => setTimeout(resolve, 5));
73
- }
74
- throw new Error("Timed out waiting for condition");
75
- }
76
-
77
- function collectSourceFiles(dir: string): string[] {
78
- const out: string[] = [];
79
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
80
- const full = path.join(dir, entry.name);
81
- if (entry.isDirectory()) {
82
- out.push(...collectSourceFiles(full));
83
- continue;
84
- }
85
- if (entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts")) {
86
- out.push(full);
87
- }
88
- }
89
- return out;
90
- }
91
-
92
- describe("edge safety (R-14)", () => {
93
- it("src/ contains no Buffer, process, or node:crypto imports", () => {
94
- const forbidden = [
95
- /\bfrom\s+["']node:crypto["']/,
96
- /\brequire\s*\(\s*["']node:crypto["']\s*\)/,
97
- /\bglobalThis\.Buffer\b/,
98
- /\bglobalThis\.process\b/,
99
- /\bprocess\.env\b/,
100
- /\bBuffer\.from\b/,
101
- /\bBuffer\.alloc\b/,
102
- ];
103
- const hits: string[] = [];
104
- for (const file of collectSourceFiles(srcDir)) {
105
- const text = readFileSync(file, "utf8");
106
- for (const pattern of forbidden) {
107
- if (pattern.test(text)) {
108
- hits.push(`${path.relative(srcDir, file)}: ${pattern.source}`);
109
- }
110
- }
111
- }
112
- expect(hits).toEqual([]);
113
- });
114
-
115
- // NOTE: we do NOT delete globalThis.Buffer/process here — vitest's own worker needs `process`
116
- // (its uncaughtException handler calls process.listeners), so deleting it crashes the runner.
117
- // Edge-safety is enforced statically by the source-scan above; this test proves the audio path
118
- // actually runs end-to-end using only the runtime-agnostic helpers.
119
- it("fromOpenAIRealtime + RealtimeBridge round-trip audio via runtime-agnostic helpers", async () => {
120
- const mock = createEdgeMockHarness();
121
- const adapter = fromOpenAIRealtime({
122
- apiKey: "edge-test-key",
123
- socketFactory: mock.factory,
124
- url: () => "wss://example.test/realtime?model=gpt-realtime-2",
125
- });
126
- const bridge = new RealtimeBridge(adapter);
127
- const bus = new PipelineBusImpl();
128
- const turnChanges: TurnChangePacket[] = [];
129
- const ttsAudio: TextToSpeechAudioPacket[] = [];
130
- bus.on("turn.change", (pkt) => { turnChanges.push(pkt as TurnChangePacket); });
131
- bus.on("tts.audio", (pkt) => { ttsAudio.push(pkt as TextToSpeechAudioPacket); });
132
-
133
- const started = bus.start();
134
-
135
- const initTask = bridge.initialize(bus, {});
136
- await waitFor(() => mock.sent.length > 0);
137
- mock.inject({ type: "session.updated" });
138
- await initTask;
139
-
140
- bus.push(Route.Main, {
141
- kind: "user.audio_received",
142
- contextId: "transport-turn",
143
- timestampMs: Date.now(),
144
- audio: pcmFromSamples([100, 200, 300, 400]),
145
- });
146
-
147
- await waitFor(() =>
148
- mock.sent.some((raw) => (JSON.parse(raw) as { type: string }).type === "input_audio_buffer.append"),
149
- );
150
-
151
- const providerPcm = pcmFromSamples(Array.from({ length: 960 }, (_, i) => i));
152
- mock.inject({ type: "response.created" });
153
- mock.inject({
154
- type: "response.output_audio.delta",
155
- delta: bytesToBase64(providerPcm),
156
- });
157
- mock.inject({ type: "response.done" });
158
-
159
- await waitFor(() => ttsAudio.length > 0 && turnChanges.length > 0);
160
-
161
- const contextId = turnChanges[0]!.contextId;
162
- expect(contextId).toMatch(
163
- /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
164
- );
165
- expect(ttsAudio[0]!.contextId).toBe(contextId);
166
- expect(ttsAudio[0]!.sampleRateHz).toBe(16_000);
167
- expect(ttsAudio[0]!.audio.byteLength).toBeGreaterThan(0);
168
-
169
- await bridge.close();
170
- bus.stop();
171
- await started;
172
- });
173
- });
@@ -1,241 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
-
3
- import { afterEach, describe, expect, it, vi } from "vitest";
4
-
5
- import type { LiveServerMessage } from "@google/genai";
6
-
7
- import type { RealtimeEvent } from "./realtime-adapter.js";
8
- import { bytesToBase64 } from "./base64.js";
9
- import { fromGeminiLive } from "./from-gemini-live.js";
10
-
11
- const sendRealtimeInput = vi.fn();
12
- const sendToolResponse = vi.fn();
13
- const sendClientContent = vi.fn();
14
- const closeSession = vi.fn();
15
-
16
- let onopen: (() => void) | null = null;
17
- let onmessage: ((msg: LiveServerMessage) => void) | null = null;
18
-
19
- const liveConnect = vi.fn().mockImplementation(async ({ callbacks }: {
20
- callbacks: {
21
- onopen?: () => void;
22
- onmessage?: (msg: LiveServerMessage) => void;
23
- };
24
- }) => {
25
- onopen = callbacks.onopen ?? null;
26
- onmessage = callbacks.onmessage ?? null;
27
- queueMicrotask(() => callbacks.onopen?.());
28
- return {
29
- sendRealtimeInput,
30
- sendToolResponse,
31
- sendClientContent,
32
- close: closeSession,
33
- };
34
- });
35
-
36
- vi.mock("@google/genai", () => ({
37
- GoogleGenAI: vi.fn().mockImplementation(() => ({
38
- live: { connect: liveConnect },
39
- })),
40
- Modality: { AUDIO: "AUDIO" },
41
- }));
42
-
43
- afterEach(() => {
44
- sendRealtimeInput.mockClear();
45
- sendToolResponse.mockClear();
46
- sendClientContent.mockClear();
47
- closeSession.mockClear();
48
- liveConnect.mockClear();
49
- onopen = null;
50
- onmessage = null;
51
- });
52
-
53
- async function collectEvents(
54
- events: AsyncIterable<RealtimeEvent>,
55
- max = 12,
56
- ): Promise<RealtimeEvent[]> {
57
- const out: RealtimeEvent[] = [];
58
- for await (const event of events) {
59
- out.push(event);
60
- if (out.length >= max) break;
61
- }
62
- return out;
63
- }
64
-
65
- function inject(msg: Partial<LiveServerMessage> & Record<string, unknown>): void {
66
- if (!onmessage) throw new Error("mock session onmessage not wired");
67
- onmessage(msg as LiveServerMessage);
68
- }
69
-
70
- describe("fromGeminiLive", () => {
71
- it("emits client calls for open, audio, and tool result", async () => {
72
- const adapter = fromGeminiLive({
73
- apiKey: "test-key",
74
- tools: [{
75
- name: "consult_knowledge",
76
- description: "Answer knowledge questions.",
77
- parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
78
- }],
79
- });
80
-
81
- await adapter.open(new AbortController().signal);
82
-
83
- expect(liveConnect).toHaveBeenCalledTimes(1);
84
- const connectArg = liveConnect.mock.calls[0]![0] as {
85
- model: string;
86
- config: Record<string, unknown>;
87
- };
88
- expect(connectArg.model).toBe("gemini-3.1-flash-live-preview");
89
- expect(connectArg.config["tools"]).toEqual([{
90
- functionDeclarations: [{
91
- name: "consult_knowledge",
92
- description: "Answer knowledge questions.",
93
- parametersJsonSchema: {
94
- type: "object",
95
- properties: { query: { type: "string" } },
96
- required: ["query"],
97
- },
98
- }],
99
- }]);
100
-
101
- const pcm = new Uint8Array([0, 1, 2, 3]);
102
- adapter.sendAudio(pcm);
103
- expect(sendRealtimeInput).toHaveBeenCalledWith({
104
- audio: {
105
- data: bytesToBase64(pcm),
106
- mimeType: "audio/pcm;rate=16000",
107
- },
108
- });
109
-
110
- inject({
111
- toolCall: {
112
- functionCalls: [{
113
- id: "call_abc",
114
- name: "consult_knowledge",
115
- args: { query: "late add" },
116
- }],
117
- },
118
- });
119
- adapter.injectToolResult("call_abc", "Late Add Petition required.");
120
- expect(sendToolResponse).toHaveBeenCalledWith({
121
- functionResponses: [{
122
- id: "call_abc",
123
- name: "consult_knowledge",
124
- response: { result: "Late Add Petition required." },
125
- }],
126
- });
127
-
128
- adapter.cancelResponse(420);
129
- expect(sendRealtimeInput).toHaveBeenCalledTimes(1);
130
- });
131
-
132
- it("G4/WBS-4: native resume — always enables sessionResumption, passes a prior handle through, surfaces new handles", async () => {
133
- const adapter = fromGeminiLive({ apiKey: "test-key", sessionResumptionHandle: "handle-prev" });
134
- expect(adapter.caps.supportsNativeResume).toBe(true);
135
-
136
- const eventsTask = collectEvents(adapter.events, 1);
137
- await adapter.open(new AbortController().signal);
138
-
139
- const connectArg = liveConnect.mock.calls[0]![0] as { config: Record<string, unknown> };
140
- // Handle passthrough — the server restores the conversation; nothing is replayed
141
- // client-side (sendClientContent untouched — R6: no double-apply).
142
- expect(connectArg.config["sessionResumption"]).toEqual({ handle: "handle-prev" });
143
- expect(sendClientContent).not.toHaveBeenCalled();
144
-
145
- inject({ sessionResumptionUpdate: { newHandle: "handle-next", resumable: true } });
146
- // Non-resumable updates carry no usable handle and must be ignored.
147
- inject({ sessionResumptionUpdate: { newHandle: "", resumable: false } });
148
-
149
- expect(await eventsTask).toEqual([{ type: "resumption_handle", handle: "handle-next" }]);
150
- await adapter.close();
151
- });
152
-
153
- it("G4/WBS-4: enables handle issuance even without a prior handle", async () => {
154
- const adapter = fromGeminiLive({ apiKey: "test-key" });
155
- await adapter.open(new AbortController().signal);
156
- const connectArg = liveConnect.mock.calls[0]![0] as { config: Record<string, unknown> };
157
- expect(connectArg.config["sessionResumption"]).toEqual({});
158
- await adapter.close();
159
- });
160
-
161
- it("sends a typed user turn via sendClientContent with turnComplete", async () => {
162
- const adapter = fromGeminiLive({ apiKey: "test-key" });
163
- await adapter.open(new AbortController().signal);
164
-
165
- adapter.sendText!("when is the late-add deadline?");
166
- expect(sendClientContent).toHaveBeenCalledWith({
167
- turns: [{ role: "user", parts: [{ text: "when is the late-add deadline?" }] }],
168
- turnComplete: true,
169
- });
170
- });
171
-
172
- it("normalizes provider server messages into RealtimeEvent", async () => {
173
- const adapter = fromGeminiLive({ apiKey: "test-key" });
174
- const eventsTask = collectEvents(adapter.events, 7);
175
- await adapter.open(new AbortController().signal);
176
-
177
- const audioBytes = new Uint8Array([9, 10, 11, 12]);
178
- inject({ setupComplete: {} });
179
- inject({
180
- serverContent: {
181
- modelTurn: {
182
- parts: [{
183
- inlineData: {
184
- data: bytesToBase64(audioBytes),
185
- mimeType: "audio/pcm;rate=24000",
186
- },
187
- }],
188
- },
189
- },
190
- });
191
- inject({ serverContent: { interrupted: true } });
192
- inject({
193
- serverContent: {
194
- inputTranscription: { text: "Can I add Biology?", finished: true },
195
- },
196
- });
197
- inject({
198
- serverContent: {
199
- outputTranscription: { text: "Let me check that.", finished: true },
200
- },
201
- });
202
- inject({
203
- toolCall: {
204
- functionCalls: [{
205
- id: "call_123",
206
- name: "consult_knowledge",
207
- args: { query: "late add biology" },
208
- }],
209
- },
210
- });
211
- inject({ serverContent: { turnComplete: true } });
212
-
213
- const events = await eventsTask;
214
- expect(events.slice(0, 7)).toEqual([
215
- { type: "response_started" },
216
- { type: "audio", pcm16: audioBytes, sampleRateHz: 24000 },
217
- { type: "speech_started" },
218
- { type: "transcript", role: "user", text: "Can I add Biology?", final: true },
219
- { type: "transcript", role: "assistant", text: "Let me check that.", final: true },
220
- {
221
- type: "tool_call",
222
- toolId: "call_123",
223
- toolName: "consult_knowledge",
224
- args: { query: "late add biology" },
225
- },
226
- { type: "response_done" },
227
- ]);
228
- });
229
-
230
- it("exposes Gemini Live capability flags", () => {
231
- const adapter = fromGeminiLive({ apiKey: "test-key" });
232
- expect(adapter.caps).toEqual({
233
- inputSampleRateHz: 16_000,
234
- outputSampleRateHz: 24_000,
235
- supportsNativeResume: true,
236
- supportsConcurrentToolAudio: false,
237
- supportsTruncate: false,
238
- emitsServerSpeechStarted: true,
239
- });
240
- });
241
- });