@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.
Files changed (28) hide show
  1. package/ARCHITECTURE.md +1 -1
  2. package/package.json +1 -1
  3. package/src/__tests__/config-schema.test.ts +12 -6
  4. package/src/calls/__tests__/call-controller.test.ts +106 -0
  5. package/src/calls/__tests__/media-stream-output.test.ts +32 -0
  6. package/src/{live-voice → calls}/__tests__/progress-narration.test.ts +4 -4
  7. package/src/{live-voice → calls}/__tests__/progress-phrases.test.ts +1 -1
  8. package/src/calls/__tests__/voice-leg-coordinator.test.ts +229 -0
  9. package/src/calls/__tests__/voice-progress-cadence.test.ts +274 -0
  10. package/src/calls/call-controller.ts +190 -113
  11. package/src/calls/call-transport.ts +16 -0
  12. package/src/calls/media-stream-output.ts +32 -0
  13. package/src/{live-voice → calls}/progress-narration.ts +2 -2
  14. package/src/calls/voice-leg-coordinator.ts +189 -0
  15. package/src/calls/voice-progress-cadence.ts +423 -0
  16. package/src/config/default-profile-catalog.ts +1 -1
  17. package/src/config/schema.ts +2 -0
  18. package/src/config/schemas/__tests__/live-voice.test.ts +0 -134
  19. package/src/config/schemas/__tests__/voice.test.ts +148 -0
  20. package/src/config/schemas/live-voice.ts +1 -153
  21. package/src/config/schemas/voice.ts +150 -0
  22. package/src/live-voice/__tests__/live-voice-progress.test.ts +15 -15
  23. package/src/live-voice/__tests__/live-voice-vad.test.ts +2 -2
  24. package/src/live-voice/live-voice-session.ts +178 -521
  25. package/src/workspace/migrations/155-move-front-model-config-to-voice.ts +78 -0
  26. package/src/workspace/migrations/__tests__/155-move-front-model-config-to-voice.test.ts +102 -0
  27. package/src/workspace/migrations/registry.ts +2 -0
  28. /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 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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.0-dev.202609111617.f60422f",
3
+ "version": "0.12.0-dev.202609111724.a78dcda",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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 { LiveVoiceProgressConfigSchema } from "../../config/schemas/live-voice.js";
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 = LiveVoiceProgressConfigSchema.parse({});
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: LiveVoiceProgressConfigSchema.parse({ generationTimeoutMs: 20 }),
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: LiveVoiceProgressConfigSchema.parse({
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
+ });