@vellumai/assistant 0.12.0-dev.202609111458.db63c16 → 0.12.0-dev.202609111617.f60422f

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/ARCHITECTURE.md CHANGED
@@ -612,6 +612,8 @@ Transcription mode is selected once per session in `media-stream-stt-session.ts`
612
612
  - **Streaming** (default): when `calls.voice.telephonyStreaming` is enabled and the `telephony` role resolves a streaming transcriber (`resolveStreamingTranscriber({ role: "telephony" })`), inbound audio is decoded (mu-law → PCM16, resampled 8 kHz → 16 kHz) and fed to the provider's realtime adapter. Replies trigger only on utterance-boundary finals (for Deepgram, `speech_final`/`UtteranceEnd`, never mid-sentence `is_final` segments), and barge-in fires from local energy VAD, never from transcriber partials.
613
613
  - **Batch fallback**: otherwise the session segments turns with the energy-based `MediaTurnDetector` and transcribes each completed turn via the same role's batch API. Both halves of a call read the `telephony` role, which is why a role names its consumer rather than a boundary.
614
614
 
615
+ Every phone turn runs the same two-leg triage as live voice through `startVoiceTurn` (`src/calls/voice-session-bridge.ts`): `call-controller.ts` opens on a toolless front-door leg (`routingLeg: "front-door"`, the `voiceFrontDoor` call site) and reads its stream through the shared `createFrontDoorVerdictMachine`; an escalate verdict speaks the capped holding phrase (or the localized canned bridge) and continues on an escalated leg pinned to the conversation's own model. Phone has no partial transcripts, so the hold verdict is never taught and routing is escalate-only. The controller also passes the bridge's turn callbacks (tool activity is recorded as `tool_use_started` / `tool_use_completed` call events, persisted row ids ride the `assistant_spoke` event), `launchedAtMs` for dispatch timing, and a `voiceTelemetry` bag keyed by the call session with a `phone_inbound` / `phone_outbound` entry.
616
+
615
617
  A credential preflight (`resolveTelephonyCredentialReadiness()` in `src/calls/telephony-credential-preflight.ts`) gates every call: it requires a credentialed, telephony-capable STT provider **and** a media-stream-playable TTS provider (the configured one or a credentialed playable fallback). Inbound calls that fail the preflight receive `<Say>` setup-required copy plus `<Hangup/>` instead of a doomed stream; outbound placement fails before dialing via `preflightVoiceIngress()` with the same user-facing message.
616
618
 
617
619
  Key modules:
@@ -2189,7 +2191,7 @@ The `TtsUseCase` discriminator (`"phone-call"` or `"message-playback"`) lets pro
2189
2191
  - **`native-twilio`** — the text-token path: spoken text is sent via `sendTextToken()`, which the media-stream transport re-synthesizes through daemon TTS. Collapsing this mode into `synthesized-play` is a documented deferred follow-up.
2190
2192
  - **`synthesized-play`** — The assistant synthesises audio via the provider's HTTP API and streams it through the audio store / `sendPlayUrl()` path.
2191
2193
 
2192
- **Phone call integration:** Phone calls run on the media-stream transport, where the daemon synthesises speech via the configured TTS provider and transcodes it to mu-law 8 kHz frames (`media-stream-output.ts`). Each catalog entry declares `mediaStreamPlayback.outputFormat` (`pcm`, `wav`, or `none`); `resolveTelephonyTtsCapability()` (`src/calls/telephony-tts-capability.ts`) combines that field with credential availability into a playable / not-playable verdict, and the call TTS resolver falls back to a credentialed playable provider rather than producing silence.
2194
+ **Phone call integration:** Phone calls run on the media-stream transport, where the daemon synthesises speech via the effective TTS provider (the configured one after managed-speech defaulting, `resolveEffectiveSpeechProviders`, the same substitution live voice makes) and transcodes it to mu-law 8 kHz frames (`media-stream-output.ts`). Each catalog entry declares `mediaStreamPlayback.outputFormat` (`pcm`, `wav`, or `none`); `resolveTelephonyTtsCapability()` (`src/calls/telephony-tts-capability.ts`) combines that field with credential availability into a playable / not-playable verdict, and the call TTS resolver falls back to a credentialed playable provider rather than producing silence.
2193
2195
 
2194
2196
  **Adding a new TTS provider (catalog-first checklist):**
