@kuralle-syrinx/cf-agents 4.4.0 → 4.5.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,359 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // withVoice(Agent) — adds a Syrinx voice pipeline (realtime OR cascaded) to a
4
+ // Cloudflare `agents` SDK Agent.
5
+ //
6
+ // Design (issue #7): a mixin OVER the Agent, not a raw Durable Object. It reuses
7
+ // the Agent's native hibernation, `keepAlive()` lease, `Connection`, and SQL —
8
+ // it does not reimplement them. Syrinx is the engine: each connection is handed
9
+ // to the published `runVoiceEdgeWebSocketConnection(socket, request, options)`
10
+ // over the Agent's `Connection` wrapped as a `ManagedSocket`. The agent's own
11
+ // kuralle runtime is the brain by default (`fromKuralleRuntime(this.runtime)`),
12
+ // so an existing agent gets voice with zero brain re-wiring.
13
+ //
14
+ // Lifecycle wrap (capture-and-patch of onConnect/onMessage/onClose) mirrors
15
+ // `@cloudflare/voice`'s withVoice (voice.ts, MIT, © Cloudflare). Unlike that
16
+ // mixin, this one does not implement the voice protocol itself — it delegates the
17
+ // whole connection to Syrinx's edge runner.
18
+ import { runVoiceEdgeWebSocketConnection, } from "@kuralle-syrinx/server-websocket/edge";
19
+ import { runTwilioEdgeWebSocketConnection } from "@kuralle-syrinx/server-websocket/edge-twilio";
20
+ import { runTelnyxEdgeWebSocketConnection } from "@kuralle-syrinx/server-websocket/edge-telnyx";
21
+ import { InMemorySessionStore } from "@kuralle-syrinx/server-websocket/session-store";
22
+ import { fromKuralleRuntime } from "@kuralle-syrinx/kuralle";
23
+ import { connectionManagedSocket, } from "./connection-socket.js";
24
+ import { buildVoiceSession, } from "./build-session.js";
25
+ import { SqliteReasonerSessionStore } from "./durable-history.js";
26
+ /** Bound on the durable realtime transcript (12 turns), mirroring ReasoningBridge's default window. */
27
+ const MAX_DURABLE_HISTORY_MESSAGES = 24;
28
+ function isKuralleRuntime(value) {
29
+ return (typeof value === "object" &&
30
+ value !== null &&
31
+ typeof value.run === "function");
32
+ }
33
+ export function withVoice(Base, options) {
34
+ class VoiceAgentMixin extends Base {
35
+ // One session store per DO instance — resumes a dropped connection within
36
+ // the agent's lifetime (keyed by the stable session id).
37
+ #store = new InMemorySessionStore();
38
+ #controllers = new Map();
39
+ #keepAliveDispose = new Map();
40
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mixin constructor must accept any args
41
+ constructor(...args) {
42
+ super(...args);
43
+ const host = this;
44
+ const onConnect = host.onConnect?.bind(this);
45
+ const onMessage = host.onMessage?.bind(this);
46
+ const onClose = host.onClose?.bind(this);
47
+ host.onConnect = (connection, ctx) => {
48
+ this.#startVoice(connection, ctx);
49
+ return onConnect?.(connection, ctx);
50
+ };
51
+ host.onMessage = (connection, message) => {
52
+ const controller = this.#controllers.get(connection.id);
53
+ if (controller) {
54
+ controller.message(message);
55
+ return undefined;
56
+ }
57
+ return onMessage?.(connection, message);
58
+ };
59
+ host.onClose = (connection, code, reason, wasClean) => {
60
+ this.#endVoice(connection, code, reason);
61
+ return onClose?.(connection, code, reason, wasClean);
62
+ };
63
+ }
64
+ forceEndVoice(connection) {
65
+ // Pump close so the edge runner tears down and the lease releases, then
66
+ // close the underlying socket.
67
+ this.#controllers.get(connection.id)?.close(1000, "force_end");
68
+ try {
69
+ connection.close(1000, "force_end");
70
+ }
71
+ catch {
72
+ /* already closing */
73
+ }
74
+ }
75
+ #startVoice(connection, ctx) {
76
+ const host = this;
77
+ const env = host.env;
78
+ const request = ctx.request;
79
+ const sessionId = this.#resolveSessionId(request);
80
+ // The agents `Connection.send` is runtime-compatible with a ManagedSocket
81
+ // (workerd's WebSocket.send accepts string/ArrayBuffer/ArrayBufferView), but
82
+ // its lib type is nominally stricter (ArrayBuffer- vs ArrayBufferLike-backed
83
+ // views). Bridge that single boundary structurally.
84
+ const voiceConnection = connection;
85
+ const { socket, controller } = connectionManagedSocket(voiceConnection);
86
+ this.#controllers.set(connection.id, controller);
87
+ // Release the keepAlive lease + drop the controller on ANY close — a client
88
+ // close (pumped via the Agent's onClose hook) OR an edge-runner-initiated
89
+ // socket.dispose() (idle / max-duration / startup failure). The socket fires
90
+ // its close handlers in both cases, so this does not depend on the platform
91
+ // delivering a server-initiated onClose.
92
+ socket.onClose(() => this.#releaseConnection(connection.id));
93
+ // Hold a keepAlive lease for the duration of the call so the DO is not
94
+ // evicted mid-conversation.
95
+ void this.keepAlive()
96
+ .then((dispose) => {
97
+ if (this.#controllers.has(connection.id)) {
98
+ this.#keepAliveDispose.set(connection.id, dispose);
99
+ }
100
+ else {
101
+ // Connection already closed before the lease resolved.
102
+ dispose();
103
+ }
104
+ })
105
+ .catch(() => {
106
+ // keepAlive() failed — the call still runs (the open socket keeps the
107
+ // isolate live), it is just not protected from idle eviction.
108
+ });
109
+ // Both runners assemble the session the same way — pipeline + (resolved) reasoner.
110
+ const createSession = async () => {
111
+ // G4 durable session state: load prior context from DO-SQLite, expose it to the
112
+ // factories via ctx.resume, and wire persistence per pipeline kind.
113
+ const durable = options.durableHistory !== false ? this.#durableStore() : undefined;
114
+ const liveHistory = durable ? [...durable.load(sessionId)] : [];
115
+ const providerHandle = durable?.loadResumeHandle(sessionId);
116
+ const ctx = {
117
+ sessionId,
118
+ ...(durable
119
+ ? {
120
+ resume: {
121
+ history: () => liveHistory
122
+ .filter((m) => m.role === "user" || m.role === "assistant")
123
+ .map((m) => ({ role: m.role, content: m.content })),
124
+ ...(providerHandle ? { providerHandle } : {}),
125
+ },
126
+ }
127
+ : {}),
128
+ };
129
+ const wiring = {
130
+ ...(options.delayCueAfterMs !== undefined ? { delayCueAfterMs: options.delayCueAfterMs } : {}),
131
+ ...(options.idleTimeout !== undefined ? { idleTimeout: options.idleTimeout } : {}),
132
+ ...(durable
133
+ ? options.pipeline.kind === "realtime"
134
+ ? { contextProvider: () => liveHistory }
135
+ : { reasonerSessionStore: durable }
136
+ : {}),
137
+ };
138
+ const reasoner = await this.#resolveReasoner(env, ctx);
139
+ const session = buildVoiceSession(options.pipeline, env, reasoner, ctx, wiring);
140
+ // Realtime pipelines have no ReasoningBridge to own history — record the
141
+ // transcript from the bus and persist the bounded snapshot per turn. The
142
+ // cascaded path persists inside ReasoningBridge instead (heard-prefix aware).
143
+ if (durable && options.pipeline.kind === "realtime") {
144
+ const persist = () => {
145
+ if (liveHistory.length > MAX_DURABLE_HISTORY_MESSAGES) {
146
+ liveHistory.splice(0, liveHistory.length - MAX_DURABLE_HISTORY_MESSAGES);
147
+ }
148
+ try {
149
+ durable.save(sessionId, liveHistory);
150
+ }
151
+ catch {
152
+ /* persistence must never fail the call */
153
+ }
154
+ };
155
+ // The realtime bridge pushes llm.done (assistant transcript) BEFORE
156
+ // eos.turn_complete (user transcript) for the same turn — buffer the
157
+ // assistant text and commit the pair in conversation order at turn end.
158
+ const pendingAssistant = new Map();
159
+ session.bus.on("llm.done", (pkt) => {
160
+ const done = pkt;
161
+ if (done.text?.trim())
162
+ pendingAssistant.set(done.contextId, done.text);
163
+ });
164
+ session.bus.on("eos.turn_complete", (pkt) => {
165
+ const turn = pkt;
166
+ const assistantText = pendingAssistant.get(turn.contextId);
167
+ pendingAssistant.delete(turn.contextId);
168
+ if (turn.text?.trim())
169
+ liveHistory.push({ role: "user", content: turn.text });
170
+ if (assistantText)
171
+ liveHistory.push({ role: "assistant", content: assistantText });
172
+ if (turn.text?.trim() || assistantText)
173
+ persist();
174
+ });
175
+ session.bus.on("realtime.resumption_handle", (pkt) => {
176
+ const handle = pkt.handle;
177
+ if (!handle)
178
+ return;
179
+ try {
180
+ durable.saveResumeHandle(sessionId, handle);
181
+ }
182
+ catch {
183
+ /* persistence must never fail the call */
184
+ }
185
+ });
186
+ }
187
+ // Surface the tool-call-start seam: VoiceAgentSession emits `agent_tool_call` the instant
188
+ // the front model invokes the delegate tool, before the reasoner runs. A throwing app
189
+ // callback must never break the call, so it is fully isolated.
190
+ if (options.onToolCallStart) {
191
+ session.on("agent_tool_call", (e) => {
192
+ try {
193
+ void Promise.resolve(options.onToolCallStart({ toolName: e.name, args: e.args, sessionId, connection: voiceConnection })).catch(() => undefined);
194
+ }
195
+ catch {
196
+ /* app hook threw synchronously — ignore */
197
+ }
198
+ });
199
+ }
200
+ // Delegate observability (G2): surface the bridge's delegate.query/delegate.result
201
+ // packets as app hooks. Same isolation contract as onToolCallStart — a throwing app
202
+ // callback must never break the call.
203
+ if (options.onDelegateQuery) {
204
+ session.on("delegate_query", (e) => {
205
+ try {
206
+ void Promise.resolve(options.onDelegateQuery({
207
+ query: e.query,
208
+ toolId: e.toolId,
209
+ toolName: e.toolName,
210
+ turnId: e.turnId,
211
+ sessionId,
212
+ connection: voiceConnection,
213
+ env,
214
+ })).catch(() => undefined);
215
+ }
216
+ catch {
217
+ /* app hook threw synchronously — ignore */
218
+ }
219
+ });
220
+ }
221
+ if (options.onDelegateResult) {
222
+ session.on("delegate_result", (e) => {
223
+ try {
224
+ void Promise.resolve(options.onDelegateResult({
225
+ query: e.query,
226
+ answer: e.answer,
227
+ durationMs: e.durationMs,
228
+ grounded: e.grounded,
229
+ toolId: e.toolId,
230
+ toolName: e.toolName,
231
+ control: e.control,
232
+ blocked: e.blocked,
233
+ turnId: e.turnId,
234
+ sessionId,
235
+ connection: voiceConnection,
236
+ env,
237
+ })).catch(() => undefined);
238
+ }
239
+ catch {
240
+ /* app hook threw synchronously — ignore */
241
+ }
242
+ });
243
+ }
244
+ return session;
245
+ };
246
+ // The runner reports startup failures to the client and disposes the socket
247
+ // itself; nothing to do here beyond not crashing the isolate.
248
+ const onRunnerSettled = () => undefined;
249
+ if (options.transport === "twilio") {
250
+ // Twilio Media Streams: μ-law 8 kHz both ways. The runner derives the session
251
+ // id from the `?sessionId=` query (the callSid), resamples to the engine rate,
252
+ // and manages its own lease/heartbeat. Recorder is edge-only.
253
+ void runTwilioEdgeWebSocketConnection(socket, request, {
254
+ sessionStore: this.#store,
255
+ createSession,
256
+ ...(options.inputSampleRateHz !== undefined
257
+ ? { engineSampleRateHz: options.inputSampleRateHz }
258
+ : {}),
259
+ ...(options.resumeWindowMs !== undefined ? { resumeWindowMs: options.resumeWindowMs } : {}),
260
+ ...(options.backgroundAudio ? { backgroundAudio: options.backgroundAudio } : {}),
261
+ }).catch(onRunnerSettled);
262
+ return;
263
+ }
264
+ if (options.transport === "telnyx") {
265
+ // Telnyx Media Streaming: PCMU/PCMA/G722/L16 per negotiated start.media_format.
266
+ // Same lease/heartbeat pattern as Twilio; recorder is edge-only.
267
+ // Live streaming_start → /telnyx is carrier-gated / unit-tested only.
268
+ void runTelnyxEdgeWebSocketConnection(socket, request, {
269
+ sessionStore: this.#store,
270
+ createSession,
271
+ ...(options.inputSampleRateHz !== undefined
272
+ ? { engineSampleRateHz: options.inputSampleRateHz }
273
+ : {}),
274
+ ...(options.resumeWindowMs !== undefined ? { resumeWindowMs: options.resumeWindowMs } : {}),
275
+ ...(options.backgroundAudio ? { backgroundAudio: options.backgroundAudio } : {}),
276
+ }).catch(onRunnerSettled);
277
+ return;
278
+ }
279
+ let recorder;
280
+ try {
281
+ recorder = options.recorder?.(env, { sessionId });
282
+ }
283
+ catch {
284
+ // A recorder factory that throws synchronously must not strand the
285
+ // connection (and its lease) — dispose, which fires the close path above.
286
+ socket.dispose();
287
+ return;
288
+ }
289
+ void runVoiceEdgeWebSocketConnection(socket, request, {
290
+ sessionStore: this.#store,
291
+ sessionId: () => sessionId,
292
+ recorder,
293
+ ...(options.inputSampleRateHz !== undefined
294
+ ? { inputSampleRateHz: options.inputSampleRateHz }
295
+ : {}),
296
+ ...(options.outputSampleRateHz !== undefined
297
+ ? { outputSampleRateHz: options.outputSampleRateHz }
298
+ : {}),
299
+ ...(options.resumeWindowMs !== undefined ? { resumeWindowMs: options.resumeWindowMs } : {}),
300
+ ...(options.backgroundAudio ? { backgroundAudio: options.backgroundAudio } : {}),
301
+ createSession,
302
+ }).catch(onRunnerSettled);
303
+ }
304
+ /** Pump the close so the edge runner tears down; the lease releases via #releaseConnection. */
305
+ #endVoice(connection, code, reason) {
306
+ this.#controllers.get(connection.id)?.close(code, reason);
307
+ }
308
+ #releaseConnection(id) {
309
+ this.#controllers.delete(id);
310
+ const dispose = this.#keepAliveDispose.get(id);
311
+ if (dispose) {
312
+ this.#keepAliveDispose.delete(id);
313
+ try {
314
+ dispose();
315
+ }
316
+ catch {
317
+ /* ignore */
318
+ }
319
+ }
320
+ }
321
+ async #resolveReasoner(env, ctx) {
322
+ if (options.reasoner)
323
+ return options.reasoner(env, ctx);
324
+ const runtime = this.runtime;
325
+ if (isKuralleRuntime(runtime))
326
+ return fromKuralleRuntime(runtime, { sessionId: ctx.sessionId });
327
+ return undefined;
328
+ }
329
+ // One durable store per DO instance; tables are created idempotently on first use.
330
+ #durable = null;
331
+ #durableStore() {
332
+ if (!this.#durable) {
333
+ const host = this;
334
+ this.#durable = new SqliteReasonerSessionStore((strings, ...values) => host.sql(strings, ...values));
335
+ }
336
+ return this.#durable;
337
+ }
338
+ // Default: the client-supplied `?sessionId=` (so a reconnecting client can
339
+ // resume its session within the resume window), else a per-connection random
340
+ // id. Crucially NOT the Agent name — two concurrent connections to one
341
+ // instance must not silently share (and cross-wire) a single VoiceAgentSession.
342
+ #resolveSessionId(request) {
343
+ const name = this.name;
344
+ if (options.sessionId)
345
+ return options.sessionId(request, name);
346
+ try {
347
+ const fromQuery = new URL(request.url).searchParams.get("sessionId");
348
+ if (fromQuery)
349
+ return fromQuery;
350
+ }
351
+ catch {
352
+ /* malformed URL — fall through to a random id */
353
+ }
354
+ return crypto.randomUUID();
355
+ }
356
+ }
357
+ return VoiceAgentMixin;
358
+ }
359
+ //# sourceMappingURL=with-voice.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"with-voice.js","sourceRoot":"","sources":["../src/with-voice.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/cf-agents",
3
- "version": "4.4.0",
3
+ "version": "4.5.0",
4
4
  "private": false,
