@crewhaus/call-session 0.1.0 → 0.1.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/call-session",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Call lifecycle state machine — idle | dialing | connected | on-hold | transferred | terminated (Section 24 VOICE)",
6
6
  "main": "src/index.ts",
@@ -12,13 +12,13 @@
12
12
  "test": "bun test src"
13
13
  },
14
14
  "dependencies": {
15
- "@crewhaus/errors": "0.0.0"
15
+ "@crewhaus/errors": "0.1.2"
16
16
  },
17
17
  "license": "Apache-2.0",
18
18
  "author": {
19
19
  "name": "Max Meier",
20
- "email": "max@studiomax.io",
21
- "url": "https://studiomax.io"
20
+ "email": "max@crewhaus.ai",
21
+ "url": "https://crewhaus.ai"
22
22
  },
23
23
  "repository": {
24
24
  "type": "git",
@@ -30,12 +30,7 @@
30
30
  "url": "https://github.com/crewhaus/factory/issues"
31
31
  },
32
32
  "publishConfig": {
33
- "access": "restricted"
33
+ "access": "public"
34
34
  },
35
- "files": [
36
- "src",
37
- "README.md",
38
- "LICENSE",
39
- "NOTICE"
40
- ]
35
+ "files": ["src", "README.md", "LICENSE", "NOTICE"]
41
36
  }
@@ -57,6 +57,154 @@ describe("twilio telephony adapter", () => {
57
57
  }),
58
58
  ).toThrow(CallSessionError);
59
59
  });
