@vellumai/assistant 0.11.3-staging.1 → 0.11.3-staging.3

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 (80) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/call-controller.test.ts +120 -0
  3. package/src/__tests__/config-loader-backfill.test.ts +19 -0
  4. package/src/__tests__/config-schema.test.ts +139 -5
  5. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +123 -0
  6. package/src/__tests__/conversation-queue.test.ts +168 -0
  7. package/src/__tests__/conversation-routes-hidden-queue.test.ts +64 -1
  8. package/src/__tests__/events-dev-bypass-actor.test.ts +112 -1
  9. package/src/__tests__/media-stream-output.test.ts +175 -0
  10. package/src/__tests__/media-stream-stt-session.test.ts +67 -0
  11. package/src/__tests__/oauth-provider-seed-logos.test.ts +65 -0
  12. package/src/__tests__/tool-grant-request-escalation.test.ts +40 -1
  13. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +68 -1
  14. package/src/approvals/guardian-request-resolvers.ts +6 -3
  15. package/src/calls/__tests__/tts-text-sanitizer.test.ts +13 -0
  16. package/src/calls/__tests__/voice-session-bridge.test.ts +81 -12
  17. package/src/calls/__tests__/voice-triage-escalate.test.ts +106 -0
  18. package/src/calls/call-controller.ts +30 -2
  19. package/src/calls/call-speech-output.ts +12 -4
  20. package/src/calls/call-transport.ts +18 -1
  21. package/src/calls/media-stream-output.ts +53 -5
  22. package/src/calls/media-stream-server.ts +12 -0
  23. package/src/calls/media-stream-stt-session.ts +34 -0
  24. package/src/calls/telephony-synthesis-language.ts +84 -0
  25. package/src/calls/tts-text-sanitizer.ts +12 -5
  26. package/src/calls/voice-session-bridge.ts +31 -3
  27. package/src/calls/voice-triage-escalate.ts +52 -5
  28. package/src/config/bundled-skills/phone-calls/references/CONFIG.md +15 -15
  29. package/src/config/loader.ts +5 -0
  30. package/src/config/schemas/calls.ts +0 -4
  31. package/src/config/schemas/timeouts.ts +1 -1
  32. package/src/config/schemas/tts.ts +63 -0
  33. package/src/daemon/conversation-agent-loop.ts +22 -3
  34. package/src/daemon/conversation-messaging.ts +21 -1
  35. package/src/daemon/conversation-process.ts +55 -5
  36. package/src/daemon/conversation-queue-manager.ts +10 -0
  37. package/src/daemon/conversation.ts +6 -0
  38. package/src/daemon/trust-context-types.ts +39 -0
  39. package/src/live-voice/__tests__/front-decision.test.ts +120 -0
  40. package/src/live-voice/__tests__/live-voice-events.test.ts +1 -1
  41. package/src/live-voice/__tests__/live-voice-progress.test.ts +178 -11
  42. package/src/live-voice/__tests__/live-voice-stt.test.ts +304 -1
  43. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +102 -2
  44. package/src/live-voice/__tests__/live-voice-tts.test.ts +148 -2
  45. package/src/live-voice/__tests__/progress-phrases.test.ts +167 -0
  46. package/src/live-voice/front-decision.ts +50 -3
  47. package/src/live-voice/live-voice-session.ts +202 -25
  48. package/src/live-voice/live-voice-tts.ts +18 -2
  49. package/src/live-voice/progress-phrases.ts +105 -2
  50. package/src/oauth/AGENTS.md +4 -2
  51. package/src/oauth/seed-providers.ts +6 -3
  52. package/src/providers/speech-to-text/deepgram-realtime.test.ts +283 -1
  53. package/src/providers/speech-to-text/deepgram-realtime.ts +117 -3
  54. package/src/providers/speech-to-text/provider-catalog.ts +38 -0
  55. package/src/runtime/__tests__/local-actor-identity-force-refresh.test.ts +104 -0
  56. package/src/runtime/assistant-event-hub.ts +23 -0
  57. package/src/runtime/local-actor-identity.ts +18 -5
  58. package/src/runtime/routes/__tests__/sse-actor-principal-heal.test.ts +165 -0
  59. package/src/runtime/routes/__tests__/surface-action-routes.test.ts +2 -0
  60. package/src/runtime/routes/conversation-routes.ts +10 -0
  61. package/src/runtime/routes/events-routes.ts +17 -16
  62. package/src/runtime/routes/sse-actor-principal-heal.ts +113 -0
  63. package/src/stt/__tests__/language-metadata.test.ts +85 -0
  64. package/src/stt/language-metadata.ts +65 -0
  65. package/src/stt/types.ts +16 -0
  66. package/src/subagent/manager.ts +9 -2
  67. package/src/tools/execution-timeout.ts +9 -2
  68. package/src/tools/tool-approval-handler.ts +45 -4
  69. package/src/tts/__tests__/provider-adapters.test.ts +147 -0
  70. package/src/tts/__tests__/speakable-segments.test.ts +470 -0
  71. package/src/tts/language-voices.ts +23 -0
  72. package/src/tts/providers/deepgram-provider.ts +3 -1
  73. package/src/tts/providers/elevenlabs-provider.ts +73 -1
  74. package/src/tts/providers/xai-provider.ts +28 -2
  75. package/src/tts/speakable-segments.ts +293 -23
  76. package/src/tts/synthesis-stream.ts +7 -0
  77. package/src/tts/types.ts +7 -0
  78. package/src/util/__tests__/language-subtag.test.ts +54 -0
  79. package/src/util/language-subtag.ts +43 -0
  80. package/src/util/unicode.ts +1 -1
