diff --git a/assistant/src/__tests__/call-domain.test.ts b/assistant/src/__tests__/call-domain.test.ts index 144af2c4b1..cae6d71220 100644 --- a/assistant/src/__tests__/call-domain.test.ts +++ b/assistant/src/__tests__/call-domain.test.ts @@ -34,10 +34,10 @@ mock.module('../util/logger.js', () => ({ })); mock.module('../calls/twilio-config.js', () => ({ - getTwilioConfig: () => ({ + getTwilioConfig: (assistantId?: string) => ({ accountSid: 'AC_test', authToken: 'test_token', - phoneNumber: '+15550001111', + phoneNumber: assistantId === 'ast-alpha' ? '+15550003333' : '+15550001111', webhookBaseUrl: 'https://test.example.com', wssBaseUrl: 'wss://test.example.com', }), @@ -97,6 +97,16 @@ describe('resolveCallerIdentity — strict implicit-default policy', () => { } }); + test('assistant_number resolves from assistant-scoped Twilio number when assistantId is provided', async () => { + const result = await resolveCallerIdentity(makeConfig(), undefined, 'ast-alpha'); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.mode).toBe('assistant_number'); + expect(result.fromNumber).toBe('+15550003333'); + expect(result.source).toBe('implicit_default'); + } + }); + test('explicit user_number succeeds when eligible', async () => { const result = await resolveCallerIdentity( makeConfig({ userNumber: '+15550002222' }), diff --git a/assistant/src/__tests__/call-routes-http.test.ts b/assistant/src/__tests__/call-routes-http.test.ts index b7e00e8d0d..dde5148884 100644 --- a/assistant/src/__tests__/call-routes-http.test.ts +++ b/assistant/src/__tests__/call-routes-http.test.ts @@ -80,10 +80,10 @@ mock.module('../calls/twilio-provider.js', () => ({ // Mock Twilio config mock.module('../calls/twilio-config.js', () => ({ - getTwilioConfig: () => ({ + getTwilioConfig: (assistantId?: string) => ({ accountSid: 'AC_test', authToken: 'test_token', - phoneNumber: '+15550001111', + phoneNumber: assistantId === 'asst-alpha' ? '+15550009999' : '+15550001111', webhookBaseUrl: 'https://test.example.com', wssBaseUrl: 'wss://test.example.com', }), @@ -168,6 +168,10 @@ describe('runtime call routes — HTTP layer', () => { return `http://127.0.0.1:${port}/v1/calls${path}`; } + function assistantCallsUrl(assistantId: string, path = ''): string { + return `http://127.0.0.1:${port}/v1/assistants/${assistantId}/calls${path}`; + } + // ── POST /v1/calls/start ──────────────────────────────────────────── test('POST /v1/calls/start returns 201 with call session', async () => { @@ -222,6 +226,27 @@ describe('runtime call routes — HTTP layer', () => { await stopServer(); }); + test('POST /v1/assistants/:assistantId/calls/start uses assistant-scoped caller number', async () => { + await startServer(); + ensureConversation('conv-start-scoped-1'); + + const res = await fetch(assistantCallsUrl('asst-alpha', '/start'), { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS }, + body: JSON.stringify({ + phoneNumber: '+15559997777', + task: 'Check order status', + conversationId: 'conv-start-scoped-1', + }), + }); + + expect(res.status).toBe(201); + const body = await res.json() as { fromNumber: string }; + expect(body.fromNumber).toBe('+15550009999'); + + await stopServer(); + }); + test('POST /v1/calls/start returns 400 for invalid phone number', async () => { await startServer(); ensureConversation('conv-start-2'); diff --git a/assistant/src/__tests__/handlers-twilio-config.test.ts b/assistant/src/__tests__/handlers-twilio-config.test.ts index 14a4b963e9..41771d94b0 100644 --- a/assistant/src/__tests__/handlers-twilio-config.test.ts +++ b/assistant/src/__tests__/handlers-twilio-config.test.ts @@ -523,6 +523,54 @@ describe('Twilio config handler', () => { } }); + test('getTwilioConfig with assistantId prefers assistant-scoped mapping over global phone number', () => { + secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef'; + secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token'; + rawConfigStore = { + sms: { + phoneNumber: '+15551234567', + assistantPhoneNumbers: { + 'ast-alpha': '+15550000001', + }, + }, + ingress: { enabled: true, publicBaseUrl: 'https://test.ngrok.io' }, + }; + + const savedEnv = process.env.TWILIO_PHONE_NUMBER; + delete process.env.TWILIO_PHONE_NUMBER; + + try { + const config = getTwilioConfig('ast-alpha'); + expect(config.phoneNumber).toBe('+15550000001'); + } finally { + if (savedEnv !== undefined) process.env.TWILIO_PHONE_NUMBER = savedEnv; + } + }); + + test('getTwilioConfig with assistantId falls back to global number when mapping is missing', () => { + secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef'; + secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token'; + rawConfigStore = { + sms: { + phoneNumber: '+15551234567', + assistantPhoneNumbers: { + 'ast-alpha': '+15550000001', + }, + }, + ingress: { enabled: true, publicBaseUrl: 'https://test.ngrok.io' }, + }; + + const savedEnv = process.env.TWILIO_PHONE_NUMBER; + delete process.env.TWILIO_PHONE_NUMBER; + + try { + const config = getTwilioConfig('ast-beta'); + expect(config.phoneNumber).toBe('+15551234567'); + } finally { + if (savedEnv !== undefined) process.env.TWILIO_PHONE_NUMBER = savedEnv; + } + }); + // ── assign_number ─────────────────────────────────────────────────── test('assign_number persists phone number to config', async () => { diff --git a/assistant/src/calls/call-domain.ts b/assistant/src/calls/call-domain.ts index c0791ca1e8..1cd8e14b3e 100644 --- a/assistant/src/calls/call-domain.ts +++ b/assistant/src/calls/call-domain.ts @@ -51,6 +51,7 @@ export type StartCallInput = { task: string; context?: string; conversationId: string; + assistantId?: string; callerIdentityMode?: 'assistant_number' | 'user_number'; }; @@ -87,7 +88,8 @@ export type CallerIdentityResult = * - If `requestedMode` is provided but overrides are disabled, return an error. * - Otherwise, always use `assistant_number` (implicit default). * - * For `assistant_number`: uses the Twilio phone number from `getTwilioConfig()`. + * For `assistant_number`: uses the Twilio phone number from + * `getTwilioConfig(assistantId)` so multi-assistant mappings are honored. * No eligibility check is performed — this is a fast path. * For `user_number`: uses `config.calls.callerIdentity.userNumber` or the * secure key `credential:twilio:user_phone_number`, then validates that the @@ -96,6 +98,7 @@ export type CallerIdentityResult = export async function resolveCallerIdentity( config: AssistantConfig, requestedMode?: 'assistant_number' | 'user_number', + assistantId?: string, ): Promise { const identityConfig = config.calls.callerIdentity; let mode: 'assistant_number' | 'user_number'; @@ -118,8 +121,8 @@ export async function resolveCallerIdentity( } if (mode === 'assistant_number') { - const twilioConfig = getTwilioConfig(); - log.info({ mode, source, fromNumber: twilioConfig.phoneNumber }, 'Resolved caller identity'); + const twilioConfig = getTwilioConfig(assistantId); + log.info({ mode, source, fromNumber: twilioConfig.phoneNumber, assistantId }, 'Resolved caller identity'); return { ok: true, mode, fromNumber: twilioConfig.phoneNumber, source }; } @@ -175,7 +178,7 @@ export async function resolveCallerIdentity( * Initiate a new outbound call. */ export async function startCall(input: StartCallInput): Promise { - const { phoneNumber, task, context: callContext, conversationId, callerIdentityMode } = input; + const { phoneNumber, task, context: callContext, conversationId, callerIdentityMode, assistantId = 'self' } = input; if (!phoneNumber || typeof phoneNumber !== 'string') { return { ok: false, error: 'phone_number is required and must be a string', status: 400 }; @@ -204,7 +207,7 @@ export async function startCall(input: StartCallInput): Promise): string { + if (assistantId) { + const assistantPhone = config.sms?.assistantPhoneNumbers?.[assistantId]; + if (assistantPhone) { + return assistantPhone; + } + } - // Phone number resolution priority: + // Global fallback order: // 1. TWILIO_PHONE_NUMBER env var (explicit override) // 2. config file sms.phoneNumber (primary storage) // 3. credential:twilio:phone_number secure key (backward-compat fallback) - const phoneNumber = - process.env.TWILIO_PHONE_NUMBER || - config.sms?.phoneNumber || - getSecureKey('credential:twilio:phone_number') || - ''; + return process.env.TWILIO_PHONE_NUMBER || config.sms?.phoneNumber || getSecureKey('credential:twilio:phone_number') || ''; +} + +export function getTwilioConfig(assistantId?: string): TwilioConfig { + const accountSid = getSecureKey('credential:twilio:account_sid'); + const authToken = getSecureKey('credential:twilio:auth_token'); + const config = loadConfig(); + const phoneNumber = resolveTwilioPhoneNumber(assistantId, config); const webhookBaseUrl = getPublicBaseUrl(config); // Always use the centralized relay URL derived from the public ingress base URL. @@ -45,7 +51,7 @@ export function getTwilioConfig(): TwilioConfig { throw new Error('Twilio credentials not configured. Set credential:twilio:account_sid and credential:twilio:auth_token via the credential_store tool.'); } if (!phoneNumber) { - throw new Error('TWILIO_PHONE_NUMBER not configured.'); + throw new Error('Twilio phone number not configured.'); } log.debug('Twilio config loaded successfully'); diff --git a/assistant/src/runtime/http-server.ts b/assistant/src/runtime/http-server.ts index 8f5ec12b07..3f99480638 100644 --- a/assistant/src/runtime/http-server.ts +++ b/assistant/src/runtime/http-server.ts @@ -794,7 +794,7 @@ export class RuntimeHttpServer { // ── Call API routes ─────────────────────────────────────────── if (endpoint === 'calls/start' && req.method === 'POST') { - return await handleStartCall(req); + return await handleStartCall(req, assistantId); } // Match calls/:callSessionId and calls/:callSessionId/cancel, calls/:callSessionId/answer, calls/:callSessionId/instruction diff --git a/assistant/src/runtime/routes/call-routes.ts b/assistant/src/runtime/routes/call-routes.ts index f217b6f15f..6fdc7ffbf8 100644 --- a/assistant/src/runtime/routes/call-routes.ts +++ b/assistant/src/runtime/routes/call-routes.ts @@ -17,7 +17,7 @@ import { VALID_CALLER_IDENTITY_MODES } from '../../config/schema.js'; * * Body: { phoneNumber: string; task: string; context?: string; conversationId: string; callerIdentityMode?: 'assistant_number' | 'user_number' } */ -export async function handleStartCall(req: Request): Promise { +export async function handleStartCall(req: Request, assistantId: string = 'self'): Promise { if (!getConfig().calls.enabled) { return Response.json( { error: 'Calls feature is disabled via configuration. Set calls.enabled to true to use this feature.' }, @@ -59,6 +59,7 @@ export async function handleStartCall(req: Request): Promise { task: body.task ?? '', context: body.context, conversationId: body.conversationId, + assistantId, callerIdentityMode: body.callerIdentityMode, }); diff --git a/assistant/src/tools/calls/call-start.ts b/assistant/src/tools/calls/call-start.ts index 40d7796a43..f79c2b8874 100644 --- a/assistant/src/tools/calls/call-start.ts +++ b/assistant/src/tools/calls/call-start.ts @@ -54,6 +54,7 @@ class CallStartTool implements Tool { task: input.task as string, context: input.context as string | undefined, conversationId: context.conversationId, + assistantId: context.assistantId, callerIdentityMode: input.caller_identity_mode as 'assistant_number' | 'user_number' | undefined, }); diff --git a/gateway/src/__tests__/config.test.ts b/gateway/src/__tests__/config.test.ts index 3a7694692a..1ec665b935 100644 --- a/gateway/src/__tests__/config.test.ts +++ b/gateway/src/__tests__/config.test.ts @@ -1,5 +1,7 @@ -import { writeFileSync, unlinkSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync, unlinkSync } from "node:fs"; import { describe, test, expect } from "bun:test"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { loadConfig } from "../config.js"; const BASE_ENV = { @@ -31,6 +33,7 @@ function withEnv(overrides: Record, fn: () => void) "GATEWAY_MAX_ATTACHMENT_CONCURRENCY", "GATEWAY_TELEGRAM_DELIVER_AUTH_BYPASS", "VELLUM_HTTP_TOKEN_PATH", + "BASE_DATA_DIR", ]; for (const key of allKeys) { @@ -259,3 +262,35 @@ describe("config: runtime bearer token", () => { ); }); }); + +describe("config: twilio assistant phone number mapping", () => { + test("loads assistantPhoneNumbers from workspace config on startup", () => { + const testBaseDir = mkdtempSync(join(tmpdir(), "gateway-config-test-")); + try { + const workspaceDir = join(testBaseDir, ".vellum", "workspace"); + mkdirSync(workspaceDir, { recursive: true }); + writeFileSync( + join(workspaceDir, "config.json"), + JSON.stringify({ + sms: { + phoneNumber: "+15550001111", + assistantPhoneNumbers: { + "asst-alpha": "+15550002222", + "asst-beta": "+15550003333", + }, + }, + }), + ); + + withEnv({ BASE_DATA_DIR: testBaseDir }, () => { + const config = loadConfig(); + expect(config.assistantPhoneNumbers).toEqual({ + "asst-alpha": "+15550002222", + "asst-beta": "+15550003333", + }); + }); + } finally { + rmSync(testBaseDir, { recursive: true, force: true }); + } + }); +}); diff --git a/gateway/src/config.ts b/gateway/src/config.ts index 70badfcbf4..f13e8b581f 100644 --- a/gateway/src/config.ts +++ b/gateway/src/config.ts @@ -255,17 +255,26 @@ export function loadConfig(): GatewayConfig { // Phone number: env var > config file sms.phoneNumber > credential store let twilioPhoneNumber: string | undefined = process.env.TWILIO_PHONE_NUMBER || undefined; - if (!twilioPhoneNumber) { - try { - const cfgPath = join(getRootDir(), "workspace", "config.json"); - const raw = readFileSync(cfgPath, "utf-8"); - const data = JSON.parse(raw); - if (data?.sms?.phoneNumber && typeof data.sms.phoneNumber === "string") { - twilioPhoneNumber = data.sms.phoneNumber; + let assistantPhoneNumbers: Record | undefined; + try { + const cfgPath = join(getRootDir(), "workspace", "config.json"); + const raw = readFileSync(cfgPath, "utf-8"); + const data = JSON.parse(raw); + if (!twilioPhoneNumber && data?.sms?.phoneNumber && typeof data.sms.phoneNumber === "string") { + twilioPhoneNumber = data.sms.phoneNumber; + } + const rawMapping = data?.sms?.assistantPhoneNumbers; + if (rawMapping && typeof rawMapping === "object" && !Array.isArray(rawMapping)) { + const normalized: Record = {}; + for (const [assistantId, phoneNumber] of Object.entries(rawMapping as Record)) { + if (typeof phoneNumber === "string" && phoneNumber.trim().length > 0) { + normalized[assistantId] = phoneNumber; + } } - } catch { - // config file may not exist yet + assistantPhoneNumbers = normalized; } + } catch { + // config file may not exist yet } if (!twilioPhoneNumber) { twilioPhoneNumber = @@ -315,6 +324,7 @@ export function loadConfig(): GatewayConfig { hasTwilioAuthToken: !!twilioAuthToken, hasTwilioAccountSid: !!twilioAccountSid, hasTwilioPhoneNumber: !!twilioPhoneNumber, + assistantPhoneNumberCount: assistantPhoneNumbers ? Object.keys(assistantPhoneNumbers).length : 0, smsDeliverAuthBypass, ingressPublicBaseUrl, }, @@ -350,6 +360,7 @@ export function loadConfig(): GatewayConfig { twilioAuthToken, twilioAccountSid, twilioPhoneNumber, + assistantPhoneNumbers, smsDeliverAuthBypass, ingressPublicBaseUrl, unmappedPolicy,