60
+
61
+ test("missing authToken throws", () => {
62
+ expect(() =>
63
+ createTwilioTelephonyAdapter({ accountSid: "A", authToken: "", fromNumber: "+1" }),
64
+ ).toThrow(/requires authToken/);
65
+ });
66
+
67
+ test("missing fromNumber throws", () => {
68
+ expect(() =>
69
+ createTwilioTelephonyAdapter({ accountSid: "A", authToken: "t", fromNumber: "" }),
70
+ ).toThrow(/requires fromNumber/);
71
+ });
72
+
73
+ test("dial includes a TwiML Url and a basic auth header derived from sid:token", async () => {
74
+ let observedAuth = "";
75
+ let observedBody = "";
76
+ const fetchImpl = (async (_url: string, init?: RequestInit) => {
77
+ observedAuth = (init?.headers as Record<string, string>)?.Authorization ?? "";
78
+ observedBody = init?.body as string;
79
+ return new Response(JSON.stringify({ sid: "CA999" }), { status: 200 });
80
+ }) as unknown as typeof fetch;
81
+ const adapter = createTwilioTelephonyAdapter({
82
+ accountSid: "AC123",
83
+ authToken: "tok",
84
+ fromNumber: "+1",
85
+ fetchImpl,
86
+ });
87
+ await adapter.dial("+2");
88
+ expect(observedBody).toContain("Url=https%3A%2F%2Fdemo.twilio.com");
89
+ // Base64 of "AC123:tok".
90
+ expect(observedAuth).toBe(`Basic ${Buffer.from("AC123:tok").toString("base64")}`);
91
+ });
92
+
93
+ test("answer is an adapter-level no-op", async () => {
94
+ const fetchImpl = (async () => {
95
+ throw new Error("answer must not hit the network");
96
+ }) as unknown as typeof fetch;
97
+ const adapter = createTwilioTelephonyAdapter({
98
+ accountSid: "A",
99
+ authToken: "t",
100
+ fromNumber: "+1",
101
+ fetchImpl,
102
+ });
103
+ await expect(adapter.answer()).resolves.toBeUndefined();
104
+ });
105
+
106
+ test("dial throws CallSessionError with status + body on a non-ok response", async () => {
107
+ const fetchImpl = (async () =>
108
+ new Response("nope", { status: 422 })) as unknown as typeof fetch;
109
+ const adapter = createTwilioTelephonyAdapter({
110
+ accountSid: "A",
111
+ authToken: "t",
112
+ fromNumber: "+1",
113
+ fetchImpl,
114
+ });
115
+ await expect(adapter.dial("+2")).rejects.toThrow(/Calls\.create returned 422: nope/);
116
+ });
117
+
118
+ test("callsUpdate throws CallSessionError on a non-ok response (via hold)", async () => {
119
+ let firstCall = true;
120
+ const fetchImpl = (async () => {
121
+ if (firstCall) {
122
+ firstCall = false;
123
+ return new Response(JSON.stringify({ sid: "CA1" }), { status: 200 });
124
+ }
125
+ return new Response("denied", { status: 500 });
126
+ }) as unknown as typeof fetch;
127
+ const adapter = createTwilioTelephonyAdapter({
128
+ accountSid: "A",
129
+ authToken: "t",
130
+ fromNumber: "+1",
131
+ fetchImpl,
132
+ });
133
+ await adapter.dial("+2");
134
+ await expect(adapter.hold()).rejects.toThrow(/Calls\.update returned 500: denied/);
135
+ });
136
+
137
+ test("hold/resume/transfer/end without an active call behave correctly", async () => {
138
+ const fetchImpl = (async () =>
139
+ new Response(JSON.stringify({ sid: "x" }), { status: 200 })) as unknown as typeof fetch;
140
+ const adapter = createTwilioTelephonyAdapter({
141
+ accountSid: "A",
142
+ authToken: "t",
143
+ fromNumber: "+1",
144
+ fetchImpl,
145
+ });
146
+ await expect(adapter.hold()).rejects.toThrow(/no active call to hold/);
147
+ await expect(adapter.resume()).rejects.toThrow(/no active call to resume/);
148
+ await expect(adapter.transfer("+2")).rejects.toThrow(/no active call to transfer/);
149
+ // end() with no active call is a silent no-op.
150
+ await expect(adapter.end("bye")).resolves.toBeUndefined();
151
+ });
152
+
153
+ test("transfer posts a redirect Url with the encoded destination", async () => {
154
+ let transferBody = "";
155
+ let calls = 0;
156
+ const fetchImpl = (async (_url: string, init?: RequestInit) => {
157
+ calls += 1;
158
+ if (calls >= 2) transferBody = init?.body as string;
159
+ return new Response(JSON.stringify({ sid: "CA1" }), { status: 200 });
160
+ }) as unknown as typeof fetch;
161
+ const adapter = createTwilioTelephonyAdapter({
162
+ accountSid: "A",
163
+ authToken: "t",
164
+ fromNumber: "+1",
165
+ fetchImpl,
166
+ });
167
+ await adapter.dial("+2");
168
+ await adapter.transfer("sip:agent@x.com");
169
+ // The destination is encodeURIComponent-ed into the Url query string, then
170
+ // the whole Url is form-urlencoded again as a POST body param — so the
171
+ // caller-supplied URI ends up double-encoded. Decoding twice recovers it.
172
+ const params = new URLSearchParams(transferBody);
173
+ const url = new URL(params.get("Url") ?? "");
174
+ expect(url.searchParams.get("to")).toBe("sip:agent@x.com");
175
+ expect(transferBody).toContain("Method=POST");
176
+ });
177
+
178
+ test("end completes the active call then clears it (next end is a no-op)", async () => {
179
+ const seen: string[] = [];
180
+ const fetchImpl = (async (url: string, init?: RequestInit) => {
181
+ seen.push((init?.body as string) ?? "");
182
+ void url;
183
+ return new Response(JSON.stringify({ sid: "CA1" }), { status: 200 });
184
+ }) as unknown as typeof fetch;
185
+ const adapter = createTwilioTelephonyAdapter({
186
+ accountSid: "A",
187
+ authToken: "t",
188
+ fromNumber: "+1",
189
+ fetchImpl,
190
+ });
191
+ await adapter.dial("+2");
192
+ await adapter.end("done");
193
+ expect(seen.some((b) => b.includes("Status=completed"))).toBe(true);
194
+ const before = seen.length;
195
+ await adapter.end("again"); // no active call → no network
196
+ expect(seen.length).toBe(before);
197
+ });
198
+
199
+ test("kind is 'twilio'", () => {
200
+ const adapter = createTwilioTelephonyAdapter({
201
+ accountSid: "A",
202
+ authToken: "t",
203
+ fromNumber: "+1",
204
+ fetchImpl: (async () => new Response("{}")) as unknown as typeof fetch,
205
+ });
206
+ expect(adapter.kind).toBe("twilio");
207
+ });
60
208
  });
61
209
 
62
210
  describe("livekit-sip telephony adapter", () => {
@@ -103,4 +251,183 @@ describe("livekit-sip telephony adapter", () => {
103
251
  }),
104
252
  ).toThrow(CallSessionError);
105
253
  });
