@vellumai/assistant 0.11.3-staging.2 → 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 (60) 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__/events-dev-bypass-actor.test.ts +112 -1
  6. package/src/__tests__/media-stream-output.test.ts +175 -0
  7. package/src/__tests__/media-stream-stt-session.test.ts +67 -0
  8. package/src/calls/__tests__/tts-text-sanitizer.test.ts +13 -0
  9. package/src/calls/__tests__/voice-session-bridge.test.ts +81 -12
  10. package/src/calls/__tests__/voice-triage-escalate.test.ts +106 -0
  11. package/src/calls/call-controller.ts +30 -2
  12. package/src/calls/call-speech-output.ts +12 -4
  13. package/src/calls/call-transport.ts +18 -1
  14. package/src/calls/media-stream-output.ts +53 -5
  15. package/src/calls/media-stream-server.ts +12 -0
  16. package/src/calls/media-stream-stt-session.ts +34 -0
  17. package/src/calls/telephony-synthesis-language.ts +84 -0
  18. package/src/calls/tts-text-sanitizer.ts +12 -5
  19. package/src/calls/voice-session-bridge.ts +31 -3
  20. package/src/calls/voice-triage-escalate.ts +52 -5
  21. package/src/config/bundled-skills/phone-calls/references/CONFIG.md +15 -15
  22. package/src/config/loader.ts +5 -0
  23. package/src/config/schemas/calls.ts +0 -4
  24. package/src/config/schemas/tts.ts +63 -0
  25. package/src/live-voice/__tests__/front-decision.test.ts +120 -0
  26. package/src/live-voice/__tests__/live-voice-events.test.ts +1 -1
  27. package/src/live-voice/__tests__/live-voice-progress.test.ts +178 -11
  28. package/src/live-voice/__tests__/live-voice-stt.test.ts +304 -1
  29. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +102 -2
  30. package/src/live-voice/__tests__/live-voice-tts.test.ts +148 -2
  31. package/src/live-voice/__tests__/progress-phrases.test.ts +167 -0
  32. package/src/live-voice/front-decision.ts +50 -3
  33. package/src/live-voice/live-voice-session.ts +202 -25
  34. package/src/live-voice/live-voice-tts.ts +18 -2
  35. package/src/live-voice/progress-phrases.ts +105 -2
  36. package/src/providers/speech-to-text/deepgram-realtime.test.ts +283 -1
  37. package/src/providers/speech-to-text/deepgram-realtime.ts +117 -3
  38. package/src/providers/speech-to-text/provider-catalog.ts +38 -0
  39. package/src/runtime/__tests__/local-actor-identity-force-refresh.test.ts +104 -0
  40. package/src/runtime/assistant-event-hub.ts +23 -0
  41. package/src/runtime/local-actor-identity.ts +18 -5
  42. package/src/runtime/routes/__tests__/sse-actor-principal-heal.test.ts +165 -0
  43. package/src/runtime/routes/__tests__/surface-action-routes.test.ts +2 -0
  44. package/src/runtime/routes/events-routes.ts +17 -16
  45. package/src/runtime/routes/sse-actor-principal-heal.ts +113 -0
  46. package/src/stt/__tests__/language-metadata.test.ts +85 -0
  47. package/src/stt/language-metadata.ts +65 -0
  48. package/src/stt/types.ts +16 -0
  49. package/src/tts/__tests__/provider-adapters.test.ts +147 -0
  50. package/src/tts/__tests__/speakable-segments.test.ts +470 -0
  51. package/src/tts/language-voices.ts +23 -0
  52. package/src/tts/providers/deepgram-provider.ts +3 -1
  53. package/src/tts/providers/elevenlabs-provider.ts +73 -1
  54. package/src/tts/providers/xai-provider.ts +28 -2
  55. package/src/tts/speakable-segments.ts +293 -23
  56. package/src/tts/synthesis-stream.ts +7 -0
  57. package/src/tts/types.ts +7 -0
  58. package/src/util/__tests__/language-subtag.test.ts +54 -0
  59. package/src/util/language-subtag.ts +43 -0
  60. package/src/util/unicode.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.3-staging.2",
3
+ "version": "0.11.3-staging.3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -377,6 +377,8 @@ function setupController(
377
377
  requiresPcmAudio?: boolean;
378
378
  /** Simulate a transport that gates teardown on playback drain. */
379
379
  awaitPlaybackDrained?: () => Promise<void>;
380
+ /** Synthesis-language resolver, as the media-stream server wires it. */
381
+ resolveSynthesisLanguage?: () => string | undefined;
380
382
  },
