@vellumai/assistant 0.12.0-dev.202609111617.f60422f → 0.12.0-dev.202609111724.a78dcda
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 +1 -1
- package/package.json +1 -1
- package/src/__tests__/config-schema.test.ts +12 -6
- package/src/calls/__tests__/call-controller.test.ts +106 -0
- package/src/calls/__tests__/media-stream-output.test.ts +32 -0
- package/src/{live-voice → calls}/__tests__/progress-narration.test.ts +4 -4
- package/src/{live-voice → calls}/__tests__/progress-phrases.test.ts +1 -1
- package/src/calls/__tests__/voice-leg-coordinator.test.ts +229 -0
- package/src/calls/__tests__/voice-progress-cadence.test.ts +274 -0
- package/src/calls/call-controller.ts +190 -113
- package/src/calls/call-transport.ts +16 -0
- package/src/calls/media-stream-output.ts +32 -0
- package/src/{live-voice → calls}/progress-narration.ts +2 -2
- package/src/calls/voice-leg-coordinator.ts +189 -0
- package/src/calls/voice-progress-cadence.ts +423 -0
- package/src/config/default-profile-catalog.ts +1 -1
- package/src/config/schema.ts +2 -0
- package/src/config/schemas/__tests__/live-voice.test.ts +0 -134
- package/src/config/schemas/__tests__/voice.test.ts +148 -0
- package/src/config/schemas/live-voice.ts +1 -153
- package/src/config/schemas/voice.ts +150 -0
- package/src/live-voice/__tests__/live-voice-progress.test.ts +15 -15
- package/src/live-voice/__tests__/live-voice-vad.test.ts +2 -2
- package/src/live-voice/live-voice-session.ts +178 -521
- package/src/workspace/migrations/155-move-front-model-config-to-voice.ts +78 -0
- package/src/workspace/migrations/__tests__/155-move-front-model-config-to-voice.test.ts +102 -0
- package/src/workspace/migrations/registry.ts +2 -0
- /package/src/{live-voice → calls}/progress-phrases.ts +0 -0
package/ARCHITECTURE.md
CHANGED
|
@@ -612,7 +612,7 @@ 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
|
|
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 drives it through the shared `createFrontDoorLegCoordinator` (`src/calls/voice-leg-coordinator.ts`), which reads the stream through the verdict machine and sequences the hand-off (pause narration, abort the leg, resolve and speak the bridge, mark it as the floor holder, start the escalated leg pinned to the conversation's own model, re-arm narration); each driver supplies only a host for how text and the bridge are spoken, how a leg is started or aborted, and (live voice only) the speculative hold and commit. 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. Both drivers share the spoken progress narration cadence (`src/calls/voice-progress-cadence.ts`, tuned by `voice.frontModel.progress`): the cadence owns the tool-activity log, the triggers (an ops burst, a long operation completing, a full interval of audible silence with news, the `maxSilenceMs` heartbeat) and the generated or static phrase, while each driver supplies its own view of audible silence (live voice from its TTS queue and playback-tail estimate; the media-stream transport from `isPlaybackIdle()` and a running sum of sent frame durations) and how to speak a phrase.
|
|
616
616
|
|
|
617
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.
|
|
618
618
|
|
package/package.json
CHANGED
|
@@ -998,6 +998,18 @@ describe("AssistantConfigSchema", () => {
|
|
|
998
998
|
echoEmaHalfLifeMs: 400,
|
|
999
999
|
echoDrainSlackMs: 300,
|
|
1000
1000
|
},
|
|
1001
|
+
flux: {
|
|
1002
|
+
turnEnd: { enabled: true },
|
|
1003
|
+
eotThreshold: 0.7,
|
|
1004
|
+
eotTimeoutMs: 5000,
|
|
1005
|
+
},
|
|
1006
|
+
archiveAudio: false,
|
|
1007
|
+
});
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
test("applies voice defaults", () => {
|
|
1011
|
+
const result = AssistantConfigSchema.parse({});
|
|
1012
|
+
expect(result.voice).toEqual({
|
|
1001
1013
|
frontModel: {
|
|
1002
1014
|
endpointDecisionTimeoutMs: 1200,
|
|
1003
1015
|
endpointExtensionMs: 1500,
|
|
@@ -1012,12 +1024,6 @@ describe("AssistantConfigSchema", () => {
|
|
|
1012
1024
|
generationTimeoutMs: 1500,
|
|
1013
1025
|
},
|
|
1014
1026
|
},
|
|
1015
|
-
flux: {
|
|
1016
|
-
turnEnd: { enabled: true },
|
|
1017
|
-
eotThreshold: 0.7,
|
|
1018
|
-
eotTimeoutMs: 5000,
|
|
1019
|
-
},
|
|
1020
|
-
archiveAudio: false,
|
|
1021
1027
|
});
|
|
1022
1028
|
});
|
|
1023
1029
|
|
|
@@ -225,6 +225,7 @@ import { resetDbForTesting } from "../../__tests__/db-test-helpers.js";
|
|
|
225
225
|
import { createGuardianBinding } from "../../__tests__/helpers/create-guardian-binding.js";
|
|
226
226
|
import { setConfig } from "../../__tests__/helpers/set-config.js";
|
|
227
227
|
import { loadConfig } from "../../config/loader.js";
|
|
228
|
+
import type { VoiceProgressConfig } from "../../config/schemas/voice.js";
|
|
228
229
|
import { getMessages } from "../../persistence/conversation-crud.js";
|
|
229
230
|
import { getDb } from "../../persistence/db-connection.js";
|
|
230
231
|
import { initializeDb } from "../../persistence/db-init.js";
|
|
@@ -240,6 +241,7 @@ import {
|
|
|
240
241
|
updateCallSession,
|
|
241
242
|
} from "../call-store.js";
|
|
242
243
|
import type { CallTransport } from "../call-transport.js";
|
|
244
|
+
import type { VoiceProgressNarrator } from "../progress-narration.js";
|
|
243
245
|
import { resolveCallTtsProvider } from "../resolve-call-tts-provider.js";
|
|
244
246
|
import {
|
|
245
247
|
ESCALATION_CONTINUATION_CONTENT,
|
|
@@ -412,6 +414,9 @@ function setupController(
|
|
|
412
414
|
awaitPlaybackDrained?: () => Promise<void>;
|
|
413
415
|
/** Synthesis-language resolver, as the media-stream server wires it. */
|
|
414
416
|
resolveSynthesisLanguage?: () => string | undefined;
|
|
417
|
+
/** Progress narration seam; an explicit null keeps the call silent. */
|
|
418
|
+
progressNarrator?: VoiceProgressNarrator | null;
|
|
419
|
+
progressConfig?: VoiceProgressConfig;
|
|
415
420
|
},
|
|
416
421
|
) {
|
|
417
422
|
ensureConversation("conv-ctrl-test");
|
|
@@ -436,6 +441,11 @@ function setupController(
|
|
|
436
441
|
assistantId: opts?.assistantId,
|
|
437
442
|
trustContext: opts?.trustContext,
|
|
438
443
|
resolveSynthesisLanguage: opts?.resolveSynthesisLanguage,
|
|
444
|
+
// Tests that do not exercise narration keep the default narrator out of
|
|
445
|
+
// the way: a null narrator never generates or speaks.
|
|
446
|
+
progressNarrator:
|
|
447
|
+
opts?.progressNarrator === undefined ? null : opts.progressNarrator,
|
|
448
|
+
progressConfig: opts?.progressConfig,
|
|
439
449
|
});
|
|
440
450
|
return { session, relay: transport, controller };
|
|
441
451
|
}
|
|
@@ -4521,6 +4531,102 @@ describe("call-controller", () => {
|
|
|
4521
4531
|
controller.destroy();
|
|
4522
4532
|
});
|
|
4523
4533
|
|
|
4534
|
+
test("progress narration speaks into the escalated leg's tool loop, audio-only", async () => {
|
|
4535
|
+
const narrationConfig: VoiceProgressConfig = {
|
|
4536
|
+
enabled: true,
|
|
4537
|
+
opsThreshold: 1,
|
|
4538
|
+
idleIntervalMs: 60_000,
|
|
4539
|
+
maxSilenceMs: 60_000,
|
|
4540
|
+
longOpMs: 15_000,
|
|
4541
|
+
minGapMs: 10,
|
|
4542
|
+
generationTimeoutMs: 1_500,
|
|
4543
|
+
};
|
|
4544
|
+
const narrator: VoiceProgressNarrator = {
|
|
4545
|
+
generateProgressText: async (input) => {
|
|
4546
|
+
expect(input.transcriptSoFar).toBe("What is on my calendar?");
|
|
4547
|
+
expect(input.currentOp?.toolName).toBe("calendar_list");
|
|
4548
|
+
return "Pulling up your calendar now.";
|
|
4549
|
+
},
|
|
4550
|
+
};
|
|
4551
|
+
const { session, relay, controller } = setupController(undefined, {
|
|
4552
|
+
progressNarrator: narrator,
|
|
4553
|
+
progressConfig: narrationConfig,
|
|
4554
|
+
});
|
|
4555
|
+
const calls: LegOpts[] = [];
|
|
4556
|
+
mockStartVoiceTurn.mockImplementation(async (opts: LegOpts) => {
|
|
4557
|
+
const index = calls.length;
|
|
4558
|
+
calls.push(opts);
|
|
4559
|
+
if (index === 0) {
|
|
4560
|
+
opts.onTextDelta("[1] One moment.");
|
|
4561
|
+
opts.onComplete();
|
|
4562
|
+
return { turnId: "run-front", abort: () => {} };
|
|
4563
|
+
}
|
|
4564
|
+
// The escalated leg runs a tool; narration must land while it runs.
|
|
4565
|
+
// The bridge just spoken is a floor holder, so the tool starts after
|
|
4566
|
+
// the (test-short) minimum gap.
|
|
4567
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
4568
|
+
opts.callbacks?.tool_use_start?.("calendar_list", { toolUseId: "c1" });
|
|
4569
|
+
await pollUntil(() =>
|
|
4570
|
+
relay.sentTokens.some((t) =>
|
|
4571
|
+
t.token.includes("Pulling up your calendar now."),
|
|
4572
|
+
),
|
|
4573
|
+
);
|
|
4574
|
+
opts.callbacks?.tool_result?.({
|
|
4575
|
+
toolName: "calendar_list",
|
|
4576
|
+
toolUseId: "c1",
|
|
4577
|
+
resultPreview: "two events",
|
|
4578
|
+
});
|
|
4579
|
+
opts.onTextDelta("You have two events today.");
|
|
4580
|
+
opts.onComplete();
|
|
4581
|
+
return { turnId: "run-escalated", abort: () => {} };
|
|
4582
|
+
});
|
|
4583
|
+
|
|
4584
|
+
await controller.handleCallerUtterance("What is on my calendar?");
|
|
4585
|
+
|
|
4586
|
+
const spoken = spokenText(relay);
|
|
4587
|
+
expect(spoken.indexOf("One moment.")).toBeLessThan(
|
|
4588
|
+
spoken.indexOf("Pulling up your calendar now."),
|
|
4589
|
+
);
|
|
4590
|
+
expect(spoken.indexOf("Pulling up your calendar now.")).toBeLessThan(
|
|
4591
|
+
spoken.indexOf("You have two events today."),
|
|
4592
|
+
);
|
|
4593
|
+
// Narration is audio-only: the turn's recorded text is the model's.
|
|
4594
|
+
expect(assistantSpokePayload(session.id).text).toBe(
|
|
4595
|
+
"One moment. You have two events today.",
|
|
4596
|
+
);
|
|
4597
|
+
|
|
4598
|
+
controller.destroy();
|
|
4599
|
+
});
|
|
4600
|
+
|
|
4601
|
+
test("no narration without a narrator", async () => {
|
|
4602
|
+
const { relay, controller } = setupController(undefined, {
|
|
4603
|
+
progressNarrator: null,
|
|
4604
|
+
progressConfig: {
|
|
4605
|
+
enabled: true,
|
|
4606
|
+
opsThreshold: 1,
|
|
4607
|
+
idleIntervalMs: 60_000,
|
|
4608
|
+
maxSilenceMs: 60_000,
|
|
4609
|
+
longOpMs: 15_000,
|
|
4610
|
+
minGapMs: 10,
|
|
4611
|
+
generationTimeoutMs: 1_500,
|
|
4612
|
+
},
|
|
4613
|
+
});
|
|
4614
|
+
scriptLegs([["Done."]], (opts) => {
|
|
4615
|
+
opts.callbacks?.tool_use_start?.("web_search", { toolUseId: "t1" });
|
|
4616
|
+
opts.callbacks?.tool_result?.({
|
|
4617
|
+
toolName: "web_search",
|
|
4618
|
+
toolUseId: "t1",
|
|
4619
|
+
resultPreview: "ok",
|
|
4620
|
+
});
|
|
4621
|
+
});
|
|
4622
|
+
|
|
4623
|
+
await controller.handleCallerUtterance("Look it up");
|
|
4624
|
+
|
|
4625
|
+
expect(spokenText(relay)).toBe("Done.");
|
|
4626
|
+
|
|
4627
|
+
controller.destroy();
|
|
4628
|
+
});
|
|
4629
|
+
|
|
4524
4630
|
test("an outbound call reports the outbound entry", async () => {
|
|
4525
4631
|
const { calls } = scriptLegs([["Hello, this is Ava."]]);
|
|
4526
4632
|
const { session, controller } = setupController("Book a table for two");
|
|
@@ -854,6 +854,38 @@ describe("MediaStreamOutput", () => {
|
|
|
854
854
|
// Initially empty
|
|
855
855
|
expect(output.getPlaybackQueueLength()).toBe(0);
|
|
856
856
|
});
|
|
857
|
+
|
|
858
|
+
test("isPlaybackIdle tracks buffered text, the queue, and the sent-audio tail", async () => {
|
|
859
|
+
const samples = Array.from({ length: 4000 }, (_, i) =>
|
|
860
|
+
Math.round(Math.sin(i * 0.1) * 10000),
|
|
861
|
+
);
|
|
862
|
+
mockSynthesize.mockResolvedValue({
|
|
863
|
+
audio: makeWavBuffer(samples),
|
|
864
|
+
contentType: "audio/wav",
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
const { ws, sent } = createMockWs();
|
|
868
|
+
const output = makeOutput(ws, "stream-1");
|
|
869
|
+
expect(output.isPlaybackIdle()).toBe(true);
|
|
870
|
+
expect(output.playbackTailUntilMs()).toBe(0);
|
|
871
|
+
|
|
872
|
+
// Text buffered toward a sentence boundary is pending speech.
|
|
873
|
+
output.sendTextToken("Still thinking", false);
|
|
874
|
+
expect(output.isPlaybackIdle()).toBe(false);
|
|
875
|
+
|
|
876
|
+
output.sendTextToken(" about it.", true);
|
|
877
|
+
await drain(() => countEvents(sent, "mark") > 0);
|
|
878
|
+
|
|
879
|
+
// Frames left faster than real time: the tail estimate still covers
|
|
880
|
+
// the audio Twilio is playing out.
|
|
881
|
+
expect(output.playbackTailUntilMs()).toBeGreaterThan(Date.now());
|
|
882
|
+
expect(output.isPlaybackIdle()).toBe(false);
|
|
883
|
+
|
|
884
|
+
// A barge-in clear drops Twilio's buffer, so nothing is audible.
|
|
885
|
+
output.clearAudio();
|
|
886
|
+
expect(output.playbackTailUntilMs()).toBe(0);
|
|
887
|
+
expect(output.isPlaybackIdle()).toBe(true);
|
|
888
|
+
});
|
|
857
889
|
});
|
|
858
890
|
|
|
859
891
|
// ---------------------------------------------------------------------------
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { VoiceProgressConfigSchema } from "../../config/schemas/voice.js";
|
|
4
4
|
import type { Provider, ProviderResponse } from "../../providers/types.js";
|
|
5
5
|
import {
|
|
6
6
|
createVoiceProgressNarrator,
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
type VoiceProgressTextInput,
|
|
9
9
|
} from "../progress-narration.js";
|
|
10
10
|
|
|
11
|
-
const config =
|
|
11
|
+
const config = VoiceProgressConfigSchema.parse({});
|
|
12
12
|
const progressInput: VoiceProgressTextInput = {
|
|
13
13
|
transcriptSoFar: "compare flight prices for next month",
|
|
14
14
|
completedOps: [
|
|
@@ -177,7 +177,7 @@ describe("createVoiceProgressNarrator", () => {
|
|
|
177
177
|
|
|
178
178
|
test("bounds provider resolution by the configured timeout", async () => {
|
|
179
179
|
const narrator = createVoiceProgressNarrator({
|
|
180
|
-
config:
|
|
180
|
+
config: VoiceProgressConfigSchema.parse({ generationTimeoutMs: 20 }),
|
|
181
181
|
getProvider: () => new Promise<Provider | null>(() => {}),
|
|
182
182
|
});
|
|
183
183
|
const startedAt = Date.now();
|
|
@@ -187,7 +187,7 @@ describe("createVoiceProgressNarrator", () => {
|
|
|
187
187
|
|
|
188
188
|
test("a caller abort settles promptly", async () => {
|
|
189
189
|
const narrator = createVoiceProgressNarrator({
|
|
190
|
-
config:
|
|
190
|
+
config: VoiceProgressConfigSchema.parse({
|
|
191
191
|
generationTimeoutMs: 60_000,
|
|
192
192
|
}),
|
|
193
193
|
getProvider: async () =>
|
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
|
|
9
9
|
import { describe, expect, test } from "bun:test";
|
|
10
10
|
|
|
11
|
-
import { BRIDGE_SENTENCE_END_REGEX } from "../../calls/voice-triage-escalate.js";
|
|
12
11
|
import { DEEPGRAM_MULTI_LANGUAGE_CODES } from "../../providers/speech-to-text/deepgram.js";
|
|
13
12
|
import {
|
|
14
13
|
APPROVAL_PENDING_PHRASE,
|
|
@@ -18,6 +17,7 @@ import {
|
|
|
18
17
|
PROGRESS_FALLBACK_PHRASES,
|
|
19
18
|
PROGRESS_FALLBACK_PHRASES_BY_LANGUAGE,
|
|
20
19
|
} from "../progress-phrases.js";
|
|
20
|
+
import { BRIDGE_SENTENCE_END_REGEX } from "../voice-triage-escalate.js";
|
|
21
21
|
|
|
22
22
|
// Scripts without space-delimited words, where a word budget is
|
|
23
23
|
// meaningless and length is asserted instead.
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createFrontDoorLegCoordinator,
|
|
5
|
+
type EscalatedLeg,
|
|
6
|
+
escalatedLegFor,
|
|
7
|
+
type FrontDoorLegHost,
|
|
8
|
+
type SpokenEscalationBridge,
|
|
9
|
+
} from "../voice-leg-coordinator.js";
|
|
10
|
+
import {
|
|
11
|
+
ESCALATION_CONTINUATION_CONTENT,
|
|
12
|
+
FALLBACK_ESCALATION_BRIDGE,
|
|
13
|
+
FALLBACK_ESCALATION_BRIDGE_BY_LANGUAGE,
|
|
14
|
+
} from "../voice-triage-escalate.js";
|
|
15
|
+
|
|
16
|
+
interface Recorded {
|
|
17
|
+
events: string[];
|
|
18
|
+
answers: string[];
|
|
19
|
+
bridges: SpokenEscalationBridge[];
|
|
20
|
+
escalated: EscalatedLeg[];
|
|
21
|
+
holds: number;
|
|
22
|
+
commits: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function harness(opts?: {
|
|
26
|
+
holdEnabled?: boolean;
|
|
27
|
+
live?: () => boolean;
|
|
28
|
+
language?: string;
|
|
29
|
+
commit?: () => boolean;
|
|
30
|
+
withProgress?: boolean;
|
|
31
|
+
withHold?: boolean;
|
|
32
|
+
}) {
|
|
33
|
+
const recorded: Recorded = {
|
|
34
|
+
events: [],
|
|
35
|
+
answers: [],
|
|
36
|
+
bridges: [],
|
|
37
|
+
escalated: [],
|
|
38
|
+
holds: 0,
|
|
39
|
+
commits: 0,
|
|
40
|
+
};
|
|
41
|
+
const host: FrontDoorLegHost = {
|
|
42
|
+
isLive: opts?.live ?? (() => true),
|
|
43
|
+
language: () => opts?.language,
|
|
44
|
+
...(opts?.withProgress === false
|
|
45
|
+
? {}
|
|
46
|
+
: {
|
|
47
|
+
progress: {
|
|
48
|
+
clear: () => recorded.events.push("progress.clear"),
|
|
49
|
+
noteFloorHolder: () => recorded.events.push("progress.floor"),
|
|
50
|
+
arm: () => recorded.events.push("progress.arm"),
|
|
51
|
+
},
|
|
52
|
+
}),
|
|
53
|
+
...(opts?.withHold
|
|
54
|
+
? {
|
|
55
|
+
onHold: () => {
|
|
56
|
+
recorded.holds += 1;
|
|
57
|
+
recorded.events.push("hold");
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
: {}),
|
|
61
|
+
...(opts?.commit
|
|
62
|
+
? {
|
|
63
|
+
commit: () => {
|
|
64
|
+
recorded.commits += 1;
|
|
65
|
+
recorded.events.push("commit");
|
|
66
|
+
return opts.commit!();
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
: {}),
|
|
70
|
+
onAnswerText: (text) => {
|
|
71
|
+
recorded.answers.push(text);
|
|
72
|
+
recorded.events.push(`answer:${text}`);
|
|
73
|
+
},
|
|
74
|
+
abortLeg: () => recorded.events.push("abort"),
|
|
75
|
+
speakBridge: (bridge) => {
|
|
76
|
+
recorded.bridges.push(bridge);
|
|
77
|
+
recorded.events.push(`speak:${bridge.spokenBridge}`);
|
|
78
|
+
},
|
|
79
|
+
startEscalatedLeg: (leg) => {
|
|
80
|
+
recorded.escalated.push(leg);
|
|
81
|
+
recorded.events.push("start-escalated");
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
const coordinator = createFrontDoorLegCoordinator({
|
|
85
|
+
holdEnabled: opts?.holdEnabled ?? false,
|
|
86
|
+
host,
|
|
87
|
+
});
|
|
88
|
+
return { coordinator, recorded };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe("escalatedLegFor", () => {
|
|
92
|
+
test("continues on the escalated leg with the spoken bridge quoted", () => {
|
|
93
|
+
expect(escalatedLegFor("One moment.")).toEqual({
|
|
94
|
+
content: ESCALATION_CONTINUATION_CONTENT,
|
|
95
|
+
routingLeg: "escalated",
|
|
96
|
+
spokenEscalationBridge: "One moment.",
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("createFrontDoorLegCoordinator", () => {
|
|
102
|
+
test("an answer releases the held leading text, then streams", () => {
|
|
103
|
+
const { coordinator, recorded } = harness();
|
|
104
|
+
coordinator.push("[");
|
|
105
|
+
expect(recorded.answers).toEqual([]);
|
|
106
|
+
coordinator.push("END_CALL] Bye");
|
|
107
|
+
coordinator.push(" now.");
|
|
108
|
+
|
|
109
|
+
expect(recorded.answers).toEqual(["[END_CALL] Bye", " now."]);
|
|
110
|
+
expect(coordinator.complete()).toBe(false);
|
|
111
|
+
expect(coordinator.handedOff).toBe(false);
|
|
112
|
+
expect(recorded.escalated).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("an escalate verdict hands off in order: pause, abort, speak, floor, start, re-arm", () => {
|
|
116
|
+
const { coordinator, recorded } = harness();
|
|
117
|
+
coordinator.push("[1] Let me check");
|
|
118
|
+
expect(coordinator.handedOff).toBe(false);
|
|
119
|
+
coordinator.push(" that.");
|
|
120
|
+
|
|
121
|
+
expect(coordinator.handedOff).toBe(true);
|
|
122
|
+
expect(recorded.events).toEqual([
|
|
123
|
+
"progress.clear",
|
|
124
|
+
"abort",
|
|
125
|
+
"speak:Let me check that.",
|
|
126
|
+
"progress.floor",
|
|
127
|
+
"start-escalated",
|
|
128
|
+
"progress.arm",
|
|
129
|
+
]);
|
|
130
|
+
expect(recorded.bridges[0]).toEqual({
|
|
131
|
+
spokenBridge: "Let me check that.",
|
|
132
|
+
usesFallback: false,
|
|
133
|
+
});
|
|
134
|
+
expect(recorded.escalated).toEqual([escalatedLegFor("Let me check that.")]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("after the hand-off the leg's stream and completion are ignored", () => {
|
|
138
|
+
const { coordinator, recorded } = harness();
|
|
139
|
+
coordinator.push("[1] One moment.");
|
|
140
|
+
coordinator.push(" Ignored past the cap.");
|
|
141
|
+
expect(coordinator.complete()).toBe(true);
|
|
142
|
+
|
|
143
|
+
expect(recorded.answers).toEqual([]);
|
|
144
|
+
expect(recorded.escalated.length).toBe(1);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("a leg that stops mid-bridge hands off on completion with the canned bridge", () => {
|
|
148
|
+
const { coordinator, recorded } = harness({ language: "es" });
|
|
149
|
+
coordinator.push("[1]");
|
|
150
|
+
expect(coordinator.handedOff).toBe(false);
|
|
151
|
+
|
|
152
|
+
expect(coordinator.complete()).toBe(true);
|
|
153
|
+
expect(recorded.bridges[0]).toEqual({
|
|
154
|
+
spokenBridge: FALLBACK_ESCALATION_BRIDGE_BY_LANGUAGE.es,
|
|
155
|
+
usesFallback: true,
|
|
156
|
+
});
|
|
157
|
+
expect(recorded.escalated[0].spokenEscalationBridge).toBe(
|
|
158
|
+
FALLBACK_ESCALATION_BRIDGE_BY_LANGUAGE.es,
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("the canned bridge for a language the table lacks is English and says so", () => {
|
|
163
|
+
const { coordinator, recorded } = harness({ language: "ko" });
|
|
164
|
+
coordinator.push("[1]");
|
|
165
|
+
coordinator.complete();
|
|
166
|
+
|
|
167
|
+
expect(recorded.bridges[0]).toEqual({
|
|
168
|
+
spokenBridge: FALLBACK_ESCALATION_BRIDGE,
|
|
169
|
+
usesFallback: true,
|
|
170
|
+
language: "en",
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("a dead turn never hands off", () => {
|
|
175
|
+
let live = true;
|
|
176
|
+
const { coordinator, recorded } = harness({ live: () => live });
|
|
177
|
+
coordinator.push("[1] One");
|
|
178
|
+
live = false;
|
|
179
|
+
coordinator.push(" moment.");
|
|
180
|
+
expect(coordinator.handedOff).toBe(false);
|
|
181
|
+
expect(coordinator.complete()).toBe(false);
|
|
182
|
+
|
|
183
|
+
expect(recorded.escalated).toEqual([]);
|
|
184
|
+
expect(recorded.events).toEqual([]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("a hold verdict on a speculative leg discards the leg", () => {
|
|
188
|
+
const { coordinator, recorded } = harness({
|
|
189
|
+
holdEnabled: true,
|
|
190
|
+
withHold: true,
|
|
191
|
+
});
|
|
192
|
+
coordinator.push("[0]");
|
|
193
|
+
|
|
194
|
+
expect(recorded.holds).toBe(1);
|
|
195
|
+
expect(recorded.answers).toEqual([]);
|
|
196
|
+
expect(coordinator.handedOff).toBe(false);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("a leg that does not teach the hold token reads [0] as an answer", () => {
|
|
200
|
+
const { coordinator, recorded } = harness({ withHold: true });
|
|
201
|
+
coordinator.push("[0] Sure.");
|
|
202
|
+
|
|
203
|
+
expect(recorded.holds).toBe(0);
|
|
204
|
+
expect(recorded.answers).toEqual(["[0] Sure."]);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("answer and escalate verdicts commit a speculative turn first; a failed commit stops the verdict", () => {
|
|
208
|
+
const committed = harness({ commit: () => true });
|
|
209
|
+
committed.coordinator.push("Hello.");
|
|
210
|
+
expect(committed.recorded.events).toEqual(["commit", "answer:Hello."]);
|
|
211
|
+
|
|
212
|
+
const gone = harness({ commit: () => false });
|
|
213
|
+
gone.coordinator.push("[1] One moment.");
|
|
214
|
+
expect(gone.recorded.commits).toBe(1);
|
|
215
|
+
expect(gone.coordinator.handedOff).toBe(false);
|
|
216
|
+
expect(gone.recorded.escalated).toEqual([]);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("works without a progress cadence", () => {
|
|
220
|
+
const { coordinator, recorded } = harness({ withProgress: false });
|
|
221
|
+
coordinator.push("[1] One moment.");
|
|
222
|
+
|
|
223
|
+
expect(recorded.events).toEqual([
|
|
224
|
+
"abort",
|
|
225
|
+
"speak:One moment.",
|
|
226
|
+
"start-escalated",
|
|
227
|
+
]);
|
|
228
|
+
});
|
|
229
|
+
});
|