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.
Files changed (61) hide show
  1. package/bun.lock +2 -2
  2. package/package.json +2 -2
  3. package/scripts/capture-x-graphql.ts +1 -18
  4. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +110 -0
  5. package/src/__tests__/call-bridge.test.ts +40 -0
  6. package/src/__tests__/call-state.test.ts +41 -0
  7. package/src/__tests__/forbidden-legacy-symbols.test.ts +8 -6
  8. package/src/__tests__/gateway-only-enforcement.test.ts +13 -89
  9. package/src/__tests__/home-base-bootstrap.test.ts +13 -8
  10. package/src/__tests__/intent-routing.test.ts +2 -5
  11. package/src/__tests__/ipc-snapshot.test.ts +49 -0
  12. package/src/__tests__/onboarding-starter-tasks.test.ts +12 -2
  13. package/src/__tests__/prebuilt-home-base-seed.test.ts +9 -5
  14. package/src/__tests__/relay-server.test.ts +55 -0
  15. package/src/__tests__/skills.test.ts +83 -0
  16. package/src/__tests__/system-prompt.test.ts +2 -24
  17. package/src/__tests__/twilio-provider.test.ts +36 -0
  18. package/src/__tests__/twilio-routes.test.ts +108 -0
  19. package/src/calls/call-orchestrator.ts +25 -5
  20. package/src/calls/call-state.ts +23 -0
  21. package/src/calls/relay-server.ts +56 -1
  22. package/src/calls/twilio-config.ts +9 -13
  23. package/src/calls/twilio-provider.ts +6 -1
  24. package/src/calls/twilio-routes.ts +10 -1
  25. package/src/cli/core-commands.ts +12 -4
  26. package/src/config/bundled-skills/app-builder/SKILL.md +57 -1
  27. package/src/config/bundled-skills/document/SKILL.md +11 -3
  28. package/src/config/bundled-skills/followups/icon.svg +24 -0
  29. package/src/config/bundled-skills/messaging/SKILL.md +7 -3
  30. package/src/config/bundled-skills/public-ingress/SKILL.md +183 -0
  31. package/src/config/bundled-skills/self-upgrade/SKILL.md +4 -10
  32. package/src/config/defaults.ts +1 -1
  33. package/src/config/schema.ts +4 -7
  34. package/src/config/system-prompt.ts +64 -360
  35. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +5 -1
  36. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +5 -1
  37. package/src/config/vellum-skills/telegram-setup/SKILL.md +2 -1
  38. package/src/daemon/handlers/config.ts +20 -9
  39. package/src/daemon/handlers/home-base.ts +3 -2
  40. package/src/daemon/handlers/identity.ts +127 -0
  41. package/src/daemon/handlers/index.ts +4 -0
  42. package/src/daemon/handlers/workspace-files.ts +75 -0
  43. package/src/daemon/ipc-contract-inventory.json +16 -4
  44. package/src/daemon/ipc-contract.ts +62 -2
  45. package/src/daemon/lifecycle.ts +16 -0
  46. package/src/daemon/session-notifiers.ts +29 -0
  47. package/src/daemon/session-surfaces.ts +5 -2
  48. package/src/daemon/session-tool-setup.ts +15 -4
  49. package/src/home-base/bootstrap.ts +3 -1
  50. package/src/home-base/prebuilt/seed.ts +16 -5
  51. package/src/inbound/public-ingress-urls.ts +15 -4
  52. package/src/runtime/http-server.ts +123 -20
  53. package/src/security/oauth2.ts +19 -161
  54. package/src/tools/browser/auto-navigate.ts +2 -2
  55. package/src/tools/browser/x-auto-navigate.ts +1 -1
  56. package/src/tools/claude-code/claude-code.ts +1 -1
  57. package/src/tools/system/version.ts +43 -0
  58. package/src/tools/tasks/work-item-run.ts +1 -1
  59. package/src/tools/terminal/parser.ts +29 -7
  60. package/src/tools/tool-manifest.ts +2 -0
  61. 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.created).toBe(true);
44
- expect(second.created).toBe(false);
45
- expect(second.appId).toBe(first.appId);
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.appId);
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.appId, {
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 that the call completed
332
- const endSession = getCallSession(this.callSessionId);
333
- if (endSession) {
334
- fireCallCompletionNotifier(endSession.conversationId, this.callSessionId);
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
  }
@@ -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