@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.
- package/package.json +1 -1
- package/src/__tests__/call-controller.test.ts +120 -0
- package/src/__tests__/config-loader-backfill.test.ts +19 -0
- package/src/__tests__/config-schema.test.ts +139 -5
- package/src/__tests__/conversation-process-app-control-preactivation.test.ts +123 -0
- package/src/__tests__/conversation-queue.test.ts +168 -0
- package/src/__tests__/conversation-routes-hidden-queue.test.ts +64 -1
- package/src/__tests__/events-dev-bypass-actor.test.ts +112 -1
- package/src/__tests__/media-stream-output.test.ts +175 -0
- package/src/__tests__/media-stream-stt-session.test.ts +67 -0
- package/src/__tests__/oauth-provider-seed-logos.test.ts +65 -0
- package/src/__tests__/tool-grant-request-escalation.test.ts +40 -1
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +68 -1
- package/src/approvals/guardian-request-resolvers.ts +6 -3
- package/src/calls/__tests__/tts-text-sanitizer.test.ts +13 -0
- package/src/calls/__tests__/voice-session-bridge.test.ts +81 -12
- package/src/calls/__tests__/voice-triage-escalate.test.ts +106 -0
- package/src/calls/call-controller.ts +30 -2
- package/src/calls/call-speech-output.ts +12 -4
- package/src/calls/call-transport.ts +18 -1
- package/src/calls/media-stream-output.ts +53 -5
- package/src/calls/media-stream-server.ts +12 -0
- package/src/calls/media-stream-stt-session.ts +34 -0
- package/src/calls/telephony-synthesis-language.ts +84 -0
- package/src/calls/tts-text-sanitizer.ts +12 -5
- package/src/calls/voice-session-bridge.ts +31 -3
- package/src/calls/voice-triage-escalate.ts +52 -5
- package/src/config/bundled-skills/phone-calls/references/CONFIG.md +15 -15
- package/src/config/loader.ts +5 -0
- package/src/config/schemas/calls.ts +0 -4
- package/src/config/schemas/timeouts.ts +1 -1
- package/src/config/schemas/tts.ts +63 -0
- package/src/daemon/conversation-agent-loop.ts +22 -3
- package/src/daemon/conversation-messaging.ts +21 -1
- package/src/daemon/conversation-process.ts +55 -5
- package/src/daemon/conversation-queue-manager.ts +10 -0
- package/src/daemon/conversation.ts +6 -0
- package/src/daemon/trust-context-types.ts +39 -0
- package/src/live-voice/__tests__/front-decision.test.ts +120 -0
- package/src/live-voice/__tests__/live-voice-events.test.ts +1 -1
- package/src/live-voice/__tests__/live-voice-progress.test.ts +178 -11
- package/src/live-voice/__tests__/live-voice-stt.test.ts +304 -1
- package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +102 -2
- package/src/live-voice/__tests__/live-voice-tts.test.ts +148 -2
- package/src/live-voice/__tests__/progress-phrases.test.ts +167 -0
- package/src/live-voice/front-decision.ts +50 -3
- package/src/live-voice/live-voice-session.ts +202 -25
- package/src/live-voice/live-voice-tts.ts +18 -2
- package/src/live-voice/progress-phrases.ts +105 -2
- package/src/oauth/AGENTS.md +4 -2
- package/src/oauth/seed-providers.ts +6 -3
- package/src/providers/speech-to-text/deepgram-realtime.test.ts +283 -1
- package/src/providers/speech-to-text/deepgram-realtime.ts +117 -3
- package/src/providers/speech-to-text/provider-catalog.ts +38 -0
- package/src/runtime/__tests__/local-actor-identity-force-refresh.test.ts +104 -0
- package/src/runtime/assistant-event-hub.ts +23 -0
- package/src/runtime/local-actor-identity.ts +18 -5
- package/src/runtime/routes/__tests__/sse-actor-principal-heal.test.ts +165 -0
- package/src/runtime/routes/__tests__/surface-action-routes.test.ts +2 -0
- package/src/runtime/routes/conversation-routes.ts +10 -0
- package/src/runtime/routes/events-routes.ts +17 -16
- package/src/runtime/routes/sse-actor-principal-heal.ts +113 -0
- package/src/stt/__tests__/language-metadata.test.ts +85 -0
- package/src/stt/language-metadata.ts +65 -0
- package/src/stt/types.ts +16 -0
- package/src/subagent/manager.ts +9 -2
- package/src/tools/execution-timeout.ts +9 -2
- package/src/tools/tool-approval-handler.ts +45 -4
- package/src/tts/__tests__/provider-adapters.test.ts +147 -0
- package/src/tts/__tests__/speakable-segments.test.ts +470 -0
- package/src/tts/language-voices.ts +23 -0
- package/src/tts/providers/deepgram-provider.ts +3 -1
- package/src/tts/providers/elevenlabs-provider.ts +73 -1
- package/src/tts/providers/xai-provider.ts +28 -2
- package/src/tts/speakable-segments.ts +293 -23
- package/src/tts/synthesis-stream.ts +7 -0
- package/src/tts/types.ts +7 -0
- package/src/util/__tests__/language-subtag.test.ts +54 -0
- package/src/util/language-subtag.ts +43 -0
- package/src/util/unicode.ts +1 -1
package/package.json
CHANGED
|
@@ -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(
|
|
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();
|
|
@@ -86,6 +86,7 @@ import {
|
|
|
86
86
|
MessageQueue,
|
|
87
87
|
type QueuedMessage,
|
|
88
88
|
} from "../daemon/conversation-queue-manager.js";
|
|
89
|
+
import type { TrustContext } from "../daemon/trust-context-types.js";
|
|
89
90
|
|
|
90
91
|
// ---------------------------------------------------------------------------
|
|
91
92
|
// Fake context — captures preactivation calls, satisfies the bare minimum
|
|
@@ -172,6 +173,7 @@ function makeQueuedMessage(opts: {
|
|
|
172
173
|
content?: string;
|
|
173
174
|
turnInterfaceContext?: TurnInterfaceContext;
|
|
174
175
|
sourceActorPrincipalId?: string;
|
|
176
|
+
trustContext?: TrustContext;
|
|
175
177
|
}): QueuedMessage {
|
|
176
178
|
return {
|
|
177
179
|
content: opts.content ?? "follow up",
|
|
@@ -185,6 +187,7 @@ function makeQueuedMessage(opts: {
|
|
|
185
187
|
authContext: opts.sourceActorPrincipalId
|
|
186
188
|
? ({ actorPrincipalId: opts.sourceActorPrincipalId } as never)
|
|
187
189
|
: undefined,
|
|
190
|
+
trustContext: opts.trustContext,
|
|
188
191
|
};
|
|
189
192
|
}
|
|
190
193
|
|
|
@@ -371,6 +374,126 @@ describe("drainQueue preactivation re-add for host-proxy interfaces", () => {
|
|
|
371
374
|
expect(ctx.currentTurnSourceActorPrincipalId).toBe("trusted-contact-user");
|
|
372
375
|
});
|
|
373
376
|
|
|
377
|
+
test("drainSingleMessage runs the turn under the queued sender's trust, not the live slot", async () => {
|
|
378
|
+
// The conversation-level slot holds whichever actor sent most recently.
|
|
379
|
+
// A message that waited while someone else sent must still run as its own
|
|
380
|
+
// sender: trust decides `trustClass`, `executionChannel`, and
|
|
381
|
+
// `requesterExternalUserId`, so reading the slot hands the whole
|
|
382
|
+
// tool-approval path the wrong identity in both directions -- a contact
|
|
383
|
+
// inheriting guardian self-approval, or a guardian's own call escalating
|
|
384
|
+
// back to her as a contact's grant request.
|
|
385
|
+
const contactTrust: TrustContext = {
|
|
386
|
+
trustClass: "trusted_contact",
|
|
387
|
+
sourceChannel: "slack",
|
|
388
|
+
requesterExternalUserId: "U-contact",
|
|
389
|
+
};
|
|
390
|
+
const queue = new MessageQueue();
|
|
391
|
+
queue.push(
|
|
392
|
+
makeQueuedMessage({
|
|
393
|
+
requestId: "req-contact",
|
|
394
|
+
trustContext: contactTrust,
|
|
395
|
+
}),
|
|
396
|
+
);
|
|
397
|
+
const ctx = makeFakeContext({ queue });
|
|
398
|
+
// Someone else sent after this message was queued, moving the slot.
|
|
399
|
+
ctx.trustContext = {
|
|
400
|
+
trustClass: "guardian",
|
|
401
|
+
sourceChannel: "vellum",
|
|
402
|
+
requesterExternalUserId: "guardian-principal",
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
await drainQueue(ctx);
|
|
406
|
+
|
|
407
|
+
expect(ctx.currentTurnTrustContext?.trustClass).toBe("trusted_contact");
|
|
408
|
+
expect(ctx.currentTurnTrustContext?.requesterExternalUserId).toBe(
|
|
409
|
+
"U-contact",
|
|
410
|
+
);
|
|
411
|
+
expect(ctx.currentTurnTrustContext?.sourceChannel).toBe("slack");
|
|
412
|
+
// The slot itself is left alone; only the turn's view is corrected.
|
|
413
|
+
expect(ctx.trustContext?.trustClass).toBe("guardian");
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("buildPassthroughBatch refuses to coalesce two channel senders", async () => {
|
|
417
|
+
// Channel senders carry no principal, so the `sourceActorPrincipalId`
|
|
418
|
+
// boundary sees `undefined === undefined` and would batch two different
|
|
419
|
+
// Slack contacts into one turn running under the head's trust. The batch
|
|
420
|
+
// must split on the sender's trust identity instead.
|
|
421
|
+
const ifCtx: TurnInterfaceContext = {
|
|
422
|
+
userMessageInterface: "web",
|
|
423
|
+
assistantMessageInterface: "web",
|
|
424
|
+
};
|
|
425
|
+
const queue = new MessageQueue();
|
|
426
|
+
queue.push(
|
|
427
|
+
makeQueuedMessage({
|
|
428
|
+
requestId: "req-contact-a",
|
|
429
|
+
content: "from A",
|
|
430
|
+
turnInterfaceContext: ifCtx,
|
|
431
|
+
trustContext: {
|
|
432
|
+
trustClass: "trusted_contact",
|
|
433
|
+
sourceChannel: "slack",
|
|
434
|
+
requesterExternalUserId: "U-alex",
|
|
435
|
+
},
|
|
436
|
+
}),
|
|
437
|
+
);
|
|
438
|
+
queue.push(
|
|
439
|
+
makeQueuedMessage({
|
|
440
|
+
requestId: "req-contact-b",
|
|
441
|
+
content: "from B",
|
|
442
|
+
turnInterfaceContext: ifCtx,
|
|
443
|
+
trustContext: {
|
|
444
|
+
trustClass: "trusted_contact",
|
|
445
|
+
sourceChannel: "slack",
|
|
446
|
+
requesterExternalUserId: "U-blake",
|
|
447
|
+
},
|
|
448
|
+
}),
|
|
449
|
+
);
|
|
450
|
+
const ctx = makeFakeContext({ queue, turnInterfaceContext: ifCtx });
|
|
451
|
+
|
|
452
|
+
await drainQueue(ctx);
|
|
453
|
+
|
|
454
|
+
// B stays queued: it gets its own turn under its own trust.
|
|
455
|
+
expect(queue.length).toBe(1);
|
|
456
|
+
expect(queue.peek(0)?.requestId).toBe("req-contact-b");
|
|
457
|
+
expect(ctx.currentTurnTrustContext?.requesterExternalUserId).toBe("U-alex");
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("buildPassthroughBatch still coalesces two messages from the same sender", async () => {
|
|
461
|
+
// Sensitivity check on the split above: identical trust must still batch,
|
|
462
|
+
// otherwise the boundary would be splitting on something incidental and
|
|
463
|
+
// the test above would pass for the wrong reason.
|
|
464
|
+
const ifCtx: TurnInterfaceContext = {
|
|
465
|
+
userMessageInterface: "web",
|
|
466
|
+
assistantMessageInterface: "web",
|
|
467
|
+
};
|
|
468
|
+
const sameTrust: TrustContext = {
|
|
469
|
+
trustClass: "trusted_contact",
|
|
470
|
+
sourceChannel: "slack",
|
|
471
|
+
requesterExternalUserId: "U-alex",
|
|
472
|
+
};
|
|
473
|
+
const queue = new MessageQueue();
|
|
474
|
+
queue.push(
|
|
475
|
+
makeQueuedMessage({
|
|
476
|
+
requestId: "req-a1",
|
|
477
|
+
content: "first",
|
|
478
|
+
turnInterfaceContext: ifCtx,
|
|
479
|
+
trustContext: { ...sameTrust },
|
|
480
|
+
}),
|
|
481
|
+
);
|
|
482
|
+
queue.push(
|
|
483
|
+
makeQueuedMessage({
|
|
484
|
+
requestId: "req-a2",
|
|
485
|
+
content: "second",
|
|
486
|
+
turnInterfaceContext: ifCtx,
|
|
487
|
+
trustContext: { ...sameTrust },
|
|
488
|
+
}),
|
|
489
|
+
);
|
|
490
|
+
const ctx = makeFakeContext({ queue, turnInterfaceContext: ifCtx });
|
|
491
|
+
|
|
492
|
+
await drainQueue(ctx);
|
|
493
|
+
|
|
494
|
+
expect(queue.length).toBe(0);
|
|
495
|
+
});
|
|
496
|
+
|
|
374
497
|
test("drainSingleMessage does NOT re-add 'app-control' for web-sourced message when no capable client is connected", async () => {
|
|
375
498
|
// mockCapabilityClients remains [] (reset by afterEach from prior test)
|
|
376
499
|
const queue = new MessageQueue();
|
|
@@ -556,6 +556,174 @@ describe("Conversation message queue", () => {
|
|
|
556
556
|
await new Promise((r) => setTimeout(r, 10));
|
|
557
557
|
});
|
|
558
558
|
|
|
559
|
+
test("enqueueMessage captures the sender's trust, immune to a later slot change", async () => {
|
|
560
|
+
// Trust must ride with the queued message. The conversation-level slot is
|
|
561
|
+
// rewritten by whoever sends next, so a message that reads it at drain time
|
|
562
|
+
// would run as the wrong actor. Capturing at enqueue is what makes the
|
|
563
|
+
// drain's identity independent of who sent afterwards.
|
|
564
|
+
const conversation = makeConversation();
|
|
565
|
+
await conversation.loadFromDb();
|
|
566
|
+
|
|
567
|
+
conversation.setTrustContext({
|
|
568
|
+
trustClass: "trusted_contact",
|
|
569
|
+
sourceChannel: "slack",
|
|
570
|
+
requesterExternalUserId: "U-contact",
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
const p1 = conversation.processMessage({
|
|
574
|
+
content: "msg-1",
|
|
575
|
+
attachments: [],
|
|
576
|
+
onEvent: () => {},
|
|
577
|
+
requestId: "req-1",
|
|
578
|
+
});
|
|
579
|
+
await waitForPendingRun(1);
|
|
580
|
+
|
|
581
|
+
conversation.enqueueMessage({ content: "msg-2", requestId: "req-2" });
|
|
582
|
+
|
|
583
|
+
// A different actor sends while the message waits, moving the slot.
|
|
584
|
+
conversation.setTrustContext({
|
|
585
|
+
trustClass: "guardian",
|
|
586
|
+
sourceChannel: "vellum",
|
|
587
|
+
requesterExternalUserId: "guardian-principal",
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
const queued = conversation.queue.peek(0);
|
|
591
|
+
expect(queued?.requestId).toBe("req-2");
|
|
592
|
+
expect(queued?.trustContext?.trustClass).toBe("trusted_contact");
|
|
593
|
+
expect(queued?.trustContext?.requesterExternalUserId).toBe("U-contact");
|
|
594
|
+
|
|
595
|
+
await resolveRun(0);
|
|
596
|
+
await p1;
|
|
597
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
test("a drained turn keeps its sender's trust once the agent loop is running", async () => {
|
|
601
|
+
// Exercises the real drain -> runAgentLoop ordering against a live
|
|
602
|
+
// Conversation. The loop re-initializes the per-turn trust snapshot on
|
|
603
|
+
// entry, so a drain that only stamped the field before calling it would
|
|
604
|
+
// have that stamp silently undone and the turn would execute as whoever
|
|
605
|
+
// sent most recently. Asserting inside the run is the point: checking
|
|
606
|
+
// before the loop starts passes either way.
|
|
607
|
+
const conversation = makeConversation();
|
|
608
|
+
await conversation.loadFromDb();
|
|
609
|
+
|
|
610
|
+
conversation.setTrustContext({
|
|
611
|
+
trustClass: "trusted_contact",
|
|
612
|
+
sourceChannel: "slack",
|
|
613
|
+
requesterExternalUserId: "U-contact",
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
const p1 = conversation.processMessage({
|
|
617
|
+
content: "msg-1",
|
|
618
|
+
attachments: [],
|
|
619
|
+
onEvent: () => {},
|
|
620
|
+
requestId: "req-1",
|
|
621
|
+
});
|
|
622
|
+
await waitForPendingRun(1);
|
|
623
|
+
|
|
624
|
+
conversation.enqueueMessage({ content: "msg-2", requestId: "req-2" });
|
|
625
|
+
|
|
626
|
+
// The guardian sends while the contact's message waits, moving the slot.
|
|
627
|
+
conversation.setTrustContext({
|
|
628
|
+
trustClass: "guardian",
|
|
629
|
+
sourceChannel: "vellum",
|
|
630
|
+
requesterExternalUserId: "guardian-principal",
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
// Finish the first turn so the queue drains into a second run.
|
|
634
|
+
await resolveRun(0);
|
|
635
|
+
await p1;
|
|
636
|
+
await waitForPendingRun(2);
|
|
637
|
+
|
|
638
|
+
// Read while run 2 is in flight: this is what tool setup and the
|
|
639
|
+
// guardian-request producers resolve against.
|
|
640
|
+
expect(conversation.currentTurnTrustContext?.trustClass).toBe(
|
|
641
|
+
"trusted_contact",
|
|
642
|
+
);
|
|
643
|
+
expect(conversation.currentTurnTrustContext?.requesterExternalUserId).toBe(
|
|
644
|
+
"U-contact",
|
|
645
|
+
);
|
|
646
|
+
|
|
647
|
+
await resolveRun(1);
|
|
648
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
test("a processMessage turn keeps its turn-start trust when the slot moves before the loop opens", async () => {
|
|
652
|
+
// processMessage is the third turn entry point, and the one a web turn
|
|
653
|
+
// takes on an idle conversation (the route only enqueues while
|
|
654
|
+
// isProcessing). It captures trust at turn start, then awaits several
|
|
655
|
+
// times before the agent loop opens. The conversation slot is writable
|
|
656
|
+
// throughout that window by paths that do not own this turn: live-voice
|
|
657
|
+
// hydration stamp-and-restore, pointer elevation, the voice bridge.
|
|
658
|
+
//
|
|
659
|
+
// Driving a real Conversation with only AgentLoop.run mocked is what makes
|
|
660
|
+
// this observable. A double that stubs runAgentLoop itself would skip the
|
|
661
|
+
// re-read entirely and pass either way.
|
|
662
|
+
const conversation = makeConversation();
|
|
663
|
+
await conversation.loadFromDb();
|
|
664
|
+
|
|
665
|
+
conversation.setTrustContext({
|
|
666
|
+
trustClass: "trusted_contact",
|
|
667
|
+
sourceChannel: "slack",
|
|
668
|
+
requesterExternalUserId: "U-contact",
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
// Land another actor's slot write inside the window, after the turn-start
|
|
672
|
+
// capture and before the loop opens. persistUserMessage is awaited there.
|
|
673
|
+
const originalPersist = conversation.persistUserMessage.bind(conversation);
|
|
674
|
+
let slotMoved = false;
|
|
675
|
+
(
|
|
676
|
+
conversation as unknown as {
|
|
677
|
+
persistUserMessage: typeof conversation.persistUserMessage;
|
|
678
|
+
}
|
|
679
|
+
).persistUserMessage = async (opts) => {
|
|
680
|
+
const result = await originalPersist(opts);
|
|
681
|
+
conversation.setTrustContext({
|
|
682
|
+
trustClass: "guardian",
|
|
683
|
+
sourceChannel: "vellum",
|
|
684
|
+
requesterExternalUserId: "guardian-principal",
|
|
685
|
+
});
|
|
686
|
+
// Also move the per-turn field, which is writable out-of-band: a wake
|
|
687
|
+
// that settles inside this window restores its prior value there
|
|
688
|
+
// (agent-wake stamps at :1470 and restores in a `finally` at :1554).
|
|
689
|
+
// Covers both writers, so a fix that reads either one back at the agent
|
|
690
|
+
// loop call still fails this test.
|
|
691
|
+
conversation.currentTurnTrustContext = {
|
|
692
|
+
trustClass: "guardian",
|
|
693
|
+
sourceChannel: "vellum",
|
|
694
|
+
requesterExternalUserId: "guardian-principal",
|
|
695
|
+
};
|
|
696
|
+
slotMoved = true;
|
|
697
|
+
return result;
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
const p1 = conversation.processMessage({
|
|
701
|
+
content: "msg-1",
|
|
702
|
+
attachments: [],
|
|
703
|
+
onEvent: () => {},
|
|
704
|
+
requestId: "req-1",
|
|
705
|
+
});
|
|
706
|
+
await waitForPendingRun(1);
|
|
707
|
+
|
|
708
|
+
// Guard the test itself: if the injection stopped running, the assertions
|
|
709
|
+
// below would pass for the wrong reason.
|
|
710
|
+
expect(slotMoved).toBe(true);
|
|
711
|
+
expect(conversation.trustContext?.trustClass).toBe("guardian");
|
|
712
|
+
|
|
713
|
+
// Read mid-run: this is what tool setup and the guardian-request producers
|
|
714
|
+
// resolve against while the turn executes.
|
|
715
|
+
expect(conversation.currentTurnTrustContext?.trustClass).toBe(
|
|
716
|
+
"trusted_contact",
|
|
717
|
+
);
|
|
718
|
+
expect(conversation.currentTurnTrustContext?.requesterExternalUserId).toBe(
|
|
719
|
+
"U-contact",
|
|
720
|
+
);
|
|
721
|
+
|
|
722
|
+
await resolveRun(0);
|
|
723
|
+
await p1;
|
|
724
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
725
|
+
});
|
|
726
|
+
|
|
559
727
|
test("[experimental] queued passthrough siblings drain as a single batched run", async () => {
|
|
560
728
|
const conversation = makeConversation();
|
|
561
729
|
await conversation.loadFromDb();
|