@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 +3 -1
- package/package.json +1 -1
- package/src/__tests__/telephony-tts-guard.test.ts +38 -3
- package/src/calls/__tests__/call-controller.test.ts +344 -1
- package/src/calls/__tests__/voice-session-bridge.test.ts +36 -0
- package/src/calls/__tests__/voice-triage-escalate.test.ts +29 -0
- package/src/calls/call-controller.ts +368 -98
- package/src/calls/resolve-call-tts-provider.ts +33 -2
- package/src/calls/tts-call-strategy.ts +9 -5
- package/src/calls/types.ts +2 -0
- package/src/calls/voice-session-bridge.ts +48 -31
- package/src/calls/voice-triage-escalate.ts +34 -1
- package/src/config/feature-flag-registry.json +1 -13
- package/src/live-voice/live-voice-session.ts +9 -16
- package/src/runtime/routes/__tests__/dictation-cleanup-guard.test.ts +107 -0
- package/src/runtime/routes/diagnostics-routes.ts +55 -3
- package/src/util/__tests__/language-subtag.test.ts +23 -1
- package/src/util/language-subtag.ts +16 -0
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
|
|
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
|
@@ -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
|
-
|
|
461
|
-
// media-stream playability
|
|
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(
|
|
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(
|