381
383
  ) {
382
384
  ensureConversation("conv-ctrl-test");
@@ -400,6 +402,7 @@ function setupController(
400
402
  const controller = new CallController(session.id, transport, task ?? null, {
401
403
  assistantId: opts?.assistantId,
402
404
  trustContext: opts?.trustContext,
405
+ resolveSynthesisLanguage: opts?.resolveSynthesisLanguage,
403
406
  });
404
407
  return { session, relay: transport, controller };
405
408
  }
@@ -3470,6 +3473,123 @@ describe("call-controller", () => {
3470
3473
  controller.destroy();
3471
3474
  });
3472
3475
 
3476
+ test("synthesized provider: the resolved language rides every segment's synthesis request", async () => {
3477
+ const requestLanguages: Array<string | undefined> = [];
3478
+ registerFishAudioSegmentRecorder({
3479
+ onSynthesizeStream: async (_text, request) => {
3480
+ requestLanguages.push(request.language);
3481
+ },
3482
+ });
3483
+ mockStartVoiceTurn.mockImplementation(
3484
+ createMockVoiceTurn(["Hola desde aqui. ", "Segunda frase."]),
3485
+ );
3486
+ const { controller } = setupController(undefined, {
3487
+ resolveSynthesisLanguage: () => "es",
3488
+ });
3489
+
3490
+ await controller.handleCallerUtterance("Hola");
3491
+
3492
+ expect(requestLanguages).toEqual(["es", "es"]);
3493
+
3494
+ controller.destroy();
3495
+ });
3496
+
3497
+ test("synthesized provider: no language on the request when nothing resolves", async () => {
3498
+ const requestLanguages: Array<string | undefined> = [];
3499
+ registerFishAudioSegmentRecorder({
3500
+ onSynthesizeStream: async (_text, request) => {
3501
+ requestLanguages.push(request.language);
3502
+ },
3503
+ });
3504
+ mockStartVoiceTurn.mockImplementation(
3505
+ createMockVoiceTurn(["Hello from here."]),
3506
+ );
3507
+ // Default resolver + default config (multilingual STT pin): no hint.
3508
+ const { controller } = setupController();
3509
+
3510
+ await controller.handleCallerUtterance("Hi");
3511
+
3512
+ expect(requestLanguages).toEqual([undefined]);
3513
+
3514
+ controller.destroy();
3515
+ });
3516
+
3517
+ function registerDeepgramRequestRecorder(): {
3518
+ requests: Array<{ voiceId?: string; language?: string }>;
3519
+ } {
3520
+ const cfg = loadConfig();
3521
+ cfg.services.tts.provider = "deepgram";
3522
+
3523
+ _resetTtsProviderOverridesForTests();
3524
+ const requests: Array<{ voiceId?: string; language?: string }> = [];
3525
+ const deepgramRecorder: TtsProvider = {
3526
+ id: "deepgram",
3527
+ capabilities: {
3528
+ supportsStreaming: false,
3529
+ supportedFormats: ["mp3", "wav", "opus"],
3530
+ },
3531
+ async synthesize(request) {
3532
+ requests.push({
3533
+ voiceId: request.voiceId,
3534
+ language: request.language,
3535
+ });
3536
+ return { audio: Buffer.from("audio"), contentType: "audio/mpeg" };
3537
+ },
3538
+ };
3539
+ _setTtsProviderForTests(deepgramRecorder);
3540
+ return { requests };
3541
+ }
3542
+
3543
+ test("synthesized provider: a configured languageVoices entry for the call language rides the request as voiceId", async () => {
3544
+ const cfg = loadConfig();
3545
+ cfg.services.tts.providers.deepgram.languageVoices = {
3546
+ hi: "aura-2-hindi-voice",
3547
+ };
3548
+ try {
3549
+ const { requests } = registerDeepgramRequestRecorder();
3550
+ mockStartVoiceTurn.mockImplementation(
3551
+ createMockVoiceTurn(["Namaste doston."]),
3552
+ );
3553
+ const { controller } = setupController(undefined, {
3554
+ resolveSynthesisLanguage: () => "hi",
3555
+ });
3556
+
3557
+ await controller.handleCallerUtterance("Namaste");
3558
+
3559
+ expect(requests).toEqual([
3560
+ { voiceId: "aura-2-hindi-voice", language: "hi" },
3561
+ ]);
3562
+
3563
+ controller.destroy();
3564
+ } finally {
3565
+ delete cfg.services.tts.providers.deepgram.languageVoices;
3566
+ }
3567
+ });
3568
+
3569
+ test("synthesized provider: no voiceId when languageVoices has no entry for the call language", async () => {
3570
+ const cfg = loadConfig();
3571
+ cfg.services.tts.providers.deepgram.languageVoices = {
3572
+ hi: "aura-2-hindi-voice",
3573
+ };
3574
+ try {
3575
+ const { requests } = registerDeepgramRequestRecorder();
3576
+ mockStartVoiceTurn.mockImplementation(
3577
+ createMockVoiceTurn(["Hola desde aqui."]),
3578
+ );
3579
+ const { controller } = setupController(undefined, {
3580
+ resolveSynthesisLanguage: () => "es",
3581
+ });
3582
+
3583
+ await controller.handleCallerUtterance("Hola");
3584
+
3585
+ expect(requests).toEqual([{ voiceId: undefined, language: "es" }]);
3586
+
3587
+ controller.destroy();
3588
+ } finally {
3589
+ delete cfg.services.tts.providers.deepgram.languageVoices;
3590
+ }
3591
+ });
3592
+
3473
3593
  test("synthesized provider: segments synthesize serially in order even when the first is slow", async () => {
3474
3594
  const events: string[] = [];
3475
3595
  registerFishAudioSegmentRecorder({
@@ -548,6 +548,25 @@ describe("loadConfig startup behavior", () => {
548
548
  expect(raw.daemon?.standaloneRecording).toBe(false);
549
549
  });
550
550
 
551
+ test("strips calls.voice.language from existing user configs", () => {
552
+ // `calls.voice.language` is a retired no-op knob: nothing read it.
553
+ // The spoken language lives at `services.stt.language` and per-language
554
+ // TTS voices at `services.tts.providers.<id>.languageVoices`. Existing
555
+ // configs that have it written to disk should load cleanly with the
556
+ // field stripped and its siblings preserved.
557
+ writeConfig({
558
+ provider: "anthropic",
559
+ calls: { voice: { language: "es-ES", telephonyStreaming: false } },
560
+ });
561
+
562
+ loadConfig();
563
+
564
+ const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
565
+ expect(raw.calls?.voice?.language).toBeUndefined();
566
+ // Sibling fields under calls.voice are preserved
567
+ expect(raw.calls?.voice?.telephonyStreaming).toBe(false);
568
+ });
569
+
551
570
  test("still writes a default config on first launch when file is absent", () => {
552
571
  // Discoverability: when no config.json exists, write one populated with
553
572
  // all schema defaults so users can see and edit available options.
@@ -888,7 +888,6 @@ describe("AssistantConfigSchema", () => {
888
888
  denyCategories: [],
889
889
  },
890
890
  voice: {
891
- language: "en-US",
892
891
  interruptSensitivity: "low",
893
892
  telephonyStreaming: true,
894
893
  utteranceEndMs: 1000,
@@ -1071,7 +1070,6 @@ describe("AssistantConfigSchema", () => {
1071
1070
 
1072
1071
  test("config without calls.voice parses correctly and produces defaults", () => {
1073
1072
  const result = AssistantConfigSchema.parse({});
1074
- expect(result.calls.voice.language).toBe("en-US");
1075
1073
  expect(result.calls.voice.interruptSensitivity).toBe("low");
1076
1074
  expect(result.calls.voice.telephonyStreaming).toBe(true);
1077
1075
  expect(result.calls.voice.utteranceEndMs).toBe(1000);
@@ -1081,17 +1079,26 @@ describe("AssistantConfigSchema", () => {
1081
1079
  const result = AssistantConfigSchema.parse({
1082
1080
  calls: {
1083
1081
  voice: {
1084
- language: "es-ES",
1085
1082
  telephonyStreaming: false,
1086
1083
  utteranceEndMs: 2500,
1087
1084
  },
1088
1085
  },
1089
1086
  });
1090
- expect(result.calls.voice.language).toBe("es-ES");
1091
1087
  expect(result.calls.voice.telephonyStreaming).toBe(false);
1092
1088
  expect(result.calls.voice.utteranceEndMs).toBe(2500);
1093
1089
  });
1094
1090
 
1091
+ test("language is no longer part of the voice config schema", () => {
1092
+ // The retired knob was read by nothing; Zod strips the unrecognized key
1093
+ // so persisted configs that still carry it keep parsing.
1094
+ const result = AssistantConfigSchema.parse({
1095
+ calls: { voice: { language: "es-ES" } },
1096
+ });
1097
+ expect(
1098
+ (result.calls.voice as Record<string, unknown>).language,
1099
+ ).toBeUndefined();
1100
+ });
1101
+
1095
1102
  test("rejects calls.voice.utteranceEndMs outside the 1000-5000 range", () => {
1096
1103
  for (const utteranceEndMs of [999, 5001]) {
1097
1104
  const result = AssistantConfigSchema.safeParse({
@@ -1409,6 +1416,131 @@ describe("AssistantConfigSchema", () => {
1409
1416
  expect(result.services.tts.providers.deepgram.format).toBe("opus");
1410
1417
  });
1411
1418
 
1419
+ test("accepts services.tts.providers languageVoices maps", () => {
1420
+ const result = AssistantConfigSchema.parse({
1421
+ services: {
1422
+ tts: {
1423
+ providers: {
1424
+ elevenlabs: {
1425
+ languageVoices: { hi: "voice-hindi", ja: "voice-japanese" },
1426
+ },
1427
+ deepgram: { languageVoices: { hi: "aura-2-hi-voice" } },
1428
+ vellum: { languageVoices: { hi: "aura-2-hi-voice" } },
1429
+ },
1430
+ },
1431
+ },
1432
+ });
1433
+ expect(result.services.tts.providers.elevenlabs.languageVoices).toEqual({
1434
+ hi: "voice-hindi",
1435
+ ja: "voice-japanese",
1436
+ });
1437
+ expect(result.services.tts.providers.deepgram.languageVoices).toEqual({
1438
+ hi: "aura-2-hi-voice",
1439
+ });
1440
+ expect(result.services.tts.providers.vellum.languageVoices).toEqual({
1441
+ hi: "aura-2-hi-voice",
1442
+ });
1443
+ });
1444
+
1445
+ test("normalizes user-entered languageVoices keys to lowercase base subtags", () => {
1446
+ // "hi-IN" or "HI" typed into config must still match a spoken "hi"
1447
+ // instead of silently never applying; the schema normalizes on parse.
1448
+ const result = AssistantConfigSchema.parse({
1449
+ services: {
1450
+ tts: {
1451
+ providers: {
1452
+ elevenlabs: {
1453
+ languageVoices: {
1454
+ "hi-IN": "voice-hindi",
1455
+ JA: "voice-japanese",
1456
+ es_419: "voice-spanish",
1457
+ " PT ": "voice-portuguese",
1458
+ },
1459
+ },
1460
+ },
1461
+ },
1462
+ },
1463
+ });
1464
+ expect(result.services.tts.providers.elevenlabs.languageVoices).toEqual({
1465
+ hi: "voice-hindi",
1466
+ ja: "voice-japanese",
1467
+ es: "voice-spanish",
1468
+ pt: "voice-portuguese",
1469
+ });
1470
+ });
1471
+
1472
+ test("keeps the first entry when languageVoices keys collide after normalization", () => {
1473
+ const result = AssistantConfigSchema.parse({
1474
+ services: {
1475
+ tts: {
1476
+ providers: {
1477
+ deepgram: {
1478
+ languageVoices: { "hi-IN": "voice-first", hi: "voice-second" },
1479
+ },
1480
+ },
1481
+ },
1482
+ },
1483
+ });
1484
+ expect(result.services.tts.providers.deepgram.languageVoices).toEqual({
1485
+ hi: "voice-first",
1486
+ });
1487
+ });
1488
+
1489
+ test("drops languageVoices keys that normalize to the empty string", () => {
1490
+ const result = AssistantConfigSchema.parse({
1491
+ services: {
1492
+ tts: {
1493
+ providers: {
1494
+ vellum: {
1495
+ languageVoices: {
1496
+ "": "voice-empty",
1497
+ " ": "voice-blank",
1498
+ "-IN": "voice-region-only",
1499
+ hi: "voice-hindi",
1500
+ },
1501
+ },
1502
+ },
1503
+ },
1504
+ },
1505
+ });
1506
+ expect(result.services.tts.providers.vellum.languageVoices).toEqual({
1507
+ hi: "voice-hindi",
1508
+ });
1509
+ });
1510
+
1511
+ test("languageVoices defaults to unset", () => {
1512
+ const result = AssistantConfigSchema.parse({});
1513
+ expect(
1514
+ result.services.tts.providers.elevenlabs.languageVoices,
1515
+ ).toBeUndefined();
1516
+ expect(
1517
+ result.services.tts.providers.deepgram.languageVoices,
1518
+ ).toBeUndefined();
1519
+ expect(result.services.tts.providers.vellum.languageVoices).toBeUndefined();
1520
+ });
1521
+
1522
+ test("rejects non-string services.tts.providers languageVoices values", () => {
1523
+ for (const provider of ["elevenlabs", "deepgram", "vellum"]) {
1524
+ const result = AssistantConfigSchema.safeParse({
1525
+ services: {
1526
+ tts: {
1527
+ providers: {
1528
+ [provider]: { languageVoices: { hi: 42 } },
1529
+ },
1530
+ },
1531
+ },
1532
+ });
1533
+ expect(result.success).toBe(false);
1534
+ if (!result.success) {
1535
+ expect(
1536
+ result.error.issues.some((issue) =>
1537
+ issue.path.join(".").includes("languageVoices"),
1538
+ ),
1539
+ ).toBe(true);
1540
+ }
1541
+ }
1542
+ });
1543
+
1412
1544
  // Legacy config objects on disk may carry a `mode` key. Parsing must strip
1413
1545
  // it rather than reject the config — and must not treat it as a managed
1414
1546
  // selection, which is what migration 130 rewrites to `provider: "vellum"`.
@@ -2424,7 +2556,9 @@ describe("loadConfig with schema validation", () => {
2424
2556
  expect(config.calls.userConsultTimeoutSeconds).toBe(120);
2425
2557
  expect(config.calls.disclosure.enabled).toBe(true);
2426
2558
  expect(config.calls.safety.denyCategories).toEqual([]);
2427
- expect(config.calls.voice.language).toBe("en-US");
2559
+ expect(
2560
+ (config.calls.voice as Record<string, unknown>).language,
2561
+ ).toBeUndefined();
2428
2562
  expect(
2429
2563
  (config.calls.voice as Record<string, unknown>).transcriptionProvider,
2430
2564
  ).toBeUndefined();
@@ -27,6 +27,10 @@ let fakeGuardianPrincipalId: string | undefined = undefined;
27
27
  // exactly when the SSE self-heal lookup completes.
28
28
  const pendingAsyncResolutions: Array<(value: string | undefined) => void> = [];
29
29
 
30
+ /** Options each async resolution was called with, in the same order. */
31
+ const asyncResolutionOptions: Array<{ forceRefresh?: boolean } | undefined> =
32
+ [];
33
+
30
34
  mock.module("../config/env.js", () => ({
31
35
  isHttpAuthDisabled: () => fakeHttpAuthDisabled,
32
36
  hasUngatedHttpAuthDisabled: () => false,
@@ -42,10 +46,14 @@ mock.module("../runtime/local-actor-identity.js", () => ({
42
46
  }
43
47
  return fakeGuardianPrincipalId;
44
48
  },
45
- resolveActorPrincipalIdForLocalGuardian: (rawHeader: string | undefined) => {
49
+ resolveActorPrincipalIdForLocalGuardian: (
50
+ rawHeader: string | undefined,
51
+ options?: { forceRefresh?: boolean },
52
+ ) => {
46
53
  if (rawHeader !== "dev-bypass" || !fakeHttpAuthDisabled) {
47
54
  return Promise.resolve(rawHeader);
48
55
  }
56
+ asyncResolutionOptions.push(options);
49
57
  return new Promise<string | undefined>((resolve) => {
50
58
  pendingAsyncResolutions.push(resolve);
51
59
  });
@@ -57,6 +65,23 @@ async function flushMicrotasks(): Promise<void> {
57
65
  await new Promise((resolve) => setTimeout(resolve, 0));
58
66
  }
59
67
 
68
+ /**
69
+ * Poll until `predicate` holds, for assertions that depend on the heal's
70
+ * retry backoff timer rather than on promise settling alone.
71
+ */
72
+ async function waitFor(
73
+ predicate: () => boolean,
74
+ timeoutMs = 3_000,
75
+ ): Promise<void> {
76
+ const deadline = Date.now() + timeoutMs;
77
+ while (!predicate()) {
78
+ if (Date.now() >= deadline) {
79
+ throw new Error("waitFor timed out");
80
+ }
81
+ await new Promise((resolve) => setTimeout(resolve, 10));
82
+ }
83
+ }
84
+
60
85
  // ── Real imports (after mocks) ────────────────────────────────────────────
61
86
 
62
87
  import { AssistantEventHub } from "../runtime/assistant-event-hub.js";
@@ -69,6 +94,7 @@ afterAll(() => {
69
94
  describe("events SSE registration — dev-bypass actor translation", () => {
70
95
  beforeEach(() => {
71
96
  pendingAsyncResolutions.length = 0;
97
+ asyncResolutionOptions.length = 0;
72
98
  });
73
99
 
74
100
  test("translates 'dev-bypass' to the real guardian principalId when auth is disabled", () => {
@@ -261,6 +287,91 @@ describe("events SSE registration — dev-bypass actor translation", () => {
261
287
  ac2.abort();
262
288
  });
263
289
 
290
+ test("retries the heal after a failed lookup instead of giving up for the connection's lifetime", async () => {
291
+ // The gateway IPC returns null on any transport failure, so the first
292
+ // lookup can miss even when a guardian is bound. A subscription left
293
+ // principal-less rejects every host-proxy result it submits, so the heal
294
+ // has to outlive one miss rather than stopping there.
295
+ fakeHttpAuthDisabled = true;
296
+ fakeGuardianPrincipalId = undefined; // cold cache: sync resolution misses
297
+
298
+ const ac = new AbortController();
299
+ const hub = new AssistantEventHub();
300
+
301
+ handleSubscribeAssistantEvents(
302
+ {
303
+ headers: {
304
+ "x-vellum-client-id": "retry-client-001",
305
+ "x-vellum-interface-id": "chrome-extension",
306
+ "x-vellum-actor-principal-id": "dev-bypass",
307
+ },
308
+ abortSignal: ac.signal,
309
+ },
310
+ { hub },
311
+ );
312
+
313
+ // First attempt fails (gateway not reachable yet).
314
+ expect(pendingAsyncResolutions).toHaveLength(1);
315
+ pendingAsyncResolutions.shift()!(undefined);
316
+ await flushMicrotasks();
317
+ expect(
318
+ hub.getActorPrincipalIdForClient("retry-client-001"),
319
+ ).toBeUndefined();
320
+
321
+ // The retry fires on the backoff and succeeds this time.
322
+ await waitFor(() => pendingAsyncResolutions.length === 1);
323
+ pendingAsyncResolutions.shift()!("guardian-real-id");
324
+ await flushMicrotasks();
325
+
326
+ expect(hub.getActorPrincipalIdForClient("retry-client-001")).toBe(
327
+ "guardian-real-id",
328
+ );
329
+
330
+ // Every attempt must bypass the guardian-delivery cache. A successful read
331
+ // that finds no binding is cached for minutes, which outlives the retry
332
+ // schedule, so a cached read would spend all attempts on the same answer
333
+ // and never observe a binding created in between.
334
+ expect(asyncResolutionOptions).toEqual([
335
+ { forceRefresh: true },
336
+ { forceRefresh: true },
337
+ ]);
338
+
339
+ ac.abort();
340
+ });
341
+
342
+ test("needsActorPrincipalHeal reports only live, principal-less client subscriptions", () => {
343
+ const hub = new AssistantEventHub();
344
+ const bare = hub.subscribe({
345
+ type: "client",
346
+ clientId: "needs-heal-001",
347
+ interfaceId: "chrome-extension",
348
+ capabilities: [],
349
+ callback: () => {},
350
+ });
351
+ const withPrincipal = hub.subscribe({
352
+ type: "client",
353
+ clientId: "needs-heal-002",
354
+ interfaceId: "macos",
355
+ capabilities: [],
356
+ actorPrincipalId: "guardian-real-id",
357
+ callback: () => {},
358
+ });
359
+ const process = hub.subscribe({ type: "process", callback: () => {} });
360
+
361
+ expect(hub.needsActorPrincipalHeal(bare.connectionId)).toBe(true);
362
+ expect(hub.needsActorPrincipalHeal(withPrincipal.connectionId)).toBe(false);
363
+ expect(hub.needsActorPrincipalHeal(process.connectionId)).toBe(false);
364
+ expect(hub.needsActorPrincipalHeal("conn-does-not-exist")).toBe(false);
365
+
366
+ // A disposed connection stops needing a heal, which is what ends the
367
+ // retry loop when a client disconnects mid-backoff.
368
+ bare.dispose();
369
+ expect(hub.needsActorPrincipalHeal(bare.connectionId)).toBe(false);
370
+
371
+ withPrincipal.dispose();
372
+ process.dispose();
373
+ });
374
+
264
375
  test("fillClientActorPrincipalId never overwrites a present principal", () => {
265
376
  const hub = new AssistantEventHub();
266
377
  const sub = hub.subscribe({