254
+
255
+ test("each required field is validated independently", () => {
256
+ expect(() =>
257
+ createLiveKitSipAdapter({ url: "u", apiKey: "", apiSecret: "s", fromNumber: "+1" }),
258
+ ).toThrow(/requires apiKey/);
259
+ expect(() =>
260
+ createLiveKitSipAdapter({ url: "u", apiKey: "k", apiSecret: "", fromNumber: "+1" }),
261
+ ).toThrow(/requires apiSecret/);
262
+ expect(() =>
263
+ createLiveKitSipAdapter({ url: "u", apiKey: "k", apiSecret: "s", fromNumber: "" }),
264
+ ).toThrow(/requires fromNumber/);
265
+ });
266
+
267
+ test("dial sends a bearer auth header and records the participant id", async () => {
268
+ let observedAuth = "";
269
+ const fetchImpl = (async (_url: string, init?: RequestInit) => {
270
+ observedAuth = (init?.headers as Record<string, string>)?.Authorization ?? "";
271
+ return new Response(JSON.stringify({ participant_id: "p42" }), { status: 200 });
272
+ }) as unknown as typeof fetch;
273
+ const adapter = createLiveKitSipAdapter({
274
+ url: "http://lk",
275
+ apiKey: "k",
276
+ apiSecret: "s",
277
+ fromNumber: "+1",
278
+ fetchImpl,
279
+ });
280
+ await adapter.dial("sip:a@x.com");
281
+ expect(observedAuth).toBe("Bearer k:s");
282
+ });
283
+
284
+ test("dial without participant_id in the response falls back to 'unknown' but stays active", async () => {
285
+ // Response omits participant_id → activeParticipantId becomes "unknown"
286
+ // (truthy), so a subsequent hold proceeds to the RPC rather than throwing.
287
+ let holdHit = false;
288
+ const fetchImpl = (async (url: string) => {
289
+ if (String(url).includes("UpdateSIPParticipant")) holdHit = true;
290
+ return new Response(JSON.stringify({}), { status: 200 });
291
+ }) as unknown as typeof fetch;
292
+ const adapter = createLiveKitSipAdapter({
293
+ url: "http://lk",
294
+ apiKey: "k",
295
+ apiSecret: "s",
296
+ fromNumber: "+1",
297
+ fetchImpl,
298
+ });
299
+ await adapter.dial("sip:a@x.com");
300
+ await adapter.hold();
301
+ expect(holdHit).toBe(true);
302
+ });
303
+
304
+ test("answer is an adapter-level no-op", async () => {
305
+ const fetchImpl = (async () => {
306
+ throw new Error("answer must not hit the network");
307
+ }) as unknown as typeof fetch;
308
+ const adapter = createLiveKitSipAdapter({
309
+ url: "http://lk",
310
+ apiKey: "k",
311
+ apiSecret: "s",
312
+ fromNumber: "+1",
313
+ fetchImpl,
314
+ });
315
+ await expect(adapter.answer()).resolves.toBeUndefined();
316
+ });
317
+
318
+ test("rpc throws CallSessionError with method, status and body on a non-ok response", async () => {
319
+ const fetchImpl = (async () =>
320
+ new Response("upstream boom", { status: 503 })) as unknown as typeof fetch;
321
+ const adapter = createLiveKitSipAdapter({
322
+ url: "http://lk",
323
+ apiKey: "k",
324
+ apiSecret: "s",
325
+ fromNumber: "+1",
326
+ fetchImpl,
327
+ });
328
+ await expect(adapter.dial("sip:a@x.com")).rejects.toThrow(
329
+ /CreateSIPParticipant returned 503: upstream boom/,
330
+ );
331
+ });
332
+
333
+ test("hold and resume mute/unmute the active participant", async () => {
334
+ const bodies: Array<Record<string, unknown>> = [];
335
+ const fetchImpl = (async (url: string, init?: RequestInit) => {
336
+ if (String(url).includes("UpdateSIPParticipant")) {
337
+ bodies.push(JSON.parse(init?.body as string));
338
+ }
339
+ return new Response(JSON.stringify({ participant_id: "p1" }), { status: 200 });
340
+ }) as unknown as typeof fetch;
341
+ const adapter = createLiveKitSipAdapter({
342
+ url: "http://lk",
343
+ apiKey: "k",
344
+ apiSecret: "s",
345
+ fromNumber: "+1",
346
+ fetchImpl,
347
+ });
348
+ await adapter.dial("sip:a@x.com");
349
+ await adapter.hold();
350
+ await adapter.resume();
351
+ expect(bodies).toEqual([
352
+ { participant_id: "p1", muted: true },
353
+ { participant_id: "p1", muted: false },
354
+ ]);
355
+ });
356
+
357
+ test("hold/resume without an active call throw", async () => {
358
+ const fetchImpl = (async () => new Response("{}", { status: 200 })) as unknown as typeof fetch;
359
+ const adapter = createLiveKitSipAdapter({
360
+ url: "http://lk",
361
+ apiKey: "k",
362
+ apiSecret: "s",
363
+ fromNumber: "+1",
364
+ fetchImpl,
365
+ });
366
+ await expect(adapter.hold()).rejects.toThrow(/no active call to hold/);
367
+ await expect(adapter.resume()).rejects.toThrow(/no active call to resume/);
368
+ });
369
+
370
+ test("transfer forwards transfer_to for the active participant", async () => {
371
+ let transferBody: Record<string, unknown> = {};
372
+ const fetchImpl = (async (url: string, init?: RequestInit) => {
373
+ if (String(url).includes("TransferSIPParticipant")) {
374
+ transferBody = JSON.parse(init?.body as string);
375
+ }
376
+ return new Response(JSON.stringify({ participant_id: "p1" }), { status: 200 });
377
+ }) as unknown as typeof fetch;
378
+ const adapter = createLiveKitSipAdapter({
379
+ url: "http://lk",
380
+ apiKey: "k",
381
+ apiSecret: "s",
382
+ fromNumber: "+1",
383
+ fetchImpl,
384
+ });
385
+ await adapter.dial("sip:a@x.com");
386
+ await adapter.transfer("sip:b@x.com");
387
+ expect(transferBody).toEqual({ participant_id: "p1", transfer_to: "sip:b@x.com" });
388
+ });
389
+
390
+ test("end deletes the active participant then clears it (next end is a no-op)", async () => {
391
+ let deletes = 0;
392
+ const fetchImpl = (async (url: string) => {
393
+ if (String(url).includes("DeleteSIPParticipant")) deletes += 1;
394
+ return new Response(JSON.stringify({ participant_id: "p1" }), { status: 200 });
395
+ }) as unknown as typeof fetch;
396
+ const adapter = createLiveKitSipAdapter({
397
+ url: "http://lk",
398
+ apiKey: "k",
399
+ apiSecret: "s",
400
+ fromNumber: "+1",
401
+ fetchImpl,
402
+ });
403
+ await adapter.dial("sip:a@x.com");
404
+ await adapter.end("done");
405
+ await adapter.end("again"); // no active participant → no extra delete
406
+ expect(deletes).toBe(1);
407
+ });
408
+
409
+ test("end with no active call is a silent no-op", async () => {
410
+ const fetchImpl = (async () => {
411
+ throw new Error("end must not hit the network when idle");
412
+ }) as unknown as typeof fetch;
413
+ const adapter = createLiveKitSipAdapter({
414
+ url: "http://lk",
415
+ apiKey: "k",
416
+ apiSecret: "s",
417
+ fromNumber: "+1",
418
+ fetchImpl,
419
+ });
420
+ await expect(adapter.end("never")).resolves.toBeUndefined();
421
+ });
422
+
423
+ test("kind is 'livekit-sip'", () => {
424
+ const adapter = createLiveKitSipAdapter({
425
+ url: "http://lk",
426
+ apiKey: "k",
427
+ apiSecret: "s",
428
+ fromNumber: "+1",
429
+ fetchImpl: (async () => new Response("{}")) as unknown as typeof fetch,
430
+ });
431
+ expect(adapter.kind).toBe("livekit-sip");
432
+ });
106
433
  });
