@kuralle-syrinx/realtime 4.2.0 → 4.4.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/README.md +28 -0
- package/package.json +4 -4
- package/src/from-gemini-live.ts +147 -9
- package/src/from-openai-realtime.ts +29 -5
- package/src/index.ts +6 -1
- package/src/openai-compatible-realtime.ts +53 -3
- package/src/realtime-adapter.ts +11 -1
- package/src/realtime-bridge.ts +128 -7
- package/src/edge-safety.test.ts +0 -173
- package/src/from-gemini-live.test.ts +0 -241
- package/src/from-openai-realtime.test.ts +0 -676
- package/src/gemini-translate.test.ts +0 -82
- package/src/openai-compatible-realtime.test.ts +0 -188
- package/src/realtime-bridge.test.ts +0 -1358
- package/src/workers-seam.test.ts +0 -143
- package/tsconfig.json +0 -21
package/src/realtime-bridge.ts
CHANGED
|
@@ -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,
|
|
@@ -536,6 +653,8 @@ export class RealtimeBridge implements VoicePlugin {
|
|
|
536
653
|
timestampMs,
|
|
537
654
|
text: this.turnUserText,
|
|
538
655
|
transcripts,
|
|
656
|
+
endpointingOwner: "timer",
|
|
657
|
+
endpointingReason: "end_of_speech",
|
|
539
658
|
};
|
|
540
659
|
|
|
541
660
|
if (this.opts.textOnly) {
|
|
@@ -558,19 +677,21 @@ export class RealtimeBridge implements VoicePlugin {
|
|
|
558
677
|
// providers that only emit non-final deltas (Gemini Live).
|
|
559
678
|
const assistantText = this.turnAssistantText.trim() || this.turnAssistantDeltas.trim();
|
|
560
679
|
if (assistantText) {
|
|
561
|
-
const delta: LlmDeltaPacket = {
|
|
562
|
-
kind: "llm.delta",
|
|
563
|
-
contextId: this.contextId,
|
|
564
|
-
timestampMs,
|
|
565
|
-
text: assistantText,
|
|
566
|
-
};
|
|
567
680
|
const done: LlmResponseDonePacket = {
|
|
568
681
|
kind: "llm.done",
|
|
569
682
|
contextId: this.contextId,
|
|
570
683
|
timestampMs,
|
|
571
684
|
text: assistantText,
|
|
572
685
|
};
|
|
573
|
-
|
|
686
|
+
if (this.turnAssistantDeltas.length === 0) {
|
|
687
|
+
bus.push(Route.Main, {
|
|
688
|
+
kind: "llm.delta",
|
|
689
|
+
contextId: this.contextId,
|
|
690
|
+
timestampMs,
|
|
691
|
+
text: assistantText,
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
bus.push(Route.Main, done);
|
|
574
695
|
}
|
|
575
696
|
bus.push(Route.Main, turnComplete, ttsEnd);
|
|
576
697
|
}
|
package/src/edge-safety.test.ts
DELETED
|
@@ -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
|
-
});
|