@kuralle-syrinx/realtime 2.1.0 → 3.0.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/package.json +3 -3
- package/src/from-gemini-live.test.ts +14 -0
- package/src/from-gemini-live.ts +8 -42
- package/src/from-openai-realtime.test.ts +25 -0
- package/src/index.ts +1 -1
- package/src/openai-compatible-realtime.ts +14 -42
- package/src/realtime-adapter.ts +5 -0
- package/src/realtime-bridge.test.ts +229 -0
- package/src/realtime-bridge.ts +87 -17
- package/src/realtime-event-stream.ts +51 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuralle-syrinx/realtime",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
"types": "./src/index.ts",
|
|
9
9
|
"dependencies": {
|
|
10
10
|
"@google/genai": "^2.8.0",
|
|
11
|
-
"@kuralle-syrinx/core": "
|
|
12
|
-
"@kuralle-syrinx/ws": "
|
|
11
|
+
"@kuralle-syrinx/core": "3.0.0",
|
|
12
|
+
"@kuralle-syrinx/ws": "3.0.0"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
15
|
"typescript": "^5.7.0",
|
|
@@ -10,6 +10,7 @@ import { fromGeminiLive } from "./from-gemini-live.js";
|
|
|
10
10
|
|
|
11
11
|
const sendRealtimeInput = vi.fn();
|
|
12
12
|
const sendToolResponse = vi.fn();
|
|
13
|
+
const sendClientContent = vi.fn();
|
|
13
14
|
const closeSession = vi.fn();
|
|
14
15
|
|
|
15
16
|
let onopen: (() => void) | null = null;
|
|
@@ -27,6 +28,7 @@ const liveConnect = vi.fn().mockImplementation(async ({ callbacks }: {
|
|
|
27
28
|
return {
|
|
28
29
|
sendRealtimeInput,
|
|
29
30
|
sendToolResponse,
|
|
31
|
+
sendClientContent,
|
|
30
32
|
close: closeSession,
|
|
31
33
|
};
|
|
32
34
|
});
|
|
@@ -41,6 +43,7 @@ vi.mock("@google/genai", () => ({
|
|
|
41
43
|
afterEach(() => {
|
|
42
44
|
sendRealtimeInput.mockClear();
|
|
43
45
|
sendToolResponse.mockClear();
|
|
46
|
+
sendClientContent.mockClear();
|
|
44
47
|
closeSession.mockClear();
|
|
45
48
|
liveConnect.mockClear();
|
|
46
49
|
onopen = null;
|
|
@@ -126,6 +129,17 @@ describe("fromGeminiLive", () => {
|
|
|
126
129
|
expect(sendRealtimeInput).toHaveBeenCalledTimes(1);
|
|
127
130
|
});
|
|
128
131
|
|
|
132
|
+
it("sends a typed user turn via sendClientContent with turnComplete", async () => {
|
|
133
|
+
const adapter = fromGeminiLive({ apiKey: "test-key" });
|
|
134
|
+
await adapter.open(new AbortController().signal);
|
|
135
|
+
|
|
136
|
+
adapter.sendText!("when is the late-add deadline?");
|
|
137
|
+
expect(sendClientContent).toHaveBeenCalledWith({
|
|
138
|
+
turns: [{ role: "user", parts: [{ text: "when is the late-add deadline?" }] }],
|
|
139
|
+
turnComplete: true,
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
129
143
|
it("normalizes provider server messages into RealtimeEvent", async () => {
|
|
130
144
|
const adapter = fromGeminiLive({ apiKey: "test-key" });
|
|
131
145
|
const eventsTask = collectEvents(adapter.events, 7);
|
package/src/from-gemini-live.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import type { Session, LiveServerMessage } from "@google/genai";
|
|
4
4
|
|
|
5
5
|
import { bytesToBase64, base64ToBytes } from "./base64.js";
|
|
6
|
+
import { RealtimeEventStream } from "./realtime-event-stream.js";
|
|
6
7
|
import type { RealtimeAdapter, RealtimeEvent, RealtimeToolDef } from "./realtime-adapter.js";
|
|
7
8
|
|
|
8
9
|
const DEFAULT_MODEL = "gemini-3.1-flash-live-preview";
|
|
@@ -16,48 +17,6 @@ export interface GeminiLiveOptions {
|
|
|
16
17
|
readonly tools?: readonly RealtimeToolDef[];
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
class RealtimeEventStream implements AsyncIterable<RealtimeEvent> {
|
|
20
|
-
private readonly queue: RealtimeEvent[] = [];
|
|
21
|
-
private readonly waiters: Array<(result: IteratorResult<RealtimeEvent>) => void> = [];
|
|
22
|
-
private closed = false;
|
|
23
|
-
|
|
24
|
-
push(event: RealtimeEvent): void {
|
|
25
|
-
if (this.closed) return;
|
|
26
|
-
const waiter = this.waiters.shift();
|
|
27
|
-
if (waiter) {
|
|
28
|
-
waiter({ value: event, done: false });
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
this.queue.push(event);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
close(): void {
|
|
35
|
-
if (this.closed) return;
|
|
36
|
-
this.closed = true;
|
|
37
|
-
while (this.waiters.length > 0) {
|
|
38
|
-
const waiter = this.waiters.shift();
|
|
39
|
-
waiter?.({ value: undefined, done: true });
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
[Symbol.asyncIterator](): AsyncIterator<RealtimeEvent> {
|
|
44
|
-
return {
|
|
45
|
-
next: () =>
|
|
46
|
-
new Promise<IteratorResult<RealtimeEvent>>((resolve) => {
|
|
47
|
-
if (this.queue.length > 0) {
|
|
48
|
-
resolve({ value: this.queue.shift()!, done: false });
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
if (this.closed) {
|
|
52
|
-
resolve({ value: undefined, done: true });
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
this.waiters.push(resolve);
|
|
56
|
-
}),
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
20
|
class GeminiLiveAdapter implements RealtimeAdapter {
|
|
62
21
|
readonly caps = {
|
|
63
22
|
inputSampleRateHz: INPUT_SAMPLE_RATE_HZ,
|
|
@@ -145,6 +104,13 @@ class GeminiLiveAdapter implements RealtimeAdapter {
|
|
|
145
104
|
});
|
|
146
105
|
}
|
|
147
106
|
|
|
107
|
+
sendText(text: string): void {
|
|
108
|
+
this.requireSession().sendClientContent({
|
|
109
|
+
turns: [{ role: "user", parts: [{ text }] }],
|
|
110
|
+
turnComplete: true,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
148
114
|
cancelResponse(_audioEndMs: number): void {
|
|
149
115
|
// Gemini handles interruption server-side via `interrupted`; no truncate API.
|
|
150
116
|
}
|
|
@@ -155,6 +155,31 @@ describe("fromOpenAIRealtime", () => {
|
|
|
155
155
|
expect(JSON.parse(mock.sent[5]!)).toEqual({ type: "response.create" });
|
|
156
156
|
});
|
|
157
157
|
|
|
158
|
+
it("sends a typed user turn as conversation.item.create(input_text) + response.create", async () => {
|
|
159
|
+
const mock = createMockSocketHarness();
|
|
160
|
+
const adapter = fromOpenAIRealtime({
|
|
161
|
+
apiKey: "test-key",
|
|
162
|
+
socketFactory: mock.factory,
|
|
163
|
+
url: () => "wss://example.test/realtime?model=gpt-realtime-2",
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const openTask = adapter.open(new AbortController().signal);
|
|
167
|
+
await waitFor(() => mock.sent.length > 0);
|
|
168
|
+
mock.inject({ type: "session.updated" });
|
|
169
|
+
await openTask;
|
|
170
|
+
|
|
171
|
+
adapter.sendText!("when is the late-add deadline?");
|
|
172
|
+
expect(JSON.parse(mock.sent[1]!)).toEqual({
|
|
173
|
+
type: "conversation.item.create",
|
|
174
|
+
item: {
|
|
175
|
+
type: "message",
|
|
176
|
+
role: "user",
|
|
177
|
+
content: [{ type: "input_text", text: "when is the late-add deadline?" }],
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
expect(JSON.parse(mock.sent[2]!)).toEqual({ type: "response.create" });
|
|
181
|
+
});
|
|
182
|
+
|
|
158
183
|
it("normalizes provider server events into RealtimeEvent", async () => {
|
|
159
184
|
const mock = createMockSocketHarness();
|
|
160
185
|
const adapter = fromOpenAIRealtime({
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { SocketFactory } from "@kuralle-syrinx/ws";
|
|
|
4
4
|
import { RealtimeSocket } from "@kuralle-syrinx/ws/realtime";
|
|
5
5
|
|
|
6
6
|
import { base64ToBytes, bytesToBase64 } from "./base64.js";
|
|
7
|
+
import { RealtimeEventStream } from "./realtime-event-stream.js";
|
|
7
8
|
import type { RealtimeAdapter, RealtimeEvent } from "./realtime-adapter.js";
|
|
8
9
|
|
|
9
10
|
export interface OpenAiCompatibleRealtimeConfig {
|
|
@@ -30,48 +31,6 @@ export interface OpenAiCompatibleRealtimeConfig {
|
|
|
30
31
|
) => boolean;
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
class RealtimeEventStream implements AsyncIterable<RealtimeEvent> {
|
|
34
|
-
private readonly queue: RealtimeEvent[] = [];
|
|
35
|
-
private readonly waiters: Array<(result: IteratorResult<RealtimeEvent>) => void> = [];
|
|
36
|
-
private closed = false;
|
|
37
|
-
|
|
38
|
-
push(event: RealtimeEvent): void {
|
|
39
|
-
if (this.closed) return;
|
|
40
|
-
const waiter = this.waiters.shift();
|
|
41
|
-
if (waiter) {
|
|
42
|
-
waiter({ value: event, done: false });
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
this.queue.push(event);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
close(): void {
|
|
49
|
-
if (this.closed) return;
|
|
50
|
-
this.closed = true;
|
|
51
|
-
while (this.waiters.length > 0) {
|
|
52
|
-
const waiter = this.waiters.shift();
|
|
53
|
-
waiter?.({ value: undefined, done: true });
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
[Symbol.asyncIterator](): AsyncIterator<RealtimeEvent> {
|
|
58
|
-
return {
|
|
59
|
-
next: () =>
|
|
60
|
-
new Promise<IteratorResult<RealtimeEvent>>((resolve) => {
|
|
61
|
-
if (this.queue.length > 0) {
|
|
62
|
-
resolve({ value: this.queue.shift()!, done: false });
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
if (this.closed) {
|
|
66
|
-
resolve({ value: undefined, done: true });
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
this.waiters.push(resolve);
|
|
70
|
-
}),
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
34
|
class OpenAiCompatibleRealtimeAdapter implements RealtimeAdapter {
|
|
76
35
|
readonly caps: RealtimeAdapter["caps"];
|
|
77
36
|
readonly events: AsyncIterable<RealtimeEvent>;
|
|
@@ -143,6 +102,19 @@ class OpenAiCompatibleRealtimeAdapter implements RealtimeAdapter {
|
|
|
143
102
|
});
|
|
144
103
|
}
|
|
145
104
|
|
|
105
|
+
sendText(text: string): void {
|
|
106
|
+
const socket = this.requireSocket();
|
|
107
|
+
socket.send({
|
|
108
|
+
type: "conversation.item.create",
|
|
109
|
+
item: {
|
|
110
|
+
type: "message",
|
|
111
|
+
role: "user",
|
|
112
|
+
content: [{ type: "input_text", text }],
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
this.requestResponseCreate();
|
|
116
|
+
}
|
|
117
|
+
|
|
146
118
|
cancelResponse(audioEndMs: number): void {
|
|
147
119
|
if (!this.activeResponse) return;
|
|
148
120
|
const socket = this.requireSocket();
|
package/src/realtime-adapter.ts
CHANGED
|
@@ -11,6 +11,11 @@ export interface RealtimeAdapter {
|
|
|
11
11
|
|
|
12
12
|
open(signal: AbortSignal): Promise<void>;
|
|
13
13
|
sendAudio(pcm16: Uint8Array): void;
|
|
14
|
+
/**
|
|
15
|
+
* Send a typed user turn to the front model and request a response. Optional: adapters whose
|
|
16
|
+
* provider cannot accept text input omit it, and the bridge silently ignores typed turns for them.
|
|
17
|
+
*/
|
|
18
|
+
sendText?(text: string): void;
|
|
14
19
|
cancelResponse(audioEndMs: number): void;
|
|
15
20
|
injectToolResult(toolId: string, text: string): void;
|
|
16
21
|
/** Close the provider socket and end the event stream. */
|
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
VoiceAgentSession,
|
|
9
9
|
type EndOfSpeechPacket,
|
|
10
10
|
type InterruptTtsPacket,
|
|
11
|
+
type LlmDeltaPacket,
|
|
12
|
+
type LlmResponseDonePacket,
|
|
11
13
|
type LlmErrorPacket,
|
|
12
14
|
type LlmToolResultPacket,
|
|
13
15
|
type Reasoner,
|
|
@@ -64,6 +66,12 @@ class FakeRealtimeAdapter implements RealtimeAdapter {
|
|
|
64
66
|
this.sentAudio.push(pcm16);
|
|
65
67
|
}
|
|
66
68
|
|
|
69
|
+
readonly sentText: string[] = [];
|
|
70
|
+
|
|
71
|
+
sendText(text: string): void {
|
|
72
|
+
this.sentText.push(text);
|
|
73
|
+
}
|
|
74
|
+
|
|
67
75
|
readonly cancelCalls: number[] = [];
|
|
68
76
|
|
|
69
77
|
cancelResponse(audioEndMs: number): void {
|
|
@@ -175,6 +183,36 @@ describe("RealtimeBridge", () => {
|
|
|
175
183
|
await started;
|
|
176
184
|
});
|
|
177
185
|
|
|
186
|
+
it("forwards a user.text_received turn to adapter.sendText (typed input), ignoring blank text", async () => {
|
|
187
|
+
const adapter = new FakeRealtimeAdapter();
|
|
188
|
+
const bridge = new RealtimeBridge(adapter);
|
|
189
|
+
const bus = new PipelineBusImpl();
|
|
190
|
+
buses.push(bus);
|
|
191
|
+
|
|
192
|
+
const started = bus.start();
|
|
193
|
+
await bridge.initialize(bus, {});
|
|
194
|
+
|
|
195
|
+
bus.push(Route.Main, {
|
|
196
|
+
kind: "user.text_received",
|
|
197
|
+
contextId: "transport-turn",
|
|
198
|
+
timestampMs: Date.now(),
|
|
199
|
+
text: "when is the late-add deadline?",
|
|
200
|
+
});
|
|
201
|
+
bus.push(Route.Main, {
|
|
202
|
+
kind: "user.text_received",
|
|
203
|
+
contextId: "transport-turn",
|
|
204
|
+
timestampMs: Date.now(),
|
|
205
|
+
text: " ",
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
await waitForCondition(() => adapter.sentText.length > 0);
|
|
209
|
+
expect(adapter.sentText).toEqual(["when is the late-add deadline?"]);
|
|
210
|
+
|
|
211
|
+
await bridge.close();
|
|
212
|
+
bus.stop();
|
|
213
|
+
await started;
|
|
214
|
+
});
|
|
215
|
+
|
|
178
216
|
it("mints a fresh contextId for each response_started (R1)", async () => {
|
|
179
217
|
const adapter = new FakeRealtimeAdapter();
|
|
180
218
|
const bridge = new RealtimeBridge(adapter);
|
|
@@ -426,6 +464,45 @@ describe("RealtimeBridge", () => {
|
|
|
426
464
|
await started;
|
|
427
465
|
});
|
|
428
466
|
|
|
467
|
+
it("R-08: delegate tool_call missing the query argument emits recoverable error, never calls reasoner", async () => {
|
|
468
|
+
const adapter = new FakeRealtimeAdapter();
|
|
469
|
+
let reasonerCalled = false;
|
|
470
|
+
const reasoner: Reasoner = {
|
|
471
|
+
stream: () => {
|
|
472
|
+
reasonerCalled = true;
|
|
473
|
+
return (async function* () {
|
|
474
|
+
yield { type: "finish", reason: "stop", text: "should not run" };
|
|
475
|
+
})();
|
|
476
|
+
},
|
|
477
|
+
};
|
|
478
|
+
const bridge = new RealtimeBridge(adapter, reasoner, "consult_knowledge");
|
|
479
|
+
const bus = new PipelineBusImpl();
|
|
480
|
+
buses.push(bus);
|
|
481
|
+
const errors: LlmErrorPacket[] = [];
|
|
482
|
+
bus.on("llm.error", (pkt) => { errors.push(pkt as LlmErrorPacket); });
|
|
483
|
+
|
|
484
|
+
const started = bus.start();
|
|
485
|
+
await bridge.initialize(bus, {});
|
|
486
|
+
|
|
487
|
+
adapter.emit({ type: "response_started" });
|
|
488
|
+
adapter.emit({
|
|
489
|
+
type: "tool_call",
|
|
490
|
+
toolId: "call_noarg",
|
|
491
|
+
toolName: "consult_knowledge",
|
|
492
|
+
args: { question: "wrong arg name" },
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
await waitForCondition(() => errors.length === 1);
|
|
496
|
+
expect(errors[0]!.isRecoverable).toBe(true);
|
|
497
|
+
expect(errors[0]!.cause.message).toContain("query");
|
|
498
|
+
expect(reasonerCalled).toBe(false);
|
|
499
|
+
expect(adapter.injectedToolResults).toHaveLength(0);
|
|
500
|
+
|
|
501
|
+
await bridge.close();
|
|
502
|
+
bus.stop();
|
|
503
|
+
await started;
|
|
504
|
+
});
|
|
505
|
+
|
|
429
506
|
it("R-09: contextProvider messages are passed to reasoner.stream", async () => {
|
|
430
507
|
const adapter = new FakeRealtimeAdapter();
|
|
431
508
|
const prior: ReasonerMessage[] = [
|
|
@@ -466,6 +543,158 @@ describe("RealtimeBridge", () => {
|
|
|
466
543
|
await started;
|
|
467
544
|
});
|
|
468
545
|
|
|
546
|
+
it("R-10: routes a non-delegate tool call to onFrontToolCall and injects its result (no error)", async () => {
|
|
547
|
+
const adapter = new FakeRealtimeAdapter();
|
|
548
|
+
const reasoner: Reasoner = {
|
|
549
|
+
stream: () => (async function* () {
|
|
550
|
+
yield { type: "finish", reason: "stop", text: "delegate not called" };
|
|
551
|
+
})(),
|
|
552
|
+
};
|
|
553
|
+
const frontCalls: Array<{ toolName: string; args: Record<string, unknown> }> = [];
|
|
554
|
+
const bridge = new RealtimeBridge(adapter, reasoner, "consult_knowledge", {
|
|
555
|
+
onFrontToolCall: (c) => {
|
|
556
|
+
frontCalls.push({ toolName: c.toolName, args: c.args });
|
|
557
|
+
return "acknowledged";
|
|
558
|
+
},
|
|
559
|
+
});
|
|
560
|
+
const bus = new PipelineBusImpl();
|
|
561
|
+
buses.push(bus);
|
|
562
|
+
const errors: LlmErrorPacket[] = [];
|
|
563
|
+
bus.on("llm.error", (pkt) => { errors.push(pkt as LlmErrorPacket); });
|
|
564
|
+
|
|
565
|
+
const started = bus.start();
|
|
566
|
+
await bridge.initialize(bus, {});
|
|
567
|
+
|
|
568
|
+
adapter.emit({ type: "response_started" });
|
|
569
|
+
adapter.emit({ type: "tool_call", toolId: "call_front", toolName: "wait_for_user", args: { reason: "hold music" } });
|
|
570
|
+
|
|
571
|
+
await waitForCondition(() => adapter.injectedToolResults.length === 1);
|
|
572
|
+
expect(frontCalls).toEqual([{ toolName: "wait_for_user", args: { reason: "hold music" } }]);
|
|
573
|
+
expect(adapter.injectedToolResults[0]).toEqual({ toolId: "call_front", text: "acknowledged" });
|
|
574
|
+
expect(errors).toEqual([]); // front tools must NOT abort the turn
|
|
575
|
+
|
|
576
|
+
await bridge.close();
|
|
577
|
+
bus.stop();
|
|
578
|
+
await started;
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it("R-11: forwards the full tool-call args to the Reasoner, not just the query", async () => {
|
|
582
|
+
const adapter = new FakeRealtimeAdapter();
|
|
583
|
+
let receivedArgs: Record<string, unknown> | undefined;
|
|
584
|
+
const reasoner: Reasoner = {
|
|
585
|
+
stream: (turn) => {
|
|
586
|
+
receivedArgs = turn.toolArgs;
|
|
587
|
+
return (async function* () {
|
|
588
|
+
yield { type: "finish", reason: "stop", text: "ok" };
|
|
589
|
+
})();
|
|
590
|
+
},
|
|
591
|
+
};
|
|
592
|
+
const bridge = new RealtimeBridge(adapter, reasoner, "consult_knowledge");
|
|
593
|
+
const bus = new PipelineBusImpl();
|
|
594
|
+
buses.push(bus);
|
|
595
|
+
|
|
596
|
+
const started = bus.start();
|
|
597
|
+
await bridge.initialize(bus, {});
|
|
598
|
+
|
|
599
|
+
adapter.emit({ type: "response_started" });
|
|
600
|
+
adapter.emit({
|
|
601
|
+
type: "tool_call",
|
|
602
|
+
toolId: "call_args",
|
|
603
|
+
toolName: "consult_knowledge",
|
|
604
|
+
args: { query: "What's the deadline?", reply_language: "si" },
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
await waitForCondition(() => adapter.injectedToolResults.length === 1);
|
|
608
|
+
expect(receivedArgs).toEqual({ query: "What's the deadline?", reply_language: "si" });
|
|
609
|
+
|
|
610
|
+
await bridge.close();
|
|
611
|
+
bus.stop();
|
|
612
|
+
await started;
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it("R-13: surfaces the assistant transcript as llm.delta/llm.done (client captions data)", async () => {
|
|
616
|
+
// Regression guard for #6: a realtime turn's assistant words must reach the bus as
|
|
617
|
+
// llm.delta/llm.done — VoiceAgentSession turns those into agent_text_delta/agent_finished,
|
|
618
|
+
// which the edge protocol forwards to the client as agent_chunk/agent_end.
|
|
619
|
+
const adapter = new FakeRealtimeAdapter();
|
|
620
|
+
const bridge = new RealtimeBridge(adapter);
|
|
621
|
+
const bus = new PipelineBusImpl();
|
|
622
|
+
buses.push(bus);
|
|
623
|
+
const deltas: LlmDeltaPacket[] = [];
|
|
624
|
+
const dones: LlmResponseDonePacket[] = [];
|
|
625
|
+
bus.on("llm.delta", (pkt) => { deltas.push(pkt as LlmDeltaPacket); });
|
|
626
|
+
bus.on("llm.done", (pkt) => { dones.push(pkt as LlmResponseDonePacket); });
|
|
627
|
+
|
|
628
|
+
const started = bus.start();
|
|
629
|
+
await bridge.initialize(bus, {});
|
|
630
|
+
|
|
631
|
+
adapter.emit({ type: "response_started" });
|
|
632
|
+
adapter.emit({ type: "transcript", role: "assistant", text: "The deadline is March 31.", final: true });
|
|
633
|
+
adapter.emit({ type: "response_done" });
|
|
634
|
+
|
|
635
|
+
await waitForCondition(() => dones.length === 1);
|
|
636
|
+
expect(deltas.map((d) => d.text).join("")).toContain("The deadline is March 31.");
|
|
637
|
+
expect(dones[0]!.text).toContain("The deadline is March 31.");
|
|
638
|
+
|
|
639
|
+
await bridge.close();
|
|
640
|
+
bus.stop();
|
|
641
|
+
await started;
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it("surfaces a delta-only assistant transcript (Gemini Live: no final transcript event)", async () => {
|
|
645
|
+
// Gemini Live streams the assistant transcript as non-final fragments and never emits a
|
|
646
|
+
// final transcript. The bridge must still surface the concatenated words as llm.delta/llm.done
|
|
647
|
+
// so client captions work — without double-counting providers (OpenAI) that DO send a final.
|
|
648
|
+
const adapter = new FakeRealtimeAdapter();
|
|
649
|
+
const bridge = new RealtimeBridge(adapter);
|
|
650
|
+
const bus = new PipelineBusImpl();
|
|
651
|
+
buses.push(bus);
|
|
652
|
+
const dones: LlmResponseDonePacket[] = [];
|
|
653
|
+
bus.on("llm.done", (pkt) => { dones.push(pkt as LlmResponseDonePacket); });
|
|
654
|
+
|
|
655
|
+
const started = bus.start();
|
|
656
|
+
await bridge.initialize(bus, {});
|
|
657
|
+
|
|
658
|
+
adapter.emit({ type: "response_started" });
|
|
659
|
+
adapter.emit({ type: "transcript", role: "assistant", text: "The capital", final: false });
|
|
660
|
+
adapter.emit({ type: "transcript", role: "assistant", text: " of France", final: false });
|
|
661
|
+
adapter.emit({ type: "transcript", role: "assistant", text: " is", final: false });
|
|
662
|
+
adapter.emit({ type: "transcript", role: "assistant", text: " Paris.", final: false });
|
|
663
|
+
adapter.emit({ type: "response_done" });
|
|
664
|
+
|
|
665
|
+
await waitForCondition(() => dones.length === 1);
|
|
666
|
+
expect(dones[0]!.text).toBe("The capital of France is Paris.");
|
|
667
|
+
|
|
668
|
+
await bridge.close();
|
|
669
|
+
bus.stop();
|
|
670
|
+
await started;
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it("prefers the final transcript over deltas (OpenAI: deltas + final, no double-count)", async () => {
|
|
674
|
+
const adapter = new FakeRealtimeAdapter();
|
|
675
|
+
const bridge = new RealtimeBridge(adapter);
|
|
676
|
+
const bus = new PipelineBusImpl();
|
|
677
|
+
buses.push(bus);
|
|
678
|
+
const dones: LlmResponseDonePacket[] = [];
|
|
679
|
+
bus.on("llm.done", (pkt) => { dones.push(pkt as LlmResponseDonePacket); });
|
|
680
|
+
|
|
681
|
+
const started = bus.start();
|
|
682
|
+
await bridge.initialize(bus, {});
|
|
683
|
+
|
|
684
|
+
adapter.emit({ type: "response_started" });
|
|
685
|
+
adapter.emit({ type: "transcript", role: "assistant", text: "Let me ", final: false });
|
|
686
|
+
adapter.emit({ type: "transcript", role: "assistant", text: "check that.", final: false });
|
|
687
|
+
adapter.emit({ type: "transcript", role: "assistant", text: "Let me check that.", final: true });
|
|
688
|
+
adapter.emit({ type: "response_done" });
|
|
689
|
+
|
|
690
|
+
await waitForCondition(() => dones.length === 1);
|
|
691
|
+
expect(dones[0]!.text).toBe("Let me check that.");
|
|
692
|
+
|
|
693
|
+
await bridge.close();
|
|
694
|
+
bus.stop();
|
|
695
|
+
await started;
|
|
696
|
+
});
|
|
697
|
+
|
|
469
698
|
it("R-12: coalesces tiny audio deltas and never emits odd-length tts.audio", async () => {
|
|
470
699
|
const adapter = new FakeRealtimeAdapter();
|
|
471
700
|
const bridge = new RealtimeBridge(adapter);
|
package/src/realtime-bridge.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
type Reasoner,
|
|
17
17
|
type ReasonerMessage,
|
|
18
18
|
type UserAudioReceivedPacket,
|
|
19
|
+
type UserTextReceivedPacket,
|
|
19
20
|
type SttResultPacket,
|
|
20
21
|
type TextToSpeechAudioPacket,
|
|
21
22
|
type TextToSpeechEndPacket,
|
|
@@ -38,13 +39,32 @@ export interface RealtimeBridgeOptions {
|
|
|
38
39
|
* stateless — each tool call receives only the query extracted from the front-model tool args.
|
|
39
40
|
*/
|
|
40
41
|
readonly contextProvider?: () => readonly ReasonerMessage[];
|
|
42
|
+
/**
|
|
43
|
+
* Name of the front-model tool argument that carries the user's query for the delegate Reasoner.
|
|
44
|
+
* Must match the argument name in the registered delegate tool's schema. Defaults to "query".
|
|
45
|
+
*/
|
|
46
|
+
readonly delegateQueryArg?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Handle a front-model tool call whose name is NOT the delegate tool — front-level tools like
|
|
49
|
+
* `wait_for_user`, `escalate_to_human`, or `finish_session` that must not hit the reasoner.
|
|
50
|
+
* Return the string to inject back to the front model as the tool result (default `""`). When
|
|
51
|
+
* omitted, a non-delegate tool call remains a recoverable error (the prior behavior).
|
|
52
|
+
*/
|
|
53
|
+
readonly onFrontToolCall?: (call: {
|
|
54
|
+
readonly toolId: string;
|
|
55
|
+
readonly toolName: string;
|
|
56
|
+
readonly args: Record<string, unknown>;
|
|
57
|
+
}) => string | undefined | Promise<string | undefined>;
|
|
41
58
|
}
|
|
42
59
|
|
|
43
60
|
export class RealtimeBridge implements VoicePlugin {
|
|
44
61
|
private bus: PipelineBus | null = null;
|
|
45
62
|
private contextId = "";
|
|
46
63
|
private turnUserText = "";
|
|
64
|
+
/** Authoritative full assistant transcript(s) — providers that emit a final transcript (OpenAI). */
|
|
47
65
|
private turnAssistantText = "";
|
|
66
|
+
/** Concatenated streamed transcript fragments — providers that emit deltas only, no final (Gemini Live). */
|
|
67
|
+
private turnAssistantDeltas = "";
|
|
48
68
|
private sessionAbort: AbortController | null = null;
|
|
49
69
|
private inflight: AbortController | undefined;
|
|
50
70
|
private playedMs = 0;
|
|
@@ -71,6 +91,9 @@ export class RealtimeBridge implements VoicePlugin {
|
|
|
71
91
|
);
|
|
72
92
|
this.adapter.sendAudio(resampled);
|
|
73
93
|
}),
|
|
94
|
+
bus.on<UserTextReceivedPacket>("user.text_received", (pkt) => {
|
|
95
|
+
if (pkt.text.trim().length > 0) this.adapter.sendText?.(pkt.text);
|
|
96
|
+
}),
|
|
74
97
|
bus.on<TextToSpeechPlayoutProgressPacket>("tts.playout_progress", (pkt) => {
|
|
75
98
|
if (pkt.contextId === this.contextId) this.playedMs = pkt.playedOutMs;
|
|
76
99
|
}),
|
|
@@ -120,25 +143,19 @@ export class RealtimeBridge implements VoicePlugin {
|
|
|
120
143
|
this.onAudio(bus, ev.pcm16, ev.sampleRateHz);
|
|
121
144
|
break;
|
|
122
145
|
case "transcript":
|
|
123
|
-
if (ev.
|
|
124
|
-
|
|
146
|
+
if (ev.role === "user") {
|
|
147
|
+
if (ev.final) this.onFinalTranscript(bus, ev.text);
|
|
148
|
+
} else if (ev.final && ev.text.trim()) {
|
|
125
149
|
this.turnAssistantText = this.turnAssistantText
|
|
126
150
|
? `${this.turnAssistantText} ${ev.text.trim()}`
|
|
127
151
|
: ev.text.trim();
|
|
152
|
+
} else if (!ev.final && ev.text) {
|
|
153
|
+
// Streamed fragments already carry their own leading spaces — concatenate verbatim.
|
|
154
|
+
this.turnAssistantDeltas += ev.text;
|
|
128
155
|
}
|
|
129
156
|
break;
|
|
130
157
|
case "tool_call":
|
|
131
|
-
|
|
132
|
-
if (ev.toolName !== this.delegateToolName) {
|
|
133
|
-
const cause = new Error(
|
|
134
|
-
`Unexpected tool call "${ev.toolName}" (expected "${this.delegateToolName}")`,
|
|
135
|
-
);
|
|
136
|
-
if (this.opts.debug) console.error("[realtime-bridge]", cause.message);
|
|
137
|
-
this.onError(bus, cause, true);
|
|
138
|
-
break;
|
|
139
|
-
}
|
|
140
|
-
await this.runDelegate(bus, ev);
|
|
141
|
-
}
|
|
158
|
+
await this.handleToolCall(bus, ev);
|
|
142
159
|
break;
|
|
143
160
|
case "response_done":
|
|
144
161
|
this.onResponseDone(bus);
|
|
@@ -154,6 +171,42 @@ export class RealtimeBridge implements VoicePlugin {
|
|
|
154
171
|
}
|
|
155
172
|
}
|
|
156
173
|
|
|
174
|
+
private async handleToolCall(
|
|
175
|
+
bus: PipelineBus,
|
|
176
|
+
ev: { toolId: string; toolName: string; args: Record<string, unknown> },
|
|
177
|
+
): Promise<void> {
|
|
178
|
+
if (ev.toolName === this.delegateToolName) {
|
|
179
|
+
if (this.reasoner) await this.runDelegate(bus, ev);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (this.opts.onFrontToolCall) {
|
|
183
|
+
await this.handleFrontTool(bus, ev);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
// No front-tool handler: with a reasoner, an unexpected tool is a recoverable error
|
|
187
|
+
// (without one, tool calls are ignored, as before).
|
|
188
|
+
if (this.reasoner) {
|
|
189
|
+
const cause = new Error(`Unexpected tool call "${ev.toolName}" (expected "${this.delegateToolName}")`);
|
|
190
|
+
if (this.opts.debug) console.error("[realtime-bridge]", cause.message);
|
|
191
|
+
this.onError(bus, cause, true);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private async handleFrontTool(
|
|
196
|
+
bus: PipelineBus,
|
|
197
|
+
ev: { toolId: string; toolName: string; args: Record<string, unknown> },
|
|
198
|
+
): Promise<void> {
|
|
199
|
+
if (!this.opts.onFrontToolCall) return;
|
|
200
|
+
let result: string;
|
|
201
|
+
try {
|
|
202
|
+
result = (await this.opts.onFrontToolCall({ toolId: ev.toolId, toolName: ev.toolName, args: ev.args })) ?? "";
|
|
203
|
+
} catch (err) {
|
|
204
|
+
this.onError(bus, err instanceof Error ? err : new Error(String(err)), true);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
this.adapter.injectToolResult(ev.toolId, result);
|
|
208
|
+
}
|
|
209
|
+
|
|
157
210
|
private async runDelegate(
|
|
158
211
|
bus: PipelineBus,
|
|
159
212
|
ev: { toolId: string; toolName: string; args: Record<string, unknown> },
|
|
@@ -170,13 +223,26 @@ export class RealtimeBridge implements VoicePlugin {
|
|
|
170
223
|
};
|
|
171
224
|
bus.push(Route.Main, toolCall);
|
|
172
225
|
|
|
226
|
+
const queryArg = this.opts.delegateQueryArg ?? "query";
|
|
227
|
+
const rawQuery = ev.args[queryArg];
|
|
228
|
+
const userText = typeof rawQuery === "string" ? rawQuery : "";
|
|
229
|
+
if (userText.trim().length === 0) {
|
|
230
|
+
this.onError(
|
|
231
|
+
bus,
|
|
232
|
+
new Error(`delegate tool "${ev.toolName}" called without a string "${queryArg}" argument`),
|
|
233
|
+
true,
|
|
234
|
+
);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
173
238
|
this.inflight = new AbortController();
|
|
174
239
|
let answer = "";
|
|
175
240
|
|
|
176
241
|
try {
|
|
177
242
|
const messages = this.opts.contextProvider?.() ?? [];
|
|
178
243
|
for await (const part of this.reasoner!.stream({
|
|
179
|
-
userText
|
|
244
|
+
userText,
|
|
245
|
+
toolArgs: ev.args,
|
|
180
246
|
messages,
|
|
181
247
|
signal: this.inflight.signal,
|
|
182
248
|
})) {
|
|
@@ -244,6 +310,7 @@ export class RealtimeBridge implements VoicePlugin {
|
|
|
244
310
|
this.contextId = crypto.randomUUID();
|
|
245
311
|
this.turnUserText = "";
|
|
246
312
|
this.turnAssistantText = "";
|
|
313
|
+
this.turnAssistantDeltas = "";
|
|
247
314
|
this.playedMs = 0;
|
|
248
315
|
this.audioRemainder = new Uint8Array(0);
|
|
249
316
|
const packet: TurnChangePacket = {
|
|
@@ -305,18 +372,21 @@ export class RealtimeBridge implements VoicePlugin {
|
|
|
305
372
|
};
|
|
306
373
|
// Surface the assistant's spoken transcript as agent text so UIs (e.g. the studio) render it.
|
|
307
374
|
// The realtime adapter already produced the audio directly; these packets are display-only.
|
|
308
|
-
|
|
375
|
+
// Prefer the authoritative final transcript (OpenAI); fall back to the streamed fragments for
|
|
376
|
+
// providers that only emit non-final deltas (Gemini Live).
|
|
377
|
+
const assistantText = this.turnAssistantText.trim() || this.turnAssistantDeltas.trim();
|
|
378
|
+
if (assistantText) {
|
|
309
379
|
const delta: LlmDeltaPacket = {
|
|
310
380
|
kind: "llm.delta",
|
|
311
381
|
contextId: this.contextId,
|
|
312
382
|
timestampMs,
|
|
313
|
-
text:
|
|
383
|
+
text: assistantText,
|
|
314
384
|
};
|
|
315
385
|
const done: LlmResponseDonePacket = {
|
|
316
386
|
kind: "llm.done",
|
|
317
387
|
contextId: this.contextId,
|
|
318
388
|
timestampMs,
|
|
319
|
-
text:
|
|
389
|
+
text: assistantText,
|
|
320
390
|
};
|
|
321
391
|
bus.push(Route.Main, delta, done);
|
|
322
392
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import type { RealtimeEvent } from "./realtime-adapter.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Single-consumer async-iterable queue of realtime provider events. Producers call
|
|
7
|
+
* `push()` / `close()`; the consumer drains it with `for await`. Events queue until
|
|
8
|
+
* consumed (no backpressure). Shared by every realtime adapter so the iteration and
|
|
9
|
+
* close semantics cannot drift between providers.
|
|
10
|
+
*/
|
|
11
|
+
export class RealtimeEventStream implements AsyncIterable<RealtimeEvent> {
|
|
12
|
+
private readonly queue: RealtimeEvent[] = [];
|
|
13
|
+
private readonly waiters: Array<(result: IteratorResult<RealtimeEvent>) => void> = [];
|
|
14
|
+
private closed = false;
|
|
15
|
+
|
|
16
|
+
push(event: RealtimeEvent): void {
|
|
17
|
+
if (this.closed) return;
|
|
18
|
+
const waiter = this.waiters.shift();
|
|
19
|
+
if (waiter) {
|
|
20
|
+
waiter({ value: event, done: false });
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
this.queue.push(event);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
close(): void {
|
|
27
|
+
if (this.closed) return;
|
|
28
|
+
this.closed = true;
|
|
29
|
+
while (this.waiters.length > 0) {
|
|
30
|
+
const waiter = this.waiters.shift();
|
|
31
|
+
waiter?.({ value: undefined, done: true });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
[Symbol.asyncIterator](): AsyncIterator<RealtimeEvent> {
|
|
36
|
+
return {
|
|
37
|
+
next: () =>
|
|
38
|
+
new Promise<IteratorResult<RealtimeEvent>>((resolve) => {
|
|
39
|
+
if (this.queue.length > 0) {
|
|
40
|
+
resolve({ value: this.queue.shift()!, done: false });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (this.closed) {
|
|
44
|
+
resolve({ value: undefined, done: true });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
this.waiters.push(resolve);
|
|
48
|
+
}),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|