package/src/index.test.ts CHANGED
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
  import {
3
3
  CallSessionError,
4
4
  type CallTransition,
5
+ type TelephonyAdapter,
5
6
  createCallSession,
6
7
  createInMemoryTelephonyAdapter,
7
8
  } from "./index.js";
@@ -103,4 +104,121 @@ describe("createCallSession (T1 + T9 state machine)", () => {
103
104
  await expect(session.hold()).rejects.toThrow(CallSessionError);
104
105
  await expect(session.transfer("c")).rejects.toThrow(CallSessionError);
105
106
  });
107
+
108
+ test("on() returns an unsubscribe that stops further notifications", async () => {
109
+ const adapter = createInMemoryTelephonyAdapter();
110
+ const session = createCallSession({ adapter });
111
+ let hits = 0;
112
+ const off = session.on(() => {
113
+ hits += 1;
114
+ });
115
+ await session.dial("tel:a"); // listener active → 1 hit
116
+ expect(hits).toBe(1);
117
+ off(); // exercises the unsubscribe closure
118
+ await session.answer(); // listener removed → still 1
119
+ expect(hits).toBe(1);
120
+ });
121
+
122
+ test("unsubscribing a never-fired listener is a harmless no-op", () => {
123
+ const adapter = createInMemoryTelephonyAdapter();
124
+ const session = createCallSession({ adapter });
125
+ const off = session.on(() => {
126
+ throw new Error("should never run");
127
+ });
128
+ // Calling unsubscribe before any transition must not throw.
129
+ expect(() => off()).not.toThrow();
130
+ });
131
+
132
+ test("reason is threaded into the transition record and omitted when absent", async () => {
133
+ const adapter = createInMemoryTelephonyAdapter();
134
+ const session = createCallSession({ adapter });
135
+ await session.dial("tel:a", { reason: "outbound campaign" });
136
+ await session.answer(); // no reason supplied
137
+ const log = session.history();
138
+ expect(log[0]?.reason).toBe("outbound campaign");
139
+ expect("reason" in (log[1] ?? {})).toBe(false);
140
+ });
141
+
142
+ test('end() defaults the adapter reason to "end" when none is given', async () => {
143
+ const adapter = createInMemoryTelephonyAdapter();
144
+ const session = createCallSession({ adapter });
145
+ await session.dial("tel:a");
146
+ await session.answer();
147
+ await session.end(); // no reason
148
+ const endCall = adapter.calls.find((c) => c.verb === "end");
149
+ expect(endCall?.arg).toBe("end");
150
+ // No transition-level reason recorded when none supplied.
151
+ const last = session.history().at(-1);
152
+ expect(last?.to).toBe("terminated");
153
+ expect("reason" in (last ?? {})).toBe(false);
154
+ });
155
+
156
+ test("end() forwards an explicit reason to the adapter", async () => {
157
+ const adapter = createInMemoryTelephonyAdapter();
158
+ const session = createCallSession({ adapter });
159
+ await session.dial("tel:a");
160
+ await session.end({ reason: "callee busy" });
161
+ const endCall = adapter.calls.find((c) => c.verb === "end");
162
+ expect(endCall?.arg).toBe("callee busy");
163
+ expect(session.history().at(-1)?.reason).toBe("callee busy");
164
+ });
165
+
166
+ test("dial failure surfaced from a non-Error rejection falls back to String()", async () => {
167
+ // Adapter whose dial rejects with a non-Error value — exercises the
168
+ // `?? String(err)` branch in the dial-failure path.
169
+ const adapter: TelephonyAdapter = {
170
+ kind: "weird",
171
+ async dial() {
172
+ throw "boom-string"; // not an Error, so `.message` is undefined
173
+ },
174
+ async answer() {},
175
+ async hold() {},
176
+ async resume() {},
177
+ async transfer() {},
178
+ async end() {},
179
+ };
180
+ const session = createCallSession({ adapter });
181
+ await expect(session.dial("tel:a")).rejects.toBe("boom-string");
182
+ expect(session.state).toBe("terminated");
183
+ expect(session.history().at(-1)?.reason).toContain("boom-string");
184
+ });
185
+
186
+ test("injected now() controls transition timestamps deterministically", async () => {
187
+ const adapter = createInMemoryTelephonyAdapter();
188
+ const fixed = new Date("2026-06-04T12:00:00.000Z");
189
+ const session = createCallSession({ adapter, now: () => fixed });
190
+ await session.dial("tel:a");
191
+ expect(session.history()[0]?.at).toBe("2026-06-04T12:00:00.000Z");
192
+ });
193
+
194
+ test("in-memory adapter: failNextDial uses its default message and resets after one dial", async () => {
195
+ const adapter = createInMemoryTelephonyAdapter();
196
+ adapter.failNextDial(); // default message branch
197
+ const s1 = createCallSession({ adapter });
198
+ await expect(s1.dial("tel:a")).rejects.toThrow(/in-memory: dial failed/);
199
+ // Failure is one-shot: a fresh session dials cleanly.
200
+ const s2 = createCallSession({ adapter });
201
+ await s2.dial("tel:b");
202
+ expect(s2.state).toBe("dialing");
203
+ });
204
+
205
+ test("in-memory adapter records every verb and argument in order", async () => {
206
+ const adapter = createInMemoryTelephonyAdapter();
207
+ expect(adapter.kind).toBe("in-memory");
208
+ const session = createCallSession({ adapter });
209
+ await session.dial("tel:a");
210
+ await session.answer();
211
+ await session.hold();
212
+ await session.resume();
213
+ await session.transfer("tel:b");
214
+ await session.end({ reason: "done" });
215
+ expect(adapter.calls).toEqual([
216
+ { verb: "dial", arg: "tel:a" },
217
+ { verb: "answer" },
218
+ { verb: "hold" },
219
+ { verb: "resume" },
220
+ { verb: "transfer", arg: "tel:b" },
221
+ { verb: "end", arg: "done" },
222
+ ]);
223
+ });
106
224
  });