@alexkroman1/aai 1.2.3 → 1.3.1
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/.turbo/turbo-build.log +14 -12
- package/CHANGELOG.md +20 -0
- package/dist/{constants-VTFoymJ-.js → constants-BL3nvg4I.js} +8 -1
- package/dist/host/_pipeline-test-fakes.d.ts +117 -0
- package/dist/host/pipeline-session-ctx.d.ts +24 -0
- package/dist/host/pipeline-session.d.ts +48 -0
- package/dist/host/providers/llm.d.ts +2 -0
- package/dist/host/providers/stt/assemblyai.d.ts +31 -0
- package/dist/host/providers/stt-barrel.d.ts +8 -0
- package/dist/host/providers/stt-barrel.js +92 -0
- package/dist/host/providers/stt.d.ts +2 -0
- package/dist/host/providers/tts/cartesia.d.ts +39 -0
- package/dist/host/providers/tts-barrel.d.ts +8 -0
- package/dist/host/providers/tts-barrel.js +182 -0
- package/dist/host/providers/tts.d.ts +2 -0
- package/dist/host/runtime-barrel.js +565 -81
- package/dist/host/runtime.d.ts +17 -0
- package/dist/host/s2s.d.ts +5 -0
- package/dist/host/session-ctx.d.ts +22 -4
- package/dist/host/to-vercel-tools.d.ts +45 -0
- package/dist/index.js +7 -2
- package/dist/sdk/_internal-types.d.ts +15 -1
- package/dist/sdk/constants.d.ts +7 -0
- package/dist/sdk/define.d.ts +21 -0
- package/dist/sdk/manifest.d.ts +22 -0
- package/dist/sdk/protocol.d.ts +3 -3
- package/dist/sdk/protocol.js +1 -1
- package/dist/sdk/providers.d.ts +70 -0
- package/dist/sdk/types.d.ts +16 -0
- package/exports-no-dev-deps.test.ts +39 -14
- package/host/_pipeline-test-fakes.ts +357 -0
- package/host/_test-utils.ts +1 -0
- package/host/integration/fixtures/README.md +49 -0
- package/host/integration/pipeline-reference.integration.test.ts +124 -0
- package/host/pipeline-session-ctx.test.ts +31 -0
- package/host/pipeline-session-ctx.ts +36 -0
- package/host/pipeline-session.test.ts +572 -0
- package/host/pipeline-session.ts +489 -0
- package/host/providers/llm.ts +3 -0
- package/host/providers/providers.test-d.ts +31 -0
- package/host/providers/stt/assemblyai.test.ts +100 -0
- package/host/providers/stt/assemblyai.ts +154 -0
- package/host/providers/stt/fixtures/assemblyai/basic-turn.json +30 -0
- package/host/providers/stt-barrel.ts +13 -0
- package/host/providers/stt.ts +3 -0
- package/host/providers/tts/cartesia.test.ts +210 -0
- package/host/providers/tts/cartesia.ts +251 -0
- package/host/providers/tts-barrel.ts +13 -0
- package/host/providers/tts.ts +3 -0
- package/host/runtime.test.ts +81 -1
- package/host/runtime.ts +61 -0
- package/host/s2s.test.ts +19 -0
- package/host/s2s.ts +10 -0
- package/host/session-ctx.ts +35 -8
- package/host/to-vercel-tools.test.ts +187 -0
- package/host/to-vercel-tools.ts +74 -0
- package/package.json +15 -1
- package/sdk/__snapshots__/exports.test.ts.snap +2 -0
- package/sdk/_internal-types.ts +16 -0
- package/sdk/constants.ts +8 -0
- package/sdk/define.test-d.ts +21 -0
- package/sdk/define.test.ts +33 -0
- package/sdk/define.ts +21 -0
- package/sdk/manifest.test-d.ts +14 -0
- package/sdk/manifest.test.ts +51 -0
- package/sdk/manifest.ts +39 -0
- package/sdk/providers.ts +90 -0
- package/sdk/types.ts +16 -0
- package/vitest.config.ts +1 -0
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
// Copyright 2025 the AAI authors. MIT license.
|
|
2
|
+
/** Tests for the pipeline-session orchestrator (see pipeline-session.ts). */
|
|
3
|
+
|
|
4
|
+
import { describe, expect, test, vi } from "vitest";
|
|
5
|
+
import type { AgentConfig } from "../sdk/_internal-types.ts";
|
|
6
|
+
import type { ClientEvent } from "../sdk/protocol.ts";
|
|
7
|
+
import { DEFAULT_SYSTEM_PROMPT } from "../sdk/types.ts";
|
|
8
|
+
import {
|
|
9
|
+
createFailingSttProvider,
|
|
10
|
+
createFailingTtsProvider,
|
|
11
|
+
createFakeLanguageModel,
|
|
12
|
+
createFakeSttProvider,
|
|
13
|
+
createFakeTtsProvider,
|
|
14
|
+
type ScriptedPart,
|
|
15
|
+
} from "./_pipeline-test-fakes.ts";
|
|
16
|
+
import { makeClient, silentLogger } from "./_test-utils.ts";
|
|
17
|
+
import { createPipelineSession, type PipelineSessionOptions } from "./pipeline-session.ts";
|
|
18
|
+
|
|
19
|
+
const CONFIG: AgentConfig = {
|
|
20
|
+
name: "pipeline-agent",
|
|
21
|
+
systemPrompt: DEFAULT_SYSTEM_PROMPT,
|
|
22
|
+
greeting: "",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function makeOpts(overrides: Partial<PipelineSessionOptions> = {}): {
|
|
26
|
+
opts: PipelineSessionOptions;
|
|
27
|
+
stt: ReturnType<typeof createFakeSttProvider>;
|
|
28
|
+
tts: ReturnType<typeof createFakeTtsProvider>;
|
|
29
|
+
client: ReturnType<typeof makeClient>;
|
|
30
|
+
} {
|
|
31
|
+
const stt = createFakeSttProvider();
|
|
32
|
+
const tts = createFakeTtsProvider();
|
|
33
|
+
const client = makeClient();
|
|
34
|
+
const opts: PipelineSessionOptions = {
|
|
35
|
+
id: "sess-1",
|
|
36
|
+
agent: "pipeline-agent",
|
|
37
|
+
client,
|
|
38
|
+
agentConfig: CONFIG,
|
|
39
|
+
toolSchemas: [],
|
|
40
|
+
executeTool: vi.fn(async () => "ok"),
|
|
41
|
+
stt,
|
|
42
|
+
llm: createFakeLanguageModel({ script: [] }),
|
|
43
|
+
tts,
|
|
44
|
+
sttApiKey: "stt-key",
|
|
45
|
+
ttsApiKey: "tts-key",
|
|
46
|
+
sampleRate: 16_000,
|
|
47
|
+
logger: silentLogger,
|
|
48
|
+
...overrides,
|
|
49
|
+
};
|
|
50
|
+
return { opts, stt, tts, client };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function eventTypes(events: readonly unknown[]): string[] {
|
|
54
|
+
return events.map((e) => (e as ClientEvent).type);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("createPipelineSession — happy path", () => {
|
|
58
|
+
test("STT final → LLM stream → TTS sendText/flush → reply_done", async () => {
|
|
59
|
+
const script: ScriptedPart[] = [
|
|
60
|
+
{ type: "text", text: "Hello" },
|
|
61
|
+
{ type: "text", text: " there" },
|
|
62
|
+
];
|
|
63
|
+
const { opts, stt, tts, client } = makeOpts({
|
|
64
|
+
llm: createFakeLanguageModel({ script }),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const session = createPipelineSession(opts);
|
|
68
|
+
await session.start();
|
|
69
|
+
|
|
70
|
+
const sttSession = stt.last();
|
|
71
|
+
expect(sttSession).toBeDefined();
|
|
72
|
+
const ttsSession = tts.last();
|
|
73
|
+
expect(ttsSession).toBeDefined();
|
|
74
|
+
if (!(sttSession && ttsSession)) return;
|
|
75
|
+
|
|
76
|
+
sttSession.firePartial("Hello");
|
|
77
|
+
sttSession.fireFinal("Hello there, how are you?");
|
|
78
|
+
await session.waitForTurn();
|
|
79
|
+
|
|
80
|
+
// Verify TTS received each text-delta, then a flush
|
|
81
|
+
expect(ttsSession.textChunks).toEqual(["Hello", " there"]);
|
|
82
|
+
expect(ttsSession.flush).toHaveBeenCalledTimes(1);
|
|
83
|
+
|
|
84
|
+
// Verify wire events in order
|
|
85
|
+
const types = eventTypes(client.events);
|
|
86
|
+
expect(types).toEqual([
|
|
87
|
+
"user_transcript",
|
|
88
|
+
"agent_transcript", // "Hello"
|
|
89
|
+
"agent_transcript", // " there"
|
|
90
|
+
"reply_done",
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
// user_transcript text matches
|
|
94
|
+
expect(client.events[0]).toMatchObject({
|
|
95
|
+
type: "user_transcript",
|
|
96
|
+
text: "Hello there, how are you?",
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await session.stop();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("createPipelineSession — empty utterance", () => {
|
|
104
|
+
test("whitespace-only final skips reply (no TTS, no LLM, no wire events)", async () => {
|
|
105
|
+
const llm = createFakeLanguageModel({ script: [{ type: "text", text: "unexpected" }] });
|
|
106
|
+
const doStreamSpy = vi.spyOn(
|
|
107
|
+
llm as unknown as { doStream: (...a: unknown[]) => unknown },
|
|
108
|
+
"doStream",
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const { opts, stt, tts, client } = makeOpts({ llm });
|
|
112
|
+
const session = createPipelineSession(opts);
|
|
113
|
+
await session.start();
|
|
114
|
+
|
|
115
|
+
const sttSession = stt.last();
|
|
116
|
+
const ttsSession = tts.last();
|
|
117
|
+
if (!(sttSession && ttsSession)) throw new Error("providers didn't open");
|
|
118
|
+
|
|
119
|
+
sttSession.firePartial(" ");
|
|
120
|
+
sttSession.fireFinal(" ");
|
|
121
|
+
await session.waitForTurn();
|
|
122
|
+
|
|
123
|
+
expect(doStreamSpy).not.toHaveBeenCalled();
|
|
124
|
+
expect(ttsSession.sendText).not.toHaveBeenCalled();
|
|
125
|
+
expect(ttsSession.flush).not.toHaveBeenCalled();
|
|
126
|
+
expect(client.events).toEqual([]);
|
|
127
|
+
|
|
128
|
+
await session.stop();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("createPipelineSession — barge-in", () => {
|
|
133
|
+
test("stt.partial during AGENT_REPLYING aborts LLM, cancels TTS, emits cancelled", async () => {
|
|
134
|
+
// Script with delayMs so we can fire a partial between parts.
|
|
135
|
+
const script: ScriptedPart[] = [
|
|
136
|
+
{ type: "text", text: "Hello " },
|
|
137
|
+
{ type: "text", text: "how can " },
|
|
138
|
+
{ type: "text", text: "I help?" },
|
|
139
|
+
];
|
|
140
|
+
const { opts, stt, tts, client } = makeOpts({
|
|
141
|
+
llm: createFakeLanguageModel({ script, delayMs: 20 }),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const session = createPipelineSession(opts);
|
|
145
|
+
await session.start();
|
|
146
|
+
|
|
147
|
+
const sttSession = stt.last();
|
|
148
|
+
const ttsSession = tts.last();
|
|
149
|
+
if (!(sttSession && ttsSession)) throw new Error("providers didn't open");
|
|
150
|
+
|
|
151
|
+
// Kick off a reply.
|
|
152
|
+
sttSession.firePartial("hi");
|
|
153
|
+
sttSession.fireFinal("hi there");
|
|
154
|
+
// Wait until at least one text delta has been forwarded to TTS so we're
|
|
155
|
+
// firmly in AGENT_REPLYING before the barge-in partial.
|
|
156
|
+
await vi.waitFor(() => {
|
|
157
|
+
expect(ttsSession.sendText.mock.calls.length).toBeGreaterThan(0);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Barge-in: user starts speaking again.
|
|
161
|
+
sttSession.firePartial("wait");
|
|
162
|
+
await session.waitForTurn();
|
|
163
|
+
|
|
164
|
+
// TTS.cancel must have been called exactly once.
|
|
165
|
+
expect(ttsSession.cancel).toHaveBeenCalledTimes(1);
|
|
166
|
+
// Wire events: user_transcript, some agent_transcript(s), then cancelled.
|
|
167
|
+
// No reply_done — barge-in short-circuits the drain.
|
|
168
|
+
const types = eventTypes(client.events);
|
|
169
|
+
expect(types).toContain("user_transcript");
|
|
170
|
+
expect(types).toContain("cancelled");
|
|
171
|
+
expect(types).not.toContain("reply_done");
|
|
172
|
+
expect(types.indexOf("cancelled")).toBeGreaterThan(types.indexOf("user_transcript"));
|
|
173
|
+
|
|
174
|
+
// After the barge-in lands, the state machine is back to USER_SPEAKING.
|
|
175
|
+
// A new final should start a fresh turn.
|
|
176
|
+
await session.stop();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("createPipelineSession — tool calls", () => {
|
|
181
|
+
test("tool-call and tool-result parts emit wire events; reply_done still fires", async () => {
|
|
182
|
+
const script: ScriptedPart[] = [
|
|
183
|
+
{ type: "text", text: "Let me check" },
|
|
184
|
+
{
|
|
185
|
+
type: "tool-call",
|
|
186
|
+
toolCallId: "tc-1",
|
|
187
|
+
toolName: "get_weather",
|
|
188
|
+
input: JSON.stringify({ city: "SF" }),
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
type: "tool-result",
|
|
192
|
+
toolCallId: "tc-1",
|
|
193
|
+
toolName: "get_weather",
|
|
194
|
+
result: "sunny, 72F",
|
|
195
|
+
},
|
|
196
|
+
{ type: "text", text: " — it's sunny." },
|
|
197
|
+
];
|
|
198
|
+
const { opts, stt, tts, client } = makeOpts({
|
|
199
|
+
llm: createFakeLanguageModel({ script }),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const session = createPipelineSession(opts);
|
|
203
|
+
await session.start();
|
|
204
|
+
|
|
205
|
+
const sttSession = stt.last();
|
|
206
|
+
const ttsSession = tts.last();
|
|
207
|
+
if (!(sttSession && ttsSession)) throw new Error("providers didn't open");
|
|
208
|
+
|
|
209
|
+
sttSession.fireFinal("how's the weather?");
|
|
210
|
+
await session.waitForTurn();
|
|
211
|
+
|
|
212
|
+
const types = eventTypes(client.events);
|
|
213
|
+
expect(types).toEqual([
|
|
214
|
+
"user_transcript",
|
|
215
|
+
"agent_transcript", // "Let me check"
|
|
216
|
+
"tool_call",
|
|
217
|
+
"tool_call_done",
|
|
218
|
+
"agent_transcript", // " — it's sunny."
|
|
219
|
+
"reply_done",
|
|
220
|
+
]);
|
|
221
|
+
|
|
222
|
+
const toolCall = client.events.find((e) => (e as ClientEvent).type === "tool_call");
|
|
223
|
+
expect(toolCall).toMatchObject({
|
|
224
|
+
type: "tool_call",
|
|
225
|
+
toolCallId: "tc-1",
|
|
226
|
+
toolName: "get_weather",
|
|
227
|
+
});
|
|
228
|
+
const toolDone = client.events.find((e) => (e as ClientEvent).type === "tool_call_done");
|
|
229
|
+
expect(toolDone).toMatchObject({
|
|
230
|
+
type: "tool_call_done",
|
|
231
|
+
toolCallId: "tc-1",
|
|
232
|
+
result: "sunny, 72F",
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
await session.stop();
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe("createPipelineSession — multi-step tool loop", () => {
|
|
240
|
+
test("streamText loops across multiple tool calls when maxSteps > 1", async () => {
|
|
241
|
+
// 4 steps: three tool calls (each in its own model step), then a final
|
|
242
|
+
// text completion. Without `stopWhen: stepCountIs(n)` the AI SDK v6
|
|
243
|
+
// default is a single step, so tool loops would terminate after the
|
|
244
|
+
// first tool-result and `executeTool` would only fire once.
|
|
245
|
+
const steps: ScriptedPart[][] = [
|
|
246
|
+
[
|
|
247
|
+
{
|
|
248
|
+
type: "tool-call",
|
|
249
|
+
toolCallId: "tc-1",
|
|
250
|
+
toolName: "get_weather",
|
|
251
|
+
input: JSON.stringify({ city: "SF" }),
|
|
252
|
+
},
|
|
253
|
+
],
|
|
254
|
+
[
|
|
255
|
+
{
|
|
256
|
+
type: "tool-call",
|
|
257
|
+
toolCallId: "tc-2",
|
|
258
|
+
toolName: "get_weather",
|
|
259
|
+
input: JSON.stringify({ city: "LA" }),
|
|
260
|
+
},
|
|
261
|
+
],
|
|
262
|
+
[
|
|
263
|
+
{
|
|
264
|
+
type: "tool-call",
|
|
265
|
+
toolCallId: "tc-3",
|
|
266
|
+
toolName: "get_weather",
|
|
267
|
+
input: JSON.stringify({ city: "NY" }),
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
[{ type: "text", text: "Weather for all three cities retrieved." }],
|
|
271
|
+
];
|
|
272
|
+
const executeTool = vi.fn(
|
|
273
|
+
async (name: string, args: Readonly<Record<string, unknown>>) =>
|
|
274
|
+
`result-${name}-${(args as { city?: string }).city ?? "?"}`,
|
|
275
|
+
);
|
|
276
|
+
const { opts, stt, tts, client } = makeOpts({
|
|
277
|
+
llm: createFakeLanguageModel({ steps }),
|
|
278
|
+
executeTool,
|
|
279
|
+
toolSchemas: [
|
|
280
|
+
{
|
|
281
|
+
name: "get_weather",
|
|
282
|
+
description: "Look up the weather for a city.",
|
|
283
|
+
parameters: {
|
|
284
|
+
type: "object",
|
|
285
|
+
properties: { city: { type: "string" } },
|
|
286
|
+
required: ["city"],
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
agentConfig: { ...CONFIG, maxSteps: 5 },
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const session = createPipelineSession(opts);
|
|
294
|
+
await session.start();
|
|
295
|
+
|
|
296
|
+
const sttSession = stt.last();
|
|
297
|
+
const ttsSession = tts.last();
|
|
298
|
+
if (!(sttSession && ttsSession)) throw new Error("providers didn't open");
|
|
299
|
+
|
|
300
|
+
sttSession.fireFinal("weather everywhere?");
|
|
301
|
+
await session.waitForTurn();
|
|
302
|
+
|
|
303
|
+
// All three tool calls ran.
|
|
304
|
+
expect(executeTool).toHaveBeenCalledTimes(3);
|
|
305
|
+
const toolCallEvents = client.events.filter((e) => (e as ClientEvent).type === "tool_call");
|
|
306
|
+
expect(toolCallEvents).toHaveLength(3);
|
|
307
|
+
|
|
308
|
+
// And the reply finished with a final text + reply_done, proving the
|
|
309
|
+
// loop actually terminated naturally rather than being cut short.
|
|
310
|
+
const types = eventTypes(client.events);
|
|
311
|
+
expect(types).toContain("reply_done");
|
|
312
|
+
expect(ttsSession.textChunks).toEqual(["Weather for all three cities retrieved."]);
|
|
313
|
+
|
|
314
|
+
await session.stop();
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
describe("createPipelineSession — STT error", () => {
|
|
319
|
+
test("stt error emits single error wire event with code stt", async () => {
|
|
320
|
+
const { opts, stt, client } = makeOpts();
|
|
321
|
+
const session = createPipelineSession(opts);
|
|
322
|
+
await session.start();
|
|
323
|
+
|
|
324
|
+
const sttSession = stt.last();
|
|
325
|
+
if (!sttSession) throw new Error("STT didn't open");
|
|
326
|
+
|
|
327
|
+
sttSession.fireError("stt_stream_error", "oops");
|
|
328
|
+
|
|
329
|
+
const errors = client.events.filter((e) => (e as ClientEvent).type === "error");
|
|
330
|
+
expect(errors).toHaveLength(1);
|
|
331
|
+
expect(errors[0]).toMatchObject({
|
|
332
|
+
type: "error",
|
|
333
|
+
code: "stt",
|
|
334
|
+
message: "oops",
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
await session.stop();
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
describe("createPipelineSession — duplicate final", () => {
|
|
342
|
+
test("second final during AGENT_REPLYING aborts prior turn and starts new one", async () => {
|
|
343
|
+
// Multi-part first step with delay so the first turn is still streaming
|
|
344
|
+
// when the second final arrives.
|
|
345
|
+
const steps: ScriptedPart[][] = [
|
|
346
|
+
[
|
|
347
|
+
{ type: "text", text: "first " },
|
|
348
|
+
{ type: "text", text: "reply " },
|
|
349
|
+
{ type: "text", text: "continues" },
|
|
350
|
+
],
|
|
351
|
+
[{ type: "text", text: "second reply" }],
|
|
352
|
+
];
|
|
353
|
+
const { opts, stt, tts, client } = makeOpts({
|
|
354
|
+
llm: createFakeLanguageModel({ steps, delayMs: 20 }),
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
const session = createPipelineSession(opts);
|
|
358
|
+
await session.start();
|
|
359
|
+
|
|
360
|
+
const sttSession = stt.last();
|
|
361
|
+
const ttsSession = tts.last();
|
|
362
|
+
if (!(sttSession && ttsSession)) throw new Error("providers didn't open");
|
|
363
|
+
|
|
364
|
+
sttSession.fireFinal("first question");
|
|
365
|
+
await vi.waitFor(() => {
|
|
366
|
+
expect(ttsSession.sendText.mock.calls.length).toBeGreaterThan(0);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// Second final arrives mid-reply.
|
|
370
|
+
sttSession.fireFinal("second question");
|
|
371
|
+
await session.waitForTurn();
|
|
372
|
+
|
|
373
|
+
// TTS.cancel fires once to abandon the first turn's audio.
|
|
374
|
+
expect(ttsSession.cancel).toHaveBeenCalledTimes(1);
|
|
375
|
+
|
|
376
|
+
// Both user transcripts reach the client.
|
|
377
|
+
const userTranscripts = client.events.filter(
|
|
378
|
+
(e) => (e as ClientEvent).type === "user_transcript",
|
|
379
|
+
);
|
|
380
|
+
expect(userTranscripts).toHaveLength(2);
|
|
381
|
+
|
|
382
|
+
// Second reply's text was synthesized.
|
|
383
|
+
expect(ttsSession.textChunks).toContain("second reply");
|
|
384
|
+
|
|
385
|
+
// Exactly one reply_done (for the second turn).
|
|
386
|
+
const replyDones = client.events.filter((e) => (e as ClientEvent).type === "reply_done");
|
|
387
|
+
expect(replyDones).toHaveLength(1);
|
|
388
|
+
|
|
389
|
+
await session.stop();
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
describe("createPipelineSession — flush timeout/abort", () => {
|
|
394
|
+
test("flush that never drains does not wedge stop()", async () => {
|
|
395
|
+
// autoDoneOnFlush: false → TTS never fires `done`, so flushTtsAndWait must
|
|
396
|
+
// resolve via the turn-abort signal when stop() fires.
|
|
397
|
+
const script: ScriptedPart[] = [{ type: "text", text: "hi" }];
|
|
398
|
+
const tts = createFakeTtsProvider({ autoDoneOnFlush: false });
|
|
399
|
+
const { opts, stt, client } = makeOpts({
|
|
400
|
+
llm: createFakeLanguageModel({ script }),
|
|
401
|
+
tts,
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
const session = createPipelineSession(opts);
|
|
405
|
+
await session.start();
|
|
406
|
+
|
|
407
|
+
const sttSession = stt.last();
|
|
408
|
+
const ttsSession = tts.last();
|
|
409
|
+
if (!(sttSession && ttsSession)) throw new Error("providers didn't open");
|
|
410
|
+
|
|
411
|
+
sttSession.fireFinal("hi");
|
|
412
|
+
// Wait until the turn has reached the flush step — without this guard,
|
|
413
|
+
// stop() aborts the controller before flushTtsAndWait is even called.
|
|
414
|
+
await vi.waitFor(() => {
|
|
415
|
+
expect(ttsSession.flush).toHaveBeenCalledTimes(1);
|
|
416
|
+
});
|
|
417
|
+
await session.stop();
|
|
418
|
+
|
|
419
|
+
// Turn aborted before reply_done could fire.
|
|
420
|
+
const types = eventTypes(client.events);
|
|
421
|
+
expect(types).not.toContain("reply_done");
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
describe("createPipelineSession — mid-session provider errors", () => {
|
|
426
|
+
test("STT error during reply aborts turn and stops further transcripts", async () => {
|
|
427
|
+
const script: ScriptedPart[] = [{ type: "text", text: "reply" }];
|
|
428
|
+
const { opts, stt, tts, client } = makeOpts({
|
|
429
|
+
llm: createFakeLanguageModel({ script, delayMs: 20 }),
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
const session = createPipelineSession(opts);
|
|
433
|
+
await session.start();
|
|
434
|
+
|
|
435
|
+
const sttSession = stt.last();
|
|
436
|
+
const ttsSession = tts.last();
|
|
437
|
+
if (!(sttSession && ttsSession)) throw new Error("providers didn't open");
|
|
438
|
+
|
|
439
|
+
sttSession.fireFinal("first");
|
|
440
|
+
await vi.waitFor(() => {
|
|
441
|
+
expect(ttsSession.sendText.mock.calls.length).toBeGreaterThan(0);
|
|
442
|
+
});
|
|
443
|
+
sttSession.fireError("stt_stream_error", "socket died");
|
|
444
|
+
await session.waitForTurn();
|
|
445
|
+
|
|
446
|
+
const errors = client.events.filter((e) => (e as ClientEvent).type === "error");
|
|
447
|
+
expect(errors).toHaveLength(1);
|
|
448
|
+
expect(errors[0]).toMatchObject({ code: "stt", message: "socket died" });
|
|
449
|
+
|
|
450
|
+
// Turn was aborted (TTS cancelled).
|
|
451
|
+
expect(ttsSession.cancel).toHaveBeenCalled();
|
|
452
|
+
|
|
453
|
+
// Further STT events are no-ops.
|
|
454
|
+
sttSession.fireFinal("ignored after error");
|
|
455
|
+
await session.waitForTurn();
|
|
456
|
+
const userTranscripts = client.events.filter(
|
|
457
|
+
(e) => (e as ClientEvent).type === "user_transcript",
|
|
458
|
+
);
|
|
459
|
+
expect(userTranscripts).toHaveLength(1);
|
|
460
|
+
|
|
461
|
+
await session.stop();
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test("TTS error during reply aborts turn and stops further user transcripts", async () => {
|
|
465
|
+
const script: ScriptedPart[] = [{ type: "text", text: "reply" }];
|
|
466
|
+
const { opts, stt, tts, client } = makeOpts({
|
|
467
|
+
llm: createFakeLanguageModel({ script, delayMs: 20 }),
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
const session = createPipelineSession(opts);
|
|
471
|
+
await session.start();
|
|
472
|
+
|
|
473
|
+
const sttSession = stt.last();
|
|
474
|
+
const ttsSession = tts.last();
|
|
475
|
+
if (!(sttSession && ttsSession)) throw new Error("providers didn't open");
|
|
476
|
+
|
|
477
|
+
sttSession.fireFinal("first");
|
|
478
|
+
await vi.waitFor(() => {
|
|
479
|
+
expect(ttsSession.sendText.mock.calls.length).toBeGreaterThan(0);
|
|
480
|
+
});
|
|
481
|
+
ttsSession.fireError("tts_stream_error", "socket died");
|
|
482
|
+
await session.waitForTurn();
|
|
483
|
+
|
|
484
|
+
const errors = client.events.filter((e) => (e as ClientEvent).type === "error");
|
|
485
|
+
expect(errors).toHaveLength(1);
|
|
486
|
+
expect(errors[0]).toMatchObject({ code: "tts", message: "socket died" });
|
|
487
|
+
|
|
488
|
+
sttSession.fireFinal("should be ignored");
|
|
489
|
+
await session.waitForTurn();
|
|
490
|
+
const userTranscripts = client.events.filter(
|
|
491
|
+
(e) => (e as ClientEvent).type === "user_transcript",
|
|
492
|
+
);
|
|
493
|
+
expect(userTranscripts).toHaveLength(1);
|
|
494
|
+
|
|
495
|
+
await session.stop();
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test("cancel/reset/history are no-ops after terminate", async () => {
|
|
499
|
+
const { opts, stt, client } = makeOpts();
|
|
500
|
+
const session = createPipelineSession(opts);
|
|
501
|
+
await session.start();
|
|
502
|
+
|
|
503
|
+
const sttSession = stt.last();
|
|
504
|
+
if (!sttSession) throw new Error("STT didn't open");
|
|
505
|
+
|
|
506
|
+
sttSession.fireError("stt_stream_error", "dead");
|
|
507
|
+
await session.waitForTurn();
|
|
508
|
+
|
|
509
|
+
const eventsBefore = client.events.length;
|
|
510
|
+
|
|
511
|
+
session.onCancel();
|
|
512
|
+
session.onReset();
|
|
513
|
+
session.onHistory([{ role: "user", content: "nope" }]);
|
|
514
|
+
|
|
515
|
+
expect(client.events).toHaveLength(eventsBefore);
|
|
516
|
+
|
|
517
|
+
await session.stop();
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
describe("createPipelineSession — atomic provider open", () => {
|
|
522
|
+
test("STT is closed when TTS open fails, session becomes terminated", async () => {
|
|
523
|
+
const stt = createFakeSttProvider();
|
|
524
|
+
const failingTts = createFailingTtsProvider("tts_connect_failed", "bad key");
|
|
525
|
+
|
|
526
|
+
const { opts, client } = makeOpts({ stt, tts: failingTts });
|
|
527
|
+
const session = createPipelineSession(opts);
|
|
528
|
+
await session.start();
|
|
529
|
+
|
|
530
|
+
const sttSession = stt.last();
|
|
531
|
+
expect(sttSession).toBeDefined();
|
|
532
|
+
expect(sttSession?.closed.value).toBe(true);
|
|
533
|
+
|
|
534
|
+
const errors = client.events.filter((e) => (e as ClientEvent).type === "error");
|
|
535
|
+
expect(errors).toHaveLength(1);
|
|
536
|
+
expect(errors[0]).toMatchObject({ code: "tts", message: "bad key" });
|
|
537
|
+
|
|
538
|
+
// Session terminated — further STT events are no-ops (even though
|
|
539
|
+
// listeners were never wired, terminate() also ensures onCancel etc. work).
|
|
540
|
+
sttSession?.fireFinal("ignored");
|
|
541
|
+
await session.waitForTurn();
|
|
542
|
+
const userTranscripts = client.events.filter(
|
|
543
|
+
(e) => (e as ClientEvent).type === "user_transcript",
|
|
544
|
+
);
|
|
545
|
+
expect(userTranscripts).toHaveLength(0);
|
|
546
|
+
|
|
547
|
+
await session.stop();
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test("TTS is never opened when STT open fails", async () => {
|
|
551
|
+
const failingStt = createFailingSttProvider("stt_connect_failed", "bad key");
|
|
552
|
+
const tts = createFakeTtsProvider();
|
|
553
|
+
const ttsOpenSpy = vi.spyOn(tts, "open");
|
|
554
|
+
|
|
555
|
+
const { opts, client } = makeOpts({ stt: failingStt, tts });
|
|
556
|
+
const session = createPipelineSession(opts);
|
|
557
|
+
await session.start();
|
|
558
|
+
|
|
559
|
+
// STT and TTS open concurrently via Promise.allSettled — TTS.open is
|
|
560
|
+
// still called, but once STT fails its result is discarded and the TTS
|
|
561
|
+
// session is closed.
|
|
562
|
+
expect(ttsOpenSpy).toHaveBeenCalledTimes(1);
|
|
563
|
+
const ttsSession = tts.last();
|
|
564
|
+
expect(ttsSession?.closed.value).toBe(true);
|
|
565
|
+
|
|
566
|
+
const errors = client.events.filter((e) => (e as ClientEvent).type === "error");
|
|
567
|
+
expect(errors).toHaveLength(1);
|
|
568
|
+
expect(errors[0]).toMatchObject({ code: "stt", message: "bad key" });
|
|
569
|
+
|
|
570
|
+
await session.stop();
|
|
571
|
+
});
|
|
572
|
+
});
|