@@ -170,6 +170,7 @@ mock.module("../channels/gateway-guardian-requests.js", () => sim.module);
170
170
  import { applyGuardianDecision } from "../approvals/guardian-decision-primitive.js";
171
171
  import type { ActorContext } from "../approvals/guardian-request-resolvers.js";
172
172
  import { getResolver } from "../approvals/guardian-request-resolvers.js";
173
+ import { getConfig } from "../config/loader.js";
173
174
  import type { TrustContext } from "../daemon/trust-context-types.js";
174
175
  import { getDb } from "../persistence/db-connection.js";
175
176
  import { initializeDb } from "../persistence/db-init.js";
@@ -177,12 +178,14 @@ import { scopedApprovalGrants } from "../persistence/schema/index.js";
177
178
  import { bridgeConfirmationRequestToGuardian } from "../runtime/confirmation-request-guardian-bridge.js";
178
179
  import { resolveRoutingState } from "../runtime/trust-context-resolver.js";
179
180
  import {
181
+ resolveInlineGrantWaitMs,
180
182
  TC_GRANT_WAIT_MAX_MS,
181
183
  ToolApprovalHandler,
182
184
  waitForInlineGrant,
183
185
  } from "../tools/tool-approval-handler.js";
184
186
  import type { ToolContext } from "../tools/types.js";
185
187
  import { seedContactChannel } from "./helpers/seed-contact-channel.js";
188
+ import { setConfig } from "./helpers/set-config.js";
186
189
 
187
190
  await initializeDb();
188
191
 
