@vellumai/assistant 0.3.7 → 0.3.9

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 (76) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +20 -0
  3. package/src/__tests__/approval-routes-http.test.ts +704 -0
  4. package/src/__tests__/call-controller.test.ts +835 -0
  5. package/src/__tests__/call-state.test.ts +24 -24
  6. package/src/__tests__/ipc-snapshot.test.ts +14 -0
  7. package/src/__tests__/relay-server.test.ts +9 -9
  8. package/src/__tests__/run-orchestrator.test.ts +399 -3
  9. package/src/__tests__/runtime-runs.test.ts +12 -4
  10. package/src/__tests__/send-endpoint-busy.test.ts +284 -0
  11. package/src/__tests__/session-init.benchmark.test.ts +3 -3
  12. package/src/__tests__/subagent-manager-notify.test.ts +3 -3
  13. package/src/__tests__/voice-session-bridge.test.ts +869 -0
  14. package/src/calls/{call-orchestrator.ts → call-controller.ts} +156 -257
  15. package/src/calls/call-domain.ts +21 -21
  16. package/src/calls/call-state.ts +12 -12
  17. package/src/calls/guardian-dispatch.ts +43 -3
  18. package/src/calls/relay-server.ts +34 -39
  19. package/src/calls/twilio-routes.ts +3 -3
  20. package/src/calls/voice-session-bridge.ts +244 -0
  21. package/src/config/bundled-skills/media-processing/SKILL.md +81 -14
  22. package/src/config/bundled-skills/media-processing/TOOLS.json +3 -3
  23. package/src/config/bundled-skills/media-processing/services/preprocess.ts +3 -3
  24. package/src/config/defaults.ts +5 -0
  25. package/src/config/notifications-schema.ts +15 -0
  26. package/src/config/schema.ts +13 -0
  27. package/src/config/types.ts +1 -0
  28. package/src/daemon/daemon-control.ts +13 -12
  29. package/src/daemon/handlers/subagents.ts +10 -3
  30. package/src/daemon/ipc-contract/notifications.ts +9 -0
  31. package/src/daemon/ipc-contract-inventory.json +2 -0
  32. package/src/daemon/ipc-contract.ts +4 -1
  33. package/src/daemon/lifecycle.ts +100 -1
  34. package/src/daemon/server.ts +8 -0
  35. package/src/daemon/session-agent-loop.ts +4 -0
  36. package/src/daemon/session-process.ts +51 -0
  37. package/src/daemon/session-runtime-assembly.ts +32 -0
  38. package/src/daemon/session.ts +5 -0
  39. package/src/memory/db-init.ts +80 -0
  40. package/src/memory/guardian-action-store.ts +2 -2
  41. package/src/memory/migrations/016-memory-segments-indexes.ts +1 -0
  42. package/src/memory/migrations/019-notification-tables-schema-migration.ts +70 -0
  43. package/src/memory/migrations/index.ts +1 -0
  44. package/src/memory/migrations/registry.ts +5 -0
  45. package/src/memory/schema-migration.ts +1 -0
  46. package/src/memory/schema.ts +59 -1
  47. package/src/notifications/README.md +134 -0
  48. package/src/notifications/adapters/macos.ts +55 -0
  49. package/src/notifications/adapters/telegram.ts +65 -0
  50. package/src/notifications/broadcaster.ts +175 -0
  51. package/src/notifications/copy-composer.ts +118 -0
  52. package/src/notifications/decision-engine.ts +391 -0
  53. package/src/notifications/decisions-store.ts +158 -0
  54. package/src/notifications/deliveries-store.ts +130 -0
  55. package/src/notifications/destination-resolver.ts +54 -0
  56. package/src/notifications/deterministic-checks.ts +187 -0
  57. package/src/notifications/emit-signal.ts +191 -0
  58. package/src/notifications/events-store.ts +145 -0
  59. package/src/notifications/preference-extractor.ts +223 -0
  60. package/src/notifications/preference-summary.ts +110 -0
  61. package/src/notifications/preferences-store.ts +142 -0
  62. package/src/notifications/runtime-dispatch.ts +100 -0
  63. package/src/notifications/signal.ts +24 -0
  64. package/src/notifications/types.ts +75 -0
  65. package/src/runtime/http-server.ts +15 -0
  66. package/src/runtime/http-types.ts +22 -0
  67. package/src/runtime/pending-interactions.ts +73 -0
  68. package/src/runtime/routes/approval-routes.ts +179 -0
  69. package/src/runtime/routes/channel-inbound-routes.ts +39 -4
  70. package/src/runtime/routes/conversation-routes.ts +107 -1
  71. package/src/runtime/routes/run-routes.ts +1 -1
  72. package/src/runtime/run-orchestrator.ts +157 -2
  73. package/src/subagent/manager.ts +6 -6
  74. package/src/tools/browser/browser-manager.ts +1 -1
  75. package/src/tools/subagent/message.ts +9 -2
  76. package/src/__tests__/call-orchestrator.test.ts +0 -1496
