@kuralle-syrinx/server-websocket 4.1.0 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +26 -6
- package/src/backchannel-cue-fixtures.ts +29 -0
- package/src/background-audio.ts +97 -38
- package/src/carrier-commands.ts +332 -0
- package/src/edge-telnyx.ts +389 -0
- package/src/edge-twilio.ts +3 -2
- package/src/edge.ts +11 -4
- package/src/index.ts +30 -2
- package/src/outbound-playout-pipeline.ts +2 -2
- package/src/smartpbx.ts +1 -0
- package/src/telnyx-codec.ts +163 -0
- package/src/telnyx.ts +55 -59
- package/src/twilio.ts +24 -0
- package/src/wire-carrier-control.ts +179 -0
- package/src/admission-control.test.ts +0 -270
- package/src/background-audio.test.ts +0 -172
- package/src/browser-opus.test.ts +0 -41
- package/src/browser-pacing.test.ts +0 -440
- package/src/edge-twilio.test.ts +0 -293
- package/src/edge.test.ts +0 -591
- package/src/graceful-drain.test.ts +0 -306
- package/src/inbound-audio.test.ts +0 -58
- package/src/index.test.ts +0 -2074
- package/src/outbound-playout-pipeline.test.ts +0 -314
- package/src/paced-playout.test.ts +0 -247
- package/src/playout-progress.test.ts +0 -56
- package/src/session-store.test.ts +0 -274
- package/src/smartpbx.test.ts +0 -967
- package/src/telnyx.test.ts +0 -1457
- package/src/transport-host.test.ts +0 -51
- package/src/turn-metrics.test.ts +0 -327
- package/src/twilio-auth.test.ts +0 -36
- package/src/twilio.test.ts +0 -1275
- package/src/websocket-close.test.ts +0 -63
- package/src/websocket-lifecycle.test.ts +0 -49
- package/tsconfig.json +0 -22
- package/vitest.config.ts +0 -7
|
@@ -1,274 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
|
|
3
|
-
import { describe, expect, it, vi } from "vitest";
|
|
4
|
-
import { Route, VoiceAgentSession } from "@kuralle-syrinx/core";
|
|
5
|
-
import { createVoiceWebSocketServer } from "./index.js";
|
|
6
|
-
import {
|
|
7
|
-
InMemorySessionStore,
|
|
8
|
-
type ManagedSession,
|
|
9
|
-
type SessionStore,
|
|
10
|
-
} from "./session-store.js";
|
|
11
|
-
import { TurnMetricsTracker } from "./turn-metrics.js";
|
|
12
|
-
import {
|
|
13
|
-
openBrowserClientAndReadReady,
|
|
14
|
-
registerServer,
|
|
15
|
-
setupTransportTestCleanup,
|
|
16
|
-
waitForCondition,
|
|
17
|
-
} from "./test-helpers.js";
|
|
18
|
-
|
|
19
|
-
setupTransportTestCleanup();
|
|
20
|
-
|
|
21
|
-
function createManagedSession(id: string): ManagedSession {
|
|
22
|
-
return {
|
|
23
|
-
id,
|
|
24
|
-
session: new VoiceAgentSession({ plugins: {} }),
|
|
25
|
-
currentContextId: "turn-test",
|
|
26
|
-
contextSampleRates: new Map(),
|
|
27
|
-
inputSequence: { lastSequence: null },
|
|
28
|
-
turnMetricsTurns: new Map(),
|
|
29
|
-
closeTimer: null,
|
|
30
|
-
connectionCount: 1,
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
describe("InMemorySessionStore", () => {
|
|
35
|
-
it("leases a new session when none exists", async () => {
|
|
36
|
-
const store = new InMemorySessionStore();
|
|
37
|
-
const create = vi.fn(async () => createManagedSession("session-a"));
|
|
38
|
-
|
|
39
|
-
const leased = await store.lease("session-a", create);
|
|
40
|
-
|
|
41
|
-
expect(create).toHaveBeenCalledTimes(1);
|
|
42
|
-
expect(leased.resumed).toBe(false);
|
|
43
|
-
expect(leased.managed.id).toBe("session-a");
|
|
44
|
-
expect(await store.get("session-a")).toBe(leased.managed);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it("resumes an existing session within the retention window", async () => {
|
|
48
|
-
const store = new InMemorySessionStore();
|
|
49
|
-
const create = vi.fn(async () => createManagedSession("session-a"));
|
|
50
|
-
const first = await store.lease("session-a", create);
|
|
51
|
-
first.managed.connectionCount = 0;
|
|
52
|
-
await store.release("session-a", 200);
|
|
53
|
-
|
|
54
|
-
const second = await store.lease("session-a", create);
|
|
55
|
-
|
|
56
|
-
expect(create).toHaveBeenCalledTimes(1);
|
|
57
|
-
expect(second.resumed).toBe(true);
|
|
58
|
-
expect(second.managed).toBe(first.managed);
|
|
59
|
-
expect(second.managed.connectionCount).toBe(1);
|
|
60
|
-
expect(second.managed.closeTimer).toBeNull();
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("creates a fresh session after the retention window expires", async () => {
|
|
64
|
-
const store = new InMemorySessionStore();
|
|
65
|
-
const create = vi.fn(async () => createManagedSession("session-a"));
|
|
66
|
-
const first = await store.lease("session-a", create);
|
|
67
|
-
const firstSession = first.managed.session;
|
|
68
|
-
first.managed.connectionCount = 0;
|
|
69
|
-
await store.release("session-a", 10);
|
|
70
|
-
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
71
|
-
|
|
72
|
-
const second = await store.lease("session-a", create);
|
|
73
|
-
|
|
74
|
-
expect(create).toHaveBeenCalledTimes(2);
|
|
75
|
-
expect(second.resumed).toBe(false);
|
|
76
|
-
expect(second.managed.session).not.toBe(firstSession);
|
|
77
|
-
expect(await store.get("session-a")).toBe(second.managed);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("closes immediately when retainMs is zero", async () => {
|
|
81
|
-
const store = new InMemorySessionStore();
|
|
82
|
-
const managed = createManagedSession("session-a");
|
|
83
|
-
const closeSpy = vi.spyOn(managed.session, "close");
|
|
84
|
-
await store.lease("session-a", async () => managed);
|
|
85
|
-
managed.connectionCount = 0;
|
|
86
|
-
await store.release("session-a", 0);
|
|
87
|
-
|
|
88
|
-
expect(closeSpy).toHaveBeenCalledTimes(1);
|
|
89
|
-
expect(await store.get("session-a")).toBeNull();
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it("does not release while connections remain active", async () => {
|
|
93
|
-
const store = new InMemorySessionStore();
|
|
94
|
-
const managed = createManagedSession("session-a");
|
|
95
|
-
const closeSpy = vi.spyOn(managed.session, "close");
|
|
96
|
-
await store.lease("session-a", async () => managed);
|
|
97
|
-
|
|
98
|
-
await store.release("session-a", 10);
|
|
99
|
-
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
100
|
-
|
|
101
|
-
expect(closeSpy).not.toHaveBeenCalled();
|
|
102
|
-
expect(await store.get("session-a")).toBe(managed);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it("serializes concurrent leases so only one session is created", async () => {
|
|
106
|
-
const store = new InMemorySessionStore();
|
|
107
|
-
let createCount = 0;
|
|
108
|
-
const create = vi.fn(async () => {
|
|
109
|
-
createCount += 1;
|
|
110
|
-
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
111
|
-
return createManagedSession("session-a");
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
const [first, second] = await Promise.all([
|
|
115
|
-
store.lease("session-a", create),
|
|
116
|
-
store.lease("session-a", create),
|
|
117
|
-
]);
|
|
118
|
-
|
|
119
|
-
expect(create).toHaveBeenCalledTimes(1);
|
|
120
|
-
expect(createCount).toBe(1);
|
|
121
|
-
expect(first.managed).toBe(second.managed);
|
|
122
|
-
expect(first.resumed).toBe(false);
|
|
123
|
-
expect(second.resumed).toBe(true);
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it("release with retainMs zero evicts immediately even when a retention timer is active", async () => {
|
|
127
|
-
const store = new InMemorySessionStore();
|
|
128
|
-
const managed = createManagedSession("session-a");
|
|
129
|
-
const closeSpy = vi.spyOn(managed.session, "close");
|
|
130
|
-
await store.lease("session-a", async () => managed);
|
|
131
|
-
managed.connectionCount = 0;
|
|
132
|
-
await store.release("session-a", 200);
|
|
133
|
-
expect(managed.closeTimer).not.toBeNull();
|
|
134
|
-
|
|
135
|
-
await store.release("session-a", 0);
|
|
136
|
-
|
|
137
|
-
expect(managed.closeTimer).toBeNull();
|
|
138
|
-
expect(closeSpy).toHaveBeenCalledTimes(1);
|
|
139
|
-
expect(await store.get("session-a")).toBeNull();
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it("persists turn metrics state across resumed connections", async () => {
|
|
143
|
-
const store = new InMemorySessionStore();
|
|
144
|
-
const managed = createManagedSession("session-a");
|
|
145
|
-
const session = managed.session;
|
|
146
|
-
await store.lease("session-a", async () => managed);
|
|
147
|
-
|
|
148
|
-
const emitted: unknown[] = [];
|
|
149
|
-
const firstTracker = new TurnMetricsTracker(session.bus, (message) => emitted.push(message), managed.turnMetricsTurns);
|
|
150
|
-
const disposers: Array<() => void> = [];
|
|
151
|
-
firstTracker.wire(disposers);
|
|
152
|
-
void session.start();
|
|
153
|
-
|
|
154
|
-
session.bus.push(Route.Main, {
|
|
155
|
-
kind: "vad.speech_ended",
|
|
156
|
-
contextId: "turn-resume",
|
|
157
|
-
timestampMs: 100,
|
|
158
|
-
});
|
|
159
|
-
session.bus.push(Route.Main, {
|
|
160
|
-
kind: "stt.result",
|
|
161
|
-
contextId: "turn-resume",
|
|
162
|
-
timestampMs: 200,
|
|
163
|
-
text: "hello",
|
|
164
|
-
confidence: 0.99,
|
|
165
|
-
});
|
|
166
|
-
await waitForCondition(() => managed.turnMetricsTurns.has("turn-resume"));
|
|
167
|
-
|
|
168
|
-
for (const dispose of disposers.splice(0)) dispose();
|
|
169
|
-
|
|
170
|
-
const resumedEmitted: unknown[] = [];
|
|
171
|
-
const resumedTracker = new TurnMetricsTracker(session.bus, (message) => resumedEmitted.push(message), managed.turnMetricsTurns);
|
|
172
|
-
const resumedDisposers: Array<() => void> = [];
|
|
173
|
-
resumedTracker.wire(resumedDisposers);
|
|
174
|
-
|
|
175
|
-
session.bus.push(Route.Main, {
|
|
176
|
-
kind: "tts.playout_progress",
|
|
177
|
-
contextId: "turn-resume",
|
|
178
|
-
timestampMs: 500,
|
|
179
|
-
playedOutMs: 20,
|
|
180
|
-
complete: true,
|
|
181
|
-
});
|
|
182
|
-
await waitForCondition(() => resumedEmitted.length === 1);
|
|
183
|
-
expect(resumedEmitted[0]).toMatchObject({
|
|
184
|
-
type: "metrics",
|
|
185
|
-
turnId: "turn-resume",
|
|
186
|
-
speechEndMs: 100,
|
|
187
|
-
sttMs: 100,
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
for (const dispose of resumedDisposers.splice(0)) dispose();
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
it("update applies mutations through the store seam", async () => {
|
|
194
|
-
const store = new InMemorySessionStore();
|
|
195
|
-
const managed = createManagedSession("session-a");
|
|
196
|
-
await store.lease("session-a", async () => managed);
|
|
197
|
-
|
|
198
|
-
store.update("session-a", (session) => {
|
|
199
|
-
session.currentContextId = "turn-updated";
|
|
200
|
-
session.inputSequence.lastSequence = 7;
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
const loaded = await store.get("session-a");
|
|
204
|
-
expect(loaded?.currentContextId).toBe("turn-updated");
|
|
205
|
-
expect(loaded?.inputSequence.lastSequence).toBe(7);
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
it("lists and clears all sessions", async () => {
|
|
209
|
-
const store = new InMemorySessionStore();
|
|
210
|
-
await store.lease("session-a", async () => createManagedSession("session-a"));
|
|
211
|
-
await store.lease("session-b", async () => createManagedSession("session-b"));
|
|
212
|
-
|
|
213
|
-
expect((await store.listAll()).map((session) => session.id).sort()).toEqual(["session-a", "session-b"]);
|
|
214
|
-
|
|
215
|
-
await store.clear();
|
|
216
|
-
expect(await store.listAll()).toEqual([]);
|
|
217
|
-
});
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
describe("createVoiceWebSocketServer sessionStore seam", () => {
|
|
221
|
-
it("routes resume through an injected SessionStore", async () => {
|
|
222
|
-
const calls: Array<{ method: "lease" | "release" | "get"; sessionId: string; retainMs?: number }> = [];
|
|
223
|
-
const backing = new InMemorySessionStore();
|
|
224
|
-
const sessionStore: SessionStore = {
|
|
225
|
-
lease: async (sessionId, create) => {
|
|
226
|
-
calls.push({ method: "lease", sessionId });
|
|
227
|
-
return backing.lease(sessionId, create);
|
|
228
|
-
},
|
|
229
|
-
release: async (sessionId, retainMs) => {
|
|
230
|
-
calls.push({ method: "release", sessionId, retainMs });
|
|
231
|
-
await backing.release(sessionId, retainMs);
|
|
232
|
-
},
|
|
233
|
-
update: (sessionId, mutate) => backing.update(sessionId, mutate),
|
|
234
|
-
get: async (sessionId) => {
|
|
235
|
-
calls.push({ method: "get", sessionId });
|
|
236
|
-
return backing.get(sessionId);
|
|
237
|
-
},
|
|
238
|
-
listAll: () => backing.listAll(),
|
|
239
|
-
clear: () => backing.clear(),
|
|
240
|
-
};
|
|
241
|
-
|
|
242
|
-
let created = 0;
|
|
243
|
-
const session = new VoiceAgentSession({ plugins: {} });
|
|
244
|
-
const server = registerServer(await createVoiceWebSocketServer({
|
|
245
|
-
port: 0,
|
|
246
|
-
resumeWindowMs: 200,
|
|
247
|
-
sessionStore,
|
|
248
|
-
createSession: () => {
|
|
249
|
-
created += 1;
|
|
250
|
-
return session;
|
|
251
|
-
},
|
|
252
|
-
contextId: () => "turn-test",
|
|
253
|
-
}));
|
|
254
|
-
const address = server.address();
|
|
255
|
-
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
256
|
-
|
|
257
|
-
const sessionUrl = `ws://127.0.0.1:${String(address.port)}/ws?sessionId=fake-store-test`;
|
|
258
|
-
const [first, firstReady] = await openBrowserClientAndReadReady(sessionUrl);
|
|
259
|
-
expect(firstReady).toMatchObject({ sessionId: "fake-store-test", resumed: false });
|
|
260
|
-
first.close();
|
|
261
|
-
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
262
|
-
|
|
263
|
-
const [, secondReady] = await openBrowserClientAndReadReady(sessionUrl);
|
|
264
|
-
expect(secondReady).toMatchObject({ sessionId: "fake-store-test", resumed: true });
|
|
265
|
-
expect(created).toBe(1);
|
|
266
|
-
expect(calls.filter((call) => call.method === "lease").map((call) => call.sessionId)).toEqual([
|
|
267
|
-
"fake-store-test",
|
|
268
|
-
"fake-store-test",
|
|
269
|
-
]);
|
|
270
|
-
expect(calls.some((call) => call.method === "release" && call.sessionId === "fake-store-test" && call.retainMs === 200)).toBe(true);
|
|
271
|
-
|
|
272
|
-
await server.close();
|
|
273
|
-
});
|
|
274
|
-
});
|