@@ -754,7 +757,10 @@ describe("(f) timeout/stale flow: stale guardian decision after inline wait time
754
757
  test("inline_wait_active staleness guard: expired marker allows retry notification", async () => {
755
758
  // Create a guardian request with a stale inline_wait_active marker
756
759
  // that simulates a daemon crash during the wait.
757
- const staleTimestamp = Date.now() - TC_GRANT_WAIT_MAX_MS - 60_000;
760
+ // Age the marker past the real wait budget, which the resolver's
761
+ // staleness threshold tracks. Deriving it from the same helper the
762
+ // waiter uses keeps this case meaningful if the budget changes.
763
+ const staleTimestamp = Date.now() - resolveInlineGrantWaitMs() - 60_000;
758
764
  const req = sim.seedRequest({
759
765
  id: `req-stale-${Date.now()}`,
760
766
  kind: "tool_grant_request",
@@ -802,6 +808,67 @@ describe("(f) timeout/stale flow: stale guardian decision after inline wait time
802
808
  expect(retryNotifications.length).toBeGreaterThan(0);
803
809
  });
804
810
 
811
+ test("inline_wait_active marker older than the fallback constant but inside the real budget still suppresses retry", async () => {
812
+ // The staleness threshold must track the wait budget, not the fallback
813
+ // constant. A marker aged past TC_GRANT_WAIT_MAX_MS + buffer but still
814
+ // well inside the configured budget belongs to a waiter that is very
815
+ // much alive: telling the requester to retry would race a call that is
816
+ // about to resume, and the retry then fails against the one-time grant
817
+ // the live waiter consumes.
818
+ const priorTimeouts = getConfig().timeouts;
819
+ // Seed the budget rather than leaning on the ambient default, so the
820
+ // window this case probes exists no matter what the suite's config holds.
821
+ setConfig("timeouts", { ...priorTimeouts, permissionTimeoutSec: 300 });
822
+ try {
823
+ const budgetMs = resolveInlineGrantWaitMs();
824
+ // Comfortably past the old 90s threshold, comfortably short of the budget.
825
+ const markerAgeMs = TC_GRANT_WAIT_MAX_MS + 45_000;
826
+ const liveTimestamp = Date.now() - markerAgeMs;
827
+ expect(markerAgeMs).toBeLessThan(budgetMs);
828
+
829
+ const req = sim.seedRequest({
830
+ id: `req-live-${Date.now()}`,
831
+ kind: "tool_grant_request",
832
+ sourceType: "channel",
833
+ sourceChannel: "telegram",
834
+ sourceConversationId: "conv-live-1",
835
+ requesterExternalUserId: "requester-1",
836
+ requesterChatId: "requester-chat-1",
837
+ guardianExternalUserId: "guardian-1",
838
+ guardianPrincipalId: "test-principal-id",
839
+ toolName: "bash",
840
+ inputDigest: "sha256:livewait",
841
+ expiresAt: Date.now() + 60_000,
842
+ });
843
+
844
+ await sim.module.updateGuardianRequest(req.id, {
845
+ followupState: `inline_wait_active:${liveTimestamp}`,
846
+ });
847
+
848
+ deliveredReplies.length = 0;
849
+ const approvalResult = await applyGuardianDecision({
850
+ requestId: req.id,
851
+ action: "approve_once",
852
+ actorContext: guardianActor(),
853
+ channelDeliveryContext: {
854
+ replyCallbackUrl: "http://localhost:3000/reply",
855
+ guardianChatId: "guardian-chat-1",
856
+ assistantId: "self",
857
+ },
858
+ });
859
+ expect(approvalResult.applied).toBe(true);
860
+
861
+ const retryNotifications = deliveredReplies.filter(
862
+ (r) =>
863
+ typeof r.payload.text === "string" &&
864
+ (r.payload.text as string).includes("Please retry"),
865
+ );
866
+ expect(retryNotifications.length).toBe(0);
867
+ } finally {
868
+ setConfig("timeouts", priorTimeouts);
869
+ }
870
+ });
871
+
805
872
  test("fresh inline_wait_active marker suppresses retry notification", async () => {
806
873
  // Create a request with a FRESH inline_wait_active marker
807
874
  const freshTimestamp = Date.now();
@@ -54,7 +54,7 @@ import {
54
54
  readBatchMetadata,
55
55
  resolvePendingQuestion,
56
56
  } from "../runtime/question-resolution.js";
57
- import { TC_GRANT_WAIT_MAX_MS } from "../tools/tool-approval-handler.js";
57
+ import { resolveInlineGrantWaitMs } from "../tools/tool-approval-handler.js";
58
58
  import { getLogger } from "../util/logger.js";
59
59
  import {
60
60
  channelCanAddressOneReaderInBand,
@@ -1587,7 +1587,10 @@ const toolGrantRequestResolver: GuardianRequestResolver = {
1587
1587
  // outlive the actual waiter if the daemon crashes or restarts during
1588
1588
  // the wait. To avoid permanently suppressing the retry notification, we
1589
1589
  // treat the marker as stale if the encoded start timestamp is older than
1590
- // the maximum wait budget plus a 30s buffer.
1590
+ // the maximum wait budget plus a 30s buffer. The budget is read from the
1591
+ // same resolver the waiter itself uses, so a config change moves both
1592
+ // together; sizing this off a constant would let it fall below the real
1593
+ // wait and declare a live waiter dead.
1591
1594
  const INLINE_WAIT_STALENESS_BUFFER_MS = 30_000;
1592
1595
  const freshRequest = await getGuardianRequestOrNull(request.id);
1593
1596
  const followupState = freshRequest?.followupState ?? "";
@@ -1604,7 +1607,7 @@ const toolGrantRequestResolver: GuardianRequestResolver = {
1604
1607
  ? Date.now() - waitStartMs
1605
1608
  : Infinity; // Treat unparseable timestamps as stale for safety.
1606
1609
  const stalenessThresholdMs =
1607
- TC_GRANT_WAIT_MAX_MS + INLINE_WAIT_STALENESS_BUFFER_MS;
1610
+ resolveInlineGrantWaitMs() + INLINE_WAIT_STALENESS_BUFFER_MS;
1608
1611
  if (markerAgeMs > stalenessThresholdMs) {
1609
1612
  log.warn(
1610
1613
  {
@@ -98,6 +98,19 @@ describe("sanitizeForTts", () => {
98
98
  "use some_function_name here",
99
99
  );
100
100
  });
101
+
102
+ test("strips italic with accented content", () => {
103
+ expect(sanitizeForTts("Hello *café* there")).toBe("Hello café there");
104
+ });
105
+
106
+ test("strips underscore italic with CJK content", () => {
107
+ expect(sanitizeForTts("値は _変数_ です")).toBe("値は 変数 です");
108
+ });
109
+
110
+ test("does not strip a span whose marker touches a non-ASCII word char", () => {
111
+ expect(sanitizeForTts("*強調*です")).toBe("*強調*です");
112
+ expect(sanitizeForTts("café*note* x")).toBe("café*note* x");
113
+ });
101
114
  });
102
115
 
103
116
  describe("headers", () => {
@@ -76,6 +76,7 @@ import { assistantEventHub } from "../../runtime/assistant-event-hub.js";
76
76
  import { CALL_OPENING_MARKER } from "../voice-control-protocol.js";
77
77
  import {
78
78
  cutFrontDoorContentAtVerdict,
79
+ preSpeechLanguageRuleFragment,
79
80
  startVoiceTurn,
80
81
  TOOL_RESULT_PREVIEW_MAX_CHARS,
81
82
  type VoiceTurnOptions,
@@ -498,6 +499,18 @@ describe("startVoiceTurn hiddenSyntheticPrompt", () => {
498
499
  });
499
500
  });
500
501
 
502
+ // The turn installs its resolved control prompt, then cleanup resets it to
503
+ // null, so capture every applied value and read the installed (non-null) one.
504
+ function captureInstalledPrompt(): () => string | undefined {
505
+ const fake = makeFakeConversation({ processing: false });
506
+ fakeConversation = fake.conversation;
507
+ const applied: Array<string | null> = [];
508
+ fake.conversation.setVoiceCallControlPrompt = (prompt) => {
509
+ applied.push(prompt);
510
+ };
511
+ return () => applied.find((p): p is string => typeof p === "string");
512
+ }
513
+
501
514
  describe("startVoiceTurn triage-and-escalate control prompt", () => {
502
515
  // Live-voice supplies its own voiceControlPrompt, bypassing
503
516
  // buildVoiceCallControlPrompt where the routing-leg rule is normally injected.
@@ -505,18 +518,6 @@ describe("startVoiceTurn triage-and-escalate control prompt", () => {
505
518
  // verdict protocol and can't hold or hand off.
506
519
  const LIVE_VOICE_PROMPT = "You are speaking in a local live voice session.";
507
520
 
508
- // The turn installs its resolved control prompt, then cleanup resets it to
509
- // null — so capture every applied value and read the installed (non-null) one.
510
- function captureInstalledPrompt(): () => string | undefined {
511
- const fake = makeFakeConversation({ processing: false });
512
- fakeConversation = fake.conversation;
513
- const applied: Array<string | null> = [];
514
- fake.conversation.setVoiceCallControlPrompt = (prompt) => {
515
- applied.push(prompt);
516
- };
517
- return () => applied.find((p): p is string => typeof p === "string");
518
- }
519
-
520
521
  test("appends the front-door decision rule to a caller-supplied prompt", async () => {
521
522
  const installed = captureInstalledPrompt();
522
523
  await startVoiceTurn({
@@ -560,6 +561,74 @@ describe("startVoiceTurn triage-and-escalate control prompt", () => {
560
561
  });
561
562
  });
562
563
 
564
+ describe("default call protocol numbered rules", () => {
565
+ // With no caller-supplied voiceControlPrompt the bridge builds the numbered
566
+ // CALL PROTOCOL RULES itself. Pin the speak-the-caller's-language rule and
567
+ // keep the numbering gapless so no rule silently shadows another.
568
+ test("teaches speaking the caller's language as its own numbered rule", async () => {
569
+ const installed = captureInstalledPrompt();
570
+ await startVoiceTurn(makeTurnOptions());
571
+ expect(installed()).toContain(
572
+ "12. Speak the caller's language: reply in the language of the caller's most recent actual speech, and follow them if they switch languages mid-call. Synthetic user turns (parenthetical markers like the call-connected and verification-completed notices) are not caller speech and never set the language. Before the caller has spoken, such as on the opening greeting turn, use the language the Task context implies, if any; otherwise default to English.",
573
+ );
574
+ });
575
+
576
+ test("the language rule excludes synthetic turns and covers pre-speech turns", async () => {
577
+ // Outbound calls open with the English "(call connected ...)" sentinel as
578
+ // the latest user-role turn, and the verification-complete sentinel does
579
+ // the same mid-call. Neither is caller speech, so neither may pull a
580
+ // Spanish or Japanese Task into an English opener.
581
+ const installed = captureInstalledPrompt();
582
+ await startVoiceTurn(makeTurnOptions());
583
+ const prompt = installed()!;
584
+ expect(prompt).toContain("most recent actual speech");
585
+ expect(prompt).toContain("not caller speech and never set the language");
586
+ expect(prompt).toContain(
587
+ "use the language the Task context implies, if any; otherwise default to English",
588
+ );
589
+ });
590
+
591
+ test("a monolingual listening language becomes the pre-speech fallback", () => {
592
+ // An assistant pinned to services.stt.language = "es" on a provider that
593
+ // honors the pin (deepgram, vellum) is already transcribing Spanish, so
594
+ // the opener must not default to English. The default test config runs
595
+ // the auto-detect branch ("multi"), so the pinned branch is covered at
596
+ // the fragment level.
597
+ expect(preSpeechLanguageRuleFragment("es", "deepgram")).toContain(
598
+ 'configured listening language ("es")',
599
+ );
600
+ expect(preSpeechLanguageRuleFragment("es", "vellum")).toContain(
601
+ "default to English only when neither gives a language",
602
+ );
603
+ for (const autoDetect of ["multi", "", " ", undefined]) {
604
+ expect(preSpeechLanguageRuleFragment(autoDetect, "deepgram")).toBe(
605
+ "use the language the Task context implies, if any; otherwise default to English",
606
+ );
607
+ }
608
+ });
609
+
610
+ test("a language pin on an auto-detecting provider keeps the English fallback", () => {
611
+ // google-gemini and openai-whisper ignore services.stt.language entirely
612
+ // (languageSelection: "auto"), so a persisted "es" pin must not force a
613
+ // Spanish greeting the transcriber will not honor.
614
+ for (const provider of ["google-gemini", "openai-whisper", undefined]) {
615
+ expect(preSpeechLanguageRuleFragment("es", provider)).toBe(
616
+ "use the language the Task context implies, if any; otherwise default to English",
617
+ );
618
+ }
619
+ });
620
+
621
+ test("rule numbers stay sequential from 0, including the routing rule", async () => {
622
+ const installed = captureInstalledPrompt();
623
+ await startVoiceTurn({ ...makeTurnOptions(), routingLeg: "escalated" });
624
+ const numbers = [...installed()!.matchAll(/^(\d+)\. /gm)].map((match) =>
625
+ Number(match[1]),
626
+ );
627
+ expect(numbers.length).toBeGreaterThan(12);
628
+ expect(numbers).toEqual(numbers.map((_, index) => index));
629
+ });
630
+ });
631
+
563
632
  describe("startVoiceTurn channel capabilities", () => {
564
633
  // Whether a call can show a surface is a property of its channel, not of
565
634
  // calls in general, so the bridge applies no voice-specific override: a
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
+ import { DEEPGRAM_MULTI_LANGUAGE_CODES } from "../../providers/speech-to-text/deepgram.js";
3
4
  import {
4
5
  capEscalationBridge,
5
6
  classifyFrontDoorLeading,
@@ -7,6 +8,8 @@ import {
7
8
  escalatedContinuationRule,
8
9
  ESCALATION_CONTINUATION_CONTENT,
9
10
  FALLBACK_ESCALATION_BRIDGE,
11
+ FALLBACK_ESCALATION_BRIDGE_BY_LANGUAGE,
12
+ fallbackEscalationBridgeFor,
10
13
  frontDoorCapabilityDigest,
11
14
  frontDoorDecisionRule,
12
15
  HOLD_VERDICT_TOKEN,
@@ -162,6 +165,33 @@ describe("front-door decision rule", () => {
162
165
  expect(rule.toLowerCase()).toContain("one short natural holding phrase");
163
166
  expect(rule.toLowerCase()).toContain("stop after that single sentence");
164
167
  });
168
+
169
+ test("hold completeness is judged in the caller's language", () => {
170
+ // Callers are not English-only: the hold branch's exemplars are English,
171
+ // so the rule must say completeness follows the grammar of the language
172
+ // being spoken, with the verb-final case called out (a missing final
173
+ // verb, not a missing conjunction, is the unfinished signal there).
174
+ const withHold = frontDoorDecisionRule({ includeHold: true });
175
+ expect(withHold.toLowerCase()).toContain("may speak any language");
176
+ expect(withHold.toLowerCase()).toContain(
177
+ "grammar of the language being spoken",
178
+ );
179
+ expect(withHold.toLowerCase()).toContain("verb-final");
180
+ expect(withHold.toLowerCase()).toContain("missing final verb");
181
+ });
182
+
183
+ test("the answer branch demands the caller's language", () => {
184
+ expect(rule).toContain("Answer in the language the caller is speaking.");
185
+ });
186
+
187
+ test("the escalation holding phrase demands the caller's language", () => {
188
+ // The bridge examples are English; without an explicit requirement a
189
+ // Spanish turn that needs a tool gets an English holding phrase before
190
+ // the localized answer. The examples stay, labeled as English only.
191
+ expect(rule).toContain("spoken in the language the caller is speaking");
192
+ expect(rule).toContain("those examples are English only");
193
+ expect(rule).toContain(`"${FALLBACK_ESCALATION_BRIDGE}"`);
194
+ });
165
195
  });
166
196
 
167
197
  describe("escalated continuation rule", () => {
@@ -190,6 +220,12 @@ describe("escalated continuation rule", () => {
190
220
  );
191
221
  });
192
222
 
223
+ test("demands the reply match the caller's language", () => {
224
+ expect(rule).toContain(
225
+ "Reply in the same language as the caller's question.",
226
+ );
227
+ });
228
+
193
229
  test("bans re-announcing the holding phrase (bridge-echo regression)", () => {
194
230
  // Regression: after the bridge "Let me check your calendar", the quality
195
231
  // model opened with "Let me check what calendar connections…" — a
@@ -201,6 +237,41 @@ describe("escalated continuation rule", () => {
201
237
  });
202
238
  });
203
239
 
240
+ describe("fallbackEscalationBridgeFor", () => {
241
+ test("covers every Deepgram code-switching language with a non-empty bridge", () => {
242
+ for (const code of DEEPGRAM_MULTI_LANGUAGE_CODES) {
243
+ const bridge = FALLBACK_ESCALATION_BRIDGE_BY_LANGUAGE[code];
244
+ expect(bridge).toBeDefined();
245
+ expect(bridge!.trim().length).toBeGreaterThan(0);
246
+ expect(fallbackEscalationBridgeFor(code)).toBe(bridge!);
247
+ }
248
+ });
249
+
250
+ test("every bridge fits the session-side cap", () => {
251
+ for (const bridge of Object.values(
252
+ FALLBACK_ESCALATION_BRIDGE_BY_LANGUAGE,
253
+ )) {
254
+ expect(bridge.length).toBeLessThanOrEqual(MAX_ESCALATION_BRIDGE_CHARS);
255
+ }
256
+ });
257
+
258
+ test("selects by lowercased base subtag", () => {
259
+ expect(fallbackEscalationBridgeFor("pt-BR")).toBe(
260
+ FALLBACK_ESCALATION_BRIDGE_BY_LANGUAGE.pt!,
261
+ );
262
+ expect(fallbackEscalationBridgeFor("JA")).toBe(
263
+ FALLBACK_ESCALATION_BRIDGE_BY_LANGUAGE.ja!,
264
+ );
265
+ });
266
+
267
+ test("falls back to English for unknown or absent languages", () => {
268
+ expect(fallbackEscalationBridgeFor()).toBe(FALLBACK_ESCALATION_BRIDGE);
269
+ expect(fallbackEscalationBridgeFor("ko")).toBe(FALLBACK_ESCALATION_BRIDGE);
270
+ expect(fallbackEscalationBridgeFor("")).toBe(FALLBACK_ESCALATION_BRIDGE);
271
+ expect(fallbackEscalationBridgeFor("en")).toBe(FALLBACK_ESCALATION_BRIDGE);
272
+ });
273
+ });
274
+
204
275
  describe("capEscalationBridge", () => {
205
276
  test("cuts just after the first sentence terminator", () => {
206
277
  expect(
@@ -218,6 +289,24 @@ describe("capEscalationBridge", () => {
218
289
  test("strips internal markers before capping", () => {
219
290
  expect(capEscalationBridge("[END_CALL] One moment.")).toBe("One moment.");
220
291
  });
292
+
293
+ test("the Unicode ellipsis still terminates a bridge", () => {
294
+ // Regression pin: widening the terminator class for non-Latin enders
295
+ // must not drop the ellipsis the original regex recognized.
296
+ expect(capEscalationBridge("One moment… and some rambling")).toBe(
297
+ "One moment…",
298
+ );
299
+ expect(isEscalationBridgeComplete("One moment…")).toBe(true);
300
+ });
301
+
302
+ test("cuts just after a non-Latin sentence terminator", () => {
303
+ expect(capEscalationBridge("少し考えさせてください。その間の余談")).toBe(
304
+ "少し考えさせてください。",
305
+ );
306
+ expect(capEscalationBridge("मुझे एक पल सोचने दीजिए। और कुछ बातें")).toBe(
307
+ "मुझे एक पल सोचने दीजिए।",
308
+ );
309
+ });
221
310
  });
222
311
 
223
312
  describe("isEscalationBridgeComplete", () => {
@@ -233,6 +322,23 @@ describe("isEscalationBridgeComplete", () => {
233
322
  isEscalationBridgeComplete("a".repeat(MAX_ESCALATION_BRIDGE_CHARS)),
234
323
  ).toBe(true);
235
324
  });
325
+
326
+ test("a Japanese bridge ending in 。 completes without waiting for the cap", () => {
327
+ expect(isEscalationBridgeComplete("少し考えさせてください")).toBe(false);
328
+ expect(isEscalationBridgeComplete("少し考えさせてください。")).toBe(true);
329
+ });
330
+
331
+ test("every localized fallback bridge ends in a recognized terminator", () => {
332
+ // A model-spoken bridge in any roster language must hand off at its
333
+ // terminator, never by buffering to the char cap; the canned bridges are
334
+ // the canonical sample of each language's ender.
335
+ for (const bridge of Object.values(
336
+ FALLBACK_ESCALATION_BRIDGE_BY_LANGUAGE,
337
+ )) {
338
+ expect(isEscalationBridgeComplete(bridge)).toBe(true);
339
+ expect(capEscalationBridge(bridge)).toBe(bridge);
340
+ }
341
+ });
236
342
  });
237
343
 
238
344
  describe("spokenBridgeText", () => {
@@ -65,6 +65,10 @@ import {
65
65
  resolveSynthesisFormats,
66
66
  } from "./resolve-call-tts-provider.js";
67
67
  import type { PromptSpeakerContext } from "./speaker-identification.js";
68
+ import {
69
+ resolveTelephonyLanguageVoice,
70
+ resolveTelephonySynthesisLanguage,
71
+ } from "./telephony-synthesis-language.js";
68
72
  import { sanitizeForTts } from "./tts-text-sanitizer.js";
69
73
  import {
70
74
  ASK_GUARDIAN_CAPTURE_REGEX,
@@ -189,6 +193,13 @@ export class CallController {
189
193
  private guardianUnavailableForCall = false;
190
194
  /** Active synthesized-TTS session — tracked so interrupt handling can close it. */
191
195
  private activeSynthesisAbort: AbortController | null = null;
196
+ /**
197
+ * Resolves the language hint for synthesized speech. The media-stream
198
+ * server supplies a resolver backed by the STT session's detected
199
+ * dominant language; the default falls back to the pin-based
200
+ * resolution only.
201
+ */
202
+ private resolveSynthesisLanguage: () => string | undefined;
192
203
 
193
204
  constructor(
194
205
  callSessionId: string,
@@ -198,6 +209,7 @@ export class CallController {
198
209
  broadcast?: (msg: AssistantEvent) => void;
199
210
  assistantId?: string;
200
211
  trustContext?: TrustContext;
212
+ resolveSynthesisLanguage?: () => string | undefined;
201
213
  },
202
214
  ) {
203
215
  this.callSessionId = callSessionId;
@@ -207,6 +219,9 @@ export class CallController {
207
219
  this.broadcast = opts?.broadcast;
208
220
  this.assistantId = opts?.assistantId ?? DAEMON_INTERNAL_ASSISTANT_ID;
209
221
  this.trustContext = opts?.trustContext ?? null;
222
+ this.resolveSynthesisLanguage =
223
+ opts?.resolveSynthesisLanguage ??
224
+ (() => resolveTelephonySynthesisLanguage());
210
225
 
211
226
  // Resolve the conversation ID and skipDisclosure from the call session
212
227
  const session = getCallSession(callSessionId);
@@ -663,7 +678,9 @@ export class CallController {
663
678
  // lock-hold wait budget, so surface a brief natural re-prompt (never a
664
679
  // technical-error message) and re-arm listening. last=true doubles as
665
680
  // the end-of-turn marker.
666
- this.transport.sendTextToken("Sorry, could you say that again?", true);
681
+ this.transport.sendTextToken("Sorry, could you say that again?", true, {
682
+ systemCopy: true,
683
+ });
667
684
  this.state = "idle";
668
685
  this.resetSilenceTimer();
669
686
  this.flushPendingInstructions();
@@ -673,6 +690,7 @@ export class CallController {
673
690
  this.transport.sendTextToken(
674
691
  "I'm sorry, I encountered a technical issue. Could you repeat that?",
675
692
  true,
693
+ { systemCopy: true },
676
694
  );
677
695
  this.state = "idle";
678
696
  this.resetSilenceTimer();
@@ -1080,11 +1098,17 @@ export class CallController {
1080
1098
 
1081
1099
  this.activeSynthesisAbort = abortController;
1082
1100
 
1101
+ const language = this.resolveSynthesisLanguage();
1102
+ // A language-known segment may select the synthesizing provider's
1103
+ // configured per-language voice; no entry keeps the provider default.
1104
+ const voiceId = resolveTelephonyLanguageVoice(provider.id, language);
1083
1105
  await synthesizeAndEmit({
1084
1106
  provider,
1085
1107
  text,
1086
1108
  useCase: "phone-call",
1087
1109
  outputFormat,
1110
+ ...(voiceId !== undefined ? { voiceId } : {}),
1111
+ ...(language !== undefined ? { language } : {}),
1088
1112
  signal: abortController.signal,
1089
1113
  isCurrent: () => this.isCurrentRun(runVersion),
1090
1114
  onChunk: sink.onChunk,
@@ -1804,6 +1828,7 @@ export class CallController {
1804
1828
  this.transport.sendTextToken(
1805
1829
  "Just to let you know, we're running low on time for this call.",
1806
1830
  true,
1831
+ { systemCopy: true },
1807
1832
  );
1808
1833
  }, warningMs);
1809
1834
  }
@@ -1816,6 +1841,7 @@ export class CallController {
1816
1841
  this.transport.sendTextToken(
1817
1842
  "I'm sorry, but we've reached the maximum time for this call. Thank you for your time. Goodbye!",
1818
1843
  true,
1844
+ { systemCopy: true },
1819
1845
  );
1820
1846
  // Give TTS a moment to play, then end
1821
1847
  this.durationEndTimer = setTimeout(() => {
@@ -1878,7 +1904,9 @@ export class CallController {
1878
1904
  { callSessionId: this.callSessionId },
1879
1905
  "Silence timeout triggered",
1880
1906
  );
1881
- this.transport.sendTextToken("Are you still there?", true);
1907
+ this.transport.sendTextToken("Are you still there?", true, {
1908
+ systemCopy: true,
1909
+ });
1882
1910
  }, getSilenceTimeoutMs());
1883
1911
  }
1884
1912
  }
@@ -68,8 +68,10 @@ export async function speakSystemPrompt(
68
68
  });
69
69
 
70
70
  if (!useSynthesizedPath || !provider) {
71
- // Native path — send text tokens through the transport.
72
- relay.sendTextToken(text, true);
71
+ // Native path: send text tokens through the transport. Marked as
72
+ // system copy so transports that re-synthesize tokens (media-stream)
73
+ // never attach the caller-language hint to these fixed English prompts.
74
+ relay.sendTextToken(text, true, { systemCopy: true });
73
75
  return;
74
76
  }
75
77
 
@@ -128,6 +130,11 @@ async function synthesizeAndPlay(
128
130
  },
129
131
  });
130
132
 
133
+ // No language hint: this path speaks fixed English copy (verification
134
+ // codes, guardian prompts), so a pin-based hint would tell an
135
+ // enforcing provider to render English text in the pinned language.
136
+ // Conversational synthesis (call-controller, media-stream-output)
137
+ // carries the hint instead.
131
138
  const result = await synthesizeAndEmit({
132
139
  provider,
133
140
  text,
@@ -258,8 +265,9 @@ async function synthesizeAndPlay(
258
265
  // Fallback: send text via native TTS so the caller still hears the message.
259
266
  // sendTextToken with last:true includes the end-of-turn signal inherently.
260
267
  // This fallback is only used for providers whose catalog entry allows
261
- // native fallback.
262
- relay.sendTextToken(text, true);
268
+ // native fallback. Marked as system copy for the same reason as the
269
+ // native path above.
270
+ relay.sendTextToken(text, true, { systemCopy: true });
263
271
  } finally {
264
272
  sink?.finalize();
265
273
  }
@@ -9,6 +9,19 @@
9
9
 
10
10
  // ── Transport interface ──────────────────────────────────────────────
11
11
 
12
+ /** Options for {@link CallTransport.sendTextToken}. */
13
+ export interface SendTextTokenOptions {
14
+ /**
15
+ * The token is fixed, known-English system copy (error recovery, silence
16
+ * checks, duration warnings, deterministic prompts) rather than
17
+ * model-generated turn text. Transports that synthesize text themselves
18
+ * must not attach the caller-language hint to these tokens: a provider
19
+ * that enforces the hint would render the English words as though they
20
+ * were the caller's language. Model text keeps the hint.
21
+ */
22
+ systemCopy?: boolean;
23
+ }
24
+
12
25
  /**
13
26
  * Minimal output surface that CallController uses to send speech,
14
27
  * audio, and lifecycle signals to the caller.
@@ -18,7 +31,11 @@ export interface CallTransport {
18
31
  * Send a text token for TTS playback. When `last` is true the
19
32
  * transport should signal end-of-turn to the caller.
20
33
  */
21
- sendTextToken(token: string, last: boolean): void;
34
+ sendTextToken(
35
+ token: string,
36
+ last: boolean,
37
+ opts?: SendTextTokenOptions,
38
+ ): void;
22
39
 
23
40
  /**
24
41
  * Send a pre-synthesized audio URL for playback.