@vellumai/assistant 0.3.4 → 0.3.5
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/Dockerfile +2 -0
- package/README.md +37 -2
- package/package.json +1 -1
- package/scripts/ipc/generate-swift.ts +13 -0
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +100 -0
- package/src/__tests__/approval-hardcoded-copy-guard.test.ts +41 -0
- package/src/__tests__/approval-message-composer.test.ts +253 -0
- package/src/__tests__/call-domain.test.ts +12 -2
- package/src/__tests__/call-orchestrator.test.ts +70 -1
- package/src/__tests__/call-routes-http.test.ts +27 -2
- package/src/__tests__/channel-approval-routes.test.ts +21 -17
- package/src/__tests__/channel-approvals.test.ts +48 -1
- package/src/__tests__/channel-guardian.test.ts +74 -22
- package/src/__tests__/channel-readiness-service.test.ts +257 -0
- package/src/__tests__/config-schema.test.ts +2 -1
- package/src/__tests__/credential-security-invariants.test.ts +1 -0
- package/src/__tests__/daemon-lifecycle.test.ts +13 -12
- package/src/__tests__/dictation-mode-detection.test.ts +63 -0
- package/src/__tests__/entity-search.test.ts +615 -0
- package/src/__tests__/handlers-twilio-config.test.ts +407 -0
- package/src/__tests__/ipc-snapshot.test.ts +63 -0
- package/src/__tests__/messaging-send-tool.test.ts +65 -0
- package/src/__tests__/run-orchestrator-assistant-events.test.ts +4 -0
- package/src/__tests__/run-orchestrator.test.ts +22 -0
- package/src/__tests__/session-runtime-assembly.test.ts +85 -1
- package/src/__tests__/sms-messaging-provider.test.ts +125 -0
- package/src/__tests__/twilio-routes.test.ts +39 -3
- package/src/__tests__/twitter-cli-error-shaping.test.ts +2 -2
- package/src/__tests__/web-search.test.ts +1 -1
- package/src/__tests__/work-item-output.test.ts +110 -0
- package/src/calls/call-domain.ts +8 -5
- package/src/calls/call-orchestrator.ts +22 -11
- package/src/calls/twilio-config.ts +17 -11
- package/src/calls/twilio-rest.ts +276 -0
- package/src/calls/twilio-routes.ts +39 -1
- package/src/config/bundled-skills/knowledge-graph/SKILL.md +15 -0
- package/src/config/bundled-skills/knowledge-graph/TOOLS.json +56 -0
- package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +185 -0
- package/src/config/bundled-skills/media-processing/SKILL.md +199 -0
- package/src/config/bundled-skills/media-processing/TOOLS.json +320 -0
- package/src/config/bundled-skills/media-processing/services/capability-registry.ts +137 -0
- package/src/config/bundled-skills/media-processing/services/event-detection-service.ts +280 -0
- package/src/config/bundled-skills/media-processing/services/feedback-aggregation.ts +144 -0
- package/src/config/bundled-skills/media-processing/services/feedback-store.ts +136 -0
- package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +261 -0
- package/src/config/bundled-skills/media-processing/services/retrieval-service.ts +95 -0
- package/src/config/bundled-skills/media-processing/services/timeline-service.ts +267 -0
- package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +301 -0
- package/src/config/bundled-skills/media-processing/tools/detect-events.ts +110 -0
- package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +190 -0
- package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +195 -0
- package/src/config/bundled-skills/media-processing/tools/ingest-media.ts +197 -0
- package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +166 -0
- package/src/config/bundled-skills/media-processing/tools/media-status.ts +75 -0
- package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +300 -0
- package/src/config/bundled-skills/media-processing/tools/recalibrate.ts +235 -0
- package/src/config/bundled-skills/media-processing/tools/select-tracking-profile.ts +142 -0
- package/src/config/bundled-skills/media-processing/tools/submit-feedback.ts +150 -0
- package/src/config/bundled-skills/messaging/SKILL.md +21 -6
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
- package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
- package/src/config/bundled-skills/twitter/SKILL.md +19 -3
- package/src/config/defaults.ts +2 -1
- package/src/config/schema.ts +9 -3
- package/src/config/system-prompt.ts +24 -0
- package/src/config/templates/IDENTITY.md +2 -2
- package/src/config/vellum-skills/catalog.json +6 -0
- package/src/config/vellum-skills/google-oauth-setup/SKILL.md +3 -3
- package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +3 -3
- package/src/config/vellum-skills/sms-setup/SKILL.md +118 -0
- package/src/config/vellum-skills/twilio-setup/SKILL.md +40 -8
- package/src/daemon/handlers/config.ts +783 -9
- package/src/daemon/handlers/dictation.ts +182 -0
- package/src/daemon/handlers/identity.ts +14 -23
- package/src/daemon/handlers/index.ts +2 -0
- package/src/daemon/handlers/sessions.ts +2 -0
- package/src/daemon/handlers/shared.ts +3 -0
- package/src/daemon/handlers/work-items.ts +15 -7
- package/src/daemon/ipc-contract-inventory.json +10 -0
- package/src/daemon/ipc-contract.ts +108 -4
- package/src/daemon/lifecycle.ts +2 -0
- package/src/daemon/ride-shotgun-handler.ts +1 -1
- package/src/daemon/server.ts +6 -2
- package/src/daemon/session-agent-loop.ts +5 -1
- package/src/daemon/session-runtime-assembly.ts +55 -0
- package/src/daemon/session-tool-setup.ts +2 -0
- package/src/daemon/session.ts +11 -1
- package/src/inbound/public-ingress-urls.ts +3 -3
- package/src/memory/channel-guardian-store.ts +2 -1
- package/src/memory/db-init.ts +144 -0
- package/src/memory/job-handlers/media-processing.ts +100 -0
- package/src/memory/jobs-store.ts +2 -1
- package/src/memory/jobs-worker.ts +4 -0
- package/src/memory/media-store.ts +759 -0
- package/src/memory/retriever.ts +6 -1
- package/src/memory/schema.ts +98 -0
- package/src/memory/search/entity.ts +208 -25
- package/src/memory/search/ranking.ts +6 -1
- package/src/memory/search/types.ts +24 -0
- package/src/messaging/provider-types.ts +2 -0
- package/src/messaging/providers/sms/adapter.ts +204 -0
- package/src/messaging/providers/sms/client.ts +93 -0
- package/src/messaging/providers/sms/types.ts +7 -0
- package/src/permissions/checker.ts +16 -2
- package/src/runtime/approval-message-composer.ts +143 -0
- package/src/runtime/channel-approvals.ts +12 -4
- package/src/runtime/channel-guardian-service.ts +44 -18
- package/src/runtime/channel-readiness-service.ts +292 -0
- package/src/runtime/channel-readiness-types.ts +29 -0
- package/src/runtime/http-server.ts +53 -27
- package/src/runtime/http-types.ts +3 -0
- package/src/runtime/routes/call-routes.ts +2 -1
- package/src/runtime/routes/channel-routes.ts +67 -21
- package/src/runtime/run-orchestrator.ts +35 -2
- package/src/tools/assets/materialize.ts +2 -2
- package/src/tools/calls/call-start.ts +1 -0
- package/src/tools/credentials/vault.ts +1 -1
- package/src/tools/execution-target.ts +11 -1
- package/src/tools/network/web-search.ts +1 -1
- package/src/tools/types.ts +2 -0
- package/src/twitter/router.ts +1 -1
- package/src/util/platform.ts +35 -0
|
@@ -3,12 +3,14 @@ import type { Message } from '../providers/types.js';
|
|
|
3
3
|
import {
|
|
4
4
|
applyRuntimeInjections,
|
|
5
5
|
injectChannelCapabilityContext,
|
|
6
|
+
injectGuardianContext,
|
|
6
7
|
injectTemporalContext,
|
|
7
8
|
resolveChannelCapabilities,
|
|
8
9
|
stripChannelCapabilityContext,
|
|
10
|
+
stripGuardianContext,
|
|
9
11
|
stripTemporalContext,
|
|
10
12
|
} from '../daemon/session-runtime-assembly.js';
|
|
11
|
-
import type { ChannelCapabilities } from '../daemon/session-runtime-assembly.js';
|
|
13
|
+
import type { ChannelCapabilities, GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
|
|
12
14
|
import { buildChannelAwarenessSection } from '../config/system-prompt.js';
|
|
13
15
|
|
|
14
16
|
// ---------------------------------------------------------------------------
|
|
@@ -277,6 +279,12 @@ describe('buildChannelAwarenessSection', () => {
|
|
|
277
279
|
const section = buildChannelAwarenessSection();
|
|
278
280
|
expect(section).toContain('computer-control permissions on non-dashboard');
|
|
279
281
|
});
|
|
282
|
+
|
|
283
|
+
test('includes guardian context contract for channel actors', () => {
|
|
284
|
+
const section = buildChannelAwarenessSection();
|
|
285
|
+
expect(section).toContain('<guardian_context>');
|
|
286
|
+
expect(section).toContain('Never infer guardian status');
|
|
287
|
+
});
|
|
280
288
|
});
|
|
281
289
|
|
|
282
290
|
// ---------------------------------------------------------------------------
|
|
@@ -474,3 +482,79 @@ describe('applyRuntimeInjections with temporalContext', () => {
|
|
|
474
482
|
expect(result[0].content.length).toBe(1);
|
|
475
483
|
});
|
|
476
484
|
});
|
|
485
|
+
|
|
486
|
+
// ---------------------------------------------------------------------------
|
|
487
|
+
// guardian_context
|
|
488
|
+
// ---------------------------------------------------------------------------
|
|
489
|
+
|
|
490
|
+
describe('injectGuardianContext', () => {
|
|
491
|
+
const baseUserMessage: Message = {
|
|
492
|
+
role: 'user',
|
|
493
|
+
content: [{ type: 'text', text: 'Can you text me updates?' }],
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
test('prepends guardian_context block to user message', () => {
|
|
497
|
+
const ctx: GuardianRuntimeContext = {
|
|
498
|
+
sourceChannel: 'sms',
|
|
499
|
+
actorRole: 'guardian',
|
|
500
|
+
guardianExternalUserId: 'guardian-user-1',
|
|
501
|
+
guardianChatId: '+15550001111',
|
|
502
|
+
requesterIdentifier: '+15550001111',
|
|
503
|
+
requesterExternalUserId: 'guardian-user-1',
|
|
504
|
+
requesterChatId: '+15550001111',
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
const result = injectGuardianContext(baseUserMessage, ctx);
|
|
508
|
+
expect(result.content.length).toBe(2);
|
|
509
|
+
const injected = result.content[0];
|
|
510
|
+
expect(injected.type).toBe('text');
|
|
511
|
+
const text = (injected as { type: 'text'; text: string }).text;
|
|
512
|
+
expect(text).toContain('<guardian_context>');
|
|
513
|
+
expect(text).toContain('actor_role: guardian');
|
|
514
|
+
expect(text).toContain('source_channel: sms');
|
|
515
|
+
expect(text).toContain('</guardian_context>');
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
describe('stripGuardianContext', () => {
|
|
520
|
+
test('strips guardian_context blocks from user messages', () => {
|
|
521
|
+
const messages: Message[] = [
|
|
522
|
+
{
|
|
523
|
+
role: 'user',
|
|
524
|
+
content: [
|
|
525
|
+
{ type: 'text', text: '<guardian_context>\nactor_role: guardian\n</guardian_context>' },
|
|
526
|
+
{ type: 'text', text: 'Hello' },
|
|
527
|
+
],
|
|
528
|
+
},
|
|
529
|
+
];
|
|
530
|
+
const result = stripGuardianContext(messages);
|
|
531
|
+
expect(result).toHaveLength(1);
|
|
532
|
+
expect(result[0].content).toHaveLength(1);
|
|
533
|
+
expect((result[0].content[0] as { type: 'text'; text: string }).text).toBe('Hello');
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
describe('applyRuntimeInjections with guardianContext', () => {
|
|
538
|
+
const baseMessages: Message[] = [
|
|
539
|
+
{
|
|
540
|
+
role: 'user',
|
|
541
|
+
content: [{ type: 'text', text: 'Help me send this over SMS.' }],
|
|
542
|
+
},
|
|
543
|
+
];
|
|
544
|
+
|
|
545
|
+
test('injects guardian context when provided', () => {
|
|
546
|
+
const result = applyRuntimeInjections(baseMessages, {
|
|
547
|
+
guardianContext: {
|
|
548
|
+
sourceChannel: 'sms',
|
|
549
|
+
actorRole: 'non-guardian',
|
|
550
|
+
guardianExternalUserId: 'guardian-1',
|
|
551
|
+
requesterExternalUserId: 'requester-1',
|
|
552
|
+
requesterIdentifier: '+15550002222',
|
|
553
|
+
requesterChatId: '+15550002222',
|
|
554
|
+
},
|
|
555
|
+
});
|
|
556
|
+
expect(result).toHaveLength(1);
|
|
557
|
+
expect(result[0].content).toHaveLength(2);
|
|
558
|
+
expect((result[0].content[0] as { type: 'text'; text: string }).text).toContain('<guardian_context>');
|
|
559
|
+
});
|
|
560
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
const sendSmsMock = mock(async (..._args: unknown[]) => ({ messageSid: 'SM-mock-sid', status: 'queued' }));
|
|
4
|
+
const getOrCreateConversationMock = mock((_key: string) => ({ conversationId: 'conv-1' }));
|
|
5
|
+
const upsertOutboundBindingMock = mock((_input: Record<string, unknown>) => {});
|
|
6
|
+
|
|
7
|
+
let secureKeys: Record<string, string | undefined> = {
|
|
8
|
+
'credential:twilio:account_sid': 'AC1234567890',
|
|
9
|
+
'credential:twilio:auth_token': 'auth-token',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
let configState: {
|
|
13
|
+
sms?: {
|
|
14
|
+
phoneNumber?: string;
|
|
15
|
+
assistantPhoneNumbers?: Record<string, string>;
|
|
16
|
+
};
|
|
17
|
+
} = {
|
|
18
|
+
sms: {},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
mock.module('../security/secure-keys.js', () => ({
|
|
22
|
+
getSecureKey: (key: string) => secureKeys[key],
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
mock.module('../util/platform.js', () => ({
|
|
26
|
+
readHttpToken: () => 'runtime-token',
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
mock.module('../config/loader.js', () => ({
|
|
30
|
+
loadConfig: () => configState,
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
mock.module('../memory/conversation-key-store.js', () => ({
|
|
34
|
+
getOrCreateConversation: (key: string) => getOrCreateConversationMock(key),
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
mock.module('../memory/external-conversation-store.js', () => ({
|
|
38
|
+
upsertOutboundBinding: (input: Record<string, unknown>) => upsertOutboundBindingMock(input),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
mock.module('../messaging/providers/sms/client.js', () => ({
|
|
42
|
+
sendMessage: (
|
|
43
|
+
gatewayUrl: string,
|
|
44
|
+
bearerToken: string,
|
|
45
|
+
to: string,
|
|
46
|
+
text: string,
|
|
47
|
+
assistantId?: string,
|
|
48
|
+
) => sendSmsMock(gatewayUrl, bearerToken, to, text, assistantId),
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
import { smsMessagingProvider } from '../messaging/providers/sms/adapter.js';
|
|
52
|
+
|
|
53
|
+
describe('smsMessagingProvider', () => {
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
sendSmsMock.mockClear();
|
|
56
|
+
getOrCreateConversationMock.mockClear();
|
|
57
|
+
upsertOutboundBindingMock.mockClear();
|
|
58
|
+
secureKeys = {
|
|
59
|
+
'credential:twilio:account_sid': 'AC1234567890',
|
|
60
|
+
'credential:twilio:auth_token': 'auth-token',
|
|
61
|
+
};
|
|
62
|
+
configState = { sms: {} };
|
|
63
|
+
delete process.env.TWILIO_PHONE_NUMBER;
|
|
64
|
+
delete process.env.GATEWAY_INTERNAL_BASE_URL;
|
|
65
|
+
delete process.env.GATEWAY_PORT;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('isConnected is true when assistant-scoped numbers exist', () => {
|
|
69
|
+
configState = {
|
|
70
|
+
sms: {
|
|
71
|
+
assistantPhoneNumbers: { 'ast-alpha': '+15550001111' },
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
expect(smsMessagingProvider.isConnected?.()).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('sendMessage forwards explicit assistant scope and avoids outbound binding writes for non-self', async () => {
|
|
79
|
+
await smsMessagingProvider.sendMessage('', '+15550002222', 'hi', {
|
|
80
|
+
assistantId: 'ast-alpha',
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(sendSmsMock).toHaveBeenCalledWith(
|
|
84
|
+
'http://127.0.0.1:7830',
|
|
85
|
+
'runtime-token',
|
|
86
|
+
'+15550002222',
|
|
87
|
+
'hi',
|
|
88
|
+
'ast-alpha',
|
|
89
|
+
);
|
|
90
|
+
expect(getOrCreateConversationMock).toHaveBeenCalledWith('asst:ast-alpha:sms:+15550002222');
|
|
91
|
+
expect(upsertOutboundBindingMock).not.toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('sendMessage uses messageSid from gateway response as result ID', async () => {
|
|
95
|
+
sendSmsMock.mockImplementation(async () => ({ messageSid: 'SM-test-12345', status: 'queued' }));
|
|
96
|
+
const result = await smsMessagingProvider.sendMessage('', '+15550009999', 'sid test', {
|
|
97
|
+
assistantId: 'self',
|
|
98
|
+
});
|
|
99
|
+
expect(result.id).toBe('SM-test-12345');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('sendMessage falls back to timestamp-based ID when messageSid is absent', async () => {
|
|
103
|
+
sendSmsMock.mockImplementation(async () => ({}));
|
|
104
|
+
const before = Date.now();
|
|
105
|
+
const result = await smsMessagingProvider.sendMessage('', '+15550009999', 'no sid', {
|
|
106
|
+
assistantId: 'self',
|
|
107
|
+
});
|
|
108
|
+
expect(result.id).toMatch(/^sms-\d+$/);
|
|
109
|
+
const ts = parseInt(result.id.replace('sms-', ''), 10);
|
|
110
|
+
expect(ts).toBeGreaterThanOrEqual(before);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('sendMessage uses canonical self key and writes outbound binding for self scope', async () => {
|
|
114
|
+
await smsMessagingProvider.sendMessage('', '+15550003333', 'hello', {
|
|
115
|
+
assistantId: 'self',
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
expect(getOrCreateConversationMock).toHaveBeenCalledWith('sms:+15550003333');
|
|
119
|
+
expect(upsertOutboundBindingMock).toHaveBeenCalledWith({
|
|
120
|
+
conversationId: 'conv-1',
|
|
121
|
+
sourceChannel: 'sms',
|
|
122
|
+
externalChatId: '+15550003333',
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -87,7 +87,7 @@ import {
|
|
|
87
87
|
updateCallSession,
|
|
88
88
|
getCallEvents,
|
|
89
89
|
} from '../calls/call-store.js';
|
|
90
|
-
import { resolveRelayUrl, handleStatusCallback, handleVoiceWebhook } from '../calls/twilio-routes.js';
|
|
90
|
+
import { resolveRelayUrl, buildWelcomeGreeting, handleStatusCallback, handleVoiceWebhook } from '../calls/twilio-routes.js';
|
|
91
91
|
import { registerCallCompletionNotifier, unregisterCallCompletionNotifier } from '../calls/call-state.js';
|
|
92
92
|
|
|
93
93
|
initializeDb();
|
|
@@ -119,14 +119,14 @@ function resetTables() {
|
|
|
119
119
|
ensuredConvIds = new Set();
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
function createTestSession(convId: string, callSid: string) {
|
|
122
|
+
function createTestSession(convId: string, callSid: string, task = 'test task') {
|
|
123
123
|
ensureConversation(convId);
|
|
124
124
|
const session = createCallSession({
|
|
125
125
|
conversationId: convId,
|
|
126
126
|
provider: 'twilio',
|
|
127
127
|
fromNumber: '+15550001111',
|
|
128
128
|
toNumber: '+15559998888',
|
|
129
|
-
task
|
|
129
|
+
task,
|
|
130
130
|
});
|
|
131
131
|
updateCallSession(session.id, { providerCallSid: callSid });
|
|
132
132
|
return session;
|
|
@@ -416,6 +416,24 @@ describe('twilio webhook routes', () => {
|
|
|
416
416
|
});
|
|
417
417
|
});
|
|
418
418
|
|
|
419
|
+
describe('buildWelcomeGreeting', () => {
|
|
420
|
+
test('builds a contextual opener from task text', () => {
|
|
421
|
+
const greeting = buildWelcomeGreeting('check store hours for tomorrow');
|
|
422
|
+
expect(greeting).toBe('Hello, I am calling about check store hours for tomorrow. Is now a good time to talk?');
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test('ignores appended Context block when building opener', () => {
|
|
426
|
+
const greeting = buildWelcomeGreeting('check store hours\n\nContext: Caller asked by email');
|
|
427
|
+
expect(greeting).toBe('Hello, I am calling about check store hours. Is now a good time to talk?');
|
|
428
|
+
expect(greeting).not.toContain('Context:');
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test('uses configured greeting override when provided', () => {
|
|
432
|
+
const greeting = buildWelcomeGreeting('check store hours', 'Custom hello');
|
|
433
|
+
expect(greeting).toBe('Custom hello');
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
419
437
|
// ── TwiML relay URL generation ──────────────────────────────────────
|
|
420
438
|
// Call handleVoiceWebhook directly since direct routes are blocked.
|
|
421
439
|
|
|
@@ -446,6 +464,24 @@ describe('twilio webhook routes', () => {
|
|
|
446
464
|
const twiml = await res.text();
|
|
447
465
|
expect(twiml).toContain('wss://gateway.example.com/v1/calls/relay');
|
|
448
466
|
});
|
|
467
|
+
|
|
468
|
+
test('TwiML welcome greeting is task-aware by default', async () => {
|
|
469
|
+
const session = createTestSession(
|
|
470
|
+
'conv-twiml-3',
|
|
471
|
+
'CA_twiml_3',
|
|
472
|
+
'confirm appointment time\n\nContext: Prior email thread',
|
|
473
|
+
);
|
|
474
|
+
const req = makeVoiceRequest(session.id, { CallSid: 'CA_twiml_3' });
|
|
475
|
+
|
|
476
|
+
const res = await handleVoiceWebhook(req);
|
|
477
|
+
|
|
478
|
+
expect(res.status).toBe(200);
|
|
479
|
+
const twiml = await res.text();
|
|
480
|
+
expect(twiml).toContain(
|
|
481
|
+
'welcomeGreeting="Hello, I am calling about confirm appointment time. Is now a good time to talk?"',
|
|
482
|
+
);
|
|
483
|
+
expect(twiml).not.toContain('Hello, how can I help you today?');
|
|
484
|
+
});
|
|
449
485
|
});
|
|
450
486
|
|
|
451
487
|
// ── Handler-level idempotency concurrency tests ─────────────────
|
|
@@ -100,7 +100,7 @@ describe('CLI error shaping', () => {
|
|
|
100
100
|
|
|
101
101
|
test('routed non-session error with suggestAlternative emits structured JSON', () => {
|
|
102
102
|
const err = Object.assign(
|
|
103
|
-
new Error('OAuth is not configured.
|
|
103
|
+
new Error('OAuth is not configured. Provide your X developer credentials here in the chat to set up OAuth, or switch to browser strategy.'),
|
|
104
104
|
{
|
|
105
105
|
pathUsed: 'oauth' as const,
|
|
106
106
|
suggestAlternative: 'browser' as const,
|
|
@@ -110,7 +110,7 @@ describe('CLI error shaping', () => {
|
|
|
110
110
|
|
|
111
111
|
expect(payload).toEqual({
|
|
112
112
|
ok: false,
|
|
113
|
-
error: 'OAuth is not configured.
|
|
113
|
+
error: 'OAuth is not configured. Provide your X developer credentials here in the chat to set up OAuth, or switch to browser strategy.',
|
|
114
114
|
pathUsed: 'oauth',
|
|
115
115
|
suggestAlternative: 'browser',
|
|
116
116
|
});
|
|
@@ -414,7 +414,7 @@ async function executeWebSearch(
|
|
|
414
414
|
|
|
415
415
|
if (!apiKey) {
|
|
416
416
|
return {
|
|
417
|
-
content: 'Error: No web search API key configured.
|
|
417
|
+
content: 'Error: No web search API key configured. Provide a PERPLEXITY_API_KEY or BRAVE_API_KEY here in the chat using the secure credential prompt, or set it from the Settings page.',
|
|
418
418
|
isError: true,
|
|
419
419
|
};
|
|
420
420
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import type { Database } from 'bun:sqlite';
|
|
4
|
+
import * as net from 'node:net';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
|
|
8
|
+
const testDir = mkdtempSync(join(tmpdir(), 'work-item-output-test-'));
|
|
9
|
+
|
|
10
|
+
mock.module('../util/platform.js', () => ({
|
|
11
|
+
getDataDir: () => testDir,
|
|
12
|
+
isMacOS: () => process.platform === 'darwin',
|
|
13
|
+
isLinux: () => process.platform === 'linux',
|
|
14
|
+
isWindows: () => process.platform === 'win32',
|
|
15
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
16
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
17
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
18
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
19
|
+
ensureDataDir: () => {},
|
|
20
|
+
migrateToDataLayout: () => {},
|
|
21
|
+
migrateToWorkspaceLayout: () => {},
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
mock.module('../util/logger.js', () => ({
|
|
25
|
+
getLogger: () => new Proxy({} as Record<string, unknown>, {
|
|
26
|
+
get: () => () => {},
|
|
27
|
+
}),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
mock.module('../config/loader.js', () => ({
|
|
31
|
+
getConfig: () => ({ memory: {} }),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
mock.module('./indexer.js', () => ({
|
|
35
|
+
indexMessageNow: () => {},
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
import { addMessage, createConversation } from '../memory/conversation-store.js';
|
|
39
|
+
import { getDb, initializeDb, resetDb } from '../memory/db.js';
|
|
40
|
+
import { createTask, createTaskRun, updateTaskRun } from '../tasks/task-store.js';
|
|
41
|
+
import { createWorkItem, updateWorkItem } from '../work-items/work-item-store.js';
|
|
42
|
+
import { handleWorkItemOutput } from '../daemon/handlers/work-items.js';
|
|
43
|
+
import type { HandlerContext } from '../daemon/handlers/shared.js';
|
|
44
|
+
|
|
45
|
+
initializeDb();
|
|
46
|
+
|
|
47
|
+
afterAll(() => {
|
|
48
|
+
resetDb();
|
|
49
|
+
try { rmSync(testDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
function getRawDb(): Database {
|
|
53
|
+
return (getDb() as unknown as { $client: Database }).$client;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('handleWorkItemOutput', () => {
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
const raw = getRawDb();
|
|
59
|
+
raw.run('DELETE FROM task_runs');
|
|
60
|
+
raw.run('DELETE FROM work_items');
|
|
61
|
+
raw.run('DELETE FROM tasks');
|
|
62
|
+
raw.run('DELETE FROM messages');
|
|
63
|
+
raw.run('DELETE FROM conversations');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('uses only the latest assistant text block for summary output', () => {
|
|
67
|
+
const task = createTask({
|
|
68
|
+
title: 'Delete weather report',
|
|
69
|
+
template: 'Delete weather_report_task.txt',
|
|
70
|
+
});
|
|
71
|
+
const run = createTaskRun(task.id);
|
|
72
|
+
const item = createWorkItem({
|
|
73
|
+
taskId: task.id,
|
|
74
|
+
title: 'Delete weather_report_task.txt',
|
|
75
|
+
});
|
|
76
|
+
const conversation = createConversation('Task output test');
|
|
77
|
+
|
|
78
|
+
updateTaskRun(run.id, {
|
|
79
|
+
status: 'completed',
|
|
80
|
+
conversationId: conversation.id,
|
|
81
|
+
finishedAt: Date.now(),
|
|
82
|
+
});
|
|
83
|
+
updateWorkItem(item.id, {
|
|
84
|
+
status: 'awaiting_review',
|
|
85
|
+
lastRunId: run.id,
|
|
86
|
+
lastRunConversationId: conversation.id,
|
|
87
|
+
lastRunStatus: 'completed',
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
addMessage(conversation.id, 'assistant', JSON.stringify([
|
|
91
|
+
{ type: 'text', text: "I'll need to delete the weather report file from your Documents folder. This will permanently remove it." },
|
|
92
|
+
{ type: 'text', text: "Looks like that file has already been deleted — it's no longer there. I'll mark this task as done." },
|
|
93
|
+
{ type: 'text', text: 'The file is already deleted, so the task is complete.' },
|
|
94
|
+
]));
|
|
95
|
+
|
|
96
|
+
const sent: Array<{ type: string; [key: string]: unknown }> = [];
|
|
97
|
+
const socket = {} as net.Socket;
|
|
98
|
+
const ctx = {
|
|
99
|
+
send: (_socket: net.Socket, msg: { type: string; [key: string]: unknown }) => sent.push(msg),
|
|
100
|
+
} as unknown as HandlerContext;
|
|
101
|
+
|
|
102
|
+
handleWorkItemOutput({ type: 'work_item_output', id: item.id }, socket, ctx);
|
|
103
|
+
|
|
104
|
+
expect(sent).toHaveLength(1);
|
|
105
|
+
const response = sent[0];
|
|
106
|
+
expect(response.type).toBe('work_item_output_response');
|
|
107
|
+
expect(response.success).toBe(true);
|
|
108
|
+
expect((response.output as { summary: string }).summary).toBe('The file is already deleted, so the task is complete.');
|
|
109
|
+
});
|
|
110
|
+
});
|
package/src/calls/call-domain.ts
CHANGED
|
@@ -51,6 +51,7 @@ export type StartCallInput = {
|
|
|
51
51
|
task: string;
|
|
52
52
|
context?: string;
|
|
53
53
|
conversationId: string;
|
|
54
|
+
assistantId?: string;
|
|
54
55
|
callerIdentityMode?: 'assistant_number' | 'user_number';
|
|
55
56
|
};
|
|
56
57
|
|
|
@@ -87,7 +88,8 @@ export type CallerIdentityResult =
|
|
|
87
88
|
* - If `requestedMode` is provided but overrides are disabled, return an error.
|
|
88
89
|
* - Otherwise, always use `assistant_number` (implicit default).
|
|
89
90
|
*
|
|
90
|
-
* For `assistant_number`: uses the Twilio phone number from
|
|
91
|
+
* For `assistant_number`: uses the Twilio phone number from
|
|
92
|
+
* `getTwilioConfig(assistantId)` so multi-assistant mappings are honored.
|
|
91
93
|
* No eligibility check is performed — this is a fast path.
|
|
92
94
|
* For `user_number`: uses `config.calls.callerIdentity.userNumber` or the
|
|
93
95
|
* secure key `credential:twilio:user_phone_number`, then validates that the
|
|
@@ -96,6 +98,7 @@ export type CallerIdentityResult =
|
|
|
96
98
|
export async function resolveCallerIdentity(
|
|
97
99
|
config: AssistantConfig,
|
|
98
100
|
requestedMode?: 'assistant_number' | 'user_number',
|
|
101
|
+
assistantId?: string,
|
|
99
102
|
): Promise<CallerIdentityResult> {
|
|
100
103
|
const identityConfig = config.calls.callerIdentity;
|
|
101
104
|
let mode: 'assistant_number' | 'user_number';
|
|
@@ -118,8 +121,8 @@ export async function resolveCallerIdentity(
|
|
|
118
121
|
}
|
|
119
122
|
|
|
120
123
|
if (mode === 'assistant_number') {
|
|
121
|
-
const twilioConfig = getTwilioConfig();
|
|
122
|
-
log.info({ mode, source, fromNumber: twilioConfig.phoneNumber }, 'Resolved caller identity');
|
|
124
|
+
const twilioConfig = getTwilioConfig(assistantId);
|
|
125
|
+
log.info({ mode, source, fromNumber: twilioConfig.phoneNumber, assistantId }, 'Resolved caller identity');
|
|
123
126
|
return { ok: true, mode, fromNumber: twilioConfig.phoneNumber, source };
|
|
124
127
|
}
|
|
125
128
|
|
|
@@ -175,7 +178,7 @@ export async function resolveCallerIdentity(
|
|
|
175
178
|
* Initiate a new outbound call.
|
|
176
179
|
*/
|
|
177
180
|
export async function startCall(input: StartCallInput): Promise<StartCallResult | CallError> {
|
|
178
|
-
const { phoneNumber, task, context: callContext, conversationId, callerIdentityMode } = input;
|
|
181
|
+
const { phoneNumber, task, context: callContext, conversationId, callerIdentityMode, assistantId = 'self' } = input;
|
|
179
182
|
|
|
180
183
|
if (!phoneNumber || typeof phoneNumber !== 'string') {
|
|
181
184
|
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<StartCallResult
|
|
|
204
207
|
const provider = new TwilioConversationRelayProvider();
|
|
205
208
|
|
|
206
209
|
// Resolve which phone number to use as caller ID
|
|
207
|
-
const identityResult = await resolveCallerIdentity(ingressConfig, callerIdentityMode);
|
|
210
|
+
const identityResult = await resolveCallerIdentity(ingressConfig, callerIdentityMode, assistantId);
|
|
208
211
|
if (!identityResult.ok) {
|
|
209
212
|
return { ok: false, error: identityResult.error, status: 400 };
|
|
210
213
|
}
|
|
@@ -26,9 +26,21 @@ const log = getLogger('call-orchestrator');
|
|
|
26
26
|
|
|
27
27
|
type OrchestratorState = 'idle' | 'processing' | 'waiting_on_user' | 'speaking';
|
|
28
28
|
|
|
29
|
-
const
|
|
29
|
+
const ASK_USER_CAPTURE_REGEX = /\[ASK_USER:\s*(.+?)\]/;
|
|
30
|
+
const ASK_USER_MARKER_REGEX = /\[ASK_USER:\s*.+?\]/g;
|
|
31
|
+
const USER_ANSWERED_MARKER_REGEX = /\[USER_ANSWERED:\s*.+?\]/g;
|
|
32
|
+
const USER_INSTRUCTION_MARKER_REGEX = /\[USER_INSTRUCTION:\s*.+?\]/g;
|
|
33
|
+
const END_CALL_MARKER_REGEX = /\[END_CALL\]/g;
|
|
30
34
|
const END_CALL_MARKER = '[END_CALL]';
|
|
31
35
|
|
|
36
|
+
function stripInternalSpeechMarkers(text: string): string {
|
|
37
|
+
return text
|
|
38
|
+
.replace(ASK_USER_MARKER_REGEX, '')
|
|
39
|
+
.replace(USER_ANSWERED_MARKER_REGEX, '')
|
|
40
|
+
.replace(USER_INSTRUCTION_MARKER_REGEX, '')
|
|
41
|
+
.replace(END_CALL_MARKER_REGEX, '');
|
|
42
|
+
}
|
|
43
|
+
|
|
32
44
|
export class CallOrchestrator {
|
|
33
45
|
private callSessionId: string;
|
|
34
46
|
private relay: RelayConnection;
|
|
@@ -314,8 +326,12 @@ export class CallOrchestrator {
|
|
|
314
326
|
const afterBracket = ttsBuffer;
|
|
315
327
|
const couldBeControl =
|
|
316
328
|
'[ASK_USER:'.startsWith(afterBracket) ||
|
|
329
|
+
'[USER_ANSWERED:'.startsWith(afterBracket) ||
|
|
330
|
+
'[USER_INSTRUCTION:'.startsWith(afterBracket) ||
|
|
317
331
|
'[END_CALL]'.startsWith(afterBracket) ||
|
|
318
332
|
afterBracket.startsWith('[ASK_USER:') ||
|
|
333
|
+
afterBracket.startsWith('[USER_ANSWERED:') ||
|
|
334
|
+
afterBracket.startsWith('[USER_INSTRUCTION:') ||
|
|
319
335
|
afterBracket === '[END_CALL' ||
|
|
320
336
|
afterBracket.startsWith('[END_CALL]');
|
|
321
337
|
|
|
@@ -339,13 +355,8 @@ export class CallOrchestrator {
|
|
|
339
355
|
if (!this.isCurrentRun(runVersion)) return;
|
|
340
356
|
ttsBuffer += text;
|
|
341
357
|
|
|
342
|
-
//
|
|
343
|
-
|
|
344
|
-
ttsBuffer = ttsBuffer.replace(ASK_USER_REGEX, '');
|
|
345
|
-
}
|
|
346
|
-
if (ttsBuffer.includes(END_CALL_MARKER)) {
|
|
347
|
-
ttsBuffer = ttsBuffer.replace(END_CALL_MARKER, '');
|
|
348
|
-
}
|
|
358
|
+
// Remove complete control markers before text reaches TTS.
|
|
359
|
+
ttsBuffer = stripInternalSpeechMarkers(ttsBuffer);
|
|
349
360
|
|
|
350
361
|
flushSafeText(false);
|
|
351
362
|
});
|
|
@@ -354,7 +365,7 @@ export class CallOrchestrator {
|
|
|
354
365
|
if (!this.isCurrentRun(runVersion)) return;
|
|
355
366
|
|
|
356
367
|
// Final sweep: strip any remaining control markers from the buffer
|
|
357
|
-
ttsBuffer = ttsBuffer
|
|
368
|
+
ttsBuffer = stripInternalSpeechMarkers(ttsBuffer);
|
|
358
369
|
if (ttsBuffer.length > 0) {
|
|
359
370
|
this.relay.sendTextToken(ttsBuffer, false);
|
|
360
371
|
}
|
|
@@ -371,7 +382,7 @@ export class CallOrchestrator {
|
|
|
371
382
|
// Record the assistant response
|
|
372
383
|
this.conversationHistory.push({ role: 'assistant', content: responseText });
|
|
373
384
|
recordCallEvent(this.callSessionId, 'assistant_spoke', { text: responseText });
|
|
374
|
-
const spokenText = responseText
|
|
385
|
+
const spokenText = stripInternalSpeechMarkers(responseText).trim();
|
|
375
386
|
if (spokenText.length > 0) {
|
|
376
387
|
const session = getCallSession(this.callSessionId);
|
|
377
388
|
if (session) {
|
|
@@ -380,7 +391,7 @@ export class CallOrchestrator {
|
|
|
380
391
|
}
|
|
381
392
|
|
|
382
393
|
// Check for ASK_USER pattern
|
|
383
|
-
const askMatch = responseText.match(
|
|
394
|
+
const askMatch = responseText.match(ASK_USER_CAPTURE_REGEX);
|
|
384
395
|
if (askMatch) {
|
|
385
396
|
const questionText = askMatch[1];
|
|
386
397
|
createPendingQuestion(this.callSessionId, questionText);
|
|
@@ -13,20 +13,26 @@ export interface TwilioConfig {
|
|
|
13
13
|
wssBaseUrl: string;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
function resolveTwilioPhoneNumber(assistantId: string | undefined, config: ReturnType<typeof loadConfig>): string {
|
|
17
|
+
if (assistantId) {
|
|
18
|
+
const assistantPhone = config.sms?.assistantPhoneNumbers?.[assistantId];
|
|
19
|
+
if (assistantPhone) {
|
|
20
|
+
return assistantPhone;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
20
23
|
|
|
21
|
-
//
|
|
24
|
+
// Global fallback order:
|
|
22
25
|
// 1. TWILIO_PHONE_NUMBER env var (explicit override)
|
|
23
26
|
// 2. config file sms.phoneNumber (primary storage)
|
|
24
27
|
// 3. credential:twilio:phone_number secure key (backward-compat fallback)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
return process.env.TWILIO_PHONE_NUMBER || config.sms?.phoneNumber || getSecureKey('credential:twilio:phone_number') || '';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getTwilioConfig(assistantId?: string): TwilioConfig {
|
|
32
|
+
const accountSid = getSecureKey('credential:twilio:account_sid');
|
|
33
|
+
const authToken = getSecureKey('credential:twilio:auth_token');
|
|
34
|
+
const config = loadConfig();
|
|
35
|
+
const phoneNumber = resolveTwilioPhoneNumber(assistantId, config);
|
|
30
36
|
const webhookBaseUrl = getPublicBaseUrl(config);
|
|
31
37
|
|
|
32
38
|
// Always use the centralized relay URL derived from the public ingress base URL.
|
|
@@ -45,7 +51,7 @@ export function getTwilioConfig(): TwilioConfig {
|
|
|
45
51
|
throw new Error('Twilio credentials not configured. Set credential:twilio:account_sid and credential:twilio:auth_token via the credential_store tool.');
|
|
46
52
|
}
|
|
47
53
|
if (!phoneNumber) {
|
|
48
|
-
throw new Error('
|
|
54
|
+
throw new Error('Twilio phone number not configured.');
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
log.debug('Twilio config loaded successfully');
|