@kuralle-syrinx/cf-agents 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.
@@ -0,0 +1,100 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { R2EdgeRecorder } from "./r2-recorder.js";
5
+
6
+ interface PutCall {
7
+ key: string;
8
+ body: Uint8Array | string;
9
+ }
10
+
11
+ function fakeBucket() {
12
+ const puts: PutCall[] = [];
13
+ const bucket = {
14
+ async put(key: string, body: Uint8Array | string) {
15
+ puts.push({ key, body });
16
+ return {} as unknown;
17
+ },
18
+ } as unknown as R2Bucket;
19
+ return { bucket, puts };
20
+ }
21
+
22
+ function asString(body: Uint8Array | string): string {
23
+ return typeof body === "string" ? body : new TextDecoder().decode(body);
24
+ }
25
+
26
+ function wavChannels(body: Uint8Array | string): number {
27
+ const wav = body as Uint8Array;
28
+ return new DataView(wav.buffer, wav.byteOffset, wav.byteLength).getUint16(22, true);
29
+ }
30
+
31
+ describe("R2EdgeRecorder", () => {
32
+ it("writes a stereo conversation.wav plus user/assistant stems and manifest", async () => {
33
+ const { bucket, puts } = fakeBucket();
34
+ const rec = new R2EdgeRecorder({ bucket, sessionId: "s1", startedAtMs: 1000, now: () => 1000 });
35
+
36
+ rec.onUserAudio("c1", new Uint8Array(640), 16000);
37
+ rec.onAssistantAudio("c1", new Uint8Array(320), 24000);
38
+ await rec.finalize({ sessionId: "s1", closedAtMs: 2000 });
39
+
40
+ const keys = puts.map((p) => p.key).sort();
41
+ expect(keys).toEqual([
42
+ "recordings/s1/1000/assistant.wav",
43
+ "recordings/s1/1000/conversation.wav",
44
+ "recordings/s1/1000/manifest.json",
45
+ "recordings/s1/1000/user.wav",
46
+ ]);
47
+
48
+ const conversation = puts.find((p) => p.key.endsWith("conversation.wav"))!.body;
49
+ expect(asString((conversation as Uint8Array).subarray(0, 4))).toBe("RIFF");
50
+ expect(wavChannels(conversation)).toBe(2); // stereo: user L / assistant R
51
+ expect(wavChannels(puts.find((p) => p.key.endsWith("user.wav"))!.body)).toBe(1);
52
+
53
+ const manifest = JSON.parse(asString(puts.find((p) => p.key.endsWith("manifest.json"))!.body)) as {
54
+ conversation: { channels: number };
55
+ };
56
+ expect(manifest.conversation.channels).toBe(2);
57
+ });
58
+
59
+ it("time-aligns the assistant after the user instead of stacking at 0", async () => {
60
+ const { bucket, puts } = fakeBucket();
61
+ let now = 0;
62
+ const rec = new R2EdgeRecorder({ bucket, sessionId: "t", startedAtMs: 0, now: () => now });
63
+
64
+ now = 0;
65
+ rec.onUserAudio("c", new Uint8Array(640), 16000); // 20ms of user at t=0
66
+ now = 1000;
67
+ rec.onAssistantAudio("c", new Uint8Array(320), 16000); // assistant starts at t=1000ms
68
+ await rec.finalize({ sessionId: "t", closedAtMs: 1100 });
69
+
70
+ const manifest = JSON.parse(asString(puts.find((p) => p.key.endsWith("manifest.json"))!.body)) as {
71
+ conversation: { durationMs: number };
72
+ assistant: { durationMs: number };
73
+ };
74
+ // Assistant anchored at ~1000ms, so both the assistant stem and the merged
75
+ // conversation run ~1010ms — not the ~20ms they'd be if stacked at offset 0.
76
+ expect(manifest.assistant.durationMs).toBe(1010);
77
+ expect(manifest.conversation.durationMs).toBe(1010);
78
+ });
79
+
80
+ it("does not write anything when no audio was captured", async () => {
81
+ const { bucket, puts } = fakeBucket();
82
+ const rec = new R2EdgeRecorder({ bucket, sessionId: "empty", startedAtMs: 1 });
83
+ await rec.finalize({ sessionId: "empty", closedAtMs: 2 });
84
+ expect(puts).toHaveLength(0);
85
+ });
86
+
87
+ it("flags truncation past the per-stream cap instead of buffering unbounded", async () => {
88
+ const { bucket, puts } = fakeBucket();
89
+ const rec = new R2EdgeRecorder({ bucket, sessionId: "big", startedAtMs: 1, maxBytesPerStream: 1000, now: () => 1 });
90
+ rec.onUserAudio("c", new Uint8Array(800), 16000);
91
+ rec.onUserAudio("c", new Uint8Array(800), 16000); // would exceed 1000 -> dropped
92
+ await rec.finalize({ sessionId: "big", closedAtMs: 2 });
93
+
94
+ const manifest = JSON.parse(asString(puts.find((p) => p.key.endsWith("manifest.json"))!.body)) as {
95
+ user: { byteLength: number; truncated: boolean };
96
+ };
97
+ expect(manifest.user.byteLength).toBe(800);
98
+ expect(manifest.user.truncated).toBe(true);
99
+ });
100
+ });
@@ -0,0 +1,177 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // R2-backed implementation of the transport EdgeRecorder. Taps inbound caller
4
+ // audio and outbound TTS audio and, on call end, writes to an R2 bucket:
5
+ // - conversation.wav : the FULL conversation, one stereo file (user = left,
6
+ // assistant = right), time-aligned by wall-clock so the
7
+ // assistant sits after the user instead of stacked at 0.
8
+ // - user.wav / assistant.wav : the per-speaker stems (useful for diarization).
9
+ // - manifest.json : durations / byte lengths / truncation flags.
10
+ // Mirrors the Node `voice-recorder` conversation-track approach (wall-clock byte
11
+ // offsets + stereo interleave) but stays edge-safe (no node:fs). Cloudflare's own
12
+ // withVoice persists transcripts to SQLite, not raw audio — this is the additive
13
+ // piece. Buffered (memory-capped) and flushed once on finalize, off the hot path.
14
+
15
+ import type { EdgeRecorder } from "@kuralle-syrinx/server-websocket/edge";
16
+ import { interleaveStereoPcm16, pcm16ToWav } from "@kuralle-syrinx/recorder/wav";
17
+
18
+ export interface R2EdgeRecorderOptions {
19
+ readonly bucket: R2Bucket;
20
+ readonly sessionId: string;
21
+ readonly startedAtMs: number;
22
+ /** Object key prefix. Default "recordings". */
23
+ readonly keyPrefix?: string;
24
+ /** Per-stream memory cap; recording past it is dropped and flagged. Default 64 MiB. */
25
+ readonly maxBytesPerStream?: number;
26
+ /** Injectable clock (test seam). Defaults to Date.now. */
27
+ readonly now?: () => number;
28
+ }
29
+
30
+ const DEFAULT_MAX_BYTES_PER_STREAM = 64 * 1024 * 1024;
31
+
32
+ interface AudioChunk {
33
+ offsetBytes: number;
34
+ data: Uint8Array;
35
+ }
36
+
37
+ interface StreamBuffer {
38
+ chunks: AudioChunk[];
39
+ cursorBytes: number; // end of the last placed chunk on the wall-clock timeline
40
+ dataBytes: number; // actual audio bytes captured (for the cap)
41
+ sampleRateHz: number;
42
+ truncated: boolean;
43
+ }
44
+
45
+ function emptyStream(): StreamBuffer {
46
+ return { chunks: [], cursorBytes: 0, dataBytes: 0, sampleRateHz: 16000, truncated: false };
47
+ }
48
+
49
+ export class R2EdgeRecorder implements EdgeRecorder {
50
+ readonly #user = emptyStream();
51
+ readonly #assistant = emptyStream();
52
+ readonly #maxBytes: number;
53
+ readonly #now: () => number;
54
+ #finalized = false;
55
+
56
+ constructor(private readonly opts: R2EdgeRecorderOptions) {
57
+ this.#maxBytes = opts.maxBytesPerStream ?? DEFAULT_MAX_BYTES_PER_STREAM;
58
+ this.#now = opts.now ?? Date.now;
59
+ }
60
+
61
+ onUserAudio(_contextId: string, audio: Uint8Array, sampleRateHz: number): void {
62
+ this.#append(this.#user, audio, sampleRateHz);
63
+ }
64
+
65
+ onAssistantAudio(_contextId: string, audio: Uint8Array, sampleRateHz: number): void {
66
+ this.#append(this.#assistant, audio, sampleRateHz);
67
+ }
68
+
69
+ async finalize(meta: { sessionId: string; closedAtMs: number }): Promise<void> {
70
+ if (this.#finalized) return;
71
+ this.#finalized = true;
72
+ if (this.#user.dataBytes === 0 && this.#assistant.dataBytes === 0) return;
73
+
74
+ const prefix = `${this.opts.keyPrefix ?? "recordings"}/${this.opts.sessionId}/${this.opts.startedAtMs}`;
75
+ const rate = this.#user.sampleRateHz; // conversation timeline runs at the user (input) rate
76
+
77
+ const userPcm = gapFill(this.#user);
78
+ const assistantRaw = gapFill(this.#assistant);
79
+ const assistantPcm =
80
+ this.#assistant.sampleRateHz === rate
81
+ ? assistantRaw
82
+ : resamplePcm16(assistantRaw, this.#assistant.sampleRateHz, rate);
83
+ const conversation = interleaveStereoPcm16(userPcm, assistantPcm);
84
+
85
+ const manifest = {
86
+ schemaVersion: 1 as const,
87
+ sessionId: meta.sessionId,
88
+ startedAtMs: this.opts.startedAtMs,
89
+ closedAtMs: meta.closedAtMs,
90
+ conversation: {
91
+ path: `${prefix}/conversation.wav`,
92
+ sampleRateHz: rate,
93
+ channels: 2 as const,
94
+ encoding: "pcm_s16le" as const,
95
+ byteLength: conversation.byteLength,
96
+ durationMs: rate > 0 ? Math.round((conversation.byteLength / 4 / rate) * 1000) : 0,
97
+ },
98
+ user: this.#describe(this.#user, `${prefix}/user.wav`),
99
+ assistant: this.#describe(this.#assistant, `${prefix}/assistant.wav`),
100
+ };
101
+
102
+ await Promise.all([
103
+ this.opts.bucket.put(`${prefix}/conversation.wav`, pcm16ToWav(conversation, rate, 2), {
104
+ httpMetadata: { contentType: "audio/wav" },
105
+ }),
106
+ this.opts.bucket.put(`${prefix}/user.wav`, pcm16ToWav(userPcm, this.#user.sampleRateHz, 1), {
107
+ httpMetadata: { contentType: "audio/wav" },
108
+ }),
109
+ this.opts.bucket.put(`${prefix}/assistant.wav`, pcm16ToWav(assistantRaw, this.#assistant.sampleRateHz, 1), {
110
+ httpMetadata: { contentType: "audio/wav" },
111
+ }),
112
+ this.opts.bucket.put(`${prefix}/manifest.json`, JSON.stringify(manifest, null, 2), {
113
+ httpMetadata: { contentType: "application/json" },
114
+ }),
115
+ ]);
116
+ }
117
+
118
+ #append(buf: StreamBuffer, audio: Uint8Array, sampleRateHz: number): void {
119
+ buf.sampleRateHz = sampleRateHz;
120
+ if (buf.dataBytes + audio.byteLength > this.#maxBytes) {
121
+ buf.truncated = true;
122
+ return;
123
+ }
124
+ // Anchor each chunk at its wall-clock position so the two speakers line up on a
125
+ // shared timeline; never overlap the previous chunk in the same stream.
126
+ const wallOffset = this.#wallOffsetBytes(sampleRateHz);
127
+ const offsetBytes = Math.max(buf.cursorBytes, wallOffset);
128
+ buf.chunks.push({ offsetBytes, data: audio.slice() });
129
+ buf.cursorBytes = offsetBytes + audio.byteLength;
130
+ buf.dataBytes += audio.byteLength;
131
+ }
132
+
133
+ #wallOffsetBytes(sampleRateHz: number): number {
134
+ const elapsedMs = Math.max(0, this.#now() - this.opts.startedAtMs);
135
+ const bytes = Math.floor((elapsedMs * sampleRateHz * 2) / 1000);
136
+ return bytes - (bytes % 2);
137
+ }
138
+
139
+ #describe(buf: StreamBuffer, path: string) {
140
+ return {
141
+ path,
142
+ sampleRateHz: buf.sampleRateHz,
143
+ encoding: "pcm_s16le" as const,
144
+ channels: 1 as const,
145
+ byteLength: buf.cursorBytes,
146
+ durationMs: buf.sampleRateHz > 0 ? Math.round((buf.cursorBytes / 2 / buf.sampleRateHz) * 1000) : 0,
147
+ truncated: buf.truncated,
148
+ };
149
+ }
150
+ }
151
+
152
+ /** Lay chunks onto a silence-filled mono timeline at their wall-clock offsets. */
153
+ function gapFill(buf: StreamBuffer): Uint8Array {
154
+ const out = new Uint8Array(buf.cursorBytes); // zero = PCM16 silence
155
+ for (const chunk of buf.chunks) out.set(chunk.data, chunk.offsetBytes);
156
+ return out;
157
+ }
158
+
159
+ function resamplePcm16(pcm: Uint8Array, fromHz: number, toHz: number): Uint8Array {
160
+ if (fromHz === toHz || pcm.byteLength === 0) return pcm;
161
+ const src = new DataView(pcm.buffer, pcm.byteOffset, pcm.byteLength);
162
+ const inSamples = pcm.byteLength >> 1;
163
+ const outSamples = Math.max(1, Math.round((inSamples * toHz) / fromHz));
164
+ const out = new Uint8Array(outSamples * 2);
165
+ const ov = new DataView(out.buffer);
166
+ const ratio = (inSamples - 1) / Math.max(1, outSamples - 1);
167
+ for (let i = 0; i < outSamples; i += 1) {
168
+ const x = i * ratio;
169
+ const i0 = Math.floor(x);
170
+ const i1 = Math.min(inSamples - 1, i0 + 1);
171
+ const frac = x - i0;
172
+ const s = src.getInt16(i0 * 2, true) * (1 - frac) + src.getInt16(i1 * 2, true) * frac;
173
+ ov.setInt16(i * 2, Math.round(s), true);
174
+ }
175
+ return out;
176
+ }
177
+
@@ -0,0 +1,41 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Compile-only proof that `withVoice` composes with the REAL `agents` SDK Agent
4
+ // (not just the test's fake base). Type-checked by `tsc --noEmit`; never run.
5
+ // If the agents SDK changes the Agent / Connection / ConnectionContext surface
6
+ // the mixin relies on, this file fails to compile.
7
+
8
+ import { Agent } from "agents";
9
+ import type { VoicePlugin } from "@kuralle-syrinx/core";
10
+ import { fromGeminiLive } from "@kuralle-syrinx/realtime";
11
+ import { withVoice } from "./with-voice.js";
12
+
13
+ interface Env extends Record<string, unknown> {
14
+ GEMINI_API_KEY: string;
15
+ }
16
+
17
+ // Realtime front: the agent's own kuralle runtime is the brain (default reasoner).
18
+ export class RealtimeVoiceAgent extends withVoice<Env, typeof Agent<Env>>(Agent<Env>, {
19
+ pipeline: {
20
+ kind: "realtime",
21
+ front: (env) => fromGeminiLive({ apiKey: env.GEMINI_API_KEY }),
22
+ delegateToolName: "consult_knowledge",
23
+ },
24
+ }) {}
25
+
26
+ // Cascaded: explicit reasoner + discrete stt/tts stages.
27
+ const noopPlugin: VoicePlugin = { initialize: async () => {}, close: async () => {} };
28
+
29
+ export class CascadedVoiceAgent extends withVoice<Env, typeof Agent<Env>>(Agent<Env>, {
30
+ pipeline: {
31
+ kind: "cascaded",
32
+ stt: () => ({ plugin: noopPlugin, config: { model: "nova-3" } }),
33
+ tts: () => ({ plugin: noopPlugin, config: { voice_id: "v" } }),
34
+ },
35
+ reasoner: () => ({
36
+ // eslint-disable-next-line require-yield
37
+ stream: async function* () {
38
+ return;
39
+ },
40
+ }),
41
+ }) {}
@@ -0,0 +1,277 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Integration: drives the real `runVoiceEdgeWebSocketConnection` + real
4
+ // `VoiceAgentSession` through the mixin, using a faithful fake Agent base (the
5
+ // capture-and-patch lifecycle the agents SDK applies) and a fake Connection.
6
+ // Only the leaf providers (stt/tts plugins, reasoner) are stubbed.
7
+
8
+ import { describe, it, expect, vi } from "vitest";
9
+ import type { PipelineBus, Reasoner, UserAudioReceivedPacket, VoicePlugin } from "@kuralle-syrinx/core";
10
+ import { encodePcm16ToMuLaw } from "@kuralle-syrinx/core/audio";
11
+ import { withVoice } from "./with-voice.js";
12
+ import type { VoicePipeline } from "./build-session.js";
13
+
14
+ // --- Fakes ---------------------------------------------------------------
15
+
16
+ class FakeAgentBase {
17
+ name = "inst-1";
18
+ env: Record<string, unknown>;
19
+ runtime?: unknown;
20
+ keepAliveCalls = 0;
21
+ disposeCalls = 0;
22
+ onConnectCalls = 0;
23
+ onCloseCalls = 0;
24
+
25
+ constructor(env: Record<string, unknown> = {}) {
26
+ this.env = env;
27
+ }
28
+
29
+ async keepAlive(): Promise<() => void> {
30
+ this.keepAliveCalls += 1;
31
+ return () => {
32
+ this.disposeCalls += 1;
33
+ };
34
+ }
35
+
36
+ getConnections(): Iterable<unknown> {
37
+ return [];
38
+ }
39
+
40
+ // Tagged-template stand-in; unused by the voice path.
41
+ sql(): unknown[] {
42
+ return [];
43
+ }
44
+
45
+ onConnect(_connection: unknown, _ctx: unknown): void {
46
+ this.onConnectCalls += 1;
47
+ }
48
+ onMessage(_connection: unknown, _message: unknown): void {}
49
+ onClose(_connection: unknown, _code: number, _reason: string, _wasClean: boolean): void {
50
+ this.onCloseCalls += 1;
51
+ }
52
+ }
53
+
54
+ interface FakeConnection {
55
+ id: string;
56
+ readyState: number;
57
+ send(data: string | ArrayBuffer | ArrayBufferView): void;
58
+ close(code?: number, reason?: string): void;
59
+ frames: Array<string | ArrayBuffer | ArrayBufferView>;
60
+ }
61
+
62
+ function fakeConnection(id = "conn-1"): FakeConnection {
63
+ const frames: Array<string | ArrayBuffer | ArrayBufferView> = [];
64
+ let open = true;
65
+ return {
66
+ id,
67
+ get readyState() {
68
+ return open ? 1 : 3;
69
+ },
70
+ send(data) {
71
+ frames.push(data);
72
+ },
73
+ close() {
74
+ open = false;
75
+ },
76
+ frames,
77
+ };
78
+ }
79
+
80
+ const jsonFrames = (conn: FakeConnection): Array<Record<string, unknown>> =>
81
+ conn.frames
82
+ .filter((f): f is string => typeof f === "string")
83
+ .map((f) => JSON.parse(f) as Record<string, unknown>);
84
+
85
+ const stubPlugin = (): VoicePlugin => ({ initialize: async () => {}, close: async () => {} });
86
+ const stubReasoner = (): Reasoner => ({
87
+ // eslint-disable-next-line require-yield
88
+ stream: async function* () {
89
+ return;
90
+ },
91
+ });
92
+
93
+ const cascadedPipeline = (): VoicePipeline<Record<string, unknown>> => ({
94
+ kind: "cascaded",
95
+ stt: () => ({ plugin: stubPlugin(), config: { model: "nova-3" } }),
96
+ tts: () => ({ plugin: stubPlugin(), config: { voice_id: "v" } }),
97
+ });
98
+
99
+ // withVoice's base constraint is the real Agent surface; the fake satisfies the
100
+ // runtime contract but not its exact tagged-template `sql` type — cast for the test.
101
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
102
+ const asBase = (cls: unknown): any => cls;
103
+
104
+ const ctx = () => ({ request: new Request("https://agent.test/agents/voice/inst-1?sessionId=test-session") });
105
+
106
+ // --- Tests ---------------------------------------------------------------
107
+
108
+ describe("withVoice(Agent)", () => {
109
+ it("starts a Syrinx voice session on connect and sends a `ready` frame", async () => {
110
+ const VoiceAgent = withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(
111
+ asBase(FakeAgentBase),
112
+ { pipeline: cascadedPipeline(), reasoner: () => stubReasoner() },
113
+ );
114
+ const agent = new VoiceAgent({});
115
+ const conn = fakeConnection();
116
+
117
+ agent.onConnect(conn, ctx());
118
+
119
+ await vi.waitFor(() => {
120
+ expect(jsonFrames(conn).some((f) => f["type"] === "ready")).toBe(true);
121
+ });
122
+ const ready = jsonFrames(conn).find((f) => f["type"] === "ready");
123
+ expect(ready?.["sessionId"]).toBe("test-session");
124
+ });
125
+
126
+ it("chains the base onConnect and holds a keepAlive lease, released on close", async () => {
127
+ const VoiceAgent = withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(
128
+ asBase(FakeAgentBase),
129
+ { pipeline: cascadedPipeline(), reasoner: () => stubReasoner() },
130
+ );
131
+ const agent = new VoiceAgent({});
132
+ const conn = fakeConnection();
133
+
134
+ agent.onConnect(conn, ctx());
135
+ await vi.waitFor(() => expect(agent.keepAliveCalls).toBe(1));
136
+ expect(agent.onConnectCalls).toBe(1); // base hook still ran
137
+ expect(agent.disposeCalls).toBe(0);
138
+
139
+ agent.onClose(conn, 1000, "bye", true);
140
+ expect(agent.onCloseCalls).toBe(1); // base hook still ran
141
+ expect(agent.disposeCalls).toBe(1); // lease released
142
+ });
143
+
144
+ it("pumps inbound frames into the running session without throwing", async () => {
145
+ const VoiceAgent = withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(
146
+ asBase(FakeAgentBase),
147
+ { pipeline: cascadedPipeline(), reasoner: () => stubReasoner() },
148
+ );
149
+ const agent = new VoiceAgent({});
150
+ const conn = fakeConnection();
151
+
152
+ agent.onConnect(conn, ctx());
153
+ await vi.waitFor(() => expect(jsonFrames(conn).some((f) => f["type"] === "ready")).toBe(true));
154
+
155
+ // A ping after ready is handled as a no-op by the edge protocol.
156
+ expect(() => agent.onMessage(conn, JSON.stringify({ type: "ping" }))).not.toThrow();
157
+ });
158
+
159
+ it("defaults the reasoner to the agent's kuralle runtime when none is supplied", async () => {
160
+ class RuntimeAgent extends FakeAgentBase {
161
+ override runtime = {
162
+ run: () => ({
163
+ events: (async function* () {
164
+ return;
165
+ })(),
166
+ }),
167
+ };
168
+ }
169
+ const VoiceAgent = withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(
170
+ asBase(RuntimeAgent),
171
+ { pipeline: cascadedPipeline() }, // no explicit reasoner
172
+ );
173
+ const agent = new VoiceAgent({});
174
+ const conn = fakeConnection();
175
+
176
+ agent.onConnect(conn, ctx());
177
+ await vi.waitFor(() => {
178
+ expect(jsonFrames(conn).some((f) => f["type"] === "ready")).toBe(true);
179
+ });
180
+ });
181
+
182
+ it("reports an initialization error frame when a cascaded agent has no brain", async () => {
183
+ const VoiceAgent = withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(
184
+ asBase(FakeAgentBase),
185
+ { pipeline: cascadedPipeline() }, // no reasoner, no runtime
186
+ );
187
+ const agent = new VoiceAgent({});
188
+ const conn = fakeConnection();
189
+
190
+ agent.onConnect(conn, ctx());
191
+ await vi.waitFor(() => {
192
+ const err = jsonFrames(conn).find((f) => f["type"] === "error");
193
+ expect(err).toBeDefined();
194
+ expect(String(err?.["message"])).toMatch(/reasoner/);
195
+ });
196
+ expect(jsonFrames(conn).some((f) => f["type"] === "ready")).toBe(false);
197
+ });
198
+
199
+ it("gives each connection a distinct session id when no ?sessionId= is supplied", async () => {
200
+ const VoiceAgent = withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(
201
+ asBase(FakeAgentBase),
202
+ { pipeline: cascadedPipeline(), reasoner: () => stubReasoner() },
203
+ );
204
+ const agent = new VoiceAgent({});
205
+ const noQuery = () => ({ request: new Request("https://agent.test/agents/voice/inst-1") });
206
+ const a = fakeConnection("a");
207
+ const b = fakeConnection("b");
208
+
209
+ agent.onConnect(a, noQuery());
210
+ agent.onConnect(b, noQuery());
211
+
212
+ const sid = async (c: FakeConnection): Promise<string> => {
213
+ await vi.waitFor(() => expect(jsonFrames(c).some((f) => f["type"] === "ready")).toBe(true));
214
+ return String(jsonFrames(c).find((f) => f["type"] === "ready")?.["sessionId"]);
215
+ };
216
+ const sidA = await sid(a);
217
+ const sidB = await sid(b);
218
+
219
+ expect(sidA).not.toBe(sidB); // not shared -> no cross-wiring
220
+ expect(sidA).not.toBe("inst-1"); // not defaulted to the agent name
221
+ expect(sidB).not.toBe("inst-1");
222
+ });
223
+
224
+ it("transport:\"twilio\" speaks Media Streams — a μ-law media frame reaches the engine", async () => {
225
+ // Capture engine-ward audio so we can prove the Twilio runner decoded the μ-law
226
+ // media event and pushed it onto the session bus (no edge-protocol error frame).
227
+ const captured: UserAudioReceivedPacket[] = [];
228
+ const capturingStt = (): VoicePlugin => ({
229
+ initialize: async (bus: PipelineBus) => {
230
+ bus.on<UserAudioReceivedPacket>("user.audio_received", (pkt) => { captured.push(pkt); });
231
+ },
232
+ close: async () => {},
233
+ });
234
+ const twilioPipeline: VoicePipeline<Record<string, unknown>> = {
235
+ kind: "cascaded",
236
+ stt: () => ({ plugin: capturingStt(), config: { model: "nova-3" } }),
237
+ tts: () => ({ plugin: stubPlugin(), config: { voice_id: "v" } }),
238
+ };
239
+ const VoiceAgent = withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(
240
+ asBase(FakeAgentBase),
241
+ { transport: "twilio", pipeline: twilioPipeline, reasoner: () => stubReasoner() },
242
+ );
243
+ const agent = new VoiceAgent({});
244
+ const conn = fakeConnection();
245
+
246
+ agent.onConnect(conn, ctx());
247
+
248
+ // Twilio handshake, then a non-silent μ-law 8 kHz frame (160 samples = 20ms).
249
+ const tone = Int16Array.from({ length: 160 }, (_, i) => Math.round(8000 * Math.sin(i / 4)));
250
+ const payload = Buffer.from(encodePcm16ToMuLaw(tone)).toString("base64");
251
+ agent.onMessage(conn, JSON.stringify({ event: "connected", protocol: "Call", version: "1.0.0" }));
252
+ agent.onMessage(conn, JSON.stringify({
253
+ event: "start",
254
+ streamSid: "MZ1",
255
+ start: { streamSid: "MZ1", callSid: "CA1", mediaFormat: { encoding: "audio/x-mulaw", sampleRate: 8000, channels: 1 } },
256
+ }));
257
+ agent.onMessage(conn, JSON.stringify({ event: "media", streamSid: "MZ1", media: { payload } }));
258
+
259
+ await vi.waitFor(() => expect(captured.length).toBeGreaterThan(0));
260
+ // Resampled 8 kHz → 16 kHz engine PCM: non-empty audio reached the engine.
261
+ expect(captured[0]!.audio.byteLength).toBeGreaterThan(0);
262
+ // No edge-protocol error frame (the Twilio runner accepted the handshake).
263
+ expect(jsonFrames(conn).some((f) => f["type"] === "error")).toBe(false);
264
+ });
265
+
266
+ it("forceEndVoice closes the connection", () => {
267
+ const VoiceAgent = withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(
268
+ asBase(FakeAgentBase),
269
+ { pipeline: cascadedPipeline(), reasoner: () => stubReasoner() },
270
+ );
271
+ const agent = new VoiceAgent({});
272
+ const conn = fakeConnection();
273
+ agent.onConnect(conn, ctx());
274
+ agent.forceEndVoice(conn);
275
+ expect(conn.readyState).toBe(3);
276
+ });
277
+ });