Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions assistant/src/__tests__/call-domain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}),
Expand Down Expand Up @@ -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' }),
Expand Down
29 changes: 27 additions & 2 deletions assistant/src/__tests__/call-routes-http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}),
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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');
Expand Down
48 changes: 48 additions & 0 deletions assistant/src/__tests__/handlers-twilio-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
13 changes: 8 additions & 5 deletions assistant/src/calls/call-domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export type StartCallInput = {
task: string;
context?: string;
conversationId: string;
assistantId?: string;
callerIdentityMode?: 'assistant_number' | 'user_number';
};

Expand Down Expand Up @@ -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
Expand All @@ -96,6 +98,7 @@ export type CallerIdentityResult =
export async function resolveCallerIdentity(
config: AssistantConfig,
requestedMode?: 'assistant_number' | 'user_number',
assistantId?: string,
): Promise<CallerIdentityResult> {
const identityConfig = config.calls.callerIdentity;
let mode: 'assistant_number' | 'user_number';
Expand All @@ -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 };
}

Expand Down Expand Up @@ -175,7 +178,7 @@ export async function resolveCallerIdentity(
* Initiate a new outbound call.
*/
export async function startCall(input: StartCallInput): Promise<StartCallResult | CallError> {
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 };
Expand Down Expand Up @@ -204,7 +207,7 @@ export async function startCall(input: StartCallInput): Promise<StartCallResult
const provider = new TwilioConversationRelayProvider();

// Resolve which phone number to use as caller ID
const identityResult = await resolveCallerIdentity(ingressConfig, callerIdentityMode);
const identityResult = await resolveCallerIdentity(ingressConfig, callerIdentityMode, assistantId);
Comment thread
noanflaherty marked this conversation as resolved.
if (!identityResult.ok) {
return { ok: false, error: identityResult.error, status: 400 };
}
Expand Down
28 changes: 17 additions & 11 deletions assistant/src/calls/twilio-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,26 @@ export interface TwilioConfig {
wssBaseUrl: string;
}

export function getTwilioConfig(): TwilioConfig {
const accountSid = getSecureKey('credential:twilio:account_sid');
const authToken = getSecureKey('credential:twilio:auth_token');
const config = loadConfig();
function resolveTwilioPhoneNumber(assistantId: string | undefined, config: ReturnType<typeof loadConfig>): 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.
Expand All @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion assistant/src/runtime/http-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion assistant/src/runtime/routes/call-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response> {
export async function handleStartCall(req: Request, assistantId: string = 'self'): Promise<Response> {
if (!getConfig().calls.enabled) {
return Response.json(
{ error: 'Calls feature is disabled via configuration. Set calls.enabled to true to use this feature.' },
Expand Down Expand Up @@ -59,6 +59,7 @@ export async function handleStartCall(req: Request): Promise<Response> {
task: body.task ?? '',
context: body.context,
conversationId: body.conversationId,
assistantId,
callerIdentityMode: body.callerIdentityMode,
});

Expand Down
1 change: 1 addition & 0 deletions assistant/src/tools/calls/call-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand Down
37 changes: 36 additions & 1 deletion gateway/src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -31,6 +33,7 @@ function withEnv(overrides: Record<string, string | undefined>, fn: () => void)
"GATEWAY_MAX_ATTACHMENT_CONCURRENCY",
"GATEWAY_TELEGRAM_DELIVER_AUTH_BYPASS",
"VELLUM_HTTP_TOKEN_PATH",
"BASE_DATA_DIR",
];

for (const key of allKeys) {
Expand Down Expand Up @@ -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 });
}
});
});
29 changes: 20 additions & 9 deletions gateway/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> | 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<string, string> = {};
for (const [assistantId, phoneNumber] of Object.entries(rawMapping as Record<string, unknown>)) {
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 =
Expand Down Expand Up @@ -315,6 +324,7 @@ export function loadConfig(): GatewayConfig {
hasTwilioAuthToken: !!twilioAuthToken,
hasTwilioAccountSid: !!twilioAccountSid,
hasTwilioPhoneNumber: !!twilioPhoneNumber,
assistantPhoneNumberCount: assistantPhoneNumbers ? Object.keys(assistantPhoneNumbers).length : 0,
smsDeliverAuthBypass,
ingressPublicBaseUrl,
},
Expand Down Expand Up @@ -350,6 +360,7 @@ export function loadConfig(): GatewayConfig {
twilioAuthToken,
twilioAccountSid,
twilioPhoneNumber,
assistantPhoneNumbers,
smsDeliverAuthBypass,
ingressPublicBaseUrl,
unmappedPolicy,
Expand Down
Loading