2195
2197
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.0-dev.202609111458.db63c16",
3
+ "version": "0.12.0-dev.202609111617.f60422f",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -455,10 +455,11 @@ describe("resolveCallTtsProvider with requiresPcmAudio", () => {
455
455
  expect(result.provider?.id).toBe("elevenlabs");
456
456
  });
457
457
 
458
- test("without requiresPcmAudio the configured provider is not swapped", async () => {
458
+ test("without requiresPcmAudio the configured provider is not swapped for playability", async () => {
459
459
  testConfig.services.tts.provider = "deepgram";
460
- // No credentials at all — the CR transport path does not consult the
461
- // media-stream playability capability.
460
+ storedKeys["deepgram"] = "dg-key";
461
+ // The CR transport path does not consult the media-stream playability
462
+ // capability.
462
463
  registerStubProvider("deepgram", async () => {
463
464
  return { audio: Buffer.from("x"), contentType: "audio/mpeg" };
464
465
  });
@@ -468,6 +469,37 @@ describe("resolveCallTtsProvider with requiresPcmAudio", () => {
468
469
  expect(result.useSynthesizedPath).toBe(true);
469
470
  expect(result.audioFormat).toBe("mp3");
470
471
  });
472
+
473
+ test("managed speech stands in for a configured provider whose credential is missing", async () => {
474
+ testConfig.services.tts.provider = "deepgram";
475
+ // No deepgram key, managed speech available: the phone speaks through
476
+ // managed speech, the same substitution live voice makes.
477
+ registerStubProvider("deepgram", async () => {
478
+ throw new Error("should not be used");
479
+ });
480
+ registerStubProvider("vellum", async () => {
481
+ return { audio: Buffer.from("x"), contentType: "audio/mpeg" };
482
+ });
483
+
484
+ const result = await resolveCallTtsProvider();
485
+ expect(result.provider?.id).toBe("vellum");
486
+ expect(result.useSynthesizedPath).toBe(true);
487
+ });
488
+
489
+ test("without managed speech a credential-less configured provider is kept", async () => {
490
+ mockManagedSpeechAvailable = false;
491
+ try {
492
+ testConfig.services.tts.provider = "deepgram";
493
+ registerStubProvider("deepgram", async () => {
494
+ return { audio: Buffer.from("x"), contentType: "audio/mpeg" };
495
+ });
496
+
497
+ const result = await resolveCallTtsProvider();
498
+ expect(result.provider?.id).toBe("deepgram");
499
+ } finally {
500
+ mockManagedSpeechAvailable = true;
501
+ }
502
+ });
471
503
  });
472
504
 
473
505
  // ---------------------------------------------------------------------------
@@ -584,6 +616,7 @@ describe("speakSystemPrompt mid-stream failure after audio started", () => {
584
616
  test("non-PCM transport: skips the native text fallback — no full-prompt re-speak", async () => {
585
617
  testConfig.services.tts.provider = "fish-audio";
586
618
  testConfig.services.tts.providers["fish-audio"].referenceId = "ref-123";
619
+ storedKeys["fish-audio"] = "fa-key";
587
620
 
588
621
  const fishStream = jest.fn(failAfterFirstChunk());
589
622
  registerStreamingStubProvider("fish-audio", fishStream);
@@ -657,6 +690,7 @@ describe("speakSystemPrompt aborted synthesis", () => {
657
690
  test("non-PCM transport with native-fallback provider: abort skips the text fallback and end-of-turn", async () => {
658
691
  testConfig.services.tts.provider = "fish-audio";
659
692
  testConfig.services.tts.providers["fish-audio"].referenceId = "ref-123";
693
+ storedKeys["fish-audio"] = "fa-key";
660
694
 
661
695
  const controller = new AbortController();
662
696
  controller.abort();
@@ -742,6 +776,7 @@ describe("speakSystemPrompt aborted synthesis", () => {
742
776
  test("provider AbortError without any signal is a failure — non-PCM native fallback still runs", async () => {
743
777
  testConfig.services.tts.provider = "fish-audio";
744
778
  testConfig.services.tts.providers["fish-audio"].referenceId = "ref-123";
779
+ storedKeys["fish-audio"] = "fa-key";
745
780
 
746
781
  const fishSynthesize = jest.fn(async () => {
747
782
  throw new DOMException("socket torn down", "AbortError");
@@ -45,6 +45,18 @@ mock.module("../../security/secure-keys.js", () => ({
45
45
  : "test-key",
46
46
  }));
47
47
 
48
+ // Managed-speech defaulting reaches the platform client and the credential
49
+ // store. The controller tests drive the bridge mock on a microtask budget, so
50
+ // the configured providers stand as the effective ones here.
51
+ mock.module("../../config/managed-speech-defaults.js", () => ({
52
+ resolveEffectiveSpeechProviders: async (config?: {
53
+ services: { stt: { provider: string }; tts: { provider: string } };
54
+ }) => {
55
+ const services = (config ?? loadConfig()).services;
56
+ return { stt: services.stt.provider, tts: services.tts.provider };
57
+ },
58
+ }));
59
+
48
60
  mock.module("../../security/credential-key.js", () => ({
49
61
  credentialKey: (...args: string[]) => args.join("/"),
50
62
  }));
@@ -229,6 +241,11 @@ import {
229
241
  } from "../call-store.js";
230
242
  import type { CallTransport } from "../call-transport.js";
231
243
  import { resolveCallTtsProvider } from "../resolve-call-tts-provider.js";
244
+ import {
245
+ ESCALATION_CONTINUATION_CONTENT,
246
+ FALLBACK_ESCALATION_BRIDGE,
247
+ FALLBACK_ESCALATION_BRIDGE_BY_LANGUAGE,
248
+ } from "../voice-triage-escalate.js";
232
249
 
233
250
  // Disable memory so persisted call messages skip background indexing, and
234
251
  // seed the ingress base URL used for synthesized-audio play URLs.
@@ -248,6 +265,8 @@ afterAll(() => {
248
265
 
249
266
  interface MockTransport extends CallTransport {
250
267
  sentTokens: Array<{ token: string; last: boolean }>;
268
+ /** Tokens sent with the system-copy flag (no caller-language hint). */
269
+ systemCopyTokens: string[];
251
270
  sentPlayUrls: string[];
252
271
  endCalled: boolean;
253
272
  endReason: string | undefined;
@@ -257,6 +276,7 @@ interface MockTransport extends CallTransport {
257
276
  function createMockTransport(): MockTransport {
258
277
  const state = {
259
278
  sentTokens: [] as Array<{ token: string; last: boolean }>,
279
+ systemCopyTokens: [] as string[],
260
280
  sentPlayUrls: [] as string[],
261
281
  _endCalled: false,
262
282
  _endReason: undefined as string | undefined,
@@ -267,6 +287,9 @@ function createMockTransport(): MockTransport {
267
287
  get sentTokens() {
268
288
  return state.sentTokens;
269
289
  },
290
+ get systemCopyTokens() {
291
+ return state.systemCopyTokens;
292
+ },
270
293
  get sentPlayUrls() {
271
294
  return state.sentPlayUrls;
272
295
  },
@@ -279,8 +302,15 @@ function createMockTransport(): MockTransport {
279
302
  get cancelPendingSpeechCount() {
280
303
  return state._cancelPendingSpeechCount;
281
304
  },
282
- sendTextToken(token: string, last: boolean) {
305
+ sendTextToken(
306
+ token: string,
307
+ last: boolean,
308
+ opts?: { systemCopy?: boolean },
309
+ ) {
283
310
  state.sentTokens.push({ token, last });
311
+ if (opts?.systemCopy === true) {
312
+ state.systemCopyTokens.push(token);
313
+ }
284
314
  },
285
315
  sendPlayUrl(url: string) {
286
316
  state.sentPlayUrls.push(url);
@@ -4194,6 +4224,319 @@ describe("call-controller", () => {
4194
4224
  });
4195
4225
  });
4196
4226
 
4227
+ // ── Triage-and-escalate routing ─────────────────────────────────────
4228
+
4229
+ describe("triage-and-escalate routing", () => {
4230
+ interface LegOpts {
4231
+ content: string;
4232
+ routingLeg?: string;
4233
+ spokenEscalationBridge?: string;
4234
+ launchedAtMs?: number;
4235
+ voiceTelemetry?: { sessionId: string; entry?: string };
4236
+ callbacks?: {
4237
+ persisted_user_message_id?: (id: string) => void;
4238
+ persisted_assistant_message_id?: (id: string) => void;
4239
+ tool_use_start?: (
4240
+ toolName: string,
4241
+ detail?: { toolUseId?: string },
4242
+ ) => void;
4243
+ tool_result?: (event: {
4244
+ toolName: string;
4245
+ toolUseId?: string;
4246
+ isError?: boolean;
4247
+ resultPreview: string;
4248
+ }) => void;
4249
+ };
4250
+ onTextDelta: (t: string) => void;
4251
+ onComplete: () => void;
4252
+ signal?: AbortSignal;
4253
+ }
4254
+
4255
+ /**
4256
+ * Script the bridge one leg per call: the front-door leg's tokens first,
4257
+ * then the escalated leg's. Records every call's options and which legs
4258
+ * the controller aborted.
4259
+ */
4260
+ function scriptLegs(
4261
+ legs: string[][],
4262
+ beforeComplete?: (opts: LegOpts, index: number) => void,
4263
+ ) {
4264
+ const calls: LegOpts[] = [];
4265
+ const aborted: number[] = [];
4266
+ mockStartVoiceTurn.mockImplementation(async (opts: LegOpts) => {
4267
+ const index = calls.length;
4268
+ calls.push(opts);
4269
+ for (const token of legs[index] ?? []) {
4270
+ opts.onTextDelta(token);
4271
+ }
4272
+ beforeComplete?.(opts, index);
4273
+ opts.onComplete();
4274
+ return { turnId: `run-${index}`, abort: () => aborted.push(index) };
4275
+ });
4276
+ return { calls, aborted };
4277
+ }
4278
+
4279
+ function spokenText(relay: MockTransport): string {
4280
+ return relay.sentTokens
4281
+ .filter((t) => !t.last)
4282
+ .map((t) => t.token)
4283
+ .join("");
4284
+ }
4285
+
4286
+ function assistantSpokePayload(sessionId: string): Record<string, unknown> {
4287
+ const spoke = getCallEvents(sessionId).filter(
4288
+ (e) => e.eventType === "assistant_spoke",
4289
+ );
4290
+ expect(spoke.length).toBe(1);
4291
+ return JSON.parse(spoke[0].payloadJson) as Record<string, unknown>;
4292
+ }
4293
+
4294
+ test("a front-door answer runs one leg and is spoken verbatim", async () => {
4295
+ const { calls, aborted } = scriptLegs([["Hello", " there."]]);
4296
+ const { session, relay, controller } = setupController();
4297
+
4298
+ await controller.handleCallerUtterance("Hi");
4299
+
4300
+ expect(calls.length).toBe(1);
4301
+ expect(calls[0].routingLeg).toBe("front-door");
4302
+ expect(calls[0].content).toBe("Hi");
4303
+ expect(calls[0].spokenEscalationBridge).toBeUndefined();
4304
+ expect(aborted).toEqual([]);
4305
+ expect(spokenText(relay)).toContain("Hello there.");
4306
+ expect(assistantSpokePayload(session.id).text).toBe("Hello there.");
4307
+
4308
+ controller.destroy();
4309
+ });
4310
+
4311
+ test("an escalate verdict speaks the holding phrase, aborts the front-door leg, and continues on the escalated leg", async () => {
4312
+ const { calls, aborted } = scriptLegs([
4313
+ // The verdict token may arrive split across deltas; the leading
4314
+ // "[" is held until it classifies.
4315
+ ["[", "1] Let me check", " that.", " Ignored past the cap."],
4316
+ ["The answer is 42."],
4317
+ ]);
4318
+ const { session, relay, controller } = setupController();
4319
+
4320
+ await controller.handleCallerUtterance("What is six times seven?");
4321
+
4322
+ expect(calls.length).toBe(2);
4323
+ expect(calls[0].routingLeg).toBe("front-door");
4324
+ expect(calls[1].routingLeg).toBe("escalated");
4325
+ expect(calls[1].content).toBe(ESCALATION_CONTINUATION_CONTENT);
4326
+ expect(calls[1].spokenEscalationBridge).toBe("Let me check that.");
4327
+ expect(aborted).toEqual([0]);
4328
+
4329
+ const spoken = spokenText(relay);
4330
+ expect(spoken).toContain("Let me check that.");
4331
+ expect(spoken).toContain("The answer is 42.");
4332
+ expect(spoken.indexOf("Let me check that.")).toBeLessThan(
4333
+ spoken.indexOf("The answer is 42."),
4334
+ );
4335
+ expect(spoken).not.toContain("[1]");
4336
+ expect(spoken).not.toContain("Ignored past the cap.");
4337
+ expect(assistantSpokePayload(session.id).text).toBe(
4338
+ "Let me check that. The answer is 42.",
4339
+ );
4340
+ // One end-of-turn signal for the whole turn, after the escalated leg.
4341
+ expect(relay.sentTokens.filter((t) => t.last).length).toBe(1);
4342
+
4343
+ controller.destroy();
4344
+ });
4345
+
4346
+ test("a bare escalate verdict speaks the canned bridge, audio-only", async () => {
4347
+ const { calls } = scriptLegs([["[1]"], ["Forty-two."]]);
4348
+ const { session, relay, controller } = setupController();
4349
+
4350
+ await controller.handleCallerUtterance("What is six times seven?");
4351
+
4352
+ expect(calls.length).toBe(2);
4353
+ expect(calls[1].spokenEscalationBridge).toBe(FALLBACK_ESCALATION_BRIDGE);
4354
+ expect(spokenText(relay)).toContain(FALLBACK_ESCALATION_BRIDGE);
4355
+ // The canned bridge is not the model's speech: the turn's text (and the
4356
+ // transcript row the bridge keeps) carry only the escalated answer.
4357
+ expect(assistantSpokePayload(session.id).text).toBe("Forty-two.");
4358
+
4359
+ controller.destroy();
4360
+ });
4361
+
4362
+ test("a leg cancelled mid-bridge never spawns the escalated leg", async () => {
4363
+ const calls: LegOpts[] = [];
4364
+ let releaseLeg: () => void = () => {};
4365
+ mockStartVoiceTurn.mockImplementation(async (opts: LegOpts) => {
4366
+ calls.push(opts);
4367
+ opts.onTextDelta("[1] Let me");
4368
+ await new Promise<void>((resolve) => {
4369
+ releaseLeg = resolve;
4370
+ opts.signal?.addEventListener("abort", () => resolve());
4371
+ });
4372
+ // The bridge reports a cancelled leg's completion too.
4373
+ opts.onComplete();
4374
+ return { turnId: "run-cancelled", abort: () => releaseLeg() };
4375
+ });
4376
+ const { relay, controller } = setupController();
4377
+
4378
+ const turnPromise = controller.handleCallerUtterance("Hi");
4379
+ for (let i = 0; i < 10; i++) {
4380
+ await Promise.resolve();
4381
+ }
4382
+ expect(calls.length).toBe(1);
4383
+
4384
+ controller.handleInterrupt();
4385
+ await turnPromise;
4386
+
4387
+ expect(calls.length).toBe(1);
4388
+ expect(spokenText(relay)).toBe("");
4389
+
4390
+ controller.destroy();
4391
+ });
4392
+
4393
+ test("passes the launch time, phone telemetry, and callbacks to the bridge", async () => {
4394
+ const before = Date.now();
4395
+ const { calls } = scriptLegs([["Done."]], (opts) => {
4396
+ opts.callbacks?.persisted_user_message_id?.("user-row-1");
4397
+ opts.callbacks?.tool_use_start?.("web_search", { toolUseId: "tu-1" });
4398
+ opts.callbacks?.tool_result?.({
4399
+ toolName: "web_search",
4400
+ toolUseId: "tu-1",
4401
+ isError: false,
4402
+ resultPreview: "three results",
4403
+ });
4404
+ opts.callbacks?.persisted_assistant_message_id?.("assistant-row-1");
4405
+ });
4406
+ const { session, controller } = setupController();
4407
+
4408
+ await controller.handleCallerUtterance("Look that up");
4409
+
4410
+ expect(calls.length).toBe(1);
4411
+ expect(calls[0].launchedAtMs).toBeGreaterThanOrEqual(before);
4412
+ expect(calls[0].launchedAtMs).toBeLessThanOrEqual(Date.now());
4413
+ expect(calls[0].voiceTelemetry).toEqual({
4414
+ sessionId: session.id,
4415
+ entry: "phone_inbound",
4416
+ });
4417
+
4418
+ const events = getCallEvents(session.id);
4419
+ const started = events.filter((e) => e.eventType === "tool_use_started");
4420
+ expect(started.length).toBe(1);
4421
+ expect(JSON.parse(started[0].payloadJson)).toEqual({
4422
+ toolName: "web_search",
4423
+ toolUseId: "tu-1",
4424
+ });
4425
+ const completed = events.filter(
4426
+ (e) => e.eventType === "tool_use_completed",
4427
+ );
4428
+ expect(completed.length).toBe(1);
4429
+ expect(JSON.parse(completed[0].payloadJson)).toEqual({
4430
+ toolName: "web_search",
4431
+ toolUseId: "tu-1",
4432
+ isError: false,
4433
+ resultPreview: "three results",
4434
+ });
4435
+ expect(assistantSpokePayload(session.id)).toEqual({
4436
+ text: "Done.",
4437
+ userMessageId: "user-row-1",
4438
+ assistantMessageId: "assistant-row-1",
4439
+ });
4440
+
4441
+ controller.destroy();
4442
+ });
4443
+
4444
+ test("the escalated leg's hidden continuation row is not the caller's utterance", async () => {
4445
+ const { calls } = scriptLegs(
4446
+ [["[1] One moment."], ["Here you go."]],
4447
+ (opts, index) => {
4448
+ opts.callbacks?.persisted_user_message_id?.(`user-row-${index}`);
4449
+ opts.callbacks?.persisted_assistant_message_id?.(
4450
+ `assistant-row-${index}`,
4451
+ );
4452
+ },
4453
+ );
4454
+ const { session, controller } = setupController();
4455
+
4456
+ await controller.handleCallerUtterance("Hi");
4457
+
4458
+ expect(calls.length).toBe(2);
4459
+ expect(assistantSpokePayload(session.id)).toEqual({
4460
+ text: "One moment. Here you go.",
4461
+ userMessageId: "user-row-0",
4462
+ assistantMessageId: "assistant-row-1",
4463
+ });
4464
+
4465
+ controller.destroy();
4466
+ });
4467
+
4468
+ test("native route: a canned bridge in a language the table lacks goes out as system copy", async () => {
4469
+ scriptLegs([["[1]"], ["Forty-two."]]);
4470
+ const { relay, controller } = setupController(undefined, {
4471
+ resolveSynthesisLanguage: () => "ko",
4472
+ });
4473
+
4474
+ await controller.handleCallerUtterance("What is six times seven?");
4475
+
4476
+ // The English fallback must not ride the caller's Korean hint.
4477
+ expect(relay.systemCopyTokens).toEqual([
4478
+ `${FALLBACK_ESCALATION_BRIDGE} `,
4479
+ ]);
4480
+ expect(spokenText(relay)).toContain("Forty-two.");
4481
+
4482
+ controller.destroy();
4483
+ });
4484
+
4485
+ test("native route: a localized canned bridge rides the caller's language like model text", async () => {
4486
+ scriptLegs([["[1]"], ["Cuarenta y dos."]]);
4487
+ const { relay, controller } = setupController(undefined, {
4488
+ resolveSynthesisLanguage: () => "es",
4489
+ });
4490
+
4491
+ await controller.handleCallerUtterance("¿Cuánto es seis por siete?");
4492
+
4493
+ expect(relay.systemCopyTokens).toEqual([]);
4494
+ expect(spokenText(relay)).toContain(
4495
+ FALLBACK_ESCALATION_BRIDGE_BY_LANGUAGE.es,
4496
+ );
4497
+
4498
+ controller.destroy();
4499
+ });
4500
+
4501
+ test("synthesized route: the canned bridge carries its own language hint, the answer the caller's", async () => {
4502
+ const requests: Array<{ text: string; language: string | undefined }> =
4503
+ [];
4504
+ registerFishAudioSegmentRecorder({
4505
+ onSynthesizeStream: async (text, request) => {
4506
+ requests.push({ text, language: request.language });
4507
+ },
4508
+ });
4509
+ scriptLegs([["[1]"], ["Forty-two."]]);
4510
+ const { controller } = setupController(undefined, {
4511
+ resolveSynthesisLanguage: () => "ko",
4512
+ });
4513
+
4514
+ await controller.handleCallerUtterance("What is six times seven?");
4515
+
4516
+ expect(requests).toEqual([
4517
+ { text: FALLBACK_ESCALATION_BRIDGE, language: "en" },
4518
+ { text: "Forty-two.", language: "ko" },
4519
+ ]);
4520
+
4521
+ controller.destroy();
4522
+ });
4523
+
4524
+ test("an outbound call reports the outbound entry", async () => {
4525
+ const { calls } = scriptLegs([["Hello, this is Ava."]]);
4526
+ const { session, controller } = setupController("Book a table for two");
4527
+
4528
+ await controller.startInitialGreeting();
4529
+
4530
+ expect(calls.length).toBe(1);
4531
+ expect(calls[0].voiceTelemetry).toEqual({
4532
+ sessionId: session.id,
4533
+ entry: "phone_outbound",
4534
+ });
4535
+
4536
+ controller.destroy();
4537
+ });
4538
+ });
4539
+
4197
4540
  // ── handleBargeIn ───────────────────────────────────────────────────
4198
4541
 
4199
4542
  describe("handleBargeIn", () => {
@@ -842,6 +842,42 @@ describe("startVoiceTurn triage-and-escalate control prompt", () => {
842
842
  expect(installed()).toContain(escalatedContinuationRule());
843
843
  });
844
844
 
845
+ test("the auto-built phone prompt carries the front-door rule anchored to the caller's words", async () => {
846
+ const installed = captureInstalledPrompt();
847
+ await startVoiceTurn({
848
+ ...makeTurnOptions(),
849
+ content: "what time is it",
850
+ routingLeg: "front-door",
851
+ });
852
+ expect(installed()).toContain("<voice_call_control>");
853
+ expect(installed()).toContain(
854
+ `13. ${frontDoorDecisionRule({ callerUtterance: "what time is it" })}`,
855
+ );
856
+ });
857
+
858
+ test("a phone sentinel anchors the front-door rule on its persisted form", async () => {
859
+ const installed = captureInstalledPrompt();
860
+ await startVoiceTurn({ ...makeTurnOptions(), routingLeg: "front-door" });
861
+ expect(installed()).toContain(
862
+ frontDoorDecisionRule({
863
+ callerUtterance: "(call connected — deliver opening greeting)",
864
+ }),
865
+ );
866
+ expect(installed()).not.toContain(JSON.stringify(CALL_OPENING_MARKER));
867
+ });
868
+
869
+ test("the auto-built phone prompt carries the escalated continuation rule", async () => {
870
+ const installed = captureInstalledPrompt();
871
+ await startVoiceTurn({
872
+ ...makeTurnOptions(),
873
+ routingLeg: "escalated",
874
+ spokenEscalationBridge: "One moment.",
875
+ });
876
+ expect(installed()).toContain(
877
+ `13. ${escalatedContinuationRule("One moment.")}`,
878
+ );
879
+ });
880
+
845
881
  test("leaves a caller-supplied prompt verbatim when no routing leg is set", async () => {
846
882
  const installed = captureInstalledPrompt();
847
883
  await startVoiceTurn({
@@ -18,6 +18,7 @@ import {
18
18
  isEscalationBridgeComplete,
19
19
  MAX_ESCALATION_BRIDGE_CHARS,
20
20
  needsFallbackBridge,
21
+ resolveSpokenEscalationBridge,
21
22
  spokenBridgeText,
22
23
  } from "../voice-triage-escalate.js";
23
24
 
@@ -282,6 +283,34 @@ describe("fallbackEscalationBridgeFor", () => {
282
283
  });
283
284
  });
284
285
 
286
+ describe("resolveSpokenEscalationBridge", () => {
287
+ test("a real capped bridge is spoken as the model's own speech", () => {
288
+ expect(resolveSpokenEscalationBridge("Let me check that.", "es")).toEqual({
289
+ spokenBridge: "Let me check that.",
290
+ usesFallback: false,
291
+ });
292
+ });
293
+
294
+ test("an empty or too-short bridge falls back to the localized canned phrase, audio-only", () => {
295
+ expect(resolveSpokenEscalationBridge("", "es")).toEqual({
296
+ spokenBridge: FALLBACK_ESCALATION_BRIDGE_BY_LANGUAGE.es,
297
+ usesFallback: true,
298
+ });
299
+ expect(resolveSpokenEscalationBridge("Ok", undefined)).toEqual({
300
+ spokenBridge: FALLBACK_ESCALATION_BRIDGE,
301
+ usesFallback: true,
302
+ });
303
+ });
304
+
305
+ test("a canned phrase the table lacks for the caller's language is English and says so", () => {
306
+ expect(resolveSpokenEscalationBridge("", "ko")).toEqual({
307
+ spokenBridge: FALLBACK_ESCALATION_BRIDGE,
308
+ usesFallback: true,
309
+ language: "en",
310
+ });
311
+ });
312
+ });
313
+
285
314
  describe("capEscalationBridge", () => {
286
315
  test("cuts just after the first sentence terminator", () => {
287
316
  expect(