5
5
  "description": "withVoice(Agent) — add a Syrinx realtime or cascaded voice pipeline to a Cloudflare agents SDK Agent",
6
6
  "keywords": [
@@ -26,32 +26,45 @@
26
26
  "url": "https://github.com/kuralle/syrinx/issues"
27
27
  },
28
28
  "type": "module",
29
- "main": "./src/index.ts",
30
- "types": "./src/index.ts",
29
+ "main": "./dist/index.js",
30
+ "types": "./dist/index.d.ts",
31
31
  "exports": {
32
- ".": "./src/index.ts",
33
- "./r2-recorder": "./src/r2-recorder.ts"
32
+ ".": {
33
+ "types": "./dist/index.d.ts",
34
+ "default": "./dist/index.js"
35
+ },
36
+ "./r2-recorder": {
37
+ "types": "./dist/r2-recorder.d.ts",
38
+ "default": "./dist/r2-recorder.js"
39
+ }
34
40
  },
35
41
  "dependencies": {
36
- "@kuralle-syrinx/kuralle": "4.4.0",
37
- "@kuralle-syrinx/realtime": "4.4.0",
38
- "@kuralle-syrinx/ws": "4.4.0",
39
- "@kuralle-syrinx/aisdk": "4.4.0",
40
- "@kuralle-syrinx/core": "4.4.0",
41
- "@kuralle-syrinx/server-websocket": "4.4.0",
42
- "@kuralle-syrinx/recorder": "4.4.0"
42
+ "@kuralle-syrinx/realtime": "4.5.0",
43
+ "@kuralle-syrinx/server-websocket": "4.5.0",
44
+ "@kuralle-syrinx/core": "4.5.0",
45
+ "@kuralle-syrinx/recorder": "4.5.0",
46
+ "@kuralle-syrinx/ws": "4.5.0",
47
+ "@kuralle-syrinx/aisdk": "4.5.0",
48
+ "@kuralle-syrinx/kuralle": "4.5.0"
43
49
  },
