@alexkroman1/aai 1.4.5 → 1.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.
Files changed (78) hide show
  1. package/.turbo/turbo-build.log +9 -9
  2. package/CHANGELOG.md +13 -0
  3. package/dist/assemblyai-C969QGi4.js +35 -0
  4. package/dist/cartesia-BfQPOQ7Y.js +37 -0
  5. package/dist/host/_pipeline-test-fakes.d.ts +3 -1
  6. package/dist/host/providers/stt/deepgram.d.ts +28 -0
  7. package/dist/host/providers/tts/cartesia.d.ts +1 -1
  8. package/dist/host/providers/tts/rime.d.ts +44 -0
  9. package/dist/host/runtime-barrel.d.ts +4 -2
  10. package/dist/host/runtime-barrel.js +1432 -1208
  11. package/dist/host/runtime.d.ts +2 -2
  12. package/dist/host/s2s.d.ts +16 -16
  13. package/dist/host/session-core.d.ts +37 -0
  14. package/dist/host/transports/pipeline-transport.d.ts +48 -0
  15. package/dist/host/transports/s2s-transport.d.ts +19 -0
  16. package/dist/host/transports/types.d.ts +45 -0
  17. package/dist/host/ws-handler.d.ts +14 -10
  18. package/dist/sdk/protocol.d.ts +6 -5
  19. package/dist/sdk/providers/llm-barrel.js +1 -1
  20. package/dist/sdk/providers/stt/deepgram.d.ts +35 -0
  21. package/dist/sdk/providers/stt-barrel.d.ts +1 -0
  22. package/dist/sdk/providers/stt-barrel.js +2 -2
  23. package/dist/sdk/providers/tts/cartesia.d.ts +12 -4
  24. package/dist/sdk/providers/tts/rime.d.ts +42 -0
  25. package/dist/sdk/providers/tts-barrel.d.ts +1 -0
  26. package/dist/sdk/providers/tts-barrel.js +2 -2
  27. package/host/_pipeline-test-fakes.ts +6 -3
  28. package/host/_test-utils.ts +209 -128
  29. package/host/cleanup.test.ts +25 -298
  30. package/host/integration/pipeline-reference.integration.test.ts +30 -35
  31. package/host/providers/resolve.ts +10 -2
  32. package/host/providers/stt/deepgram.test.ts +229 -0
  33. package/host/providers/stt/deepgram.ts +172 -0
  34. package/host/providers/tts/cartesia.ts +7 -3
  35. package/host/providers/tts/rime.test.ts +251 -0
  36. package/host/providers/tts/rime.ts +322 -0
  37. package/host/runtime-barrel.ts +4 -2
  38. package/host/runtime.test.ts +13 -46
  39. package/host/runtime.ts +131 -23
  40. package/host/s2s.test.ts +122 -131
  41. package/host/s2s.ts +44 -52
  42. package/host/session-core.test.ts +257 -0
  43. package/host/session-core.ts +262 -0
  44. package/host/transports/pipeline-transport.test.ts +651 -0
  45. package/host/transports/pipeline-transport.ts +532 -0
  46. package/host/{fixture-replay.test.ts → transports/s2s-transport-fixtures.test.ts} +76 -106
  47. package/host/transports/s2s-transport.test.ts +56 -0
  48. package/host/transports/s2s-transport.ts +116 -0
  49. package/host/transports/types.test.ts +22 -0
  50. package/host/transports/types.ts +51 -0
  51. package/host/ws-handler.test.ts +324 -242
  52. package/host/ws-handler.ts +56 -59
  53. package/package.json +2 -1
  54. package/sdk/__snapshots__/exports.test.ts.snap +3 -3
  55. package/sdk/protocol-compat.test.ts +8 -0
  56. package/sdk/protocol.ts +6 -5
  57. package/sdk/providers/stt/deepgram.ts +43 -0
  58. package/sdk/providers/stt-barrel.ts +2 -0
  59. package/sdk/providers/tts/cartesia.ts +15 -5
  60. package/sdk/providers/tts/rime.ts +52 -0
  61. package/sdk/providers/tts-barrel.ts +2 -0
  62. package/dist/assemblyai-Cxg9eobY.js +0 -18
  63. package/dist/cartesia-DwDk2tEu.js +0 -10
  64. package/dist/host/pipeline-session-ctx.d.ts +0 -24
  65. package/dist/host/pipeline-session.d.ts +0 -52
  66. package/dist/host/session-ctx.d.ts +0 -73
  67. package/dist/host/session.d.ts +0 -62
  68. package/host/pipeline-session-ctx.test.ts +0 -31
  69. package/host/pipeline-session-ctx.ts +0 -36
  70. package/host/pipeline-session.test.ts +0 -672
  71. package/host/pipeline-session.ts +0 -533
  72. package/host/s2s-fixtures.test.ts +0 -237
  73. package/host/session-ctx.test.ts +0 -387
  74. package/host/session-ctx.ts +0 -134
  75. package/host/session-fixture-replay.test.ts +0 -128
  76. package/host/session.test.ts +0 -634
  77. package/host/session.ts +0 -412
  78. /package/dist/{anthropic-BrUCPKUc.js → anthropic-CcLZygAr.js} +0 -0
