vellum 0.2.12 → 0.2.14
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/README.md +32 -0
- package/bun.lock +2 -2
- package/docs/skills.md +4 -4
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
- package/src/__tests__/app-git-history.test.ts +176 -0
- package/src/__tests__/app-git-service.test.ts +169 -0
- package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
- package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
- package/src/__tests__/browser-skill-endstate.test.ts +6 -6
- package/src/__tests__/call-bridge.test.ts +105 -13
- package/src/__tests__/call-domain.test.ts +163 -0
- package/src/__tests__/call-orchestrator.test.ts +171 -0
- package/src/__tests__/call-routes-http.test.ts +246 -6
- package/src/__tests__/channel-approval-routes.test.ts +438 -0
- package/src/__tests__/channel-approval.test.ts +266 -0
- package/src/__tests__/channel-approvals.test.ts +393 -0
- package/src/__tests__/channel-delivery-store.test.ts +447 -0
- package/src/__tests__/checker.test.ts +607 -1048
- package/src/__tests__/cli.test.ts +1 -56
- package/src/__tests__/config-schema.test.ts +402 -5
- package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
- package/src/__tests__/conflict-policy.test.ts +121 -0
- package/src/__tests__/conflict-store.test.ts +2 -0
- package/src/__tests__/contacts-tools.test.ts +3 -3
- package/src/__tests__/contradiction-checker.test.ts +99 -1
- package/src/__tests__/credential-security-invariants.test.ts +22 -6
- package/src/__tests__/credential-vault-unit.test.ts +780 -0
- package/src/__tests__/elevenlabs-client.test.ts +271 -0
- package/src/__tests__/ephemeral-permissions.test.ts +73 -23
- package/src/__tests__/filesystem-tools.test.ts +579 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
- package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
- package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
- package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
- package/src/__tests__/handlers-slack-config.test.ts +2 -1
- package/src/__tests__/handlers-telegram-config.test.ts +855 -0
- package/src/__tests__/handlers-twitter-config.test.ts +141 -1
- package/src/__tests__/hooks-runner.test.ts +6 -2
- package/src/__tests__/host-file-edit-tool.test.ts +124 -0
- package/src/__tests__/host-file-read-tool.test.ts +62 -0
- package/src/__tests__/host-file-write-tool.test.ts +59 -0
- package/src/__tests__/host-shell-tool.test.ts +251 -0
- package/src/__tests__/ingress-reconcile.test.ts +581 -0
- package/src/__tests__/ipc-snapshot.test.ts +100 -41
- package/src/__tests__/ipc-validate.test.ts +50 -0
- package/src/__tests__/key-migration.test.ts +23 -0
- package/src/__tests__/memory-regressions.test.ts +99 -0
- package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
- package/src/__tests__/oauth-callback-registry.test.ts +11 -4
- package/src/__tests__/playbook-execution.test.ts +502 -0
- package/src/__tests__/playbook-tools.test.ts +4 -6
- package/src/__tests__/public-ingress-urls.test.ts +34 -0
- package/src/__tests__/qdrant-manager.test.ts +267 -0
- package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
- package/src/__tests__/recurrence-engine.test.ts +9 -0
- package/src/__tests__/recurrence-types.test.ts +8 -0
- package/src/__tests__/registry.test.ts +1 -1
- package/src/__tests__/runtime-runs.test.ts +1 -25
- package/src/__tests__/schedule-store.test.ts +16 -14
- package/src/__tests__/schedule-tools.test.ts +83 -0
- package/src/__tests__/scheduler-recurrence.test.ts +111 -10
- package/src/__tests__/secret-allowlist.test.ts +18 -17
- package/src/__tests__/secret-ingress-handler.test.ts +11 -0
- package/src/__tests__/secret-scanner.test.ts +43 -0
- package/src/__tests__/session-conflict-gate.test.ts +442 -6
- package/src/__tests__/session-init.benchmark.test.ts +3 -0
- package/src/__tests__/session-process-bridge.test.ts +242 -0
- package/src/__tests__/session-skill-tools.test.ts +1 -1
- package/src/__tests__/shell-identity.test.ts +256 -0
- package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
- package/src/__tests__/subagent-tools.test.ts +637 -54
- package/src/__tests__/task-management-tools.test.ts +936 -0
- package/src/__tests__/task-runner.test.ts +2 -2
- package/src/__tests__/terminal-tools.test.ts +840 -0
- package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
- package/src/__tests__/tool-executor.test.ts +85 -151
- package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
- package/src/__tests__/trust-store.test.ts +28 -453
- package/src/__tests__/twilio-provider.test.ts +153 -3
- package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
- package/src/__tests__/twilio-routes-twiml.test.ts +127 -0
- package/src/__tests__/twilio-routes.test.ts +17 -262
- package/src/__tests__/twitter-auth-handler.test.ts +2 -1
- package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
- package/src/__tests__/twitter-cli-routing.test.ts +252 -0
- package/src/__tests__/twitter-oauth-client.test.ts +209 -0
- package/src/__tests__/workspace-policy.test.ts +213 -0
- package/src/calls/call-bridge.ts +92 -19
- package/src/calls/call-domain.ts +157 -5
- package/src/calls/call-orchestrator.ts +96 -8
- package/src/calls/call-store.ts +6 -0
- package/src/calls/elevenlabs-client.ts +97 -0
- package/src/calls/elevenlabs-config.ts +31 -0
- package/src/calls/twilio-provider.ts +91 -0
- package/src/calls/twilio-routes.ts +50 -6
- package/src/calls/types.ts +3 -1
- package/src/calls/voice-quality.ts +114 -0
- package/src/cli/twitter.ts +200 -21
- package/src/cli.ts +1 -20
- package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
- package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
- package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
- package/src/config/bundled-skills/messaging/SKILL.md +17 -2
- package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
- package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +207 -19
- package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
- package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
- package/src/config/bundled-skills/twitter/SKILL.md +103 -17
- package/src/config/defaults.ts +26 -2
- package/src/config/schema.ts +178 -9
- package/src/config/types.ts +3 -0
- package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
- package/src/daemon/assistant-attachments.ts +4 -2
- package/src/daemon/handlers/apps.ts +69 -0
- package/src/daemon/handlers/config.ts +543 -24
- package/src/daemon/handlers/index.ts +1 -0
- package/src/daemon/handlers/sessions.ts +22 -6
- package/src/daemon/handlers/shared.ts +2 -1
- package/src/daemon/handlers/skills.ts +5 -20
- package/src/daemon/ipc-contract-inventory.json +28 -0
- package/src/daemon/ipc-contract.ts +168 -10
- package/src/daemon/ipc-validate.ts +17 -0
- package/src/daemon/lifecycle.ts +2 -0
- package/src/daemon/server.ts +78 -72
- package/src/daemon/session-attachments.ts +1 -1
- package/src/daemon/session-conflict-gate.ts +62 -6
- package/src/daemon/session-notifiers.ts +1 -1
- package/src/daemon/session-process.ts +62 -3
- package/src/daemon/session-tool-setup.ts +1 -2
- package/src/daemon/tls-certs.ts +189 -0
- package/src/daemon/video-thumbnail.ts +5 -3
- package/src/hooks/manager.ts +5 -9
- package/src/memory/app-git-service.ts +295 -0
- package/src/memory/app-store.ts +21 -0
- package/src/memory/conflict-intent.ts +47 -4
- package/src/memory/conflict-policy.ts +73 -0
- package/src/memory/conflict-store.ts +9 -1
- package/src/memory/contradiction-checker.ts +28 -0
- package/src/memory/conversation-key-store.ts +15 -0
- package/src/memory/db.ts +81 -0
- package/src/memory/embedding-local.ts +3 -13
- package/src/memory/external-conversation-store.ts +234 -0
- package/src/memory/job-handlers/conflict.ts +22 -2
- package/src/memory/jobs-worker.ts +67 -28
- package/src/memory/runs-store.ts +54 -7
- package/src/memory/schema.ts +20 -0
- package/src/messaging/provider.ts +9 -0
- package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
- package/src/messaging/providers/telegram-bot/client.ts +104 -0
- package/src/messaging/providers/telegram-bot/types.ts +15 -0
- package/src/messaging/registry.ts +1 -0
- package/src/permissions/checker.ts +48 -44
- package/src/permissions/defaults.ts +11 -0
- package/src/permissions/prompter.ts +0 -4
- package/src/permissions/shell-identity.ts +227 -0
- package/src/permissions/trust-store.ts +76 -53
- package/src/permissions/types.ts +0 -19
- package/src/permissions/workspace-policy.ts +114 -0
- package/src/providers/retry.ts +12 -37
- package/src/runtime/assistant-event-hub.ts +41 -4
- package/src/runtime/channel-approval-parser.ts +60 -0
- package/src/runtime/channel-approval-types.ts +71 -0
- package/src/runtime/channel-approvals.ts +145 -0
- package/src/runtime/gateway-client.ts +16 -0
- package/src/runtime/http-server.ts +29 -9
- package/src/runtime/routes/call-routes.ts +52 -2
- package/src/runtime/routes/channel-routes.ts +296 -16
- package/src/runtime/routes/conversation-routes.ts +12 -5
- package/src/runtime/routes/events-routes.ts +97 -28
- package/src/runtime/routes/run-routes.ts +2 -7
- package/src/runtime/run-orchestrator.ts +0 -3
- package/src/schedule/recurrence-engine.ts +26 -2
- package/src/schedule/recurrence-types.ts +1 -1
- package/src/schedule/schedule-store.ts +12 -3
- package/src/security/secret-scanner.ts +7 -0
- package/src/tasks/ephemeral-permissions.ts +0 -2
- package/src/tasks/task-scheduler.ts +2 -1
- package/src/tools/calls/call-start.ts +8 -0
- package/src/tools/execution-target.ts +21 -0
- package/src/tools/execution-timeout.ts +49 -0
- package/src/tools/executor.ts +6 -135
- package/src/tools/network/web-search.ts +9 -32
- package/src/tools/policy-context.ts +29 -0
- package/src/tools/schedule/update.ts +8 -1
- package/src/tools/terminal/parser.ts +16 -18
- package/src/tools/types.ts +4 -11
- package/src/twitter/oauth-client.ts +102 -0
- package/src/twitter/router.ts +101 -0
- package/src/util/debounce.ts +88 -0
- package/src/util/network-info.ts +47 -0
- package/src/util/platform.ts +29 -4
- package/src/util/promise-guard.ts +37 -0
- package/src/util/retry.ts +98 -0
- package/src/util/truncate.ts +1 -1
- package/src/workspace/git-service.ts +129 -112
- package/src/tools/contacts/contact-merge.ts +0 -55
- package/src/tools/contacts/contact-search.ts +0 -58
- package/src/tools/contacts/contact-upsert.ts +0 -64
- package/src/tools/playbooks/index.ts +0 -4
- package/src/tools/playbooks/playbook-create.ts +0 -96
- package/src/tools/playbooks/playbook-delete.ts +0 -52
- package/src/tools/playbooks/playbook-list.ts +0 -74
- package/src/tools/playbooks/playbook-update.ts +0 -111
|
@@ -101,7 +101,7 @@ import {
|
|
|
101
101
|
fireCallCompletionNotifier,
|
|
102
102
|
} from '../calls/call-state.js';
|
|
103
103
|
import { CallOrchestrator } from '../calls/call-orchestrator.js';
|
|
104
|
-
import {
|
|
104
|
+
import { tryRouteCallMessage } from '../calls/call-bridge.js';
|
|
105
105
|
import * as conversationStore from '../memory/conversation-store.js';
|
|
106
106
|
import type { RelayConnection } from '../calls/relay-server.js';
|
|
107
107
|
|
|
@@ -177,26 +177,27 @@ describe('call-bridge', () => {
|
|
|
177
177
|
mockStreamFn.mockImplementation(() => createMockStream(['Hello']));
|
|
178
178
|
});
|
|
179
179
|
|
|
180
|
-
// ──
|
|
180
|
+
// ── tryRouteCallMessage — answer path ───────────────────────
|
|
181
181
|
|
|
182
182
|
test('returns handled:false when no active call exists', async () => {
|
|
183
183
|
ensureConversation('conv-no-call');
|
|
184
|
-
const result = await
|
|
184
|
+
const result = await tryRouteCallMessage('conv-no-call', 'some answer');
|
|
185
185
|
expect(result.handled).toBe(false);
|
|
186
186
|
expect(result.reason).toBe('no_active_call');
|
|
187
187
|
});
|
|
188
188
|
|
|
189
|
-
test('returns
|
|
190
|
-
ensureConversation('conv-no-
|
|
189
|
+
test('returns instruction_relay_failed (consumed) when call exists but no orchestrator and no pending question', async () => {
|
|
190
|
+
ensureConversation('conv-no-orch');
|
|
191
191
|
createCallSession({
|
|
192
|
-
conversationId: 'conv-no-
|
|
192
|
+
conversationId: 'conv-no-orch',
|
|
193
193
|
provider: 'twilio',
|
|
194
194
|
fromNumber: '+15551111111',
|
|
195
195
|
toNumber: '+15552222222',
|
|
196
196
|
});
|
|
197
|
-
const result = await
|
|
198
|
-
expect(result.handled).toBe(
|
|
199
|
-
expect(result.reason).toBe('
|
|
197
|
+
const result = await tryRouteCallMessage('conv-no-orch', 'some instruction');
|
|
198
|
+
expect(result.handled).toBe(true);
|
|
199
|
+
expect(result.reason).toBe('instruction_relay_failed');
|
|
200
|
+
expect(result.userFacingText).toBe('Failed to relay instruction to the active call.');
|
|
200
201
|
});
|
|
201
202
|
|
|
202
203
|
test('returns handled:false when orchestrator is not found (call still active but no orchestrator)', async () => {
|
|
@@ -215,7 +216,7 @@ describe('call-bridge', () => {
|
|
|
215
216
|
// Create a pending question without an orchestrator
|
|
216
217
|
createPendingQuestion(callSession.id, 'What time?');
|
|
217
218
|
|
|
218
|
-
const result = await
|
|
219
|
+
const result = await tryRouteCallMessage('conv-ended', 'Too late');
|
|
219
220
|
expect(result.handled).toBe(false);
|
|
220
221
|
expect(result.reason).toBe('orchestrator_not_found');
|
|
221
222
|
});
|
|
@@ -231,7 +232,7 @@ describe('call-bridge', () => {
|
|
|
231
232
|
// Mark the call as completed — getActiveCallSessionForConversation will return null
|
|
232
233
|
updateCallSession(callSession.id, { status: 'completed', endedAt: Date.now() });
|
|
233
234
|
|
|
234
|
-
const result = await
|
|
235
|
+
const result = await tryRouteCallMessage('conv-completed', 'Too late');
|
|
235
236
|
expect(result.handled).toBe(false);
|
|
236
237
|
expect(result.reason).toBe('no_active_call');
|
|
237
238
|
});
|
|
@@ -252,7 +253,7 @@ describe('call-bridge', () => {
|
|
|
252
253
|
// Create a pending question in the DB but orchestrator is idle, not waiting_on_user
|
|
253
254
|
createPendingQuestion(callSession.id, 'What time?');
|
|
254
255
|
|
|
255
|
-
const result = await
|
|
256
|
+
const result = await tryRouteCallMessage('conv-not-waiting', 'answer');
|
|
256
257
|
expect(result.handled).toBe(false);
|
|
257
258
|
expect(result.reason).toBe('orchestrator_not_waiting');
|
|
258
259
|
|
|
@@ -284,7 +285,7 @@ describe('call-bridge', () => {
|
|
|
284
285
|
// Now provide the answer — set up mock for the LLM call after answer
|
|
285
286
|
mockStreamFn.mockImplementation(() => createMockStream(['Great, booking for tomorrow.']));
|
|
286
287
|
|
|
287
|
-
const result = await
|
|
288
|
+
const result = await tryRouteCallMessage('conv-bridge', 'Tomorrow at noon');
|
|
288
289
|
expect(result.handled).toBe(true);
|
|
289
290
|
|
|
290
291
|
// Wait for the fire-and-forget LLM call
|
|
@@ -298,6 +299,97 @@ describe('call-bridge', () => {
|
|
|
298
299
|
orchestrator.destroy();
|
|
299
300
|
});
|
|
300
301
|
|
|
302
|
+
// ── tryRouteCallMessage — instruction path ────────────────────
|
|
303
|
+
|
|
304
|
+
test('routes instruction to orchestrator when active call exists with no pending question', async () => {
|
|
305
|
+
ensureConversation('conv-instruct');
|
|
306
|
+
const callSession = createCallSession({
|
|
307
|
+
conversationId: 'conv-instruct',
|
|
308
|
+
provider: 'twilio',
|
|
309
|
+
fromNumber: '+15551111111',
|
|
310
|
+
toNumber: '+15552222222',
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const relay = createMockRelay();
|
|
314
|
+
const orchestrator = new CallOrchestrator(callSession.id, relay as unknown as RelayConnection, 'test task');
|
|
315
|
+
|
|
316
|
+
const result = await tryRouteCallMessage('conv-instruct', 'Please ask about pricing');
|
|
317
|
+
expect(result.handled).toBe(true);
|
|
318
|
+
expect(result.userFacingText).toBe('Instruction relayed to active call.');
|
|
319
|
+
|
|
320
|
+
// Verify acknowledgement was persisted
|
|
321
|
+
const msgs = getMessagesForConversation('conv-instruct');
|
|
322
|
+
const ackMsg = msgs.find((m) => m.content.includes('Instruction relayed'));
|
|
323
|
+
expect(ackMsg).toBeDefined();
|
|
324
|
+
expect(ackMsg!.role).toBe('assistant');
|
|
325
|
+
|
|
326
|
+
orchestrator.destroy();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test('prefers answer path over instruction path when pending question exists', async () => {
|
|
330
|
+
// Setup: trigger ASK_USER to put orchestrator in waiting_on_user state
|
|
331
|
+
mockStreamFn.mockImplementation(() =>
|
|
332
|
+
createMockStream(['Hold on. [ASK_USER: Budget range?]']),
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
ensureConversation('conv-prefer-answer');
|
|
336
|
+
const callSession = createCallSession({
|
|
337
|
+
conversationId: 'conv-prefer-answer',
|
|
338
|
+
provider: 'twilio',
|
|
339
|
+
fromNumber: '+15551111111',
|
|
340
|
+
toNumber: '+15552222222',
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const relay = createMockRelay();
|
|
344
|
+
const orchestrator = new CallOrchestrator(callSession.id, relay as unknown as RelayConnection, 'test task');
|
|
345
|
+
|
|
346
|
+
await orchestrator.handleCallerUtterance('What is your budget?');
|
|
347
|
+
expect(orchestrator.getState()).toBe('waiting_on_user');
|
|
348
|
+
|
|
349
|
+
// Mock the next LLM call
|
|
350
|
+
mockStreamFn.mockImplementation(() => createMockStream(['Got it, thanks.']));
|
|
351
|
+
|
|
352
|
+
// This should route as answer, not instruction
|
|
353
|
+
const result = await tryRouteCallMessage('conv-prefer-answer', '$500');
|
|
354
|
+
expect(result.handled).toBe(true);
|
|
355
|
+
|
|
356
|
+
// Wait for fire-and-forget LLM call
|
|
357
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
358
|
+
|
|
359
|
+
// Should have answered the pending question, not relayed as instruction
|
|
360
|
+
const question = getPendingQuestion(callSession.id);
|
|
361
|
+
expect(question).toBeNull();
|
|
362
|
+
|
|
363
|
+
// No instruction acknowledgement should be persisted
|
|
364
|
+
const msgs = getMessagesForConversation('conv-prefer-answer');
|
|
365
|
+
const ackMsg = msgs.find((m) => m.content.includes('Instruction relayed'));
|
|
366
|
+
expect(ackMsg).toBeUndefined();
|
|
367
|
+
|
|
368
|
+
orchestrator.destroy();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test('instruction relay failure persists notice and is consumed (handled:true)', async () => {
|
|
372
|
+
ensureConversation('conv-no-orch-instruct');
|
|
373
|
+
createCallSession({
|
|
374
|
+
conversationId: 'conv-no-orch-instruct',
|
|
375
|
+
provider: 'twilio',
|
|
376
|
+
fromNumber: '+15551111111',
|
|
377
|
+
toNumber: '+15552222222',
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// No orchestrator registered — relay should fail but still be consumed
|
|
381
|
+
const result = await tryRouteCallMessage('conv-no-orch-instruct', 'Change the topic');
|
|
382
|
+
expect(result.handled).toBe(true);
|
|
383
|
+
expect(result.reason).toBe('instruction_relay_failed');
|
|
384
|
+
expect(result.userFacingText).toBe('Failed to relay instruction to the active call.');
|
|
385
|
+
|
|
386
|
+
// Verify failure notice was persisted in-thread
|
|
387
|
+
const msgs = getMessagesForConversation('conv-no-orch-instruct');
|
|
388
|
+
const failMsg = msgs.find((m) => m.content.includes('Failed to relay'));
|
|
389
|
+
expect(failMsg).toBeDefined();
|
|
390
|
+
expect(failMsg!.role).toBe('assistant');
|
|
391
|
+
});
|
|
392
|
+
|
|
301
393
|
// ── Call question notifier ──────────────────────────────────────
|
|
302
394
|
|
|
303
395
|
test('call question notifier persists assistant message and emits events', () => {
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for caller identity resolution in call-domain.ts.
|
|
3
|
+
*
|
|
4
|
+
* Validates the strict implicit-default policy:
|
|
5
|
+
* - Implicit calls (no explicit mode) always use assistant_number.
|
|
6
|
+
* - Explicit user_number calls succeed when eligible.
|
|
7
|
+
* - Explicit user_number calls fail clearly when missing/ineligible.
|
|
8
|
+
* - Explicit override rejected when allowPerCallOverride=false.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, test, expect, mock } from 'bun:test';
|
|
11
|
+
import { mkdtempSync, realpathSync } from 'node:fs';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
const testDir = realpathSync(mkdtempSync(join(tmpdir(), 'call-domain-test-')));
|
|
16
|
+
|
|
17
|
+
mock.module('../util/platform.js', () => ({
|
|
18
|
+
getRootDir: () => testDir,
|
|
19
|
+
getDataDir: () => testDir,
|
|
20
|
+
isMacOS: () => process.platform === 'darwin',
|
|
21
|
+
isLinux: () => process.platform === 'linux',
|
|
22
|
+
isWindows: () => process.platform === 'win32',
|
|
23
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
24
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
25
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
26
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
27
|
+
ensureDataDir: () => {},
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
mock.module('../util/logger.js', () => ({
|
|
31
|
+
getLogger: () => new Proxy({} as Record<string, unknown>, {
|
|
32
|
+
get: () => () => {},
|
|
33
|
+
}),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
mock.module('../calls/twilio-config.js', () => ({
|
|
37
|
+
getTwilioConfig: () => ({
|
|
38
|
+
accountSid: 'AC_test',
|
|
39
|
+
authToken: 'test_token',
|
|
40
|
+
phoneNumber: '+15550001111',
|
|
41
|
+
webhookBaseUrl: 'https://test.example.com',
|
|
42
|
+
wssBaseUrl: 'wss://test.example.com',
|
|
43
|
+
}),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
mock.module('../calls/twilio-provider.js', () => ({
|
|
47
|
+
TwilioConversationRelayProvider: class {
|
|
48
|
+
async checkCallerIdEligibility(number: string) {
|
|
49
|
+
// Simulate: +15550002222 is eligible, others are not
|
|
50
|
+
if (number === '+15550002222') return { eligible: true };
|
|
51
|
+
return { eligible: false, reason: `${number} is not eligible as a caller ID` };
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
mock.module('../security/secure-keys.js', () => ({
|
|
57
|
+
getSecureKey: () => null,
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
import { resolveCallerIdentity } from '../calls/call-domain.js';
|
|
61
|
+
import type { AssistantConfig } from '../config/types.js';
|
|
62
|
+
|
|
63
|
+
function makeConfig(overrides: {
|
|
64
|
+
allowPerCallOverride?: boolean;
|
|
65
|
+
userNumber?: string;
|
|
66
|
+
} = {}): AssistantConfig {
|
|
67
|
+
return {
|
|
68
|
+
calls: {
|
|
69
|
+
callerIdentity: {
|
|
70
|
+
allowPerCallOverride: overrides.allowPerCallOverride ?? true,
|
|
71
|
+
userNumber: overrides.userNumber,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
} as unknown as AssistantConfig;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe('resolveCallerIdentity — strict implicit-default policy', () => {
|
|
78
|
+
test('implicit call defaults to assistant_number', async () => {
|
|
79
|
+
const result = await resolveCallerIdentity(makeConfig());
|
|
80
|
+
expect(result.ok).toBe(true);
|
|
81
|
+
if (result.ok) {
|
|
82
|
+
expect(result.mode).toBe('assistant_number');
|
|
83
|
+
expect(result.fromNumber).toBe('+15550001111');
|
|
84
|
+
expect(result.source).toBe('implicit_default');
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('implicit call uses assistant_number even when userNumber is configured', async () => {
|
|
89
|
+
const result = await resolveCallerIdentity(
|
|
90
|
+
makeConfig({ userNumber: '+15550002222' }),
|
|
91
|
+
);
|
|
92
|
+
expect(result.ok).toBe(true);
|
|
93
|
+
if (result.ok) {
|
|
94
|
+
expect(result.mode).toBe('assistant_number');
|
|
95
|
+
expect(result.fromNumber).toBe('+15550001111');
|
|
96
|
+
expect(result.source).toBe('implicit_default');
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('explicit user_number succeeds when eligible', async () => {
|
|
101
|
+
const result = await resolveCallerIdentity(
|
|
102
|
+
makeConfig({ userNumber: '+15550002222' }),
|
|
103
|
+
'user_number',
|
|
104
|
+
);
|
|
105
|
+
expect(result.ok).toBe(true);
|
|
106
|
+
if (result.ok) {
|
|
107
|
+
expect(result.mode).toBe('user_number');
|
|
108
|
+
expect(result.fromNumber).toBe('+15550002222');
|
|
109
|
+
expect(result.source).toBe('user_config');
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('explicit user_number fails when no user phone configured', async () => {
|
|
114
|
+
const result = await resolveCallerIdentity(makeConfig(), 'user_number');
|
|
115
|
+
expect(result.ok).toBe(false);
|
|
116
|
+
if (!result.ok) {
|
|
117
|
+
expect(result.error).toContain('user_number');
|
|
118
|
+
expect(result.error).toContain('user phone number');
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('explicit user_number fails when number is ineligible', async () => {
|
|
123
|
+
const result = await resolveCallerIdentity(
|
|
124
|
+
makeConfig({ userNumber: '+15559999999' }),
|
|
125
|
+
'user_number',
|
|
126
|
+
);
|
|
127
|
+
expect(result.ok).toBe(false);
|
|
128
|
+
if (!result.ok) {
|
|
129
|
+
expect(result.error).toContain('not eligible');
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('explicit override rejected when allowPerCallOverride=false', async () => {
|
|
134
|
+
const result = await resolveCallerIdentity(
|
|
135
|
+
makeConfig({ allowPerCallOverride: false, userNumber: '+15550002222' }),
|
|
136
|
+
'user_number',
|
|
137
|
+
);
|
|
138
|
+
expect(result.ok).toBe(false);
|
|
139
|
+
if (!result.ok) {
|
|
140
|
+
expect(result.error).toContain('override is disabled');
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('explicit assistant_number override succeeds when allowed', async () => {
|
|
145
|
+
const result = await resolveCallerIdentity(makeConfig(), 'assistant_number');
|
|
146
|
+
expect(result.ok).toBe(true);
|
|
147
|
+
if (result.ok) {
|
|
148
|
+
expect(result.mode).toBe('assistant_number');
|
|
149
|
+
expect(result.source).toBe('per_call_override');
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('invalid mode returns error', async () => {
|
|
154
|
+
const result = await resolveCallerIdentity(
|
|
155
|
+
makeConfig(),
|
|
156
|
+
'custom_number' as 'assistant_number',
|
|
157
|
+
);
|
|
158
|
+
expect(result.ok).toBe(false);
|
|
159
|
+
if (!result.ok) {
|
|
160
|
+
expect(result.error).toContain('Invalid callerIdentityMode');
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -29,6 +29,8 @@ mock.module('../util/logger.js', () => ({
|
|
|
29
29
|
|
|
30
30
|
// ── Config mock ─────────────────────────────────────────────────────
|
|
31
31
|
|
|
32
|
+
let mockCallModel: string | undefined = undefined;
|
|
33
|
+
|
|
32
34
|
mock.module('../config/loader.js', () => ({
|
|
33
35
|
getConfig: () => ({
|
|
34
36
|
apiKeys: { anthropic: 'test-key' },
|
|
@@ -41,6 +43,7 @@ mock.module('../config/loader.js', () => ({
|
|
|
41
43
|
silenceTimeoutSeconds: 30,
|
|
42
44
|
disclosure: { enabled: false, text: '' },
|
|
43
45
|
safety: { denyCategories: [] },
|
|
46
|
+
model: mockCallModel,
|
|
44
47
|
},
|
|
45
48
|
}),
|
|
46
49
|
}));
|
|
@@ -97,6 +100,7 @@ import { conversations } from '../memory/schema.js';
|
|
|
97
100
|
import {
|
|
98
101
|
createCallSession,
|
|
99
102
|
getCallSession,
|
|
103
|
+
getCallEvents,
|
|
100
104
|
getPendingQuestion,
|
|
101
105
|
updateCallSession,
|
|
102
106
|
} from '../calls/call-store.js';
|
|
@@ -192,6 +196,7 @@ function setupOrchestrator(task?: string) {
|
|
|
192
196
|
describe('call-orchestrator', () => {
|
|
193
197
|
beforeEach(() => {
|
|
194
198
|
resetTables();
|
|
199
|
+
mockCallModel = undefined;
|
|
195
200
|
// Reset the stream mock to default behaviour
|
|
196
201
|
mockStreamFn.mockImplementation(() => createMockStream(['Hello', ' there']));
|
|
197
202
|
});
|
|
@@ -451,4 +456,170 @@ describe('call-orchestrator', () => {
|
|
|
451
456
|
// Second destroy should not throw
|
|
452
457
|
expect(() => orchestrator.destroy()).not.toThrow();
|
|
453
458
|
});
|
|
459
|
+
|
|
460
|
+
// ── Model override from config ──────────────────────────────────────
|
|
461
|
+
|
|
462
|
+
test('uses default model when calls.model is not set', async () => {
|
|
463
|
+
mockCallModel = undefined;
|
|
464
|
+
mockStreamFn.mockImplementation((...args: unknown[]) => {
|
|
465
|
+
const firstArg = args[0] as { model: string };
|
|
466
|
+
expect(firstArg.model).toBe('claude-sonnet-4-20250514');
|
|
467
|
+
return createMockStream(['Default model response.']);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
const { orchestrator } = setupOrchestrator();
|
|
471
|
+
await orchestrator.handleCallerUtterance('Hello');
|
|
472
|
+
orchestrator.destroy();
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test('uses calls.model override from config when set', async () => {
|
|
476
|
+
mockCallModel = 'claude-haiku-4-5-20251001';
|
|
477
|
+
mockStreamFn.mockImplementation((...args: unknown[]) => {
|
|
478
|
+
const firstArg = args[0] as { model: string };
|
|
479
|
+
expect(firstArg.model).toBe('claude-haiku-4-5-20251001');
|
|
480
|
+
return createMockStream(['Override model response.']);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
const { orchestrator } = setupOrchestrator();
|
|
484
|
+
await orchestrator.handleCallerUtterance('Hello');
|
|
485
|
+
orchestrator.destroy();
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test('treats empty string calls.model as unset and falls back to default', async () => {
|
|
489
|
+
mockCallModel = '';
|
|
490
|
+
mockStreamFn.mockImplementation((...args: unknown[]) => {
|
|
491
|
+
const firstArg = args[0] as { model: string };
|
|
492
|
+
expect(firstArg.model).toBe('claude-sonnet-4-20250514');
|
|
493
|
+
return createMockStream(['Fallback model response.']);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
const { orchestrator } = setupOrchestrator();
|
|
497
|
+
await orchestrator.handleCallerUtterance('Hello');
|
|
498
|
+
orchestrator.destroy();
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test('treats whitespace-only calls.model as unset and falls back to default', async () => {
|
|
502
|
+
mockCallModel = ' ';
|
|
503
|
+
mockStreamFn.mockImplementation((...args: unknown[]) => {
|
|
504
|
+
const firstArg = args[0] as { model: string };
|
|
505
|
+
expect(firstArg.model).toBe('claude-sonnet-4-20250514');
|
|
506
|
+
return createMockStream(['Fallback model response.']);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
const { orchestrator } = setupOrchestrator();
|
|
510
|
+
await orchestrator.handleCallerUtterance('Hello');
|
|
511
|
+
orchestrator.destroy();
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
// ── handleUserInstruction ─────────────────────────────────────────
|
|
515
|
+
|
|
516
|
+
test('handleUserInstruction: injects instruction marker into conversation history and triggers LLM when idle', async () => {
|
|
517
|
+
mockStreamFn.mockImplementation((...args: unknown[]) => {
|
|
518
|
+
const firstArg = args[0] as { messages: Array<{ role: string; content: string }> };
|
|
519
|
+
const instructionMsg = firstArg.messages.find((m) =>
|
|
520
|
+
m.role === 'user' && m.content.includes('[USER_INSTRUCTION:'),
|
|
521
|
+
);
|
|
522
|
+
expect(instructionMsg).toBeDefined();
|
|
523
|
+
expect(instructionMsg!.content).toContain('[USER_INSTRUCTION: Ask about their weekend plans]');
|
|
524
|
+
return createMockStream(['Sure, do you have any weekend plans?']);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
const { relay, orchestrator } = setupOrchestrator();
|
|
528
|
+
|
|
529
|
+
await orchestrator.handleUserInstruction('Ask about their weekend plans');
|
|
530
|
+
|
|
531
|
+
// Should have streamed a response since orchestrator was idle
|
|
532
|
+
const nonEmptyTokens = relay.sentTokens.filter((t) => t.token.length > 0);
|
|
533
|
+
expect(nonEmptyTokens.length).toBeGreaterThan(0);
|
|
534
|
+
|
|
535
|
+
orchestrator.destroy();
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test('handleUserInstruction: does not break existing answer flow', async () => {
|
|
539
|
+
// Step 1: Caller says something, LLM responds normally
|
|
540
|
+
mockStreamFn.mockImplementation(() => createMockStream(['Hello! How can I help you today?']));
|
|
541
|
+
const { session: _session, relay, orchestrator } = setupOrchestrator('Book appointment');
|
|
542
|
+
|
|
543
|
+
await orchestrator.handleCallerUtterance('Hi there');
|
|
544
|
+
|
|
545
|
+
// Step 2: Inject an instruction while idle
|
|
546
|
+
mockStreamFn.mockImplementation((...args: unknown[]) => {
|
|
547
|
+
const firstArg = args[0] as { messages: Array<{ role: string; content: string }> };
|
|
548
|
+
// Verify the history contains both the original exchange and the instruction
|
|
549
|
+
const messages = firstArg.messages;
|
|
550
|
+
expect(messages.length).toBeGreaterThanOrEqual(3); // user utterance + assistant response + instruction
|
|
551
|
+
const instructionMsg = messages.find((m) =>
|
|
552
|
+
m.role === 'user' && m.content.includes('[USER_INSTRUCTION:'),
|
|
553
|
+
);
|
|
554
|
+
expect(instructionMsg).toBeDefined();
|
|
555
|
+
return createMockStream(['Of course, let me mention the weekend special.']);
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
await orchestrator.handleUserInstruction('Mention the weekend special');
|
|
559
|
+
|
|
560
|
+
// Step 3: Caller speaks again — the flow should continue normally
|
|
561
|
+
mockStreamFn.mockImplementation(() =>
|
|
562
|
+
createMockStream(['Great choice! The weekend special is 20% off.']),
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
await orchestrator.handleCallerUtterance('Tell me more about that');
|
|
566
|
+
|
|
567
|
+
// Verify state is idle after the normal flow
|
|
568
|
+
expect(orchestrator.getState()).toBe('idle');
|
|
569
|
+
|
|
570
|
+
// Verify relay received tokens from all exchanges
|
|
571
|
+
const allText = relay.sentTokens.map((t) => t.token).join('');
|
|
572
|
+
expect(allText).toContain('Hello');
|
|
573
|
+
expect(allText).toContain('weekend special');
|
|
574
|
+
|
|
575
|
+
orchestrator.destroy();
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
test('handleUserInstruction: emits user_instruction_relayed event', async () => {
|
|
579
|
+
mockStreamFn.mockImplementation(() => createMockStream(['Understood, adjusting approach.']));
|
|
580
|
+
|
|
581
|
+
const { session, orchestrator } = setupOrchestrator();
|
|
582
|
+
|
|
583
|
+
await orchestrator.handleUserInstruction('Be more formal in your tone');
|
|
584
|
+
|
|
585
|
+
const events = getCallEvents(session.id);
|
|
586
|
+
const instructionEvents = events.filter((e) => e.eventType === 'user_instruction_relayed');
|
|
587
|
+
expect(instructionEvents.length).toBe(1);
|
|
588
|
+
|
|
589
|
+
const payload = JSON.parse(instructionEvents[0].payloadJson);
|
|
590
|
+
expect(payload.instruction).toBe('Be more formal in your tone');
|
|
591
|
+
|
|
592
|
+
orchestrator.destroy();
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
test('handleUserInstruction: does not trigger LLM when orchestrator is not idle', async () => {
|
|
596
|
+
// First, trigger ASK_USER so orchestrator enters waiting_on_user
|
|
597
|
+
mockStreamFn.mockImplementation(() =>
|
|
598
|
+
createMockStream(['Hold on. [ASK_USER: What time?]']),
|
|
599
|
+
);
|
|
600
|
+
|
|
601
|
+
const { session, orchestrator } = setupOrchestrator();
|
|
602
|
+
await orchestrator.handleCallerUtterance('I need an appointment');
|
|
603
|
+
expect(orchestrator.getState()).toBe('waiting_on_user');
|
|
604
|
+
|
|
605
|
+
// Track how many times the stream mock is called
|
|
606
|
+
let streamCallCount = 0;
|
|
607
|
+
mockStreamFn.mockImplementation(() => {
|
|
608
|
+
streamCallCount++;
|
|
609
|
+
return createMockStream(['Response after instruction.']);
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
// Inject instruction while in waiting_on_user state
|
|
613
|
+
await orchestrator.handleUserInstruction('Suggest morning slots');
|
|
614
|
+
|
|
615
|
+
// The LLM should NOT have been triggered since we're not idle
|
|
616
|
+
expect(streamCallCount).toBe(0);
|
|
617
|
+
|
|
618
|
+
// But the event should still be recorded
|
|
619
|
+
const events = getCallEvents(session.id);
|
|
620
|
+
const instructionEvents = events.filter((e) => e.eventType === 'user_instruction_relayed');
|
|
621
|
+
expect(instructionEvents.length).toBe(1);
|
|
622
|
+
|
|
623
|
+
orchestrator.destroy();
|
|
624
|
+
});
|
|
454
625
|
});
|