@vellumai/assistant 0.9.0-dev.202606181324.0804a69 → 0.9.0-dev.202606181513.fd39213
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/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +4 -9
- package/package.json +1 -1
- package/src/__tests__/cancel-clears-processing.test.ts +22 -19
- package/src/__tests__/conversation-agent-loop.test.ts +48 -41
- package/src/__tests__/media-stream-server-integration.test.ts +317 -13
- package/src/__tests__/relay-server.test.ts +285 -0
- package/src/__tests__/twilio-routes.test.ts +81 -1
- package/src/calls/__tests__/channel-admission-reader.test.ts +132 -0
- package/src/calls/__tests__/relay-setup-router.test.ts +350 -0
- package/src/calls/channel-admission-reader.ts +104 -0
- package/src/calls/media-stream-server.ts +84 -2
- package/src/calls/relay-server.ts +66 -0
- package/src/calls/relay-setup-router.ts +77 -0
- package/src/calls/twilio-routes.ts +17 -8
- package/src/daemon/conversation-agent-loop.ts +122 -56
- package/src/daemon/conversation.ts +7 -0
- package/src/daemon/handlers/conversations.ts +6 -8
- package/src/runtime/routes/inbound-stages/admission-policy.test.ts +4 -2
- package/src/telemetry/usage-telemetry-reporter.test.ts +59 -13
- package/src/telemetry/usage-telemetry-reporter.ts +28 -16
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { createHash, randomUUID } from "node:crypto";
|
|
17
17
|
import {
|
|
18
18
|
afterAll,
|
|
19
|
+
beforeAll,
|
|
19
20
|
beforeEach,
|
|
20
21
|
describe,
|
|
21
22
|
expect,
|
|
@@ -25,6 +26,10 @@ import {
|
|
|
25
26
|
test,
|
|
26
27
|
} from "bun:test";
|
|
27
28
|
|
|
29
|
+
import { ADMISSION_FLOOR } from "@vellumai/gateway-client";
|
|
30
|
+
|
|
31
|
+
import { TRUST_CLASS_RANK } from "../runtime/actor-trust-resolver.js";
|
|
32
|
+
|
|
28
33
|
// ── Platform + logger mocks (must come before any source imports) ────
|
|
29
34
|
|
|
30
35
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
@@ -100,6 +105,80 @@ mock.module("../config/loader.js", () => ({
|
|
|
100
105
|
loadConfig: () => mockConfig,
|
|
101
106
|
}));
|
|
102
107
|
|
|
108
|
+
// ── Channel admission policy reader mock ────────────────────────────
|
|
109
|
+
//
|
|
110
|
+
// handleSetup resolves the phone admission floor via this reader and forwards
|
|
111
|
+
// it into routeSetup. Tests drive the floor by setting `mockAdmissionPolicy`
|
|
112
|
+
// (or making the reader throw to exercise the fail-open guard).
|
|
113
|
+
|
|
114
|
+
let mockAdmissionPolicy:
|
|
115
|
+
| import("@vellumai/gateway-client").AdmissionPolicy
|
|
116
|
+
| null = null;
|
|
117
|
+
// Optional gate to hold the reader open mid-setup so tests can drive the
|
|
118
|
+
// race window where a prompt arrives while handleSetup is still awaiting.
|
|
119
|
+
let mockAdmissionGate: Promise<void> | null = null;
|
|
120
|
+
// `mock.module` is process-global in Bun and leaks into sibling files that run
|
|
121
|
+
// later in the same `bun test` invocation. channel-admission-reader.test.ts
|
|
122
|
+
// imports the reader for real, so an unconditional stub here would feed it this
|
|
123
|
+
// stub and break its assertions. Delegate to the real implementation unless
|
|
124
|
+
// this file's tests are active (`admissionMockActive`, toggled in
|
|
125
|
+
// beforeAll/afterAll). Snapshot the real exports into a plain object NOW,
|
|
126
|
+
// before the stub registers — a module namespace is a live view, so reading
|
|
127
|
+
// the real export after the stub installs would resolve back to the stub
|
|
128
|
+
// (infinite recursion).
|
|
129
|
+
const realChannelAdmissionReaderModule = {
|
|
130
|
+
...(await import("../calls/channel-admission-reader.js")),
|
|
131
|
+
};
|
|
132
|
+
let admissionMockActive = false;
|
|
133
|
+
mock.module("../calls/channel-admission-reader.js", () => ({
|
|
134
|
+
...realChannelAdmissionReaderModule,
|
|
135
|
+
getChannelAdmissionPolicy: async (
|
|
136
|
+
channelType: import("../channels/types.js").ChannelId,
|
|
137
|
+
) => {
|
|
138
|
+
if (!admissionMockActive) {
|
|
139
|
+
return realChannelAdmissionReaderModule.getChannelAdmissionPolicy(
|
|
140
|
+
channelType,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
if (mockAdmissionGate) await mockAdmissionGate;
|
|
144
|
+
return mockAdmissionPolicy;
|
|
145
|
+
},
|
|
146
|
+
}));
|
|
147
|
+
|
|
148
|
+
// Real floor semantics, mirroring relay-setup-router.test.ts. The
|
|
149
|
+
// enforceAdmissionPolicy mock omits the exempt-channel short-circuit so the
|
|
150
|
+
// deny path can be exercised end-to-end regardless of channel.
|
|
151
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
152
|
+
const realAdmissionPolicyModule = require("../runtime/routes/inbound-stages/admission-policy.js");
|
|
153
|
+
mock.module("../runtime/routes/inbound-stages/admission-policy.js", () => ({
|
|
154
|
+
...realAdmissionPolicyModule,
|
|
155
|
+
enforceAdmissionPolicy: (input: {
|
|
156
|
+
trustClass: string;
|
|
157
|
+
memberStatus: string | undefined;
|
|
158
|
+
policy: import("@vellumai/gateway-client").AdmissionPolicy;
|
|
159
|
+
}) => {
|
|
160
|
+
if (input.memberStatus === "blocked" || input.memberStatus === "revoked") {
|
|
161
|
+
return {
|
|
162
|
+
admitted: false,
|
|
163
|
+
reason:
|
|
164
|
+
input.memberStatus === "blocked" ? "member_blocked" : "member_revoked",
|
|
165
|
+
shouldChallenge: false,
|
|
166
|
+
effectivePolicy: input.policy,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
const rank =
|
|
170
|
+
(TRUST_CLASS_RANK as Record<string, number>)[input.trustClass] ?? 0;
|
|
171
|
+
const floor = ADMISSION_FLOOR[input.policy];
|
|
172
|
+
if (rank >= floor) return { admitted: true };
|
|
173
|
+
return {
|
|
174
|
+
admitted: false,
|
|
175
|
+
reason: `admission_policy_${input.policy}`,
|
|
176
|
+
shouldChallenge: false,
|
|
177
|
+
effectivePolicy: input.policy,
|
|
178
|
+
};
|
|
179
|
+
},
|
|
180
|
+
}));
|
|
181
|
+
|
|
103
182
|
// ── TTS provider mocks (for call-speech-output) ─────────────────────
|
|
104
183
|
|
|
105
184
|
let mockTtsProviderId: string = "elevenlabs";
|
|
@@ -240,7 +319,15 @@ import { createGuardianBinding } from "./helpers/create-guardian-binding.js";
|
|
|
240
319
|
|
|
241
320
|
initializeDb();
|
|
242
321
|
|
|
322
|
+
// Activate the channel-admission-reader stub only while this file's tests run,
|
|
323
|
+
// so the registered (process-global) mock delegates to the real module for
|
|
324
|
+
// sibling files that load later in the same worker.
|
|
325
|
+
beforeAll(() => {
|
|
326
|
+
admissionMockActive = true;
|
|
327
|
+
});
|
|
328
|
+
|
|
243
329
|
afterAll(() => {
|
|
330
|
+
admissionMockActive = false;
|
|
244
331
|
resetDbForTesting();
|
|
245
332
|
});
|
|
246
333
|
|
|
@@ -404,6 +491,8 @@ describe("relay-server", () => {
|
|
|
404
491
|
activeRelayConnections.clear();
|
|
405
492
|
mockUserReference = "my human";
|
|
406
493
|
mockAssistantName = "Vellum";
|
|
494
|
+
mockAdmissionPolicy = null;
|
|
495
|
+
mockAdmissionGate = null;
|
|
407
496
|
mockSendMessage.mockImplementation(createMockProviderResponse(["Hello"]));
|
|
408
497
|
mockConfig.calls.verification.enabled = false;
|
|
409
498
|
mockConfig.calls.verification.maxAttempts = 3;
|
|
@@ -840,6 +929,121 @@ describe("relay-server", () => {
|
|
|
840
929
|
relay.destroy();
|
|
841
930
|
});
|
|
842
931
|
|
|
932
|
+
test("handleMessage: prompt arriving during setup (setting_up) is dropped", async () => {
|
|
933
|
+
ensureConversation("conv-relay-setting-up");
|
|
934
|
+
const session = createCallSession({
|
|
935
|
+
conversationId: "conv-relay-setting-up",
|
|
936
|
+
provider: "twilio",
|
|
937
|
+
fromNumber: "+15551111111",
|
|
938
|
+
toNumber: "+15552222222",
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
addTrustedVoiceContact("+15551111111");
|
|
942
|
+
|
|
943
|
+
const { ws, relay } = createMockWs(session.id);
|
|
944
|
+
|
|
945
|
+
// Hold the admission reader open so handleSetup stays in "setting_up".
|
|
946
|
+
let releaseGate: () => void = () => {};
|
|
947
|
+
mockAdmissionGate = new Promise<void>((resolve) => {
|
|
948
|
+
releaseGate = resolve;
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
// Fire setup without awaiting — it suspends inside getChannelAdmissionPolicy.
|
|
952
|
+
const setupPromise = relay.handleMessage(
|
|
953
|
+
JSON.stringify({
|
|
954
|
+
type: "setup",
|
|
955
|
+
callSid: "CA_setting_up_123",
|
|
956
|
+
from: "+15551111111",
|
|
957
|
+
to: "+15552222222",
|
|
958
|
+
}),
|
|
959
|
+
);
|
|
960
|
+
|
|
961
|
+
// Let the setup task reach the await; state must be "setting_up".
|
|
962
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
963
|
+
expect(relay.getConnectionState()).toBe("setting_up");
|
|
964
|
+
|
|
965
|
+
// A prompt arriving now must be dropped: no transcript, no fallback.
|
|
966
|
+
await relay.handleMessage(
|
|
967
|
+
JSON.stringify({
|
|
968
|
+
type: "prompt",
|
|
969
|
+
voicePrompt: "Wire me money",
|
|
970
|
+
lang: "en-US",
|
|
971
|
+
last: true,
|
|
972
|
+
}),
|
|
973
|
+
);
|
|
974
|
+
|
|
975
|
+
const textMessages = ws.sentMessages
|
|
976
|
+
.map((m) => JSON.parse(m))
|
|
977
|
+
.filter((m: { type: string }) => m.type === "text");
|
|
978
|
+
expect(
|
|
979
|
+
textMessages.some((m) => (m.token ?? "").includes("still setting up")),
|
|
980
|
+
).toBe(false);
|
|
981
|
+
expect(relay.getConversationHistory().length).toBe(0);
|
|
982
|
+
expect(
|
|
983
|
+
getMessages("conv-relay-setting-up").filter((m) => m.role === "user")
|
|
984
|
+
.length,
|
|
985
|
+
).toBe(0);
|
|
986
|
+
|
|
987
|
+
// Release the reader and let setup finish.
|
|
988
|
+
releaseGate();
|
|
989
|
+
await setupPromise;
|
|
990
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
991
|
+
expect(relay.getConnectionState()).toBe("connected");
|
|
992
|
+
|
|
993
|
+
relay.destroy();
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
test("handleMessage: prompt after normal-call setup completes is handled", async () => {
|
|
997
|
+
ensureConversation("conv-relay-postsetup");
|
|
998
|
+
const session = createCallSession({
|
|
999
|
+
conversationId: "conv-relay-postsetup",
|
|
1000
|
+
provider: "twilio",
|
|
1001
|
+
fromNumber: "+15551111111",
|
|
1002
|
+
toNumber: "+15552222222",
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
addTrustedVoiceContact("+15551111111");
|
|
1006
|
+
mockSendMessage.mockImplementation(
|
|
1007
|
+
createMockProviderResponse(["Sure, happy to help."]),
|
|
1008
|
+
);
|
|
1009
|
+
|
|
1010
|
+
const { relay } = createMockWs(session.id);
|
|
1011
|
+
|
|
1012
|
+
await relay.handleMessage(
|
|
1013
|
+
JSON.stringify({
|
|
1014
|
+
type: "setup",
|
|
1015
|
+
callSid: "CA_postsetup_123",
|
|
1016
|
+
from: "+15551111111",
|
|
1017
|
+
to: "+15552222222",
|
|
1018
|
+
}),
|
|
1019
|
+
);
|
|
1020
|
+
|
|
1021
|
+
// Setup completed normally → "connected".
|
|
1022
|
+
expect(relay.getConnectionState()).toBe("connected");
|
|
1023
|
+
|
|
1024
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1025
|
+
|
|
1026
|
+
// A subsequent prompt routes to the controller (no "still setting up").
|
|
1027
|
+
await relay.handleMessage(
|
|
1028
|
+
JSON.stringify({
|
|
1029
|
+
type: "prompt",
|
|
1030
|
+
voicePrompt: "Hello there",
|
|
1031
|
+
lang: "en-US",
|
|
1032
|
+
last: true,
|
|
1033
|
+
}),
|
|
1034
|
+
);
|
|
1035
|
+
|
|
1036
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1037
|
+
|
|
1038
|
+
const userMessages = getMessages("conv-relay-postsetup").filter(
|
|
1039
|
+
(m) => m.role === "user",
|
|
1040
|
+
);
|
|
1041
|
+
expect(userMessages.length).toBeGreaterThan(0);
|
|
1042
|
+
expect(relay.getConnectionState()).toBe("connected");
|
|
1043
|
+
|
|
1044
|
+
relay.destroy();
|
|
1045
|
+
});
|
|
1046
|
+
|
|
843
1047
|
// ── Interrupt handling ──────────────────────────────────────────
|
|
844
1048
|
|
|
845
1049
|
test("handleMessage: interrupt message is handled without error", async () => {
|
|
@@ -2722,6 +2926,87 @@ describe("relay-server", () => {
|
|
|
2722
2926
|
relay.destroy();
|
|
2723
2927
|
});
|
|
2724
2928
|
|
|
2929
|
+
// ── Inbound admission floor (reader → routeSetup wiring) ───────────
|
|
2930
|
+
|
|
2931
|
+
test("admission floor: reader floor that excludes caller denies the call end-to-end", async () => {
|
|
2932
|
+
ensureConversation("conv-admission-floor-deny");
|
|
2933
|
+
const session = createCallSession({
|
|
2934
|
+
conversationId: "conv-admission-floor-deny",
|
|
2935
|
+
provider: "twilio",
|
|
2936
|
+
fromNumber: "+15557776666",
|
|
2937
|
+
toNumber: "+15551111111",
|
|
2938
|
+
});
|
|
2939
|
+
|
|
2940
|
+
// A trusted contact (rank 3) is below the `guardian_only` floor (rank 4).
|
|
2941
|
+
addTrustedVoiceContact("+15557776666");
|
|
2942
|
+
mockAdmissionPolicy = "guardian_only";
|
|
2943
|
+
|
|
2944
|
+
const { ws, relay } = createMockWs(session.id);
|
|
2945
|
+
|
|
2946
|
+
await relay.handleMessage(
|
|
2947
|
+
JSON.stringify({
|
|
2948
|
+
type: "setup",
|
|
2949
|
+
callSid: "CA_admission_floor_deny",
|
|
2950
|
+
from: "+15557776666",
|
|
2951
|
+
to: "+15551111111",
|
|
2952
|
+
}),
|
|
2953
|
+
);
|
|
2954
|
+
|
|
2955
|
+
// Floor-excluded caller takes the deny path.
|
|
2956
|
+
expect(relay.getConnectionState()).toBe("disconnecting");
|
|
2957
|
+
|
|
2958
|
+
const updated = getCallSession(session.id);
|
|
2959
|
+
expect(updated).not.toBeNull();
|
|
2960
|
+
expect(updated!.status).toBe("failed");
|
|
2961
|
+
|
|
2962
|
+
const textMessages = ws.sentMessages
|
|
2963
|
+
.map((raw) => JSON.parse(raw) as { type: string; token?: string })
|
|
2964
|
+
.filter((m) => m.type === "text");
|
|
2965
|
+
expect(
|
|
2966
|
+
textMessages.some((m) => (m.token ?? "").includes("not authorized")),
|
|
2967
|
+
).toBe(true);
|
|
2968
|
+
|
|
2969
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
2970
|
+
|
|
2971
|
+
relay.destroy();
|
|
2972
|
+
});
|
|
2973
|
+
|
|
2974
|
+
test("admission floor: null policy from reader leaves the call flow unchanged", async () => {
|
|
2975
|
+
ensureConversation("conv-admission-floor-null");
|
|
2976
|
+
const session = createCallSession({
|
|
2977
|
+
conversationId: "conv-admission-floor-null",
|
|
2978
|
+
provider: "twilio",
|
|
2979
|
+
fromNumber: "+15557776666",
|
|
2980
|
+
toNumber: "+15551111111",
|
|
2981
|
+
});
|
|
2982
|
+
|
|
2983
|
+
addTrustedVoiceContact("+15557776666");
|
|
2984
|
+
mockAdmissionPolicy = null; // no floor supplied
|
|
2985
|
+
|
|
2986
|
+
mockSendMessage.mockImplementation(
|
|
2987
|
+
createMockProviderResponse(["Hello, how can I help you?"]),
|
|
2988
|
+
);
|
|
2989
|
+
|
|
2990
|
+
const { relay } = createMockWs(session.id);
|
|
2991
|
+
|
|
2992
|
+
await relay.handleMessage(
|
|
2993
|
+
JSON.stringify({
|
|
2994
|
+
type: "setup",
|
|
2995
|
+
callSid: "CA_admission_floor_null",
|
|
2996
|
+
from: "+15557776666",
|
|
2997
|
+
to: "+15551111111",
|
|
2998
|
+
}),
|
|
2999
|
+
);
|
|
3000
|
+
|
|
3001
|
+
// Trusted contact proceeds normally — no deny.
|
|
3002
|
+
expect(relay.getConnectionState()).toBe("connected");
|
|
3003
|
+
|
|
3004
|
+
const updated = getCallSession(session.id);
|
|
3005
|
+
expect(updated!.status).toBe("in_progress");
|
|
3006
|
+
|
|
3007
|
+
relay.destroy();
|
|
3008
|
+
});
|
|
3009
|
+
|
|
2725
3010
|
test("name capture flow: access request creates canonical request for guardian", async () => {
|
|
2726
3011
|
ensureConversation("conv-access-req-canonical");
|
|
2727
3012
|
const session = createCallSession({
|
|
@@ -92,8 +92,21 @@ let mockRouteSetupResult: {
|
|
|
92
92
|
},
|
|
93
93
|
};
|
|
94
94
|
|
|
95
|
+
// Captures the last context passed to routeSetup so preflight tests can
|
|
96
|
+
// assert the resolved phone admission policy was threaded through.
|
|
97
|
+
let lastRouteSetupCtx: Record<string, unknown> | null = null;
|
|
95
98
|
mock.module("../calls/relay-setup-router.js", () => ({
|
|
96
|
-
routeSetup: () =>
|
|
99
|
+
routeSetup: (ctx: Record<string, unknown>) => {
|
|
100
|
+
lastRouteSetupCtx = ctx;
|
|
101
|
+
return mockRouteSetupResult;
|
|
102
|
+
},
|
|
103
|
+
}));
|
|
104
|
+
|
|
105
|
+
// Mock the phone channel admission reader used by the media-stream preflight.
|
|
106
|
+
// Tests override mockAdmissionPolicy to exercise floor classification.
|
|
107
|
+
let mockAdmissionPolicy: unknown = null;
|
|
108
|
+
mock.module("../calls/channel-admission-reader.js", () => ({
|
|
109
|
+
getChannelAdmissionPolicy: async () => mockAdmissionPolicy,
|
|
97
110
|
}));
|
|
98
111
|
|
|
99
112
|
mock.module("../config/env.js", () => ({
|
|
@@ -454,6 +467,9 @@ describe("twilio webhook routes", () => {
|
|
|
454
467
|
mockTwilioApiValidationBody = JSON.stringify({ sid: "AC_validated" });
|
|
455
468
|
// Reset STT config to defaults between tests
|
|
456
469
|
mockConfigObj.services.stt.provider = "deepgram" as any;
|
|
470
|
+
// Reset admission policy + captured routeSetup context between tests
|
|
471
|
+
mockAdmissionPolicy = null;
|
|
472
|
+
lastRouteSetupCtx = null;
|
|
457
473
|
// Reset routeSetup mock to default normal_call
|
|
458
474
|
mockRouteSetupResult = {
|
|
459
475
|
outcome: { action: "normal_call", isInbound: true },
|
|
@@ -1228,6 +1244,70 @@ describe("twilio webhook routes", () => {
|
|
|
1228
1244
|
expect(twiml).not.toContain("<ConversationRelay");
|
|
1229
1245
|
});
|
|
1230
1246
|
|
|
1247
|
+
test("media-stream: resolves the phone admission floor and threads it into the preflight routeSetup", async () => {
|
|
1248
|
+
mockConfigObj.services.stt.provider = "openai-whisper" as any;
|
|
1249
|
+
mockAdmissionPolicy = "guardian_only";
|
|
1250
|
+
mockRouteSetupResult = {
|
|
1251
|
+
outcome: { action: "normal_call", isInbound: true },
|
|
1252
|
+
resolved: {
|
|
1253
|
+
assistantId: "self",
|
|
1254
|
+
isInbound: true,
|
|
1255
|
+
otherPartyNumber: "+15559998888",
|
|
1256
|
+
actorTrust: { trustClass: "guardian", memberRecord: null },
|
|
1257
|
+
},
|
|
1258
|
+
};
|
|
1259
|
+
|
|
1260
|
+
const session = createTestSession("conv-ms-floor-1", "CA_ms_floor_1");
|
|
1261
|
+
const req = makeVoiceRequest(session.id, { CallSid: "CA_ms_floor_1" });
|
|
1262
|
+
|
|
1263
|
+
const res = await handleVoiceWebhook(req);
|
|
1264
|
+
expect(res.status).toBe(200);
|
|
1265
|
+
|
|
1266
|
+
// The resolved floor was passed into the preflight routeSetup so its
|
|
1267
|
+
// classification matches the real stream-start enforcement.
|
|
1268
|
+
expect(lastRouteSetupCtx).not.toBeNull();
|
|
1269
|
+
expect(lastRouteSetupCtx?.admissionPolicy).toBe("guardian_only");
|
|
1270
|
+
});
|
|
1271
|
+
|
|
1272
|
+
test("media-stream: floor-denied caller classified deny still produces Stream TwiML (deny handled at stream level)", async () => {
|
|
1273
|
+
mockConfigObj.services.stt.provider = "openai-whisper" as any;
|
|
1274
|
+
mockAdmissionPolicy = "guardian_only";
|
|
1275
|
+
// With the floor wired, the real router returns `deny` for a
|
|
1276
|
+
// below-floor trusted_contact caller; the mock reflects that.
|
|
1277
|
+
mockRouteSetupResult = {
|
|
1278
|
+
outcome: {
|
|
1279
|
+
action: "deny",
|
|
1280
|
+
message:
|
|
1281
|
+
"This number is not authorized to reach the assistant right now.",
|
|
1282
|
+
logReason: "Inbound voice admission floor: guardian_only",
|
|
1283
|
+
},
|
|
1284
|
+
resolved: {
|
|
1285
|
+
assistantId: "self",
|
|
1286
|
+
isInbound: true,
|
|
1287
|
+
otherPartyNumber: "+14155550000",
|
|
1288
|
+
actorTrust: { trustClass: "trusted_contact", memberRecord: null },
|
|
1289
|
+
},
|
|
1290
|
+
};
|
|
1291
|
+
|
|
1292
|
+
const session = createTestSession(
|
|
1293
|
+
"conv-ms-floor-deny-1",
|
|
1294
|
+
"CA_ms_floor_deny_1",
|
|
1295
|
+
);
|
|
1296
|
+
const req = makeVoiceRequest(session.id, {
|
|
1297
|
+
CallSid: "CA_ms_floor_deny_1",
|
|
1298
|
+
});
|
|
1299
|
+
|
|
1300
|
+
const res = await handleVoiceWebhook(req);
|
|
1301
|
+
expect(res.status).toBe(200);
|
|
1302
|
+
|
|
1303
|
+
// Policy was threaded; deny is supported on media-stream, so Stream
|
|
1304
|
+
// TwiML is still produced (denial spoken + torn down at stream start).
|
|
1305
|
+
expect(lastRouteSetupCtx?.admissionPolicy).toBe("guardian_only");
|
|
1306
|
+
const twiml = await res.text();
|
|
1307
|
+
expect(twiml).toContain("<Stream");
|
|
1308
|
+
expect(twiml).not.toContain("<ConversationRelay");
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1231
1311
|
test("media-stream: deny setup proceeds with Stream TwiML (deny is handled at stream level)", async () => {
|
|
1232
1312
|
mockConfigObj.services.stt.provider = "openai-whisper" as any;
|
|
1233
1313
|
mockRouteSetupResult = {
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the gateway-backed channel admission policy reader.
|
|
3
|
+
*
|
|
4
|
+
* The reader fails open (returns null = admit) so a gateway IPC failure can
|
|
5
|
+
* never block a live call. These tests pin that contract plus the TTL cache.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
9
|
+
|
|
10
|
+
// ── Controllable IPC mock ────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
type IpcHandler = (params?: Record<string, unknown>) => unknown;
|
|
13
|
+
|
|
14
|
+
const ipcHandlers = new Map<string, IpcHandler>();
|
|
15
|
+
const ipcCallLog: Array<{
|
|
16
|
+
method: string;
|
|
17
|
+
params?: Record<string, unknown>;
|
|
18
|
+
timeoutMs?: number;
|
|
19
|
+
}> = [];
|
|
20
|
+
|
|
21
|
+
mock.module("../../ipc/gateway-client.js", () => ({
|
|
22
|
+
ipcCall: async (
|
|
23
|
+
method: string,
|
|
24
|
+
params?: Record<string, unknown>,
|
|
25
|
+
timeoutMs?: number,
|
|
26
|
+
) => {
|
|
27
|
+
ipcCallLog.push({ method, params, timeoutMs });
|
|
28
|
+
const handler = ipcHandlers.get(method);
|
|
29
|
+
return handler ? handler(params) : undefined;
|
|
30
|
+
},
|
|
31
|
+
ipcCallPersistent: async () => undefined,
|
|
32
|
+
resetPersistentClient: () => {},
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
_clearCacheForTesting,
|
|
37
|
+
getChannelAdmissionPolicy,
|
|
38
|
+
} from "../channel-admission-reader.js";
|
|
39
|
+
|
|
40
|
+
const METHOD = "get_channel_admission_policy";
|
|
41
|
+
|
|
42
|
+
function countCalls(method: string): number {
|
|
43
|
+
return ipcCallLog.filter((c) => c.method === method).length;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("getChannelAdmissionPolicy", () => {
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
_clearCacheForTesting();
|
|
49
|
+
ipcHandlers.clear();
|
|
50
|
+
ipcCallLog.length = 0;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("returns the gateway-resolved policy and caches it within the TTL", async () => {
|
|
54
|
+
ipcHandlers.set(METHOD, () => ({ policy: "guardian_only" }));
|
|
55
|
+
|
|
56
|
+
expect(await getChannelAdmissionPolicy("telegram")).toBe("guardian_only");
|
|
57
|
+
// Second call within TTL is served from cache — no extra IPC round-trip.
|
|
58
|
+
expect(await getChannelAdmissionPolicy("telegram")).toBe("guardian_only");
|
|
59
|
+
expect(countCalls(METHOD)).toBe(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("bounds the IPC read with a short timeout so it fails open promptly", async () => {
|
|
63
|
+
ipcHandlers.set(METHOD, () => ({ policy: "guardian_only" }));
|
|
64
|
+
|
|
65
|
+
await getChannelAdmissionPolicy("telegram");
|
|
66
|
+
|
|
67
|
+
const call = ipcCallLog.find((c) => c.method === METHOD);
|
|
68
|
+
expect(call?.timeoutMs).toBe(1_000);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("returns null when IPC transport fails (undefined)", async () => {
|
|
72
|
+
ipcHandlers.set(METHOD, () => undefined);
|
|
73
|
+
expect(await getChannelAdmissionPolicy("telegram")).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("returns null when the gateway explicitly resolves no policy", async () => {
|
|
77
|
+
ipcHandlers.set(METHOD, () => ({ policy: null }));
|
|
78
|
+
expect(await getChannelAdmissionPolicy("telegram")).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("returns null when the IPC call throws", async () => {
|
|
82
|
+
ipcHandlers.set(METHOD, () => {
|
|
83
|
+
throw new Error("socket exploded");
|
|
84
|
+
});
|
|
85
|
+
expect(await getChannelAdmissionPolicy("telegram")).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("returns null for an invalid policy string", async () => {
|
|
89
|
+
ipcHandlers.set(METHOD, () => ({ policy: "definitely-not-a-policy" }));
|
|
90
|
+
expect(await getChannelAdmissionPolicy("telegram")).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("caches the explicit no-enforcement (null) answer within the TTL", async () => {
|
|
94
|
+
// The gateway successfully said "no enforcement" — a real, cacheable answer.
|
|
95
|
+
ipcHandlers.set(METHOD, () => ({ policy: null }));
|
|
96
|
+
expect(await getChannelAdmissionPolicy("telegram")).toBeNull();
|
|
97
|
+
expect(await getChannelAdmissionPolicy("telegram")).toBeNull();
|
|
98
|
+
expect(countCalls(METHOD)).toBe(1);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("does NOT cache a transport failure (undefined) — re-consults the gateway", async () => {
|
|
102
|
+
// First setup: gateway hiccup → fail open to null, NOT cached.
|
|
103
|
+
ipcHandlers.set(METHOD, () => undefined);
|
|
104
|
+
expect(await getChannelAdmissionPolicy("telegram")).toBeNull();
|
|
105
|
+
|
|
106
|
+
// Gateway recovers with a restrictive policy: the next setup must re-attempt
|
|
107
|
+
// the IPC (not serve a stale fail-open null) and honor the now-recovered floor.
|
|
108
|
+
ipcHandlers.set(METHOD, () => ({ policy: "guardian_only" }));
|
|
109
|
+
expect(await getChannelAdmissionPolicy("telegram")).toBe("guardian_only");
|
|
110
|
+
expect(countCalls(METHOD)).toBe(2);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("does NOT cache a thrown IPC error — re-consults the gateway", async () => {
|
|
114
|
+
ipcHandlers.set(METHOD, () => {
|
|
115
|
+
throw new Error("socket exploded");
|
|
116
|
+
});
|
|
117
|
+
expect(await getChannelAdmissionPolicy("telegram")).toBeNull();
|
|
118
|
+
|
|
119
|
+
ipcHandlers.set(METHOD, () => ({ policy: "no_one" }));
|
|
120
|
+
expect(await getChannelAdmissionPolicy("telegram")).toBe("no_one");
|
|
121
|
+
expect(countCalls(METHOD)).toBe(2);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("does NOT cache a malformed shape — re-consults the gateway", async () => {
|
|
125
|
+
ipcHandlers.set(METHOD, () => ({ policy: "definitely-not-a-policy" }));
|
|
126
|
+
expect(await getChannelAdmissionPolicy("telegram")).toBeNull();
|
|
127
|
+
|
|
128
|
+
ipcHandlers.set(METHOD, () => ({ policy: "guardian_only" }));
|
|
129
|
+
expect(await getChannelAdmissionPolicy("telegram")).toBe("guardian_only");
|
|
130
|
+
expect(countCalls(METHOD)).toBe(2);
|
|
131
|
+
});
|
|
132
|
+
});
|