@@ -0,0 +1,651 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+
3
+ import { describe, expect, test, vi } from "vitest";
4
+ import {
5
+ createFailingSttProvider,
6
+ createFailingTtsProvider,
7
+ createFakeLanguageModel,
8
+ createFakeSttProvider,
9
+ createFakeTtsProvider,
10
+ type ScriptedPart,
11
+ } from "../_pipeline-test-fakes.ts";
12
+ import { silentLogger } from "../_test-utils.ts";
13
+ import { createPipelineTransport, type PipelineTransportOptions } from "./pipeline-transport.ts";
14
+ import type { TransportCallbacks } from "./types.ts";
15
+
16
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
17
+
18
+ function makeCallbacks(): TransportCallbacks {
19
+ return {
20
+ onReplyStarted: vi.fn(),
21
+ onReplyDone: vi.fn(),
22
+ onCancelled: vi.fn(),
23
+ onAudioChunk: vi.fn(),
24
+ onAudioDone: vi.fn(),
25
+ onUserTranscript: vi.fn(),
26
+ onAgentTranscript: vi.fn(),
27
+ onToolCall: vi.fn(),
28
+ onError: vi.fn(),
29
+ onSpeechStarted: vi.fn(),
30
+ onSpeechStopped: vi.fn(),
31
+ onSessionReady: vi.fn(),
32
+ };
33
+ }
34
+
35
+ function makeOpts(
36
+ overrides: Partial<PipelineTransportOptions> = {},
37
+ {
38
+ stt = createFakeSttProvider(),
39
+ tts = createFakeTtsProvider(),
40
+ callbacks = makeCallbacks(),
41
+ }: {
42
+ stt?: ReturnType<typeof createFakeSttProvider>;
43
+ tts?: ReturnType<typeof createFakeTtsProvider>;
44
+ callbacks?: TransportCallbacks;
45
+ } = {},
46
+ ): {
47
+ opts: PipelineTransportOptions;
48
+ stt: ReturnType<typeof createFakeSttProvider>;
49
+ tts: ReturnType<typeof createFakeTtsProvider>;
50
+ callbacks: TransportCallbacks;
51
+ } {
52
+ const opts: PipelineTransportOptions = {
53
+ sid: "test-sid",
54
+ agent: "test-agent",
55
+ stt,
56
+ llm: createFakeLanguageModel({ script: [] }),
57
+ tts,
58
+ callbacks,
59
+ sessionConfig: {
60
+ systemPrompt: "You are a test assistant.",
61
+ greeting: "",
62
+ },
63
+ providerKeys: { stt: "stt-key", tts: "tts-key" },
64
+ logger: silentLogger,
65
+ ...overrides,
66
+ };
67
+ return { opts, stt, tts, callbacks };
68
+ }
69
+
70
+ // ─── Tests ───────────────────────────────────────────────────────────────────
71
+
72
+ describe("PipelineTransport", () => {
73
+ describe("start()", () => {
74
+ test("opens both STT and TTS sessions", async () => {
75
+ const { opts, stt, tts } = makeOpts({ sessionConfig: { systemPrompt: "s", greeting: "" } });
76
+ const t = createPipelineTransport(opts);
77
+ await t.start();
78
+ expect(stt.last()).toBeDefined();
79
+ expect(tts.last()).toBeDefined();
80
+ await t.stop();
81
+ });
82
+
83
+ test("passes correct keys and sample rate to STT opener", async () => {
84
+ const stt = createFakeSttProvider();
85
+ const { opts } = makeOpts(
86
+ {
87
+ stt,
88
+ providerKeys: { stt: "MY_STT_KEY", tts: "t" },
89
+ sttSampleRate: 8000,
90
+ sttPrompt: "be brief",
91
+ sessionConfig: { systemPrompt: "s", greeting: "" },
92
+ },
93
+ { stt },
94
+ );
95
+ const t = createPipelineTransport(opts);
96
+ await t.start();
97
+ expect(stt.last()?.opts.sampleRate).toBe(8000);
98
+ expect(stt.last()?.opts.apiKey).toBe("MY_STT_KEY");
99
+ expect(stt.last()?.opts.sttPrompt).toBe("be brief");
100
+ await t.stop();
101
+ });
102
+
103
+ test("fires onSessionReady with the sid", async () => {
104
+ const { opts, callbacks } = makeOpts({ sessionConfig: { systemPrompt: "s", greeting: "" } });
105
+ const t = createPipelineTransport(opts);
106
+ await t.start();
107
+ expect(callbacks.onSessionReady).toHaveBeenCalledWith("test-sid");
108
+ await t.stop();
109
+ });
110
+ });
111
+
112
+ describe("greeting", () => {
113
+ test("sends greeting via ttsSession.sendText and fires onReplyStarted + onAgentTranscript + onReplyDone", async () => {
114
+ const stt = createFakeSttProvider();
115
+ const tts = createFakeTtsProvider();
116
+ const callbacks = makeCallbacks();
117
+ const { opts } = makeOpts(
118
+ { sessionConfig: { systemPrompt: "s", greeting: "Hi there!" } },
119
+ { stt, tts, callbacks },
120
+ );
121
+ const t = createPipelineTransport(opts);
122
+ await t.start();
123
+ // Greeting runs as a chained turn — waitFor covers the async flush.
124
+ await vi.waitFor(() => {
125
+ expect(callbacks.onReplyDone).toHaveBeenCalledOnce();
126
+ });
127
+ expect(tts.last()?.textChunks).toContain("Hi there!");
128
+ expect(callbacks.onReplyStarted).toHaveBeenCalledWith(expect.stringContaining("greeting"));
129
+ expect(callbacks.onAgentTranscript).toHaveBeenCalledWith("Hi there!", false);
130
+ // onAudioDone is NOT fired by the transport — session-core's flushReply
131
+ // (triggered by onReplyDone) owns the audioDone + replyDone pairing.
132
+ expect(callbacks.onAudioDone).not.toHaveBeenCalled();
133
+ await t.stop();
134
+ });
135
+
136
+ test("skipGreeting suppresses the greeting turn", async () => {
137
+ const tts = createFakeTtsProvider();
138
+ const callbacks = makeCallbacks();
139
+ const { opts } = makeOpts(
140
+ {
141
+ skipGreeting: true,
142
+ sessionConfig: { systemPrompt: "s", greeting: "Hello!" },
143
+ },
144
+ { tts, callbacks },
145
+ );
146
+ const t = createPipelineTransport(opts);
147
+ await t.start();
148
+ await new Promise((r) => setTimeout(r, 20));
149
+ expect(callbacks.onReplyStarted).not.toHaveBeenCalled();
150
+ expect(tts.last()?.textChunks).toHaveLength(0);
151
+ await t.stop();
152
+ });
153
+ });
154
+
155
+ describe("STT → LLM turn", () => {
156
+ test("final STT event fires onUserTranscript and onReplyStarted", async () => {
157
+ const stt = createFakeSttProvider();
158
+ const callbacks = makeCallbacks();
159
+ const { opts } = makeOpts(
160
+ { sessionConfig: { systemPrompt: "s", greeting: "" } },
161
+ { stt, callbacks },
162
+ );
163
+ const t = createPipelineTransport(opts);
164
+ await t.start();
165
+ stt.last()?.fireFinal("Hello agent");
166
+ await vi.waitFor(() => {
167
+ expect(callbacks.onUserTranscript).toHaveBeenCalledWith("Hello agent");
168
+ });
169
+ expect(callbacks.onReplyStarted).toHaveBeenCalledWith(expect.stringMatching(/^pipeline-/));
170
+ await t.stop();
171
+ });
172
+
173
+ test("empty / whitespace-only final is ignored", async () => {
174
+ const stt = createFakeSttProvider();
175
+ const callbacks = makeCallbacks();
176
+ const { opts } = makeOpts(
177
+ { sessionConfig: { systemPrompt: "s", greeting: "" } },
178
+ { stt, callbacks },
179
+ );
180
+ const t = createPipelineTransport(opts);
181
+ await t.start();
182
+ stt.last()?.fireFinal(" ");
183
+ await new Promise((r) => setTimeout(r, 10));
184
+ expect(callbacks.onUserTranscript).not.toHaveBeenCalled();
185
+ expect(callbacks.onReplyStarted).not.toHaveBeenCalled();
186
+ await t.stop();
187
+ });
188
+
189
+ test("LLM text chunk is forwarded to ttsSession.sendText", async () => {
190
+ const script: ScriptedPart[] = [
191
+ { type: "text", text: "I am " },
192
+ { type: "text", text: "the answer" },
193
+ ];
194
+ const stt = createFakeSttProvider();
195
+ const tts = createFakeTtsProvider();
196
+ const { opts } = makeOpts(
197
+ {
198
+ llm: createFakeLanguageModel({ script }),
199
+ sessionConfig: { systemPrompt: "s", greeting: "" },
200
+ },
201
+ { stt, tts },
202
+ );
203
+ const t = createPipelineTransport(opts);
204
+ await t.start();
205
+ stt.last()?.fireFinal("what is the answer?");
206
+ await vi.waitFor(() => {
207
+ expect(tts.last()?.textChunks.length).toBeGreaterThan(0);
208
+ });
209
+ expect(tts.last()?.textChunks.join("")).toContain("the answer");
210
+ await t.stop();
211
+ });
212
+
213
+ test("TTS audio event is forwarded to callbacks.onAudioChunk as Uint8Array", async () => {
214
+ const stt = createFakeSttProvider();
215
+ const tts = createFakeTtsProvider();
216
+ const callbacks = makeCallbacks();
217
+ const { opts } = makeOpts(
218
+ { sessionConfig: { systemPrompt: "s", greeting: "" } },
219
+ { stt, tts, callbacks },
220
+ );
221
+ const t = createPipelineTransport(opts);
222
+ await t.start();
223
+ const pcm = new Int16Array([100, 200, 300]);
224
+ tts.last()?.fireAudio(pcm);
225
+ expect(callbacks.onAudioChunk).toHaveBeenCalledOnce();
226
+ // biome-ignore lint/style/noNonNullAssertion: test assertion — calledOnce proven above
227
+ const arg = (callbacks.onAudioChunk as ReturnType<typeof vi.fn>).mock
228
+ .calls[0]![0] as Uint8Array;
229
+ expect(arg).toBeInstanceOf(Uint8Array);
230
+ expect(arg.byteLength).toBe(pcm.byteLength);
231
+ await t.stop();
232
+ });
233
+
234
+ test("full turn: onUserTranscript → onReplyStarted → onAgentTranscript → onReplyDone (no transport-level onAudioDone)", async () => {
235
+ const script: ScriptedPart[] = [{ type: "text", text: "Sure!" }];
236
+ const stt = createFakeSttProvider();
237
+ const tts = createFakeTtsProvider();
238
+ const callbacks = makeCallbacks();
239
+ const { opts } = makeOpts(
240
+ {
241
+ llm: createFakeLanguageModel({ script }),
242
+ sessionConfig: { systemPrompt: "s", greeting: "" },
243
+ },
244
+ { stt, tts, callbacks },
245
+ );
246
+ const t = createPipelineTransport(opts);
247
+ await t.start();
248
+ stt.last()?.fireFinal("test question");
249
+ await vi.waitFor(() => {
250
+ expect(callbacks.onReplyDone).toHaveBeenCalledOnce();
251
+ });
252
+ expect(callbacks.onUserTranscript).toHaveBeenCalledWith("test question");
253
+ expect(callbacks.onReplyStarted).toHaveBeenCalled();
254
+ expect(callbacks.onAgentTranscript).toHaveBeenCalledWith("Sure!", false);
255
+ // onAudioDone is NOT fired by the transport — session-core's flushReply
256
+ // (triggered by onReplyDone) owns the audioDone + replyDone pairing.
257
+ expect(callbacks.onAudioDone).not.toHaveBeenCalled();
258
+ await t.stop();
259
+ });
260
+
261
+ test("TTS flush is called after LLM stream finishes", async () => {
262
+ const script: ScriptedPart[] = [{ type: "text", text: "hi" }];
263
+ const stt = createFakeSttProvider();
264
+ const tts = createFakeTtsProvider();
265
+ const { opts } = makeOpts(
266
+ {
267
+ llm: createFakeLanguageModel({ script }),
268
+ sessionConfig: { systemPrompt: "s", greeting: "" },
269
+ },
270
+ { stt, tts },
271
+ );
272
+ const t = createPipelineTransport(opts);
273
+ await t.start();
274
+ stt.last()?.fireFinal("go");
275
+ await vi.waitFor(() => {
276
+ expect(tts.last()?.flush).toHaveBeenCalledOnce();
277
+ });
278
+ await t.stop();
279
+ });
280
+ });
281
+
282
+ describe("streamText config plumbing", () => {
283
+ const dummyToolSchemas = [
284
+ {
285
+ name: "noop",
286
+ description: "No-op tool for plumbing tests.",
287
+ parameters: { type: "object" as const, properties: {}, additionalProperties: false },
288
+ },
289
+ ];
290
+ const dummyExecuteTool = async () => "{}";
291
+
292
+ test("forwards toolChoice to doStream (default 'auto' when omitted)", async () => {
293
+ const stt = createFakeSttProvider();
294
+ const llm = createFakeLanguageModel({ script: [{ type: "text", text: "ok" }] });
295
+ const { opts } = makeOpts(
296
+ {
297
+ llm,
298
+ toolSchemas: dummyToolSchemas,
299
+ executeTool: dummyExecuteTool,
300
+ sessionConfig: { systemPrompt: "s", greeting: "" },
301
+ },
302
+ { stt },
303
+ );
304
+ const t = createPipelineTransport(opts);
305
+ await t.start();
306
+ stt.last()?.fireFinal("hi");
307
+ await vi.waitFor(() => {
308
+ expect(llm.calls.length).toBeGreaterThan(0);
309
+ });
310
+ expect(llm.calls[0]?.toolChoice).toEqual({ type: "auto" });
311
+ await t.stop();
312
+ });
313
+
314
+ test("forwards explicit toolChoice='required' to doStream", async () => {
315
+ const stt = createFakeSttProvider();
316
+ const llm = createFakeLanguageModel({ script: [{ type: "text", text: "ok" }] });
317
+ const { opts } = makeOpts(
318
+ {
319
+ llm,
320
+ toolChoice: "required",
321
+ toolSchemas: dummyToolSchemas,
322
+ executeTool: dummyExecuteTool,
323
+ sessionConfig: { systemPrompt: "s", greeting: "" },
324
+ },
325
+ { stt },
326
+ );
327
+ const t = createPipelineTransport(opts);
328
+ await t.start();
329
+ stt.last()?.fireFinal("hi");
330
+ await vi.waitFor(() => {
331
+ expect(llm.calls.length).toBeGreaterThan(0);
332
+ });
333
+ expect(llm.calls[0]?.toolChoice).toEqual({ type: "required" });
334
+ await t.stop();
335
+ });
336
+
337
+ test("maxSteps caps the doStream loop", async () => {
338
+ // Script two steps that each emit a text part. With maxSteps=1 only the
339
+ // first step should run; without plumbing it would default to 5 and both
340
+ // would fire.
341
+ const stt = createFakeSttProvider();
342
+ const llm = createFakeLanguageModel({
343
+ steps: [[{ type: "text", text: "step1" }], [{ type: "text", text: "step2" }]],
344
+ });
345
+ const { opts } = makeOpts(
346
+ {
347
+ llm,
348
+ maxSteps: 1,
349
+ sessionConfig: { systemPrompt: "s", greeting: "" },
350
+ },
351
+ { stt },
352
+ );
353
+ const t = createPipelineTransport(opts);
354
+ await t.start();
355
+ stt.last()?.fireFinal("hi");
356
+ await vi.waitFor(() => {
357
+ expect(llm.calls.length).toBeGreaterThanOrEqual(1);
358
+ });
359
+ // Let any extra step have a chance to run.
360
+ await new Promise((r) => setTimeout(r, 20));
361
+ expect(llm.calls.length).toBe(1);
362
+ await t.stop();
363
+ });
364
+ });
365
+
366
+ describe("barge-in", () => {
367
+ test("partial STT event during an in-flight turn triggers cancel and onCancelled", async () => {
368
+ const script: ScriptedPart[] = [
369
+ { type: "text", text: "Hello " },
370
+ { type: "text", text: "how can " },
371
+ { type: "text", text: "I help?" },
372
+ ];
373
+ const stt = createFakeSttProvider();
374
+ const tts = createFakeTtsProvider();
375
+ const callbacks = makeCallbacks();
376
+ const { opts } = makeOpts(
377
+ {
378
+ llm: createFakeLanguageModel({ script, delayMs: 20 }),
379
+ sessionConfig: { systemPrompt: "s", greeting: "" },
380
+ },
381
+ { stt, tts, callbacks },
382
+ );
383
+ const t = createPipelineTransport(opts);
384
+ await t.start();
385
+
386
+ // Start a turn, wait until TTS is receiving text (deep in AGENT_REPLYING).
387
+ stt.last()?.fireFinal("hi there");
388
+ await vi.waitFor(() => {
389
+ expect(tts.last()?.textChunks.length).toBeGreaterThan(0);
390
+ });
391
+
392
+ // Fire barge-in partial.
393
+ stt.last()?.firePartial("wait");
394
+ expect(callbacks.onCancelled).toHaveBeenCalled();
395
+ expect(tts.last()?.cancel).toHaveBeenCalled();
396
+ await t.stop();
397
+ });
398
+
399
+ test("cancelReply() aborts the turn and calls ttsSession.cancel()", async () => {
400
+ const script: ScriptedPart[] = [
401
+ { type: "text", text: "some " },
402
+ { type: "text", text: "reply" },
403
+ ];
404
+ const stt = createFakeSttProvider();
405
+ const tts = createFakeTtsProvider();
406
+ const callbacks = makeCallbacks();
407
+ const { opts } = makeOpts(
408
+ {
409
+ llm: createFakeLanguageModel({ script, delayMs: 20 }),
410
+ sessionConfig: { systemPrompt: "s", greeting: "" },
411
+ },
412
+ { stt, tts, callbacks },
413
+ );
414
+ const t = createPipelineTransport(opts);
415
+ await t.start();
416
+
417
+ stt.last()?.fireFinal("question");
418
+ await vi.waitFor(() => {
419
+ expect(tts.last()?.textChunks.length).toBeGreaterThan(0);
420
+ });
421
+
422
+ t.cancelReply();
423
+ expect(tts.last()?.cancel).toHaveBeenCalled();
424
+ // cancelReply() does NOT fire callbacks.onCancelled — session-core calls
425
+ // client.cancelled() itself when the cancel originates from the client.
426
+ // onCancelled is only fired from within the transport for barge-in (STT partial).
427
+ expect(callbacks.onCancelled).not.toHaveBeenCalled();
428
+ await t.stop();
429
+ });
430
+ });
431
+
432
+ describe("stop()", () => {
433
+ test("closes both STT and TTS sessions", async () => {
434
+ const stt = createFakeSttProvider();
435
+ const tts = createFakeTtsProvider();
436
+ const { opts } = makeOpts(
437
+ { sessionConfig: { systemPrompt: "s", greeting: "" } },
438
+ { stt, tts },
439
+ );
440
+ const t = createPipelineTransport(opts);
441
+ await t.start();
442
+ await t.stop();
443
+ expect(stt.last()?.closed.value).toBe(true);
444
+ expect(tts.last()?.closed.value).toBe(true);
445
+ });
446
+
447
+ test("stop() is idempotent", async () => {
448
+ const stt = createFakeSttProvider();
449
+ const tts = createFakeTtsProvider();
450
+ const { opts } = makeOpts(
451
+ { sessionConfig: { systemPrompt: "s", greeting: "" } },
452
+ { stt, tts },
453
+ );
454
+ const t = createPipelineTransport(opts);
455
+ await t.start();
456
+ await t.stop();
457
+ await t.stop(); // should not throw or double-close
458
+ expect(stt.last()?.closed.value).toBe(true);
459
+ });
460
+ });
461
+
462
+ describe("sendUserAudio()", () => {
463
+ test("converts aligned Uint8Array to Int16Array and calls sttSession.sendAudio", async () => {
464
+ const stt = createFakeSttProvider();
465
+ const { opts } = makeOpts({ sessionConfig: { systemPrompt: "s", greeting: "" } }, { stt });
466
+ const t = createPipelineTransport(opts);
467
+ await t.start();
468
+ const buf = new ArrayBuffer(4);
469
+ const bytes = new Uint8Array(buf);
470
+ bytes.set([0x01, 0x02, 0x03, 0x04]);
471
+ t.sendUserAudio(bytes);
472
+ const sttSession = stt.last();
473
+ expect(sttSession?.sendAudio).toHaveBeenCalledOnce();
474
+ // biome-ignore lint/style/noNonNullAssertion: test assertion — calledOnce proven above
475
+ const pcm = (sttSession?.sendAudio as ReturnType<typeof vi.fn>).mock
476
+ .calls[0]![0] as Int16Array;
477
+ expect(pcm).toBeInstanceOf(Int16Array);
478
+ expect(pcm.length).toBe(2);
479
+ await t.stop();
480
+ });
481
+
482
+ test("handles odd-length Uint8Array by copying and truncating", async () => {
483
+ const stt = createFakeSttProvider();
484
+ const { opts } = makeOpts({ sessionConfig: { systemPrompt: "s", greeting: "" } }, { stt });
485
+ const t = createPipelineTransport(opts);
486
+ await t.start();
487
+ const bytes = new Uint8Array([1, 2, 3]); // 3 bytes → 1 sample
488
+ t.sendUserAudio(bytes);
489
+ // biome-ignore lint/style/noNonNullAssertion: test assertion — audio was sent synchronously above
490
+ const pcm = (stt.last()?.sendAudio as ReturnType<typeof vi.fn>).mock
491
+ .calls[0]![0] as Int16Array;
492
+ expect(pcm.length).toBe(1);
493
+ await t.stop();
494
+ });
495
+ });
496
+
497
+ describe("sendToolResult()", () => {
498
+ test("is a no-op (Option A: inline tool execution)", async () => {
499
+ const { opts } = makeOpts({ sessionConfig: { systemPrompt: "s", greeting: "" } });
500
+ const t = createPipelineTransport(opts);
501
+ await t.start();
502
+ expect(() => t.sendToolResult("call-1", "result")).not.toThrow();
503
+ await t.stop();
504
+ });
505
+ });
506
+
507
+ describe("tool observability", () => {
508
+ test("callbacks.onToolCall fires for each tool-call stream part", async () => {
509
+ const executeTool = vi.fn(async () => "sunny");
510
+ const script: ScriptedPart[] = [
511
+ {
512
+ type: "tool-call",
513
+ toolCallId: "tc-1",
514
+ toolName: "get_weather",
515
+ input: JSON.stringify({ city: "SF" }),
516
+ },
517
+ { type: "tool-result", toolCallId: "tc-1", toolName: "get_weather", result: "sunny" },
518
+ { type: "text", text: "It's sunny." },
519
+ ];
520
+ const stt = createFakeSttProvider();
521
+ const tts = createFakeTtsProvider();
522
+ const callbacks = makeCallbacks();
523
+ const { opts } = makeOpts(
524
+ {
525
+ llm: createFakeLanguageModel({ script }),
526
+ executeTool,
527
+ toolSchemas: [
528
+ {
529
+ name: "get_weather",
530
+ description: "Look up the weather.",
531
+ parameters: {
532
+ type: "object" as const,
533
+ properties: { city: { type: "string" } },
534
+ required: ["city"],
535
+ },
536
+ },
537
+ ],
538
+ sessionConfig: { systemPrompt: "s", greeting: "" },
539
+ },
540
+ { stt, tts, callbacks },
541
+ );
542
+ const t = createPipelineTransport(opts);
543
+ await t.start();
544
+ stt.last()?.fireFinal("how's the weather?");
545
+ await vi.waitFor(() => {
546
+ expect(callbacks.onReplyDone).toHaveBeenCalled();
547
+ });
548
+ // onToolCall fires for observability (Option A).
549
+ expect(callbacks.onToolCall).toHaveBeenCalledWith("tc-1", "get_weather", expect.any(Object));
550
+ await t.stop();
551
+ });
552
+ });
553
+
554
+ describe("provider errors", () => {
555
+ test("STT error fires onError('stt', ...) and terminates transport", async () => {
556
+ const stt = createFakeSttProvider();
557
+ const callbacks = makeCallbacks();
558
+ const { opts } = makeOpts(
559
+ { sessionConfig: { systemPrompt: "s", greeting: "" } },
560
+ { stt, callbacks },
561
+ );
562
+ const t = createPipelineTransport(opts);
563
+ await t.start();
564
+ stt.last()?.fireError("stt_stream_error", "stt failed");
565
+ expect(callbacks.onError).toHaveBeenCalledWith("stt", "stt failed");
566
+ await t.stop();
567
+ });
568
+
569
+ test("TTS error fires onError('tts', ...) and terminates transport", async () => {
570
+ const tts = createFakeTtsProvider();
571
+ const callbacks = makeCallbacks();
572
+ const { opts } = makeOpts(
573
+ { sessionConfig: { systemPrompt: "s", greeting: "" } },
574
+ { tts, callbacks },
575
+ );
576
+ const t = createPipelineTransport(opts);
577
+ await t.start();
578
+ tts.last()?.fireError("tts_stream_error", "tts failed");
579
+ expect(callbacks.onError).toHaveBeenCalledWith("tts", "tts failed");
580
+ await t.stop();
581
+ });
582
+
583
+ test("STT open failure fires onError('stt', ...) via reportOpenRejection", async () => {
584
+ const callbacks = makeCallbacks();
585
+ const { opts } = makeOpts(
586
+ {
587
+ stt: createFailingSttProvider("stt_connect_failed", "connect failed"),
588
+ sessionConfig: { systemPrompt: "s", greeting: "" },
589
+ },
590
+ { callbacks },
591
+ );
592
+ const t = createPipelineTransport(opts);
593
+ await t.start();
594
+ expect(callbacks.onError).toHaveBeenCalledWith("stt", "connect failed");
595
+ await t.stop();
596
+ });
597
+
598
+ test("TTS open failure fires onError('tts', ...) via reportOpenRejection", async () => {
599
+ const callbacks = makeCallbacks();
600
+ const { opts } = makeOpts(
601
+ {
602
+ tts: createFailingTtsProvider("tts_connect_failed", "tts connect failed"),
603
+ sessionConfig: { systemPrompt: "s", greeting: "" },
604
+ },
605
+ { callbacks },
606
+ );
607
+ const t = createPipelineTransport(opts);
608
+ await t.start();
609
+ expect(callbacks.onError).toHaveBeenCalledWith("tts", "tts connect failed");
610
+ await t.stop();
611
+ });
612
+
613
+ test("when STT fails, TTS session is still opened but then immediately closed", async () => {
614
+ const tts = createFakeTtsProvider();
615
+ const { opts } = makeOpts(
616
+ {
617
+ stt: createFailingSttProvider("stt_connect_failed", "bad key"),
618
+ tts,
619
+ sessionConfig: { systemPrompt: "s", greeting: "" },
620
+ },
621
+ { tts },
622
+ );
623
+ const t = createPipelineTransport(opts);
624
+ await t.start();
625
+ // TTS was opened (Promise.allSettled runs both concurrently) but then closed.
626
+ expect(tts.last()?.closed.value).toBe(true);
627
+ await t.stop();
628
+ });
629
+ });
630
+
631
+ describe("history seeding", () => {
632
+ test("sessionConfig.history is used as initial conversation messages", async () => {
633
+ // History seeding is internal — we verify it indirectly by checking
634
+ // that the LLM receives the correct message array.
635
+ // For this test we just ensure start() doesn't throw when history is set.
636
+ const { opts } = makeOpts({
637
+ sessionConfig: {
638
+ systemPrompt: "s",
639
+ greeting: "",
640
+ history: [
641
+ { role: "user", content: "hi" },
642
+ { role: "assistant", content: "hello" },
643
+ ],
644
+ },
645
+ });
646
+ const t = createPipelineTransport(opts);
647
+ await expect(t.start()).resolves.toBeUndefined();
648
+ await t.stop();
649
+ });
650
+ });
651
+ });