44
50
  "peerDependencies": {
45
51
  "agents": ">=0.14.0 <1.0.0"
46
52
  },
47
53
  "devDependencies": {
48
54
  "@cloudflare/workers-types": "^4.20260601.0",
55
+ "@types/node": "^22.0.0",
49
56
  "agents": "0.14.0",
50
57
  "typescript": "^5.7.0",
51
58
  "vitest": "^3.2.6"
52
59
  },
60
+ "files": [
61
+ "dist",
62
+ "src",
63
+ "README.md"
64
+ ],
53
65
  "scripts": {
54
66
  "typecheck": "tsc --noEmit",
55
- "test": "vitest run"
67
+ "test": "vitest run",
68
+ "build": "tsc -p tsconfig.build.json"
56
69
  }
57
70
  }
@@ -0,0 +1,125 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, it, expect } from "vitest";
4
+ import { VoiceAgentSession, type Reasoner, type VoicePlugin } from "@kuralle-syrinx/core";
5
+ import type { RealtimeAdapter } from "@kuralle-syrinx/realtime";
6
+ import { buildVoiceSession, type VoicePipeline } from "./build-session.js";
7
+
8
+ const stubPlugin = (): VoicePlugin => ({
9
+ initialize: async () => {},
10
+ close: async () => {},
11
+ });
12
+
13
+ const stubReasoner = (): Reasoner => ({
14
+ // eslint-disable-next-line require-yield
15
+ stream: async function* () {
16
+ return;
17
+ },
18
+ });
19
+
20
+ const stubFront = (): RealtimeAdapter => ({}) as unknown as RealtimeAdapter;
21
+
22
+ const ctx = { sessionId: "s1" };
23
+
24
+ describe("buildVoiceSession", () => {
25
+ it("builds a realtime session from a realtime pipeline", () => {
26
+ const pipeline: VoicePipeline<unknown> = {
27
+ kind: "realtime",
28
+ front: () => stubFront(),
29
+ delegateToolName: "consult_knowledge",
30
+ };
31
+ const session = buildVoiceSession(pipeline, {}, stubReasoner(), ctx);
32
+ expect(session).toBeInstanceOf(VoiceAgentSession);
33
+ });
34
+
35
+ it("allows a realtime session with no reasoner (front-only)", () => {
36
+ const pipeline: VoicePipeline<unknown> = { kind: "realtime", front: () => stubFront() };
37
+ const session = buildVoiceSession(pipeline, {}, undefined, ctx);
38
+ expect(session).toBeInstanceOf(VoiceAgentSession);
39
+ });
40
+
41
+ it("builds a cascaded session from a cascaded pipeline", () => {
42
+ const pipeline: VoicePipeline<unknown> = {
43
+ kind: "cascaded",
44
+ stt: () => ({ plugin: stubPlugin(), config: { model: "nova-3" } }),
45
+ tts: () => ({ plugin: stubPlugin(), config: { voice_id: "v" } }),
46
+ };
47
+ const session = buildVoiceSession(pipeline, {}, stubReasoner(), ctx);
48
+ expect(session).toBeInstanceOf(VoiceAgentSession);
49
+ });
50
+
51
+ it("carries sttForceFinalizeTimeoutMs through to the cascaded session", () => {
52
+ // Provider-endpointed cascades (e.g. Deepgram) tune this below the engine default; the mixin
53
+ // must thread it through instead of silently reverting to 7000ms.
54
+ const pipeline: VoicePipeline<unknown> = {
55
+ kind: "cascaded",
56
+ stt: () => ({ plugin: stubPlugin(), config: { model: "nova-3" } }),
57
+ tts: () => ({ plugin: stubPlugin(), config: { voice_id: "v" } }),
58
+ endpointingOwner: "provider_stt",
59
+ sttForceFinalizeTimeoutMs: 3500,
60
+ };
61
+ const session = buildVoiceSession(pipeline, {}, stubReasoner(), ctx);
62
+ expect(session).toBeInstanceOf(VoiceAgentSession);
63
+ });
64
+
65
+ it("throws a clear error when a cascaded pipeline has no reasoner", () => {
66
+ const pipeline: VoicePipeline<unknown> = {
67
+ kind: "cascaded",
68
+ stt: () => ({ plugin: stubPlugin() }),
69
+ tts: () => ({ plugin: stubPlugin() }),
70
+ };
71
+ expect(() => buildVoiceSession(pipeline, {}, undefined, ctx)).toThrow(/cascaded pipeline needs a reasoner/);
72
+ });
73
+
74
+ it('throws when endpointingOwner is "smart_turn" but no eos stage is provided', () => {
75
+ const pipeline: VoicePipeline<unknown> = {
76
+ kind: "cascaded",
77
+ stt: () => ({ plugin: stubPlugin() }),
78
+ tts: () => ({ plugin: stubPlugin() }),
79
+ endpointingOwner: "smart_turn",
80
+ };
81
+ expect(() => buildVoiceSession(pipeline, {}, stubReasoner(), ctx)).toThrow(/smart_turn/);
82
+ });
83
+
84
+ it("resolves endpointingOwner when provided as an (env) => owner factory", () => {
85
+ let seenEnv: { ai: boolean } | undefined;
86
+ const pipeline: VoicePipeline<{ ai: boolean }> = {
87
+ kind: "cascaded",
88
+ stt: () => ({ plugin: stubPlugin() }),
89
+ tts: () => ({ plugin: stubPlugin() }),
90
+ endpointingOwner: (env) => {
91
+ seenEnv = env;
92
+ return env.ai ? "smart_turn" : "provider_stt";
93
+ },
94
+ };
95
+
96
+ const providerSession = buildVoiceSession(pipeline, { ai: false }, stubReasoner(), ctx);
97
+ expect(providerSession).toBeInstanceOf(VoiceAgentSession);
98
+ expect(seenEnv).toEqual({ ai: false });
99
+
100
+ expect(() => buildVoiceSession(pipeline, { ai: true }, stubReasoner(), ctx)).toThrow(/smart_turn/);
101
+ });
102
+
103
+ it("builds smart_turn when the factory returns smart_turn and eos is present", () => {
104
+ const pipeline: VoicePipeline<{ ai: boolean }> = {
105
+ kind: "cascaded",
106
+ stt: () => ({ plugin: stubPlugin() }),
107
+ tts: () => ({ plugin: stubPlugin() }),
108
+ eos: () => ({ plugin: stubPlugin(), config: {} }),
109
+ endpointingOwner: (env) => (env.ai ? "smart_turn" : "provider_stt"),
110
+ };
111
+ const session = buildVoiceSession(pipeline, { ai: true }, stubReasoner(), ctx);
112
+ expect(session).toBeInstanceOf(VoiceAgentSession);
113
+ });
114
+
115
+ it("treats an eos factory that returns undefined as no eos stage", () => {
116
+ const pipeline: VoicePipeline<unknown> = {
117
+ kind: "cascaded",
118
+ stt: () => ({ plugin: stubPlugin() }),
119
+ tts: () => ({ plugin: stubPlugin() }),
120
+ eos: () => undefined,
121
+ endpointingOwner: "provider_stt",
122
+ };
123
+ expect(buildVoiceSession(pipeline, {}, stubReasoner(), ctx)).toBeInstanceOf(VoiceAgentSession);
124
+ });
125
+ });