vellum 0.2.9 → 0.2.11
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/bun.lock +2 -2
- package/package.json +2 -2
- package/scripts/capture-x-graphql.ts +1 -18
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +110 -0
- package/src/__tests__/call-bridge.test.ts +40 -0
- package/src/__tests__/call-state.test.ts +41 -0
- package/src/__tests__/forbidden-legacy-symbols.test.ts +8 -6
- package/src/__tests__/gateway-only-enforcement.test.ts +13 -89
- package/src/__tests__/home-base-bootstrap.test.ts +13 -8
- package/src/__tests__/intent-routing.test.ts +2 -5
- package/src/__tests__/ipc-snapshot.test.ts +49 -0
- package/src/__tests__/onboarding-starter-tasks.test.ts +12 -2
- package/src/__tests__/prebuilt-home-base-seed.test.ts +9 -5
- package/src/__tests__/relay-server.test.ts +55 -0
- package/src/__tests__/skills.test.ts +83 -0
- package/src/__tests__/system-prompt.test.ts +2 -24
- package/src/__tests__/twilio-provider.test.ts +36 -0
- package/src/__tests__/twilio-routes.test.ts +108 -0
- package/src/calls/call-orchestrator.ts +25 -5
- package/src/calls/call-state.ts +23 -0
- package/src/calls/relay-server.ts +56 -1
- package/src/calls/twilio-config.ts +9 -13
- package/src/calls/twilio-provider.ts +6 -1
- package/src/calls/twilio-routes.ts +10 -1
- package/src/cli/core-commands.ts +12 -4
- package/src/config/bundled-skills/app-builder/SKILL.md +57 -1
- package/src/config/bundled-skills/document/SKILL.md +11 -3
- package/src/config/bundled-skills/followups/icon.svg +24 -0
- package/src/config/bundled-skills/messaging/SKILL.md +7 -3
- package/src/config/bundled-skills/public-ingress/SKILL.md +183 -0
- package/src/config/bundled-skills/self-upgrade/SKILL.md +4 -10
- package/src/config/defaults.ts +1 -1
- package/src/config/schema.ts +4 -7
- package/src/config/system-prompt.ts +64 -360
- package/src/config/vellum-skills/google-oauth-setup/SKILL.md +5 -1
- package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +5 -1
- package/src/config/vellum-skills/telegram-setup/SKILL.md +2 -1
- package/src/daemon/handlers/config.ts +20 -9
- package/src/daemon/handlers/home-base.ts +3 -2
- package/src/daemon/handlers/identity.ts +127 -0
- package/src/daemon/handlers/index.ts +4 -0
- package/src/daemon/handlers/workspace-files.ts +75 -0
- package/src/daemon/ipc-contract-inventory.json +16 -4
- package/src/daemon/ipc-contract.ts +62 -2
- package/src/daemon/lifecycle.ts +16 -0
- package/src/daemon/session-notifiers.ts +29 -0
- package/src/daemon/session-surfaces.ts +5 -2
- package/src/daemon/session-tool-setup.ts +15 -4
- package/src/home-base/bootstrap.ts +3 -1
- package/src/home-base/prebuilt/seed.ts +16 -5
- package/src/inbound/public-ingress-urls.ts +15 -4
- package/src/runtime/http-server.ts +123 -20
- package/src/security/oauth2.ts +19 -161
- package/src/tools/browser/auto-navigate.ts +2 -2
- package/src/tools/browser/x-auto-navigate.ts +1 -1
- package/src/tools/claude-code/claude-code.ts +1 -1
- package/src/tools/system/version.ts +43 -0
- package/src/tools/tasks/work-item-run.ts +1 -1
- package/src/tools/terminal/parser.ts +29 -7
- package/src/tools/tool-manifest.ts +2 -0
- package/src/tools/ui-surface/definitions.ts +9 -2
|
@@ -501,6 +501,21 @@ const clientMessages: Record<ClientMessageType, ClientMessage> = {
|
|
|
501
501
|
subagentId: 'sub-001',
|
|
502
502
|
content: 'Hello subagent',
|
|
503
503
|
},
|
|
504
|
+
subagent_detail_request: {
|
|
505
|
+
type: 'subagent_detail_request',
|
|
506
|
+
subagentId: 'sub-001',
|
|
507
|
+
conversationId: 'conv-001',
|
|
508
|
+
},
|
|
509
|
+
workspace_files_list: {
|
|
510
|
+
type: 'workspace_files_list',
|
|
511
|
+
},
|
|
512
|
+
workspace_file_read: {
|
|
513
|
+
type: 'workspace_file_read',
|
|
514
|
+
path: 'IDENTITY.md',
|
|
515
|
+
},
|
|
516
|
+
identity_get: {
|
|
517
|
+
type: 'identity_get',
|
|
518
|
+
},
|
|
504
519
|
};
|
|
505
520
|
|
|
506
521
|
// ---------------------------------------------------------------------------
|
|
@@ -1136,6 +1151,7 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
|
|
|
1136
1151
|
},
|
|
1137
1152
|
ingress_config_response: {
|
|
1138
1153
|
type: 'ingress_config_response',
|
|
1154
|
+
enabled: true,
|
|
1139
1155
|
publicBaseUrl: 'https://example.com',
|
|
1140
1156
|
localGatewayTarget: 'http://127.0.0.1:7830',
|
|
1141
1157
|
success: true,
|
|
@@ -1441,6 +1457,39 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
|
|
|
1441
1457
|
sessionId: 'sub-sess-001',
|
|
1442
1458
|
},
|
|
1443
1459
|
},
|
|
1460
|
+
subagent_detail_response: {
|
|
1461
|
+
type: 'subagent_detail_response',
|
|
1462
|
+
subagentId: 'sub-001',
|
|
1463
|
+
objective: 'Search for documentation',
|
|
1464
|
+
events: [
|
|
1465
|
+
{
|
|
1466
|
+
type: 'tool_use',
|
|
1467
|
+
content: 'Reading file...',
|
|
1468
|
+
toolName: 'read_file',
|
|
1469
|
+
isError: false,
|
|
1470
|
+
},
|
|
1471
|
+
],
|
|
1472
|
+
},
|
|
1473
|
+
workspace_files_list_response: {
|
|
1474
|
+
type: 'workspace_files_list_response',
|
|
1475
|
+
files: [
|
|
1476
|
+
{ path: 'IDENTITY.md', name: 'IDENTITY.md', exists: true },
|
|
1477
|
+
],
|
|
1478
|
+
},
|
|
1479
|
+
workspace_file_read_response: {
|
|
1480
|
+
type: 'workspace_file_read_response',
|
|
1481
|
+
path: 'IDENTITY.md',
|
|
1482
|
+
content: '# My Identity',
|
|
1483
|
+
},
|
|
1484
|
+
identity_get_response: {
|
|
1485
|
+
type: 'identity_get_response',
|
|
1486
|
+
found: true,
|
|
1487
|
+
name: 'Vex',
|
|
1488
|
+
role: 'AI assistant',
|
|
1489
|
+
personality: 'Friendly',
|
|
1490
|
+
emoji: '✨',
|
|
1491
|
+
home: '~/workspace',
|
|
1492
|
+
},
|
|
1444
1493
|
};
|
|
1445
1494
|
|
|
1446
1495
|
// ---------------------------------------------------------------------------
|
|
@@ -134,14 +134,23 @@ describe('starter task playbook integration with buildSystemPrompt', () => {
|
|
|
134
134
|
}
|
|
135
135
|
});
|
|
136
136
|
|
|
137
|
-
test('buildSystemPrompt includes the starter task playbook section', () => {
|
|
137
|
+
test('buildSystemPrompt includes the starter task playbook section when BOOTSTRAP.md exists', () => {
|
|
138
138
|
writeFileSync(join(TEST_DIR, 'IDENTITY.md'), 'I am Vellum.');
|
|
139
|
+
writeFileSync(join(TEST_DIR, 'BOOTSTRAP.md'), '# First run');
|
|
139
140
|
const result = buildSystemPrompt();
|
|
140
141
|
expect(result).toContain('## Starter Task Playbooks');
|
|
141
142
|
});
|
|
142
143
|
|
|
144
|
+
test('buildSystemPrompt omits starter task playbooks after onboarding is complete', () => {
|
|
145
|
+
writeFileSync(join(TEST_DIR, 'IDENTITY.md'), 'I am Vellum.');
|
|
146
|
+
// No BOOTSTRAP.md → onboarding complete
|
|
147
|
+
const result = buildSystemPrompt();
|
|
148
|
+
expect(result).not.toContain('## Starter Task Playbooks');
|
|
149
|
+
});
|
|
150
|
+
|
|
143
151
|
test('starter task playbook appears before channel awareness', () => {
|
|
144
152
|
writeFileSync(join(TEST_DIR, 'IDENTITY.md'), 'I am Vellum.');
|
|
153
|
+
writeFileSync(join(TEST_DIR, 'BOOTSTRAP.md'), '# First run');
|
|
145
154
|
const result = buildSystemPrompt();
|
|
146
155
|
const starterIdx = result.indexOf('## Starter Task Playbooks');
|
|
147
156
|
const channelIdx = result.indexOf('## Channel Awareness & Trust Gating');
|
|
@@ -150,8 +159,9 @@ describe('starter task playbook integration with buildSystemPrompt', () => {
|
|
|
150
159
|
expect(starterIdx).toBeLessThan(channelIdx);
|
|
151
160
|
});
|
|
152
161
|
|
|
153
|
-
test('all three kickoff intents present in full system prompt', () => {
|
|
162
|
+
test('all three kickoff intents present in full system prompt during onboarding', () => {
|
|
154
163
|
writeFileSync(join(TEST_DIR, 'IDENTITY.md'), 'I am Vellum.');
|
|
164
|
+
writeFileSync(join(TEST_DIR, 'BOOTSTRAP.md'), '# First run');
|
|
155
165
|
const result = buildSystemPrompt();
|
|
156
166
|
expect(result).toContain('[STARTER_TASK:make_it_yours]');
|
|
157
167
|
expect(result).toContain('[STARTER_TASK:research_topic]');
|
|
@@ -40,18 +40,21 @@ describe('prebuilt home base seed', () => {
|
|
|
40
40
|
const first = ensurePrebuiltHomeBaseSeeded();
|
|
41
41
|
const second = ensurePrebuiltHomeBaseSeeded();
|
|
42
42
|
|
|
43
|
-
expect(first
|
|
44
|
-
expect(second
|
|
45
|
-
expect(
|
|
43
|
+
expect(first).not.toBeNull();
|
|
44
|
+
expect(second).not.toBeNull();
|
|
45
|
+
expect(first!.created).toBe(true);
|
|
46
|
+
expect(second!.created).toBe(false);
|
|
47
|
+
expect(second!.appId).toBe(first!.appId);
|
|
46
48
|
expect(listApps().filter((app) => app.name === 'Home Base').length).toBe(1);
|
|
47
49
|
});
|
|
48
50
|
|
|
49
51
|
test('findSeededHomeBaseApp resolves the seeded app', () => {
|
|
50
52
|
const seeded = ensurePrebuiltHomeBaseSeeded();
|
|
53
|
+
expect(seeded).not.toBeNull();
|
|
51
54
|
const found = findSeededHomeBaseApp();
|
|
52
55
|
|
|
53
56
|
expect(found).not.toBeNull();
|
|
54
|
-
expect(found?.id).toBe(seeded
|
|
57
|
+
expect(found?.id).toBe(seeded!.appId);
|
|
55
58
|
// listApps() (used by findSeededHomeBaseApp) no longer stores htmlDefinition
|
|
56
59
|
// in the JSON file — it is persisted as index.html on disk.
|
|
57
60
|
// Use getApp() to load the full definition including htmlDefinition.
|
|
@@ -61,9 +64,10 @@ describe('prebuilt home base seed', () => {
|
|
|
61
64
|
|
|
62
65
|
test('rejects updates that remove required Home Base anchors', () => {
|
|
63
66
|
const seeded = ensurePrebuiltHomeBaseSeeded();
|
|
67
|
+
expect(seeded).not.toBeNull();
|
|
64
68
|
|
|
65
69
|
expect(() => {
|
|
66
|
-
updateApp(seeded
|
|
70
|
+
updateApp(seeded!.appId, {
|
|
67
71
|
htmlDefinition: '<main id="home-base-root"></main>',
|
|
68
72
|
});
|
|
69
73
|
}).toThrow('missing required anchors');
|
|
@@ -104,6 +104,7 @@ import {
|
|
|
104
104
|
getCallSession,
|
|
105
105
|
getCallEvents,
|
|
106
106
|
} from '../calls/call-store.js';
|
|
107
|
+
import { registerCallCompletionNotifier, unregisterCallCompletionNotifier } from '../calls/call-state.js';
|
|
107
108
|
import { RelayConnection, activeRelayConnections } from '../calls/relay-server.js';
|
|
108
109
|
import type { RelayWebSocketData } from '../calls/relay-server.js';
|
|
109
110
|
|
|
@@ -196,6 +197,8 @@ describe('relay-server', () => {
|
|
|
196
197
|
const updated = getCallSession(session.id);
|
|
197
198
|
expect(updated).not.toBeNull();
|
|
198
199
|
expect(updated!.providerCallSid).toBe('CA_relay_setup_123');
|
|
200
|
+
expect(updated!.status).toBe('in_progress');
|
|
201
|
+
expect(updated!.startedAt).not.toBeNull();
|
|
199
202
|
|
|
200
203
|
// Verify event was recorded
|
|
201
204
|
const events = getCallEvents(session.id);
|
|
@@ -208,6 +211,58 @@ describe('relay-server', () => {
|
|
|
208
211
|
relay.destroy();
|
|
209
212
|
});
|
|
210
213
|
|
|
214
|
+
test('handleTransportClosed: normal close marks call completed and notifies completion', () => {
|
|
215
|
+
ensureConversation('conv-relay-close-normal');
|
|
216
|
+
const session = createCallSession({
|
|
217
|
+
conversationId: 'conv-relay-close-normal',
|
|
218
|
+
provider: 'twilio',
|
|
219
|
+
fromNumber: '+15551111111',
|
|
220
|
+
toNumber: '+15552222222',
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const { relay } = createMockWs(session.id);
|
|
224
|
+
let completionCount = 0;
|
|
225
|
+
registerCallCompletionNotifier('conv-relay-close-normal', () => {
|
|
226
|
+
completionCount += 1;
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
relay.handleTransportClosed(1000, 'Closing websocket session');
|
|
230
|
+
|
|
231
|
+
const updated = getCallSession(session.id);
|
|
232
|
+
expect(updated).not.toBeNull();
|
|
233
|
+
expect(updated!.status).toBe('completed');
|
|
234
|
+
expect(updated!.endedAt).not.toBeNull();
|
|
235
|
+
const endedEvents = getCallEvents(session.id).filter((e) => e.eventType === 'call_ended');
|
|
236
|
+
expect(endedEvents.length).toBe(1);
|
|
237
|
+
expect(completionCount).toBe(1);
|
|
238
|
+
|
|
239
|
+
unregisterCallCompletionNotifier('conv-relay-close-normal');
|
|
240
|
+
relay.destroy();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('handleTransportClosed: abnormal close marks call failed', () => {
|
|
244
|
+
ensureConversation('conv-relay-close-abnormal');
|
|
245
|
+
const session = createCallSession({
|
|
246
|
+
conversationId: 'conv-relay-close-abnormal',
|
|
247
|
+
provider: 'twilio',
|
|
248
|
+
fromNumber: '+15551111111',
|
|
249
|
+
toNumber: '+15552222222',
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const { relay } = createMockWs(session.id);
|
|
253
|
+
relay.handleTransportClosed(1006, 'abnormal closure');
|
|
254
|
+
|
|
255
|
+
const updated = getCallSession(session.id);
|
|
256
|
+
expect(updated).not.toBeNull();
|
|
257
|
+
expect(updated!.status).toBe('failed');
|
|
258
|
+
expect(updated!.endedAt).not.toBeNull();
|
|
259
|
+
expect(updated!.lastError).toContain('abnormal closure');
|
|
260
|
+
const failEvents = getCallEvents(session.id).filter((e) => e.eventType === 'call_failed');
|
|
261
|
+
expect(failEvents.length).toBe(1);
|
|
262
|
+
|
|
263
|
+
relay.destroy();
|
|
264
|
+
});
|
|
265
|
+
|
|
211
266
|
test('handleMessage: setup message with custom parameters', async () => {
|
|
212
267
|
ensureConversation('conv-relay-custom');
|
|
213
268
|
const session = createCallSession({
|
|
@@ -538,6 +538,89 @@ describe('bundled browser skill', () => {
|
|
|
538
538
|
});
|
|
539
539
|
});
|
|
540
540
|
|
|
541
|
+
describe('bundled public-ingress skill', () => {
|
|
542
|
+
beforeEach(() => {
|
|
543
|
+
mkdirSync(join(TEST_DIR, 'skills'), { recursive: true });
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
afterEach(() => {
|
|
547
|
+
if (existsSync(TEST_DIR)) {
|
|
548
|
+
rmSync(TEST_DIR, { recursive: true, force: true });
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
test('public-ingress skill appears in full catalog (including bundled)', () => {
|
|
553
|
+
const catalog = loadSkillCatalog();
|
|
554
|
+
const skill = catalog.find((s) => s.id === 'public-ingress');
|
|
555
|
+
expect(skill).toBeDefined();
|
|
556
|
+
expect(skill!.name).toBe('Public Ingress');
|
|
557
|
+
expect(skill!.bundled).toBe(true);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test('public-ingress skill has correct description', () => {
|
|
561
|
+
const catalog = loadSkillCatalog();
|
|
562
|
+
const skill = catalog.find((s) => s.id === 'public-ingress');
|
|
563
|
+
expect(skill).toBeDefined();
|
|
564
|
+
expect(skill!.description).toContain('ngrok');
|
|
565
|
+
expect(skill!.description).toContain('ingress.publicBaseUrl');
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test('public-ingress skill is user-invocable', () => {
|
|
569
|
+
const catalog = loadSkillCatalog();
|
|
570
|
+
const skill = catalog.find((s) => s.id === 'public-ingress');
|
|
571
|
+
expect(skill).toBeDefined();
|
|
572
|
+
expect(skill!.userInvocable).toBe(true);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
test('public-ingress skill has no tool manifest (instructions-only)', () => {
|
|
576
|
+
const catalog = loadSkillCatalog();
|
|
577
|
+
const skill = catalog.find((s) => s.id === 'public-ingress');
|
|
578
|
+
expect(skill).toBeDefined();
|
|
579
|
+
expect(skill!.toolManifest).toBeUndefined();
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
describe('ingress-dependent setup skills declare public-ingress', () => {
|
|
584
|
+
const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
|
|
585
|
+
const VELLUM_SKILLS_DIR = join(import.meta.dir, '..', 'config', 'vellum-skills');
|
|
586
|
+
|
|
587
|
+
function readVellumSkillIncludes(skillId: string): string[] | undefined {
|
|
588
|
+
const content = require('node:fs').readFileSync(join(VELLUM_SKILLS_DIR, skillId, 'SKILL.md'), 'utf-8');
|
|
589
|
+
const match = content.match(FRONTMATTER_REGEX);
|
|
590
|
+
if (!match) return undefined;
|
|
591
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
592
|
+
const sep = line.indexOf(':');
|
|
593
|
+
if (sep === -1) continue;
|
|
594
|
+
const key = line.slice(0, sep).trim();
|
|
595
|
+
if (key !== 'includes') continue;
|
|
596
|
+
const val = line.slice(sep + 1).trim();
|
|
597
|
+
try {
|
|
598
|
+
const parsed = JSON.parse(val);
|
|
599
|
+
if (Array.isArray(parsed)) return parsed as string[];
|
|
600
|
+
} catch { /* ignore */ }
|
|
601
|
+
}
|
|
602
|
+
return undefined;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
test('telegram-setup includes public-ingress', () => {
|
|
606
|
+
const includes = readVellumSkillIncludes('telegram-setup');
|
|
607
|
+
expect(includes).toBeDefined();
|
|
608
|
+
expect(includes).toContain('public-ingress');
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
test('google-oauth-setup includes public-ingress', () => {
|
|
612
|
+
const includes = readVellumSkillIncludes('google-oauth-setup');
|
|
613
|
+
expect(includes).toBeDefined();
|
|
614
|
+
expect(includes).toContain('public-ingress');
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
test('slack-oauth-setup includes public-ingress', () => {
|
|
618
|
+
const includes = readVellumSkillIncludes('slack-oauth-setup');
|
|
619
|
+
expect(includes).toBeDefined();
|
|
620
|
+
expect(includes).toContain('public-ingress');
|
|
621
|
+
});
|
|
622
|
+
});
|
|
623
|
+
|
|
541
624
|
describe('bundled computer-use skill', () => {
|
|
542
625
|
beforeEach(() => {
|
|
543
626
|
mkdirSync(join(TEST_DIR, 'skills'), { recursive: true });
|
|
@@ -208,37 +208,15 @@ describe('buildSystemPrompt', () => {
|
|
|
208
208
|
});
|
|
209
209
|
|
|
210
210
|
describe('app-builder tool ownership guidance', () => {
|
|
211
|
-
test('does not list app_open as skill-provided', () => {
|
|
212
|
-
const result = buildSystemPrompt();
|
|
213
|
-
// The "Loading app tools" section should NOT include app_open in the skill-provided list
|
|
214
|
-
const skillProvidedMatch = result.match(/Most `app_\*` tools \([^)]+\) are provided by the `app-builder` skill\./);
|
|
215
|
-
expect(skillProvidedMatch).not.toBeNull();
|
|
216
|
-
expect(skillProvidedMatch![0]).not.toContain('app_open');
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
test('mentions app_open is always available as a core tool', () => {
|
|
220
|
-
const result = buildSystemPrompt();
|
|
221
|
-
expect(result).toContain('`app_open` is always available as a core tool');
|
|
222
|
-
});
|
|
223
|
-
|
|
224
211
|
test('iteration guidance does not mention app_update for HTML changes', () => {
|
|
225
212
|
const result = buildSystemPrompt();
|
|
226
213
|
// The iteration line should not reference app_update for changing HTML
|
|
227
214
|
expect(result).not.toContain('use `app_update` to change the HTML');
|
|
228
215
|
});
|
|
229
216
|
|
|
230
|
-
test('iteration guidance recommends app_file_edit for code changes', () => {
|
|
231
|
-
const result = buildSystemPrompt();
|
|
232
|
-
expect(result).toContain('use `app_file_edit` for targeted code changes');
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
test('Home Base guidance uses app_file_edit or app_file_write, not app_update', () => {
|
|
236
|
-
const result = buildSystemPrompt();
|
|
237
|
-
expect(result).toContain('apply changes through `app_file_edit` or `app_file_write`');
|
|
238
|
-
expect(result).not.toContain('apply updates through normal `app_update` flows');
|
|
239
|
-
});
|
|
240
|
-
|
|
241
217
|
test('onboarding playbook uses app_file_edit for accent color, not app_update', () => {
|
|
218
|
+
// Starter task playbooks only included during onboarding (BOOTSTRAP.md exists)
|
|
219
|
+
writeFileSync(join(TEST_DIR, 'BOOTSTRAP.md'), '# First run');
|
|
242
220
|
const result = buildSystemPrompt();
|
|
243
221
|
expect(result).toContain('using `app_file_edit` to update the theme styles');
|
|
244
222
|
expect(result).not.toContain('using `app_update` to regenerate the Home Base HTML');
|
|
@@ -30,10 +30,12 @@ mock.module('../util/logger.js', () => ({
|
|
|
30
30
|
|
|
31
31
|
// Start with a configured auth token
|
|
32
32
|
let mockAuthToken: string | undefined = 'test-auth-token-secret';
|
|
33
|
+
let mockAccountSid: string | undefined = 'AC_test_account';
|
|
33
34
|
|
|
34
35
|
mock.module('../security/secure-keys.js', () => ({
|
|
35
36
|
getSecureKey: (account: string) => {
|
|
36
37
|
if (account === 'credential:twilio:auth_token') return mockAuthToken;
|
|
38
|
+
if (account === 'credential:twilio:account_sid') return mockAccountSid;
|
|
37
39
|
return undefined;
|
|
38
40
|
},
|
|
39
41
|
}));
|
|
@@ -60,6 +62,7 @@ function computeValidSignature(
|
|
|
60
62
|
describe('TwilioConversationRelayProvider', () => {
|
|
61
63
|
beforeEach(() => {
|
|
62
64
|
mockAuthToken = 'test-auth-token-secret';
|
|
65
|
+
mockAccountSid = 'AC_test_account';
|
|
63
66
|
});
|
|
64
67
|
|
|
65
68
|
describe('verifyWebhookSignature', () => {
|
|
@@ -140,4 +143,37 @@ describe('TwilioConversationRelayProvider', () => {
|
|
|
140
143
|
expect(token).toBeNull();
|
|
141
144
|
});
|
|
142
145
|
});
|
|
146
|
+
|
|
147
|
+
describe('initiateCall', () => {
|
|
148
|
+
test('sends repeated StatusCallbackEvent parameters', async () => {
|
|
149
|
+
const provider = new TwilioConversationRelayProvider();
|
|
150
|
+
const originalFetch = globalThis.fetch;
|
|
151
|
+
let capturedBody = '';
|
|
152
|
+
|
|
153
|
+
globalThis.fetch = (async (_url: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
154
|
+
capturedBody = String(init?.body ?? '');
|
|
155
|
+
return new Response(JSON.stringify({ sid: 'CA_test_123' }), { status: 200 });
|
|
156
|
+
}) as typeof fetch;
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const result = await provider.initiateCall({
|
|
160
|
+
from: '+15550001111',
|
|
161
|
+
to: '+15550002222',
|
|
162
|
+
webhookUrl: 'https://example.com/webhooks/twilio/voice?callSessionId=s1',
|
|
163
|
+
statusCallbackUrl: 'https://example.com/webhooks/twilio/status',
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
expect(result.callSid).toBe('CA_test_123');
|
|
167
|
+
const params = new URLSearchParams(capturedBody);
|
|
168
|
+
expect(params.getAll('StatusCallbackEvent')).toEqual([
|
|
169
|
+
'initiated',
|
|
170
|
+
'ringing',
|
|
171
|
+
'answered',
|
|
172
|
+
'completed',
|
|
173
|
+
]);
|
|
174
|
+
} finally {
|
|
175
|
+
globalThis.fetch = originalFetch;
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
});
|
|
143
179
|
});
|
|
@@ -119,6 +119,7 @@ import { RuntimeHttpServer } from '../runtime/http-server.js';
|
|
|
119
119
|
import * as callStore from '../calls/call-store.js';
|
|
120
120
|
import {
|
|
121
121
|
createCallSession,
|
|
122
|
+
getCallSession,
|
|
122
123
|
updateCallSession,
|
|
123
124
|
getCallEvents,
|
|
124
125
|
buildCallbackDedupeKey,
|
|
@@ -126,6 +127,7 @@ import {
|
|
|
126
127
|
releaseCallbackClaim,
|
|
127
128
|
} from '../calls/call-store.js';
|
|
128
129
|
import { resolveRelayUrl, handleStatusCallback } from '../calls/twilio-routes.js';
|
|
130
|
+
import { registerCallCompletionNotifier, unregisterCallCompletionNotifier } from '../calls/call-state.js';
|
|
129
131
|
|
|
130
132
|
initializeDb();
|
|
131
133
|
|
|
@@ -535,6 +537,112 @@ describe('twilio webhook routes', () => {
|
|
|
535
537
|
});
|
|
536
538
|
});
|
|
537
539
|
|
|
540
|
+
describe('status mapping and completion notifications', () => {
|
|
541
|
+
test('initiated status callback is accepted and recorded as call_started', async () => {
|
|
542
|
+
const session = createTestSession('conv-status-init-1', 'CA_status_init_1');
|
|
543
|
+
const params = new URLSearchParams({
|
|
544
|
+
CallSid: 'CA_status_init_1',
|
|
545
|
+
CallStatus: 'initiated',
|
|
546
|
+
Timestamp: '2025-01-21T10:00:00Z',
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
const req = new Request('http://127.0.0.1/v1/calls/twilio/status', {
|
|
550
|
+
method: 'POST',
|
|
551
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
552
|
+
body: params.toString(),
|
|
553
|
+
});
|
|
554
|
+
const res = await handleStatusCallback(req);
|
|
555
|
+
expect(res.status).toBe(200);
|
|
556
|
+
|
|
557
|
+
const updated = getCallSession(session.id);
|
|
558
|
+
expect(updated).not.toBeNull();
|
|
559
|
+
expect(updated!.status).toBe('initiated');
|
|
560
|
+
const events = getCallEvents(session.id);
|
|
561
|
+
expect(events.filter((e) => e.eventType === 'call_started').length).toBe(1);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test('answered status callback transitions to in_progress', async () => {
|
|
565
|
+
const session = createTestSession('conv-status-answered-1', 'CA_status_answered_1');
|
|
566
|
+
const params = new URLSearchParams({
|
|
567
|
+
CallSid: 'CA_status_answered_1',
|
|
568
|
+
CallStatus: 'answered',
|
|
569
|
+
Timestamp: '2025-01-21T10:05:00Z',
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
const req = new Request('http://127.0.0.1/v1/calls/twilio/status', {
|
|
573
|
+
method: 'POST',
|
|
574
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
575
|
+
body: params.toString(),
|
|
576
|
+
});
|
|
577
|
+
const res = await handleStatusCallback(req);
|
|
578
|
+
expect(res.status).toBe(200);
|
|
579
|
+
|
|
580
|
+
const updated = getCallSession(session.id);
|
|
581
|
+
expect(updated).not.toBeNull();
|
|
582
|
+
expect(updated!.status).toBe('in_progress');
|
|
583
|
+
expect(updated!.startedAt).not.toBeNull();
|
|
584
|
+
const events = getCallEvents(session.id);
|
|
585
|
+
expect(events.filter((e) => e.eventType === 'call_connected').length).toBe(1);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
test('completed status callback fires completion notifier when first entering terminal state', async () => {
|
|
589
|
+
const session = createTestSession('conv-status-complete-1', 'CA_status_complete_1');
|
|
590
|
+
updateCallSession(session.id, { status: 'in_progress', startedAt: Date.now() - 20_000 });
|
|
591
|
+
const params = new URLSearchParams({
|
|
592
|
+
CallSid: 'CA_status_complete_1',
|
|
593
|
+
CallStatus: 'completed',
|
|
594
|
+
Timestamp: '2025-01-21T10:10:00Z',
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
let fired = 0;
|
|
598
|
+
registerCallCompletionNotifier('conv-status-complete-1', () => {
|
|
599
|
+
fired += 1;
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
const req = new Request('http://127.0.0.1/v1/calls/twilio/status', {
|
|
603
|
+
method: 'POST',
|
|
604
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
605
|
+
body: params.toString(),
|
|
606
|
+
});
|
|
607
|
+
const res = await handleStatusCallback(req);
|
|
608
|
+
expect(res.status).toBe(200);
|
|
609
|
+
|
|
610
|
+
const updated = getCallSession(session.id);
|
|
611
|
+
expect(updated).not.toBeNull();
|
|
612
|
+
expect(updated!.status).toBe('completed');
|
|
613
|
+
expect(updated!.endedAt).not.toBeNull();
|
|
614
|
+
expect(fired).toBe(1);
|
|
615
|
+
|
|
616
|
+
unregisterCallCompletionNotifier('conv-status-complete-1');
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
test('completed callback does not re-fire completion notifier for already terminal call', async () => {
|
|
620
|
+
const session = createTestSession('conv-status-complete-2', 'CA_status_complete_2');
|
|
621
|
+
updateCallSession(session.id, { status: 'completed', startedAt: Date.now() - 20_000, endedAt: Date.now() - 5_000 });
|
|
622
|
+
const params = new URLSearchParams({
|
|
623
|
+
CallSid: 'CA_status_complete_2',
|
|
624
|
+
CallStatus: 'completed',
|
|
625
|
+
Timestamp: '2025-01-21T10:15:00Z',
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
let fired = 0;
|
|
629
|
+
registerCallCompletionNotifier('conv-status-complete-2', () => {
|
|
630
|
+
fired += 1;
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
const req = new Request('http://127.0.0.1/v1/calls/twilio/status', {
|
|
634
|
+
method: 'POST',
|
|
635
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
636
|
+
body: params.toString(),
|
|
637
|
+
});
|
|
638
|
+
const res = await handleStatusCallback(req);
|
|
639
|
+
expect(res.status).toBe(200);
|
|
640
|
+
expect(fired).toBe(0);
|
|
641
|
+
|
|
642
|
+
unregisterCallCompletionNotifier('conv-status-complete-2');
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
|
|
538
646
|
// ── resolveRelayUrl unit tests ──────────────────────────────────────
|
|
539
647
|
|
|
540
648
|
describe('resolveRelayUrl', () => {
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
} from './call-store.js';
|
|
19
19
|
import { getMaxCallDurationMs, getUserConsultationTimeoutMs, SILENCE_TIMEOUT_MS } from './call-constants.js';
|
|
20
20
|
import type { RelayConnection } from './relay-server.js';
|
|
21
|
-
import { registerCallOrchestrator, unregisterCallOrchestrator, fireCallQuestionNotifier, fireCallCompletionNotifier } from './call-state.js';
|
|
21
|
+
import { registerCallOrchestrator, unregisterCallOrchestrator, fireCallQuestionNotifier, fireCallCompletionNotifier, fireCallTranscriptNotifier } from './call-state.js';
|
|
22
22
|
import type { PromptSpeakerContext } from './speaker-identification.js';
|
|
23
23
|
|
|
24
24
|
const log = getLogger('call-orchestrator');
|
|
@@ -290,6 +290,13 @@ export class CallOrchestrator {
|
|
|
290
290
|
// Record the assistant response
|
|
291
291
|
this.conversationHistory.push({ role: 'assistant', content: responseText });
|
|
292
292
|
recordCallEvent(this.callSessionId, 'assistant_spoke', { text: responseText });
|
|
293
|
+
const spokenText = responseText.replace(ASK_USER_REGEX, '').replace(END_CALL_MARKER, '').trim();
|
|
294
|
+
if (spokenText.length > 0) {
|
|
295
|
+
const session = getCallSession(this.callSessionId);
|
|
296
|
+
if (session) {
|
|
297
|
+
fireCallTranscriptNotifier(session.conversationId, this.callSessionId, 'assistant', spokenText);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
293
300
|
|
|
294
301
|
// Check for ASK_USER pattern
|
|
295
302
|
const askMatch = responseText.match(ASK_USER_REGEX);
|
|
@@ -324,14 +331,19 @@ export class CallOrchestrator {
|
|
|
324
331
|
|
|
325
332
|
// Check for END_CALL marker
|
|
326
333
|
if (responseText.includes(END_CALL_MARKER)) {
|
|
334
|
+
const currentSession = getCallSession(this.callSessionId);
|
|
335
|
+
const shouldNotifyCompletion = currentSession
|
|
336
|
+
? currentSession.status !== 'completed' && currentSession.status !== 'failed' && currentSession.status !== 'cancelled'
|
|
337
|
+
: false;
|
|
338
|
+
|
|
327
339
|
this.relay.endSession('Call completed');
|
|
328
340
|
updateCallSession(this.callSessionId, { status: 'completed', endedAt: Date.now() });
|
|
329
341
|
recordCallEvent(this.callSessionId, 'call_ended', { reason: 'completed' });
|
|
330
342
|
|
|
331
|
-
// Notify the conversation
|
|
332
|
-
|
|
333
|
-
if (
|
|
334
|
-
fireCallCompletionNotifier(
|
|
343
|
+
// Notify the conversation when this is the first transition
|
|
344
|
+
// into a terminal call state.
|
|
345
|
+
if (shouldNotifyCompletion && currentSession) {
|
|
346
|
+
fireCallCompletionNotifier(currentSession.conversationId, this.callSessionId);
|
|
335
347
|
}
|
|
336
348
|
this.state = 'idle';
|
|
337
349
|
return;
|
|
@@ -373,9 +385,17 @@ export class CallOrchestrator {
|
|
|
373
385
|
);
|
|
374
386
|
// Give TTS a moment to play, then end
|
|
375
387
|
this.durationEndTimer = setTimeout(() => {
|
|
388
|
+
const currentSession = getCallSession(this.callSessionId);
|
|
389
|
+
const shouldNotifyCompletion = currentSession
|
|
390
|
+
? currentSession.status !== 'completed' && currentSession.status !== 'failed' && currentSession.status !== 'cancelled'
|
|
391
|
+
: false;
|
|
392
|
+
|
|
376
393
|
this.relay.endSession('Maximum call duration reached');
|
|
377
394
|
updateCallSession(this.callSessionId, { status: 'completed', endedAt: Date.now() });
|
|
378
395
|
recordCallEvent(this.callSessionId, 'call_ended', { reason: 'max_duration' });
|
|
396
|
+
if (shouldNotifyCompletion && currentSession) {
|
|
397
|
+
fireCallCompletionNotifier(currentSession.conversationId, this.callSessionId);
|
|
398
|
+
}
|
|
379
399
|
}, 3000);
|
|
380
400
|
}, maxDurationMs);
|
|
381
401
|
}
|
package/src/calls/call-state.ts
CHANGED
|
@@ -28,6 +28,29 @@ export function fireCallQuestionNotifier(conversationId: string, callSessionId:
|
|
|
28
28
|
questionNotifiers.get(conversationId)?.(callSessionId, question);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// ── Transcript notifiers ────────────────────────────────────────────
|
|
32
|
+
const transcriptNotifiers = new Map<string, (callSessionId: string, speaker: 'caller' | 'assistant', text: string) => void>();
|
|
33
|
+
|
|
34
|
+
export function registerCallTranscriptNotifier(
|
|
35
|
+
conversationId: string,
|
|
36
|
+
callback: (callSessionId: string, speaker: 'caller' | 'assistant', text: string) => void,
|
|
37
|
+
): void {
|
|
38
|
+
transcriptNotifiers.set(conversationId, callback);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function unregisterCallTranscriptNotifier(conversationId: string): void {
|
|
42
|
+
transcriptNotifiers.delete(conversationId);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function fireCallTranscriptNotifier(
|
|
46
|
+
conversationId: string,
|
|
47
|
+
callSessionId: string,
|
|
48
|
+
speaker: 'caller' | 'assistant',
|
|
49
|
+
text: string,
|
|
50
|
+
): void {
|
|
51
|
+
transcriptNotifiers.get(conversationId)?.(callSessionId, speaker, text);
|
|
52
|
+
}
|
|
53
|
+
|
|
31
54
|
// ── Completion notifiers ────────────────────────────────────────────
|
|
32
55
|
const completionNotifiers = new Map<string, (callSessionId: string) => void>();
|
|
33
56
|
|