@@ -0,0 +1,835 @@
1
+ import { describe, test, expect, beforeEach, afterAll, mock, type Mock } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ const testDir = mkdtempSync(join(tmpdir(), 'call-controller-test-'));
7
+
8
+ // ── Platform + logger mocks (must come before any source imports) ────
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
+ readHttpToken: () => null,
21
+ }));
22
+
23
+ mock.module('../util/logger.js', () => ({
24
+ getLogger: () =>
25
+ new Proxy({} as Record<string, unknown>, {
26
+ get: () => () => {},
27
+ }),
28
+ }));
29
+
30
+ // ── Config mock ─────────────────────────────────────────────────────
31
+
32
+ mock.module('../config/loader.js', () => ({
33
+ getConfig: () => ({
34
+ provider: 'anthropic',
35
+ providerOrder: ['anthropic'],
36
+ apiKeys: { anthropic: 'test-key' },
37
+ calls: {
38
+ enabled: true,
39
+ provider: 'twilio',
40
+ maxDurationSeconds: 12 * 60,
41
+ userConsultTimeoutSeconds: 90,
42
+ userConsultationTimeoutSeconds: 90,
43
+ silenceTimeoutSeconds: 30,
44
+ disclosure: { enabled: false, text: '' },
45
+ safety: { denyCategories: [] },
46
+ model: undefined,
47
+ },
48
+ memory: { enabled: false },
49
+ }),
50
+ }));
51
+
52
+ // ── Voice session bridge mock ────────────────────────────────────────
53
+
54
+ /**
55
+ * Creates a mock startVoiceTurn implementation that emits text_delta
56
+ * events for each token and calls onComplete when done.
57
+ */
58
+ function createMockVoiceTurn(tokens: string[]) {
59
+ return async (opts: {
60
+ conversationId: string;
61
+ content: string;
62
+ assistantId?: string;
63
+ onTextDelta: (text: string) => void;
64
+ onComplete: () => void;
65
+ onError: (message: string) => void;
66
+ signal?: AbortSignal;
67
+ }) => {
68
+ // Check for abort before proceeding
69
+ if (opts.signal?.aborted) {
70
+ const err = new Error('aborted');
71
+ err.name = 'AbortError';
72
+ throw err;
73
+ }
74
+
75
+ // Emit text deltas
76
+ for (const token of tokens) {
77
+ if (opts.signal?.aborted) break;
78
+ opts.onTextDelta(token);
79
+ }
80
+
81
+ if (!opts.signal?.aborted) {
82
+ opts.onComplete();
83
+ }
84
+
85
+ let aborted = false;
86
+ return {
87
+ runId: `run-${Date.now()}`,
88
+ abort: () => { aborted = true; },
89
+ };
90
+ };
91
+ }
92
+
93
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
94
+ let mockStartVoiceTurn: Mock<any>;
95
+
96
+ mock.module('../calls/voice-session-bridge.js', () => {
97
+ mockStartVoiceTurn = mock(createMockVoiceTurn(['Hello', ' there']));
98
+ return {
99
+ startVoiceTurn: (...args: unknown[]) => mockStartVoiceTurn(...args),
100
+ setVoiceBridgeOrchestrator: () => {},
101
+ };
102
+ });
103
+
104
+ // ── Import source modules after all mocks are registered ────────────
105
+
106
+ import { initializeDb, getDb, resetDb } from '../memory/db.js';
107
+ import { conversations } from '../memory/schema.js';
108
+ import {
109
+ createCallSession,
110
+ getCallSession,
111
+ getCallEvents,
112
+ getPendingQuestion,
113
+ updateCallSession,
114
+ } from '../calls/call-store.js';
115
+ import {
116
+ getCallController,
117
+ } from '../calls/call-state.js';
118
+ import { CallController } from '../calls/call-controller.js';
119
+ import type { RelayConnection } from '../calls/relay-server.js';
120
+
121
+ initializeDb();
122
+
123
+ afterAll(() => {
124
+ resetDb();
125
+ try {
126
+ rmSync(testDir, { recursive: true });
127
+ } catch {
128
+ /* best effort */
129
+ }
130
+ });
131
+
132
+ // ── RelayConnection mock factory ────────────────────────────────────
133
+
134
+ interface MockRelay extends RelayConnection {
135
+ sentTokens: Array<{ token: string; last: boolean }>;
136
+ endCalled: boolean;
137
+ endReason: string | undefined;
138
+ }
139
+
140
+ function createMockRelay(): MockRelay {
141
+ const state = {
142
+ sentTokens: [] as Array<{ token: string; last: boolean }>,
143
+ _endCalled: false,
144
+ _endReason: undefined as string | undefined,
145
+ };
146
+
147
+ return {
148
+ get sentTokens() { return state.sentTokens; },
149
+ get endCalled() { return state._endCalled; },
150
+ get endReason() { return state._endReason; },
151
+ sendTextToken(token: string, last: boolean) {
152
+ state.sentTokens.push({ token, last });
153
+ },
154
+ endSession(reason?: string) {
155
+ state._endCalled = true;
156
+ state._endReason = reason;
157
+ },
158
+ } as unknown as MockRelay;
159
+ }
160
+
161
+ // ── Helpers ─────────────────────────────────────────────────────────
162
+
163
+ let ensuredConvIds = new Set<string>();
164
+ function ensureConversation(id: string): void {
165
+ if (ensuredConvIds.has(id)) return;
166
+ const db = getDb();
167
+ const now = Date.now();
168
+ db.insert(conversations).values({
169
+ id,
170
+ title: `Test conversation ${id}`,
171
+ createdAt: now,
172
+ updatedAt: now,
173
+ }).run();
174
+ ensuredConvIds.add(id);
175
+ }
176
+
177
+ function resetTables() {
178
+ const db = getDb();
179
+ db.run('DELETE FROM guardian_action_deliveries');
180
+ db.run('DELETE FROM guardian_action_requests');
181
+ db.run('DELETE FROM call_pending_questions');
182
+ db.run('DELETE FROM call_events');
183
+ db.run('DELETE FROM call_sessions');
184
+ db.run('DELETE FROM tool_invocations');
185
+ db.run('DELETE FROM messages');
186
+ db.run('DELETE FROM conversations');
187
+ ensuredConvIds = new Set();
188
+ }
189
+
190
+ /**
191
+ * Create a call session and a controller wired to a mock relay.
192
+ */
193
+ function setupController(task?: string, opts?: { assistantId?: string; guardianContext?: import('../daemon/session-runtime-assembly.js').GuardianRuntimeContext }) {
194
+ ensureConversation('conv-ctrl-test');
195
+ const session = createCallSession({
196
+ conversationId: 'conv-ctrl-test',
197
+ provider: 'twilio',
198
+ fromNumber: '+15551111111',
199
+ toNumber: '+15552222222',
200
+ task,
201
+ });
202
+ updateCallSession(session.id, { status: 'in_progress' });
203
+ const relay = createMockRelay();
204
+ const controller = new CallController(session.id, relay as unknown as RelayConnection, task ?? null, {
205
+ assistantId: opts?.assistantId,
206
+ guardianContext: opts?.guardianContext,
207
+ });
208
+ return { session, relay, controller };
209
+ }
210
+
211
+ describe('call-controller', () => {
212
+ beforeEach(() => {
213
+ resetTables();
214
+ // Reset the bridge mock to default behaviour
215
+ mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(['Hello', ' there']));
216
+ });
217
+
218
+ // ── handleCallerUtterance ─────────────────────────────────────────
219
+
220
+ test('handleCallerUtterance: streams tokens via sendTextToken', async () => {
221
+ mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(['Hi', ', how', ' are you?']));
222
+ const { relay, controller } = setupController();
223
+
224
+ await controller.handleCallerUtterance('Hello');
225
+
226
+ // Verify tokens were sent to the relay
227
+ const nonEmptyTokens = relay.sentTokens.filter((t) => t.token.length > 0);
228
+ expect(nonEmptyTokens.length).toBeGreaterThan(0);
229
+ // The last token should have last=true (empty string token signaling end)
230
+ const lastToken = relay.sentTokens[relay.sentTokens.length - 1];
231
+ expect(lastToken.last).toBe(true);
232
+
233
+ controller.destroy();
234
+ });
235
+
236
+ test('handleCallerUtterance: sends last=true at end of turn', async () => {
237
+ mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(['Simple response.']));
238
+ const { relay, controller } = setupController();
239
+
240
+ await controller.handleCallerUtterance('Test');
241
+
242
+ // Find the final empty-string token that marks end of turn
243
+ const endMarkers = relay.sentTokens.filter((t) => t.last === true);
244
+ expect(endMarkers.length).toBeGreaterThanOrEqual(1);
245
+
246
+ controller.destroy();
247
+ });
248
+
249
+ test('handleCallerUtterance: includes speaker context in voice turn content', async () => {
250
+ mockStartVoiceTurn.mockImplementation(async (opts: { content: string; onTextDelta: (t: string) => void; onComplete: () => void }) => {
251
+ expect(opts.content).toContain('[SPEAKER id="speaker-1" label="Aaron" source="provider" confidence="0.91"]');
252
+ expect(opts.content).toContain('Can you summarize this meeting?');
253
+ opts.onTextDelta('Sure, here is a summary.');
254
+ opts.onComplete();
255
+ return { runId: 'run-1', abort: () => {} };
256
+ });
257
+
258
+ const { controller } = setupController();
259
+
260
+ await controller.handleCallerUtterance('Can you summarize this meeting?', {
261
+ speakerId: 'speaker-1',
262
+ speakerLabel: 'Aaron',
263
+ speakerConfidence: 0.91,
264
+ source: 'provider',
265
+ });
266
+
267
+ controller.destroy();
268
+ });
269
+
270
+ test('startInitialGreeting: sends CALL_OPENING content and strips control marker from speech', async () => {
271
+ let turnCount = 0;
272
+ mockStartVoiceTurn.mockImplementation(async (opts: { content: string; onTextDelta: (t: string) => void; onComplete: () => void }) => {
273
+ turnCount++;
274
+ expect(opts.content).toContain('[CALL_OPENING]');
275
+ const tokens = ['Hi, I am calling about your appointment request. Is now a good time to talk?'];
276
+ for (const token of tokens) {
277
+ opts.onTextDelta(token);
278
+ }
279
+ opts.onComplete();
280
+ return { runId: 'run-1', abort: () => {} };
281
+ });
282
+
283
+ const { relay, controller } = setupController('Confirm appointment');
284
+
285
+ await controller.startInitialGreeting();
286
+ await controller.startInitialGreeting(); // should be no-op
287
+
288
+ const allText = relay.sentTokens.map((t) => t.token).join('');
289
+ expect(allText).toContain('appointment request');
290
+ expect(allText).toContain('good time to talk');
291
+ expect(allText).not.toContain('[CALL_OPENING]');
292
+ expect(turnCount).toBe(1); // idempotent
293
+
294
+ controller.destroy();
295
+ });
296
+
297
+ test('startInitialGreeting: tags only the first caller response with CALL_OPENING_ACK', async () => {
298
+ let turnCount = 0;
299
+ mockStartVoiceTurn.mockImplementation(async (opts: { content: string; onTextDelta: (t: string) => void; onComplete: () => void }) => {
300
+ turnCount++;
301
+
302
+ let tokens: string[];
303
+ if (turnCount === 1) {
304
+ expect(opts.content).toContain('[CALL_OPENING]');
305
+ tokens = ['Hey Noa, it\'s Credence calling about your joke request. Is now okay for a quick one?'];
306
+ } else if (turnCount === 2) {
307
+ expect(opts.content).toContain('[CALL_OPENING_ACK]');
308
+ expect(opts.content).toContain('Yeah. Sure. What\'s up?');
309
+ tokens = ['Great, here\'s one right away. Why did the scarecrow win an award?'];
310
+ } else {
311
+ expect(opts.content).not.toContain('[CALL_OPENING_ACK]');
312
+ expect(opts.content).toContain('Tell me the punchline');
313
+ tokens = ['Because he was outstanding in his field.'];
314
+ }
315
+
316
+ for (const token of tokens) {
317
+ opts.onTextDelta(token);
318
+ }
319
+ opts.onComplete();
320
+ return { runId: `run-${turnCount}`, abort: () => {} };
321
+ });
322
+
323
+ const { controller } = setupController('Tell a joke immediately');
324
+
325
+ await controller.startInitialGreeting();
326
+ await controller.handleCallerUtterance('Yeah. Sure. What\'s up?');
327
+ await controller.handleCallerUtterance('Tell me the punchline');
328
+
329
+ expect(turnCount).toBe(3);
330
+
331
+ controller.destroy();
332
+ });
333
+
334
+ // ── ASK_GUARDIAN pattern ──────────────────────────────────────────
335
+
336
+ test('ASK_GUARDIAN pattern: detects pattern, creates pending question, enters waiting_on_user', async () => {
337
+ mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
338
+ ['Let me check on that. ', '[ASK_GUARDIAN: What date works best?]'],
339
+ ));
340
+ const { session, relay, controller } = setupController('Book appointment');
341
+
342
+ await controller.handleCallerUtterance('I need to schedule something');
343
+
344
+ // Verify a pending question was created
345
+ const question = getPendingQuestion(session.id);
346
+ expect(question).not.toBeNull();
347
+ expect(question!.questionText).toBe('What date works best?');
348
+ expect(question!.status).toBe('pending');
349
+
350
+ // Verify session status was updated to waiting_on_user
351
+ const updatedSession = getCallSession(session.id);
352
+ expect(updatedSession!.status).toBe('waiting_on_user');
353
+
354
+ // The ASK_GUARDIAN marker text should NOT appear in the relay tokens
355
+ const allText = relay.sentTokens.map((t) => t.token).join('');
356
+ expect(allText).not.toContain('[ASK_GUARDIAN:');
357
+
358
+ controller.destroy();
359
+ });
360
+
361
+ test('strips internal context markers from spoken output', async () => {
362
+ mockStartVoiceTurn.mockImplementation(createMockVoiceTurn([
363
+ 'Thanks for waiting. ',
364
+ '[USER_ANSWERED: The guardian said 3 PM works.] ',
365
+ '[USER_INSTRUCTION: Keep this short.] ',
366
+ '[CALL_OPENING_ACK] ',
367
+ 'I can confirm 3 PM works.',
368
+ ]));
369
+ const { relay, controller } = setupController();
370
+
371
+ await controller.handleCallerUtterance('Any update?');
372
+
373
+ const allText = relay.sentTokens.map((t) => t.token).join('');
374
+ expect(allText).toContain('Thanks for waiting.');
375
+ expect(allText).toContain('I can confirm 3 PM works.');
376
+ expect(allText).not.toContain('[USER_ANSWERED:');
377
+ expect(allText).not.toContain('[USER_INSTRUCTION:');
378
+ expect(allText).not.toContain('[CALL_OPENING_ACK]');
379
+ expect(allText).not.toContain('USER_ANSWERED');
380
+ expect(allText).not.toContain('USER_INSTRUCTION');
381
+ expect(allText).not.toContain('CALL_OPENING_ACK');
382
+
383
+ controller.destroy();
384
+ });
385
+
386
+ // ── END_CALL pattern ──────────────────────────────────────────────
387
+
388
+ test('END_CALL pattern: detects marker, calls endSession, updates status to completed', async () => {
389
+ mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
390
+ ['Thank you for calling, goodbye! ', '[END_CALL]'],
391
+ ));
392
+ const { session, relay, controller } = setupController();
393
+
394
+ await controller.handleCallerUtterance('That is all, thanks');
395
+
396
+ // endSession should have been called
397
+ expect(relay.endCalled).toBe(true);
398
+
399
+ // Session status should be completed
400
+ const updatedSession = getCallSession(session.id);
401
+ expect(updatedSession!.status).toBe('completed');
402
+ expect(updatedSession!.endedAt).not.toBeNull();
403
+
404
+ // The END_CALL marker text should NOT appear in the relay tokens
405
+ const allText = relay.sentTokens.map((t) => t.token).join('');
406
+ expect(allText).not.toContain('[END_CALL]');
407
+
408
+ controller.destroy();
409
+ });
410
+
411
+ // ── handleUserAnswer ──────────────────────────────────────────────
412
+
413
+ test('handleUserAnswer: returns true immediately and fires LLM asynchronously', async () => {
414
+ // First utterance triggers ASK_GUARDIAN
415
+ mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
416
+ ['Hold on. [ASK_GUARDIAN: Preferred time?]'],
417
+ ));
418
+ const { relay, controller } = setupController();
419
+
420
+ await controller.handleCallerUtterance('I need an appointment');
421
+
422
+ // Now provide the answer — reset mock for second turn
423
+ mockStartVoiceTurn.mockImplementation(async (opts: { content: string; onTextDelta: (t: string) => void; onComplete: () => void }) => {
424
+ expect(opts.content).toContain('[USER_ANSWERED: 3pm tomorrow]');
425
+ const tokens = ['Great, I have scheduled for 3pm tomorrow.'];
426
+ for (const token of tokens) {
427
+ opts.onTextDelta(token);
428
+ }
429
+ opts.onComplete();
430
+ return { runId: 'run-2', abort: () => {} };
431
+ });
432
+
433
+ const accepted = await controller.handleUserAnswer('3pm tomorrow');
434
+ expect(accepted).toBe(true);
435
+
436
+ // handleUserAnswer fires runTurn without awaiting, so give the
437
+ // microtask queue a tick to let the async work complete.
438
+ await new Promise((r) => setTimeout(r, 50));
439
+
440
+ // Should have streamed a response for the answer
441
+ const tokensAfterAnswer = relay.sentTokens.filter((t) => t.token.includes('3pm'));
442
+ expect(tokensAfterAnswer.length).toBeGreaterThan(0);
443
+
444
+ controller.destroy();
445
+ });
446
+
447
+ // ── Full mid-call question flow ──────────────────────────────────
448
+
449
+ test('mid-call question flow: unavailable time -> ask user -> user confirms -> resumed call', async () => {
450
+ // Step 1: Caller says "7:30" but it's unavailable. The LLM asks the user.
451
+ mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
452
+ ['I\'m sorry, 7:30 is not available. ', '[ASK_GUARDIAN: Is 8:00 okay instead?]'],
453
+ ));
454
+
455
+ const { session, relay, controller } = setupController('Schedule a haircut');
456
+
457
+ await controller.handleCallerUtterance('Can I book for 7:30?');
458
+
459
+ // Verify we're in waiting_on_user state
460
+ expect(controller.getState()).toBe('waiting_on_user');
461
+ const question = getPendingQuestion(session.id);
462
+ expect(question).not.toBeNull();
463
+ expect(question!.questionText).toBe('Is 8:00 okay instead?');
464
+
465
+ // Verify session status
466
+ const midSession = getCallSession(session.id);
467
+ expect(midSession!.status).toBe('waiting_on_user');
468
+
469
+ // Step 2: User answers "Yes, 8:00 works"
470
+ mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
471
+ ['Great, I\'ve booked you for 8:00. See you then! ', '[END_CALL]'],
472
+ ));
473
+
474
+ const accepted = await controller.handleUserAnswer('Yes, 8:00 works for me');
475
+ expect(accepted).toBe(true);
476
+
477
+ // Give the fire-and-forget LLM call time to complete
478
+ await new Promise((r) => setTimeout(r, 50));
479
+
480
+ // Step 3: Verify call completed
481
+ const endSession = getCallSession(session.id);
482
+ expect(endSession!.status).toBe('completed');
483
+ expect(endSession!.endedAt).not.toBeNull();
484
+
485
+ // Verify the END_CALL marker triggered endSession on relay
486
+ expect(relay.endCalled).toBe(true);
487
+
488
+ controller.destroy();
489
+ });
490
+
491
+ // ── Error handling ────────────────────────────────────────────────
492
+
493
+ test('Voice turn error: sends error message to caller and returns to idle', async () => {
494
+ mockStartVoiceTurn.mockImplementation(async (opts: { onError: (msg: string) => void }) => {
495
+ opts.onError('API rate limit exceeded');
496
+ return { runId: 'run-err', abort: () => {} };
497
+ });
498
+
499
+ const { relay, controller } = setupController();
500
+
501
+ await controller.handleCallerUtterance('Hello');
502
+
503
+ // Should have sent an error recovery message
504
+ const errorTokens = relay.sentTokens.filter((t) =>
505
+ t.token.includes('technical issue'),
506
+ );
507
+ expect(errorTokens.length).toBeGreaterThan(0);
508
+
509
+ // State should return to idle after error
510
+ expect(controller.getState()).toBe('idle');
511
+
512
+ controller.destroy();
513
+ });
514
+
515
+ test('handleUserAnswer: returns false when not in waiting_on_user state', async () => {
516
+ const { controller } = setupController();
517
+
518
+ // Controller starts in idle state
519
+ const result = await controller.handleUserAnswer('some answer');
520
+ expect(result).toBe(false);
521
+
522
+ controller.destroy();
523
+ });
524
+
525
+ // ── handleInterrupt ───────────────────────────────────────────────
526
+
527
+ test('handleInterrupt: resets state to idle', () => {
528
+ const { controller } = setupController();
529
+
530
+ // Calling handleInterrupt should not throw
531
+ controller.handleInterrupt();
532
+
533
+ controller.destroy();
534
+ });
535
+
536
+ test('handleInterrupt: sends turn terminator when interrupting active speech', async () => {
537
+ mockStartVoiceTurn.mockImplementation(async (opts: { signal?: AbortSignal; onTextDelta: (t: string) => void; onComplete: () => void }) => {
538
+ return new Promise((resolve) => {
539
+ // Simulate a long-running turn that can be aborted
540
+ const timeout = setTimeout(() => {
541
+ opts.onTextDelta('This should be interrupted');
542
+ opts.onComplete();
543
+ resolve({ runId: 'run-1', abort: () => {} });
544
+ }, 1000);
545
+
546
+ opts.signal?.addEventListener('abort', () => {
547
+ clearTimeout(timeout);
548
+ // In the real system, generation_cancelled triggers
549
+ // onComplete via the event sink. The AbortSignal listener
550
+ // in call-controller also resolves turnComplete defensively.
551
+ opts.onComplete();
552
+ resolve({ runId: 'run-1', abort: () => {} });
553
+ }, { once: true });
554
+ });
555
+ });
556
+
557
+ const { relay, controller } = setupController();
558
+ const turnPromise = controller.handleCallerUtterance('Start speaking');
559
+ await new Promise((r) => setTimeout(r, 5));
560
+ controller.handleInterrupt();
561
+ await turnPromise;
562
+
563
+ const endTurnMarkers = relay.sentTokens.filter((t) => t.token === '' && t.last === true);
564
+ expect(endTurnMarkers.length).toBeGreaterThan(0);
565
+
566
+ controller.destroy();
567
+ });
568
+
569
+ test('handleInterrupt: turnComplete settles even when event sink callbacks are not called', async () => {
570
+ // Simulate a turn that never calls onComplete or onError on abort —
571
+ // the defensive AbortSignal listener in runTurn() should settle the promise.
572
+ mockStartVoiceTurn.mockImplementation(async (opts: { signal?: AbortSignal; onTextDelta: (t: string) => void; onComplete: () => void }) => {
573
+ return new Promise((resolve) => {
574
+ const timeout = setTimeout(() => {
575
+ opts.onTextDelta('Long running turn');
576
+ opts.onComplete();
577
+ resolve({ runId: 'run-1', abort: () => {} });
578
+ }, 5000);
579
+
580
+ opts.signal?.addEventListener('abort', () => {
581
+ clearTimeout(timeout);
582
+ // Intentionally do NOT call onComplete — simulates the old
583
+ // broken path where generation_cancelled was not forwarded.
584
+ resolve({ runId: 'run-1', abort: () => {} });
585
+ }, { once: true });
586
+ });
587
+ });
588
+
589
+ const { controller } = setupController();
590
+ const turnPromise = controller.handleCallerUtterance('Start speaking');
591
+ await new Promise((r) => setTimeout(r, 5));
592
+ controller.handleInterrupt();
593
+
594
+ // Should not hang — the AbortSignal listener resolves the promise
595
+ await turnPromise;
596
+
597
+ expect(controller.getState()).toBe('idle');
598
+
599
+ controller.destroy();
600
+ });
601
+
602
+ // ── Guardian context pass-through ──────────────────────────────────
603
+
604
+ test('handleCallerUtterance: passes guardian context to startVoiceTurn', async () => {
605
+ const guardianCtx = {
606
+ sourceChannel: 'voice' as const,
607
+ actorRole: 'non-guardian' as const,
608
+ guardianExternalUserId: '+15550009999',
609
+ guardianChatId: '+15550009999',
610
+ requesterExternalUserId: '+15550002222',
611
+ };
612
+
613
+ let capturedGuardianContext: unknown = undefined;
614
+ mockStartVoiceTurn.mockImplementation(async (opts: {
615
+ guardianContext?: unknown;
616
+ onTextDelta: (t: string) => void;
617
+ onComplete: () => void;
618
+ }) => {
619
+ capturedGuardianContext = opts.guardianContext;
620
+ opts.onTextDelta('Hello.');
621
+ opts.onComplete();
622
+ return { runId: 'run-gc', abort: () => {} };
623
+ });
624
+
625
+ const { controller } = setupController(undefined, { guardianContext: guardianCtx });
626
+
627
+ await controller.handleCallerUtterance('Hello');
628
+
629
+ expect(capturedGuardianContext).toEqual(guardianCtx);
630
+
631
+ controller.destroy();
632
+ });
633
+
634
+ test('handleCallerUtterance: passes assistantId to startVoiceTurn', async () => {
635
+ let capturedAssistantId: string | undefined;
636
+ mockStartVoiceTurn.mockImplementation(async (opts: {
637
+ assistantId?: string;
638
+ onTextDelta: (t: string) => void;
639
+ onComplete: () => void;
640
+ }) => {
641
+ capturedAssistantId = opts.assistantId;
642
+ opts.onTextDelta('Hello.');
643
+ opts.onComplete();
644
+ return { runId: 'run-aid', abort: () => {} };
645
+ });
646
+
647
+ const { controller } = setupController(undefined, { assistantId: 'my-assistant' });
648
+
649
+ await controller.handleCallerUtterance('Hello');
650
+
651
+ expect(capturedAssistantId).toBe('my-assistant');
652
+
653
+ controller.destroy();
654
+ });
655
+
656
+ test('setGuardianContext: subsequent turns use updated guardian context', async () => {
657
+ const initialCtx = {
658
+ sourceChannel: 'voice' as const,
659
+ actorRole: 'unverified_channel' as const,
660
+ denialReason: 'no_binding' as const,
661
+ };
662
+
663
+ const upgradedCtx = {
664
+ sourceChannel: 'voice' as const,
665
+ actorRole: 'guardian' as const,
666
+ guardianExternalUserId: '+15550003333',
667
+ guardianChatId: '+15550003333',
668
+ };
669
+
670
+ const capturedContexts: unknown[] = [];
671
+ mockStartVoiceTurn.mockImplementation(async (opts: {
672
+ guardianContext?: unknown;
673
+ onTextDelta: (t: string) => void;
674
+ onComplete: () => void;
675
+ }) => {
676
+ capturedContexts.push(opts.guardianContext);
677
+ opts.onTextDelta('Response.');
678
+ opts.onComplete();
679
+ return { runId: `run-${capturedContexts.length}`, abort: () => {} };
680
+ });
681
+
682
+ const { controller } = setupController(undefined, { guardianContext: initialCtx });
683
+
684
+ // First turn: unverified
685
+ await controller.handleCallerUtterance('Hello');
686
+ expect(capturedContexts[0]).toEqual(initialCtx);
687
+
688
+ // Simulate guardian verification succeeding
689
+ controller.setGuardianContext(upgradedCtx);
690
+
691
+ // Second turn: should use upgraded guardian context
692
+ await controller.handleCallerUtterance('I verified');
693
+ expect(capturedContexts[1]).toEqual(upgradedCtx);
694
+
695
+ controller.destroy();
696
+ });
697
+
698
+ // ── destroy ───────────────────────────────────────────────────────
699
+
700
+ test('destroy: unregisters controller', () => {
701
+ const { session, controller } = setupController();
702
+
703
+ // Controller should be registered
704
+ expect(getCallController(session.id)).toBeDefined();
705
+
706
+ controller.destroy();
707
+
708
+ // After destroy, controller should be unregistered
709
+ expect(getCallController(session.id)).toBeUndefined();
710
+ });
711
+
712
+ test('destroy: can be called multiple times without error', () => {
713
+ const { controller } = setupController();
714
+
715
+ controller.destroy();
716
+ // Second destroy should not throw
717
+ expect(() => controller.destroy()).not.toThrow();
718
+ });
719
+
720
+ test('destroy: during active turn does not trigger post-turn side effects', async () => {
721
+ // Simulate a turn that completes after destroy() is called
722
+ mockStartVoiceTurn.mockImplementation(async (opts: { signal?: AbortSignal; onTextDelta: (t: string) => void; onComplete: () => void }) => {
723
+ return new Promise((resolve) => {
724
+ const timeout = setTimeout(() => {
725
+ opts.onTextDelta('This is a long response');
726
+ opts.onComplete();
727
+ resolve({ runId: 'run-1', abort: () => {} });
728
+ }, 1000);
729
+
730
+ opts.signal?.addEventListener('abort', () => {
731
+ clearTimeout(timeout);
732
+ // The defensive abort listener in runTurn resolves turnComplete
733
+ opts.onComplete();
734
+ resolve({ runId: 'run-1', abort: () => {} });
735
+ }, { once: true });
736
+ });
737
+ });
738
+
739
+ const { relay, controller } = setupController();
740
+ const turnPromise = controller.handleCallerUtterance('Start speaking');
741
+
742
+ // Let the turn start
743
+ await new Promise((r) => setTimeout(r, 5));
744
+
745
+ // Destroy the controller while the turn is active
746
+ controller.destroy();
747
+
748
+ // Wait for the turn to settle
749
+ await turnPromise;
750
+
751
+ // Verify that NO spurious post-turn side effects occurred after destroy:
752
+ // - No final empty-string sendTextToken('', true) call after abort
753
+ // The only end marker should be from handleInterrupt, not from post-turn logic
754
+ const endMarkers = relay.sentTokens.filter((t) => t.token === '' && t.last === true);
755
+
756
+ // destroy() increments llmRunVersion, so isCurrentRun() returns false
757
+ // for the aborted turn, preventing post-turn side effects including
758
+ // the spurious relay.sendTextToken('', true) on line 418.
759
+ expect(endMarkers.length).toBe(0);
760
+ });
761
+
762
+ // ── handleUserInstruction ─────────────────────────────────────────
763
+
764
+ test('handleUserInstruction: injects instruction marker and triggers turn when idle', async () => {
765
+ mockStartVoiceTurn.mockImplementation(async (opts: { content: string; onTextDelta: (t: string) => void; onComplete: () => void }) => {
766
+ expect(opts.content).toContain('[USER_INSTRUCTION: Ask about their weekend plans]');
767
+ const tokens = ['Sure, do you have any weekend plans?'];
768
+ for (const token of tokens) {
769
+ opts.onTextDelta(token);
770
+ }
771
+ opts.onComplete();
772
+ return { runId: 'run-instr', abort: () => {} };
773
+ });
774
+
775
+ const { relay, controller } = setupController();
776
+
777
+ await controller.handleUserInstruction('Ask about their weekend plans');
778
+
779
+ // Should have streamed a response since controller was idle
780
+ const nonEmptyTokens = relay.sentTokens.filter((t) => t.token.length > 0);
781
+ expect(nonEmptyTokens.length).toBeGreaterThan(0);
782
+
783
+ controller.destroy();
784
+ });
785
+
786
+ test('handleUserInstruction: emits user_instruction_relayed event', async () => {
787
+ mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(['Understood, adjusting approach.']));
788
+
789
+ const { session, controller } = setupController();
790
+
791
+ await controller.handleUserInstruction('Be more formal in your tone');
792
+
793
+ const events = getCallEvents(session.id);
794
+ const instructionEvents = events.filter((e) => e.eventType === 'user_instruction_relayed');
795
+ expect(instructionEvents.length).toBe(1);
796
+
797
+ const payload = JSON.parse(instructionEvents[0].payloadJson);
798
+ expect(payload.instruction).toBe('Be more formal in your tone');
799
+
800
+ controller.destroy();
801
+ });
802
+
803
+ test('handleUserInstruction: does not trigger turn when controller is not idle', async () => {
804
+ // First, trigger ASK_GUARDIAN so controller enters waiting_on_user
805
+ mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
806
+ ['Hold on. [ASK_GUARDIAN: What time?]'],
807
+ ));
808
+
809
+ const { session, controller } = setupController();
810
+ await controller.handleCallerUtterance('I need an appointment');
811
+ expect(controller.getState()).toBe('waiting_on_user');
812
+
813
+ // Track how many times startVoiceTurn is called
814
+ let turnCallCount = 0;
815
+ mockStartVoiceTurn.mockImplementation(async (opts: { onTextDelta: (t: string) => void; onComplete: () => void }) => {
816
+ turnCallCount++;
817
+ opts.onTextDelta('Response after instruction.');
818
+ opts.onComplete();
819
+ return { runId: 'run-2', abort: () => {} };
820
+ });
821
+
822
+ // Inject instruction while in waiting_on_user state
823
+ await controller.handleUserInstruction('Suggest morning slots');
824
+
825
+ // The turn should NOT have been triggered since we're not idle
826
+ expect(turnCallCount).toBe(0);
827
+
828
+ // But the event should still be recorded
829
+ const events = getCallEvents(session.id);
830
+ const instructionEvents = events.filter((e) => e.eventType === 'user_instruction_relayed');
831
+ expect(instructionEvents.length).toBe(1);
832
+
833
+ controller.destroy();
834
+ });
835
+ });