@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
@@ -1,1496 +0,0 @@
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-orchestrator-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
- // ── User reference mock ──────────────────────────────────────────────
31
-
32
- let mockUserReference = 'my human';
33
-
34
- mock.module('../config/user-reference.js', () => ({
35
- resolveUserReference: () => mockUserReference,
36
- }));
37
-
38
- // ── Config mock ─────────────────────────────────────────────────────
39
-
40
- let mockCallModel: string | undefined = undefined;
41
- let mockDisclosure: { enabled: boolean; text: string } = { enabled: false, text: '' };
42
-
43
- mock.module('../config/loader.js', () => ({
44
- getConfig: () => ({
45
- provider: 'anthropic',
46
- providerOrder: ['anthropic'],
47
- apiKeys: { anthropic: 'test-key' },
48
- calls: {
49
- enabled: true,
50
- provider: 'twilio',
51
- maxDurationSeconds: 12 * 60,
52
- userConsultTimeoutSeconds: 90,
53
- userConsultationTimeoutSeconds: 90,
54
- silenceTimeoutSeconds: 30,
55
- disclosure: mockDisclosure,
56
- safety: { denyCategories: [] },
57
- model: mockCallModel,
58
- },
59
- memory: { enabled: false },
60
- }),
61
- }));
62
-
63
- // ── Helpers for building mock provider responses ────────────────────
64
-
65
- /**
66
- * Creates a mock provider sendMessage implementation that emits text_delta
67
- * events for each token and resolves with the full response.
68
- */
69
- function createMockProviderResponse(tokens: string[]) {
70
- const fullText = tokens.join('');
71
- return async (
72
- _messages: unknown[],
73
- _tools: unknown[],
74
- _systemPrompt: string,
75
- options?: { onEvent?: (event: { type: string; text?: string }) => void; signal?: AbortSignal },
76
- ) => {
77
- // Emit text_delta events for each token
78
- for (const token of tokens) {
79
- options?.onEvent?.({ type: 'text_delta', text: token });
80
- }
81
- return {
82
- content: [{ type: 'text', text: fullText }],
83
- model: 'claude-sonnet-4-20250514',
84
- usage: { inputTokens: 100, outputTokens: 50 },
85
- stopReason: 'end_turn',
86
- };
87
- };
88
- }
89
-
90
- // ── Provider registry mock ──────────────────────────────────────────
91
-
92
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
93
- let mockSendMessage: Mock<any>;
94
-
95
- mock.module('../providers/registry.js', () => {
96
- mockSendMessage = mock(createMockProviderResponse(['Hello', ' there']));
97
- return {
98
- listProviders: () => ['anthropic'],
99
- getFailoverProvider: () => ({
100
- name: 'anthropic',
101
- sendMessage: (...args: unknown[]) => mockSendMessage(...args),
102
- }),
103
- getDefaultModel: (providerName: string) => {
104
- const defaults: Record<string, string> = {
105
- anthropic: 'claude-opus-4-6',
106
- openai: 'gpt-5.2',
107
- gemini: 'gemini-3-flash',
108
- ollama: 'llama3.2',
109
- fireworks: 'accounts/fireworks/models/kimi-k2p5',
110
- openrouter: 'x-ai/grok-4',
111
- };
112
- return defaults[providerName] ?? defaults.anthropic;
113
- },
114
- };
115
- });
116
-
117
- mock.module('../providers/provider-send-message.js', () => ({
118
- resolveConfiguredProvider: () => ({
119
- provider: {
120
- name: 'anthropic',
121
- sendMessage: (...args: unknown[]) => mockSendMessage(...args),
122
- },
123
- configuredProviderName: 'anthropic',
124
- selectedProviderName: 'anthropic',
125
- usedFallbackPrimary: false,
126
- }),
127
- getConfiguredProvider: () => ({
128
- name: 'anthropic',
129
- sendMessage: (...args: unknown[]) => mockSendMessage(...args),
130
- }),
131
- }));
132
-
133
- // ── Import source modules after all mocks are registered ────────────
134
-
135
- import { initializeDb, getDb, resetDb } from '../memory/db.js';
136
- import { conversations } from '../memory/schema.js';
137
- import {
138
- createCallSession,
139
- getCallSession,
140
- getCallEvents,
141
- getPendingQuestion,
142
- updateCallSession,
143
- } from '../calls/call-store.js';
144
- import {
145
- getCallOrchestrator,
146
- } from '../calls/call-state.js';
147
- import { CallOrchestrator } from '../calls/call-orchestrator.js';
148
- import type { RelayConnection } from '../calls/relay-server.js';
149
-
150
- initializeDb();
151
-
152
- afterAll(() => {
153
- resetDb();
154
- try {
155
- rmSync(testDir, { recursive: true });
156
- } catch {
157
- /* best effort */
158
- }
159
- });
160
-
161
- // ── RelayConnection mock factory ────────────────────────────────────
162
-
163
- interface MockRelay extends RelayConnection {
164
- sentTokens: Array<{ token: string; last: boolean }>;
165
- endCalled: boolean;
166
- endReason: string | undefined;
167
- }
168
-
169
- function createMockRelay(): MockRelay {
170
- const state = {
171
- sentTokens: [] as Array<{ token: string; last: boolean }>,
172
- _endCalled: false,
173
- _endReason: undefined as string | undefined,
174
- };
175
-
176
- return {
177
- get sentTokens() { return state.sentTokens; },
178
- get endCalled() { return state._endCalled; },
179
- get endReason() { return state._endReason; },
180
- sendTextToken(token: string, last: boolean) {
181
- state.sentTokens.push({ token, last });
182
- },
183
- endSession(reason?: string) {
184
- state._endCalled = true;
185
- state._endReason = reason;
186
- },
187
- } as unknown as MockRelay;
188
- }
189
-
190
- // ── Helpers ─────────────────────────────────────────────────────────
191
-
192
- let ensuredConvIds = new Set<string>();
193
- function ensureConversation(id: string): void {
194
- if (ensuredConvIds.has(id)) return;
195
- const db = getDb();
196
- const now = Date.now();
197
- db.insert(conversations).values({
198
- id,
199
- title: `Test conversation ${id}`,
200
- createdAt: now,
201
- updatedAt: now,
202
- }).run();
203
- ensuredConvIds.add(id);
204
- }
205
-
206
- function resetTables() {
207
- const db = getDb();
208
- db.run('DELETE FROM guardian_action_deliveries');
209
- db.run('DELETE FROM guardian_action_requests');
210
- db.run('DELETE FROM call_pending_questions');
211
- db.run('DELETE FROM call_events');
212
- db.run('DELETE FROM call_sessions');
213
- db.run('DELETE FROM tool_invocations');
214
- db.run('DELETE FROM messages');
215
- db.run('DELETE FROM conversations');
216
- ensuredConvIds = new Set();
217
- }
218
-
219
- /**
220
- * Create a call session and an orchestrator wired to a mock relay.
221
- */
222
- function setupOrchestrator(task?: string) {
223
- ensureConversation('conv-orch-test');
224
- const session = createCallSession({
225
- conversationId: 'conv-orch-test',
226
- provider: 'twilio',
227
- fromNumber: '+15551111111',
228
- toNumber: '+15552222222',
229
- task,
230
- });
231
- updateCallSession(session.id, { status: 'in_progress' });
232
- const relay = createMockRelay();
233
- const orchestrator = new CallOrchestrator(session.id, relay as unknown as RelayConnection, task ?? null);
234
- return { session, relay, orchestrator };
235
- }
236
-
237
- describe('call-orchestrator', () => {
238
- beforeEach(() => {
239
- resetTables();
240
- mockCallModel = undefined;
241
- mockUserReference = 'my human';
242
- mockDisclosure = { enabled: false, text: '' };
243
- // Reset the provider mock to default behaviour
244
- mockSendMessage.mockImplementation(createMockProviderResponse(['Hello', ' there']));
245
- });
246
-
247
- // ── handleCallerUtterance ─────────────────────────────────────────
248
-
249
- test('handleCallerUtterance: streams tokens via sendTextToken', async () => {
250
- mockSendMessage.mockImplementation(createMockProviderResponse(['Hi', ', how', ' are you?']));
251
- const { relay, orchestrator } = setupOrchestrator();
252
-
253
- await orchestrator.handleCallerUtterance('Hello');
254
-
255
- // Verify tokens were sent to the relay
256
- const nonEmptyTokens = relay.sentTokens.filter((t) => t.token.length > 0);
257
- expect(nonEmptyTokens.length).toBeGreaterThan(0);
258
- // The last token should have last=true (empty string token signaling end)
259
- const lastToken = relay.sentTokens[relay.sentTokens.length - 1];
260
- expect(lastToken.last).toBe(true);
261
-
262
- orchestrator.destroy();
263
- });
264
-
265
- test('handleCallerUtterance: sends last=true at end of turn', async () => {
266
- mockSendMessage.mockImplementation(createMockProviderResponse(['Simple response.']));
267
- const { relay, orchestrator } = setupOrchestrator();
268
-
269
- await orchestrator.handleCallerUtterance('Test');
270
-
271
- // Find the final empty-string token that marks end of turn
272
- const endMarkers = relay.sentTokens.filter((t) => t.last === true);
273
- expect(endMarkers.length).toBeGreaterThanOrEqual(1);
274
-
275
- orchestrator.destroy();
276
- });
277
-
278
- test('handleCallerUtterance: includes speaker context in model message', async () => {
279
- mockSendMessage.mockImplementation(async (messages: unknown[], ..._rest: unknown[]) => {
280
- const msgs = messages as Array<{ role: string; content: Array<{ type: string; text: string }> }>;
281
- const userMessage = msgs.find((m) => m.role === 'user');
282
- const userText = userMessage?.content?.[0]?.text ?? '';
283
- expect(userText).toContain('[SPEAKER id="speaker-1" label="Aaron" source="provider" confidence="0.91"]');
284
- expect(userText).toContain('Can you summarize this meeting?');
285
- return {
286
- content: [{ type: 'text', text: 'Sure, here is a summary.' }],
287
- model: 'claude-sonnet-4-20250514',
288
- usage: { inputTokens: 100, outputTokens: 50 },
289
- stopReason: 'end_turn',
290
- };
291
- });
292
-
293
- const { orchestrator } = setupOrchestrator();
294
-
295
- await orchestrator.handleCallerUtterance('Can you summarize this meeting?', {
296
- speakerId: 'speaker-1',
297
- speakerLabel: 'Aaron',
298
- speakerConfidence: 0.91,
299
- source: 'provider',
300
- });
301
-
302
- orchestrator.destroy();
303
- });
304
-
305
- test('startInitialGreeting: generates model-driven opening and strips control marker from speech', async () => {
306
- mockSendMessage.mockImplementation(async (messages: unknown[], ..._rest: unknown[]) => {
307
- const msgs = messages as Array<{ role: string; content: Array<{ type: string; text: string }> }>;
308
- const firstUser = msgs.find((m) => m.role === 'user');
309
- expect(firstUser?.content?.[0]?.text).toContain('[CALL_OPENING]');
310
- const tokens = ['Hi, I am calling about your appointment request. Is now a good time to talk?'];
311
- const opts = _rest[2] as { onEvent?: (event: { type: string; text?: string }) => void } | undefined;
312
- for (const token of tokens) {
313
- opts?.onEvent?.({ type: 'text_delta', text: token });
314
- }
315
- return {
316
- content: [{ type: 'text', text: tokens.join('') }],
317
- model: 'claude-sonnet-4-20250514',
318
- usage: { inputTokens: 100, outputTokens: 50 },
319
- stopReason: 'end_turn',
320
- };
321
- });
322
-
323
- const { relay, orchestrator } = setupOrchestrator('Confirm appointment');
324
-
325
- const callCountBefore = mockSendMessage.mock.calls.length;
326
- await orchestrator.startInitialGreeting();
327
- await orchestrator.startInitialGreeting();
328
-
329
- const allText = relay.sentTokens.map((t) => t.token).join('');
330
- expect(allText).toContain('appointment request');
331
- expect(allText).toContain('good time to talk');
332
- expect(allText).not.toContain('[CALL_OPENING]');
333
- expect(mockSendMessage.mock.calls.length - callCountBefore).toBe(1);
334
-
335
- orchestrator.destroy();
336
- });
337
-
338
- test('startInitialGreeting: tags only the first caller response with CALL_OPENING_ACK', async () => {
339
- let callCount = 0;
340
- mockSendMessage.mockImplementation(async (messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
341
- callCount++;
342
- const msgs = messages as Array<{ role: string; content: Array<{ type: string; text: string }> }>;
343
- const userMessages = msgs.filter((m) => m.role === 'user');
344
- const lastUser = userMessages[userMessages.length - 1]?.content?.[0]?.text ?? '';
345
-
346
- let tokens: string[];
347
- if (callCount === 1) {
348
- expect(lastUser).toContain('[CALL_OPENING]');
349
- tokens = ['Hey Noa, it\'s Credence calling about your joke request. Is now okay for a quick one?'];
350
- } else if (callCount === 2) {
351
- expect(lastUser).toContain('[CALL_OPENING_ACK]');
352
- expect(lastUser).toContain('Yeah. Sure. What\'s up?');
353
- tokens = ['Great, here\'s one right away. Why did the scarecrow win an award?'];
354
- } else {
355
- expect(lastUser).not.toContain('[CALL_OPENING_ACK]');
356
- expect(lastUser).toContain('Tell me the punchline');
357
- tokens = ['Because he was outstanding in his field.'];
358
- }
359
-
360
- for (const token of tokens) {
361
- options?.onEvent?.({ type: 'text_delta', text: token });
362
- }
363
- return {
364
- content: [{ type: 'text', text: tokens.join('') }],
365
- model: 'claude-sonnet-4-20250514',
366
- usage: { inputTokens: 100, outputTokens: 50 },
367
- stopReason: 'end_turn',
368
- };
369
- });
370
-
371
- const { orchestrator } = setupOrchestrator('Tell a joke immediately');
372
-
373
- await orchestrator.startInitialGreeting();
374
- await orchestrator.handleCallerUtterance('Yeah. Sure. What\'s up?');
375
- await orchestrator.handleCallerUtterance('Tell me the punchline');
376
-
377
- expect(callCount).toBe(3);
378
-
379
- orchestrator.destroy();
380
- });
381
-
382
- // ── ASK_GUARDIAN pattern ──────────────────────────────────────────
383
-
384
- test('ASK_GUARDIAN pattern: detects pattern, creates pending question, enters waiting_on_user', async () => {
385
- mockSendMessage.mockImplementation(createMockProviderResponse(
386
- ['Let me check on that. ', '[ASK_GUARDIAN: What date works best?]'],
387
- ));
388
- const { session, relay, orchestrator } = setupOrchestrator('Book appointment');
389
-
390
- await orchestrator.handleCallerUtterance('I need to schedule something');
391
-
392
- // Verify a pending question was created
393
- const question = getPendingQuestion(session.id);
394
- expect(question).not.toBeNull();
395
- expect(question!.questionText).toBe('What date works best?');
396
- expect(question!.status).toBe('pending');
397
-
398
- // Verify session status was updated to waiting_on_user
399
- const updatedSession = getCallSession(session.id);
400
- expect(updatedSession!.status).toBe('waiting_on_user');
401
-
402
- // The ASK_GUARDIAN marker text should NOT appear in the relay tokens
403
- const allText = relay.sentTokens.map((t) => t.token).join('');
404
- expect(allText).not.toContain('[ASK_GUARDIAN:');
405
-
406
- orchestrator.destroy();
407
- });
408
-
409
- test('strips internal context markers from spoken output', async () => {
410
- mockSendMessage.mockImplementation(createMockProviderResponse([
411
- 'Thanks for waiting. ',
412
- '[USER_ANSWERED: The guardian said 3 PM works.] ',
413
- '[USER_INSTRUCTION: Keep this short.] ',
414
- '[CALL_OPENING_ACK] ',
415
- 'I can confirm 3 PM works.',
416
- ]));
417
- const { relay, orchestrator } = setupOrchestrator();
418
-
419
- await orchestrator.handleCallerUtterance('Any update?');
420
-
421
- const allText = relay.sentTokens.map((t) => t.token).join('');
422
- expect(allText).toContain('Thanks for waiting.');
423
- expect(allText).toContain('I can confirm 3 PM works.');
424
- expect(allText).not.toContain('[USER_ANSWERED:');
425
- expect(allText).not.toContain('[USER_INSTRUCTION:');
426
- expect(allText).not.toContain('[CALL_OPENING_ACK]');
427
- expect(allText).not.toContain('USER_ANSWERED');
428
- expect(allText).not.toContain('USER_INSTRUCTION');
429
- expect(allText).not.toContain('CALL_OPENING_ACK');
430
-
431
- orchestrator.destroy();
432
- });
433
-
434
- // ── END_CALL pattern ──────────────────────────────────────────────
435
-
436
- test('END_CALL pattern: detects marker, calls endSession, updates status to completed', async () => {
437
- mockSendMessage.mockImplementation(createMockProviderResponse(
438
- ['Thank you for calling, goodbye! ', '[END_CALL]'],
439
- ));
440
- const { session, relay, orchestrator } = setupOrchestrator();
441
-
442
- await orchestrator.handleCallerUtterance('That is all, thanks');
443
-
444
- // endSession should have been called
445
- expect(relay.endCalled).toBe(true);
446
-
447
- // Session status should be completed
448
- const updatedSession = getCallSession(session.id);
449
- expect(updatedSession!.status).toBe('completed');
450
- expect(updatedSession!.endedAt).not.toBeNull();
451
-
452
- // The END_CALL marker text should NOT appear in the relay tokens
453
- const allText = relay.sentTokens.map((t) => t.token).join('');
454
- expect(allText).not.toContain('[END_CALL]');
455
-
456
- orchestrator.destroy();
457
- });
458
-
459
- // ── handleUserAnswer ──────────────────────────────────────────────
460
-
461
- test('handleUserAnswer: returns true immediately and fires LLM asynchronously', async () => {
462
- // First utterance triggers ASK_GUARDIAN
463
- mockSendMessage.mockImplementation(createMockProviderResponse(
464
- ['Hold on. [ASK_GUARDIAN: Preferred time?]'],
465
- ));
466
- const { relay, orchestrator } = setupOrchestrator();
467
-
468
- await orchestrator.handleCallerUtterance('I need an appointment');
469
-
470
- // Now provide the answer — reset mock for second LLM call
471
- mockSendMessage.mockImplementation(async (messages: unknown[], ..._rest: unknown[]) => {
472
- // Verify the messages include the USER_ANSWERED marker
473
- const msgs = messages as Array<{ role: string; content: Array<{ type: string; text: string }> }>;
474
- const lastUserMsg = msgs.filter((m) => m.role === 'user').pop();
475
- expect(lastUserMsg?.content?.[0]?.text).toContain('[USER_ANSWERED: 3pm tomorrow]');
476
- const tokens = ['Great, I have scheduled for 3pm tomorrow.'];
477
- const opts = _rest[2] as { onEvent?: (event: { type: string; text?: string }) => void } | undefined;
478
- for (const token of tokens) {
479
- opts?.onEvent?.({ type: 'text_delta', text: token });
480
- }
481
- return {
482
- content: [{ type: 'text', text: tokens.join('') }],
483
- model: 'claude-sonnet-4-20250514',
484
- usage: { inputTokens: 100, outputTokens: 50 },
485
- stopReason: 'end_turn',
486
- };
487
- });
488
-
489
- const accepted = await orchestrator.handleUserAnswer('3pm tomorrow');
490
- expect(accepted).toBe(true);
491
-
492
- // handleUserAnswer fires runLlm without awaiting, so give the
493
- // microtask queue a tick to let the async LLM work complete.
494
- await new Promise((r) => setTimeout(r, 50));
495
-
496
- // Should have streamed a response for the answer
497
- const tokensAfterAnswer = relay.sentTokens.filter((t) => t.token.includes('3pm'));
498
- expect(tokensAfterAnswer.length).toBeGreaterThan(0);
499
-
500
- orchestrator.destroy();
501
- });
502
-
503
- // ── Full mid-call question flow ──────────────────────────────────
504
-
505
- test('mid-call question flow: unavailable time → ask user → user confirms → resumed call', async () => {
506
- // Step 1: Caller says "7:30" but it's unavailable. The LLM asks the user.
507
- mockSendMessage.mockImplementation(createMockProviderResponse(
508
- ['I\'m sorry, 7:30 is not available. ', '[ASK_GUARDIAN: Is 8:00 okay instead?]'],
509
- ));
510
-
511
- const { session, relay, orchestrator } = setupOrchestrator('Schedule a haircut');
512
-
513
- await orchestrator.handleCallerUtterance('Can I book for 7:30?');
514
-
515
- // Verify we're in waiting_on_user state
516
- expect(orchestrator.getState()).toBe('waiting_on_user');
517
- const question = getPendingQuestion(session.id);
518
- expect(question).not.toBeNull();
519
- expect(question!.questionText).toBe('Is 8:00 okay instead?');
520
-
521
- // Verify session status
522
- const midSession = getCallSession(session.id);
523
- expect(midSession!.status).toBe('waiting_on_user');
524
-
525
- // Step 2: User answers "Yes, 8:00 works"
526
- mockSendMessage.mockImplementation(createMockProviderResponse(
527
- ['Great, I\'ve booked you for 8:00. See you then! ', '[END_CALL]'],
528
- ));
529
-
530
- const accepted = await orchestrator.handleUserAnswer('Yes, 8:00 works for me');
531
- expect(accepted).toBe(true);
532
-
533
- // Give the fire-and-forget LLM call time to complete
534
- await new Promise((r) => setTimeout(r, 50));
535
-
536
- // Step 3: Verify call completed
537
- const endSession = getCallSession(session.id);
538
- expect(endSession!.status).toBe('completed');
539
- expect(endSession!.endedAt).not.toBeNull();
540
-
541
- // Verify the END_CALL marker triggered endSession on relay
542
- expect(relay.endCalled).toBe(true);
543
-
544
- orchestrator.destroy();
545
- });
546
-
547
- // ── Provider / LLM failure paths ───────────────────────────────
548
-
549
- test('LLM error: sends error message to caller and returns to idle', async () => {
550
- // Make sendMessage reject with an error
551
- mockSendMessage.mockImplementation(async () => {
552
- throw new Error('API rate limit exceeded');
553
- });
554
-
555
- const { relay, orchestrator } = setupOrchestrator();
556
-
557
- await orchestrator.handleCallerUtterance('Hello');
558
-
559
- // Should have sent an error recovery message
560
- const errorTokens = relay.sentTokens.filter((t) =>
561
- t.token.includes('technical issue'),
562
- );
563
- expect(errorTokens.length).toBeGreaterThan(0);
564
-
565
- // State should return to idle after error
566
- expect(orchestrator.getState()).toBe('idle');
567
-
568
- orchestrator.destroy();
569
- });
570
-
571
- test('LLM APIUserAbortError: treats as expected abort without technical-issue fallback', async () => {
572
- mockSendMessage.mockImplementation(async () => {
573
- const err = new Error('user abort');
574
- err.name = 'APIUserAbortError';
575
- throw err;
576
- });
577
-
578
- const { relay, orchestrator } = setupOrchestrator();
579
- await orchestrator.handleCallerUtterance('Hello');
580
-
581
- const errorTokens = relay.sentTokens.filter((t) => t.token.includes('technical issue'));
582
- expect(errorTokens.length).toBe(0);
583
- expect(orchestrator.getState()).toBe('idle');
584
-
585
- orchestrator.destroy();
586
- });
587
-
588
- test('stale superseded turn errors do not emit technical-issue fallback', async () => {
589
- let callCount = 0;
590
- mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
591
- callCount++;
592
- if (callCount === 1) {
593
- return new Promise((_, reject) => {
594
- setTimeout(() => reject(new Error('stale stream failure')), 20);
595
- });
596
- }
597
- const tokens = ['Second turn response.'];
598
- for (const token of tokens) {
599
- options?.onEvent?.({ type: 'text_delta', text: token });
600
- }
601
- return {
602
- content: [{ type: 'text', text: tokens.join('') }],
603
- model: 'claude-sonnet-4-20250514',
604
- usage: { inputTokens: 100, outputTokens: 50 },
605
- stopReason: 'end_turn',
606
- };
607
- });
608
-
609
- const { relay, orchestrator } = setupOrchestrator();
610
-
611
- const firstTurnPromise = orchestrator.handleCallerUtterance('First utterance');
612
- // Allow the first turn to enter runLlm before the second utterance interrupts it.
613
- await new Promise((r) => setTimeout(r, 5));
614
- const secondTurnPromise = orchestrator.handleCallerUtterance('Second utterance');
615
-
616
- await Promise.all([firstTurnPromise, secondTurnPromise]);
617
-
618
- const allTokens = relay.sentTokens.map((t) => t.token).join('');
619
- expect(allTokens).toContain('Second turn response.');
620
- expect(allTokens).not.toContain('technical issue');
621
-
622
- orchestrator.destroy();
623
- });
624
-
625
- test('barge-in cleanup never sends empty user turns to provider', async () => {
626
- let callCount = 0;
627
- mockSendMessage.mockImplementation(async (messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void; signal?: AbortSignal }) => {
628
- callCount++;
629
-
630
- // Initial outbound opener
631
- if (callCount === 1) {
632
- const tokens = ['Hey Noa, this is Credence calling.'];
633
- for (const token of tokens) {
634
- options?.onEvent?.({ type: 'text_delta', text: token });
635
- }
636
- return {
637
- content: [{ type: 'text', text: tokens.join('') }],
638
- model: 'claude-sonnet-4-20250514',
639
- usage: { inputTokens: 100, outputTokens: 50 },
640
- stopReason: 'end_turn',
641
- };
642
- }
643
-
644
- // First caller turn enters an in-flight LLM run that gets interrupted
645
- if (callCount === 2) {
646
- return new Promise((_, reject) => {
647
- options?.signal?.addEventListener('abort', () => {
648
- const err = new Error('aborted');
649
- err.name = 'AbortError';
650
- reject(err);
651
- }, { once: true });
652
- });
653
- }
654
-
655
- // Second caller turn should never include an empty user message.
656
- const msgs = messages as Array<{ role: string; content: Array<{ type: string; text: string }> }>;
657
- const userMessages = msgs.filter((m) => m.role === 'user');
658
- expect(userMessages.length).toBeGreaterThan(0);
659
- expect(userMessages.every((m) => m.content?.[0]?.text?.trim().length > 0)).toBe(true);
660
- const tokens = ['Got it, thanks for clarifying.'];
661
- for (const token of tokens) {
662
- options?.onEvent?.({ type: 'text_delta', text: token });
663
- }
664
- return {
665
- content: [{ type: 'text', text: tokens.join('') }],
666
- model: 'claude-sonnet-4-20250514',
667
- usage: { inputTokens: 100, outputTokens: 50 },
668
- stopReason: 'end_turn',
669
- };
670
- });
671
-
672
- const { relay, orchestrator } = setupOrchestrator('Quick check-in');
673
- await orchestrator.startInitialGreeting();
674
-
675
- const firstTurnPromise = orchestrator.handleCallerUtterance('Hello?');
676
- await new Promise((r) => setTimeout(r, 5));
677
- const secondTurnPromise = orchestrator.handleCallerUtterance('What have you been up to lately?');
678
-
679
- await Promise.all([firstTurnPromise, secondTurnPromise]);
680
-
681
- const allTokens = relay.sentTokens.map((t) => t.token).join('');
682
- expect(allTokens).toContain('Got it, thanks for clarifying.');
683
- expect(allTokens).not.toContain('technical issue');
684
-
685
- orchestrator.destroy();
686
- });
687
-
688
- test('rapid caller barge-in coalesces contiguous user turns for role alternation', async () => {
689
- let callCount = 0;
690
- mockSendMessage.mockImplementation(async (messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void; signal?: AbortSignal }) => {
691
- callCount++;
692
- if (callCount === 1) {
693
- return new Promise((_, reject) => {
694
- options?.signal?.addEventListener('abort', () => {
695
- const err = new Error('aborted');
696
- err.name = 'AbortError';
697
- reject(err);
698
- }, { once: true });
699
- });
700
- }
701
-
702
- const msgs = messages as Array<{ role: string; content: Array<{ type: string; text: string }> }>;
703
- const roles = msgs.map((m) => m.role);
704
- for (let i = 1; i < roles.length; i++) {
705
- expect(!(roles[i - 1] === 'user' && roles[i] === 'user')).toBe(true);
706
- }
707
- const userMessages = msgs.filter((m) => m.role === 'user');
708
- const lastUser = userMessages[userMessages.length - 1];
709
- expect(lastUser?.content?.[0]?.text).toContain('First caller utterance');
710
- expect(lastUser?.content?.[0]?.text).toContain('Second caller utterance');
711
- const tokens = ['Merged turn handled.'];
712
- for (const token of tokens) {
713
- options?.onEvent?.({ type: 'text_delta', text: token });
714
- }
715
- return {
716
- content: [{ type: 'text', text: tokens.join('') }],
717
- model: 'claude-sonnet-4-20250514',
718
- usage: { inputTokens: 100, outputTokens: 50 },
719
- stopReason: 'end_turn',
720
- };
721
- });
722
-
723
- const { relay, orchestrator } = setupOrchestrator();
724
- const firstTurnPromise = orchestrator.handleCallerUtterance('First caller utterance');
725
- await new Promise((r) => setTimeout(r, 5));
726
- const secondTurnPromise = orchestrator.handleCallerUtterance('Second caller utterance');
727
-
728
- await Promise.all([firstTurnPromise, secondTurnPromise]);
729
-
730
- const allTokens = relay.sentTokens.map((t) => t.token).join('');
731
- expect(allTokens).toContain('Merged turn handled.');
732
-
733
- orchestrator.destroy();
734
- });
735
-
736
- test('interrupt then next caller prompt still preserves role alternation', async () => {
737
- let callCount = 0;
738
- mockSendMessage.mockImplementation(async (messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void; signal?: AbortSignal }) => {
739
- callCount++;
740
- if (callCount === 1) {
741
- return new Promise((_, reject) => {
742
- options?.signal?.addEventListener('abort', () => {
743
- const err = new Error('aborted');
744
- err.name = 'AbortError';
745
- reject(err);
746
- }, { once: true });
747
- });
748
- }
749
-
750
- const msgs = messages as Array<{ role: string; content: Array<{ type: string; text: string }> }>;
751
- const roles = msgs.map((m) => m.role);
752
- for (let i = 1; i < roles.length; i++) {
753
- expect(!(roles[i - 1] === 'user' && roles[i] === 'user')).toBe(true);
754
- }
755
- const userMessages = msgs.filter((m) => m.role === 'user');
756
- const lastUser = userMessages[userMessages.length - 1];
757
- expect(lastUser?.content?.[0]?.text).toContain('First caller utterance');
758
- expect(lastUser?.content?.[0]?.text).toContain('Second caller utterance');
759
- const tokens = ['Post-interrupt response.'];
760
- for (const token of tokens) {
761
- options?.onEvent?.({ type: 'text_delta', text: token });
762
- }
763
- return {
764
- content: [{ type: 'text', text: tokens.join('') }],
765
- model: 'claude-sonnet-4-20250514',
766
- usage: { inputTokens: 100, outputTokens: 50 },
767
- stopReason: 'end_turn',
768
- };
769
- });
770
-
771
- const { relay, orchestrator } = setupOrchestrator();
772
- const firstTurnPromise = orchestrator.handleCallerUtterance('First caller utterance');
773
- await new Promise((r) => setTimeout(r, 5));
774
- orchestrator.handleInterrupt();
775
- const secondTurnPromise = orchestrator.handleCallerUtterance('Second caller utterance');
776
-
777
- await Promise.all([firstTurnPromise, secondTurnPromise]);
778
-
779
- const allTokens = relay.sentTokens.map((t) => t.token).join('');
780
- expect(allTokens).toContain('Post-interrupt response.');
781
- expect(allTokens).not.toContain('technical issue');
782
-
783
- orchestrator.destroy();
784
- });
785
-
786
- test('handleUserAnswer: returns false when not in waiting_on_user state', async () => {
787
- const { orchestrator } = setupOrchestrator();
788
-
789
- // Orchestrator starts in idle state
790
- const result = await orchestrator.handleUserAnswer('some answer');
791
- expect(result).toBe(false);
792
-
793
- orchestrator.destroy();
794
- });
795
-
796
- // ── handleInterrupt ───────────────────────────────────────────────
797
-
798
- test('handleInterrupt: resets state to idle', () => {
799
- const { orchestrator } = setupOrchestrator();
800
-
801
- // Calling handleInterrupt should not throw
802
- orchestrator.handleInterrupt();
803
-
804
- orchestrator.destroy();
805
- });
806
-
807
- test('handleInterrupt: increments llmRunVersion to suppress stale turn side effects', async () => {
808
- // Use a sendMessage that resolves immediately but whose continuation
809
- // (the code after `await provider.sendMessage()`) will run asynchronously.
810
- // This simulates the race where the promise microtask is queued right
811
- // as handleInterrupt fires.
812
- mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
813
- // Emit some tokens synchronously
814
- options?.onEvent?.({ type: 'text_delta', text: 'Stale response that should be suppressed.' });
815
- return {
816
- content: [{ type: 'text', text: 'Stale response that should be suppressed.' }],
817
- model: 'claude-sonnet-4-20250514',
818
- usage: { inputTokens: 100, outputTokens: 50 },
819
- stopReason: 'end_turn',
820
- };
821
- });
822
-
823
- const { relay, orchestrator } = setupOrchestrator();
824
-
825
- // Start an LLM turn (don't await — we want to interrupt mid-flight)
826
- const turnPromise = orchestrator.handleCallerUtterance('Hello');
827
-
828
- // Interrupt immediately. Because sendMessage resolves as a microtask,
829
- // its continuation hasn't run yet. handleInterrupt increments
830
- // llmRunVersion so the continuation's isCurrentRun check will fail.
831
- orchestrator.handleInterrupt();
832
-
833
- // Let the stale turn's microtask continuation execute
834
- await turnPromise;
835
-
836
- // The orchestrator should remain idle — the stale turn must not
837
- // have pushed state to waiting_on_user or any other post-turn state.
838
- expect(orchestrator.getState()).toBe('idle');
839
-
840
- // No technical-issue fallback should have been sent
841
- const errorTokens = relay.sentTokens.filter((t) => t.token.includes('technical issue'));
842
- expect(errorTokens.length).toBe(0);
843
-
844
- // endSession should NOT have been called by the stale turn
845
- expect(relay.endCalled).toBe(false);
846
-
847
- orchestrator.destroy();
848
- });
849
-
850
- test('handleInterrupt: sends turn terminator when interrupting active speech', async () => {
851
- mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void; signal?: AbortSignal }) => {
852
- return new Promise((_, reject) => {
853
- options?.signal?.addEventListener('abort', () => {
854
- const err = new Error('aborted');
855
- err.name = 'AbortError';
856
- reject(err);
857
- }, { once: true });
858
- });
859
- });
860
-
861
- const { relay, orchestrator } = setupOrchestrator();
862
- const turnPromise = orchestrator.handleCallerUtterance('Start speaking');
863
- await new Promise((r) => setTimeout(r, 5));
864
- orchestrator.handleInterrupt();
865
- await turnPromise;
866
-
867
- const endTurnMarkers = relay.sentTokens.filter((t) => t.token === '' && t.last === true);
868
- expect(endTurnMarkers.length).toBeGreaterThan(0);
869
-
870
- orchestrator.destroy();
871
- });
872
-
873
- // ── destroy ───────────────────────────────────────────────────────
874
-
875
- test('destroy: unregisters orchestrator', () => {
876
- const { session, orchestrator } = setupOrchestrator();
877
-
878
- // Orchestrator should be registered
879
- expect(getCallOrchestrator(session.id)).toBeDefined();
880
-
881
- orchestrator.destroy();
882
-
883
- // After destroy, orchestrator should be unregistered
884
- expect(getCallOrchestrator(session.id)).toBeUndefined();
885
- });
886
-
887
- test('destroy: can be called multiple times without error', () => {
888
- const { orchestrator } = setupOrchestrator();
889
-
890
- orchestrator.destroy();
891
- // Second destroy should not throw
892
- expect(() => orchestrator.destroy()).not.toThrow();
893
- });
894
-
895
- // ── Model override from config ──────────────────────────────────────
896
-
897
- test('does not override model when calls.model is not set (preserves cross-provider failover)', async () => {
898
- mockCallModel = undefined;
899
- mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { config?: { model?: string }; onEvent?: (event: { type: string; text?: string }) => void }) => {
900
- // When calls.model is unset, no model override should be passed so each
901
- // provider in the failover chain uses its own default model.
902
- expect(options?.config?.model).toBeUndefined();
903
- const tokens = ['Default model response.'];
904
- for (const token of tokens) {
905
- options?.onEvent?.({ type: 'text_delta', text: token });
906
- }
907
- return {
908
- content: [{ type: 'text', text: tokens.join('') }],
909
- model: 'claude-opus-4-6',
910
- usage: { inputTokens: 100, outputTokens: 50 },
911
- stopReason: 'end_turn',
912
- };
913
- });
914
-
915
- const { orchestrator } = setupOrchestrator();
916
- await orchestrator.handleCallerUtterance('Hello');
917
- orchestrator.destroy();
918
- });
919
-
920
- test('uses calls.model override from config when set', async () => {
921
- mockCallModel = 'claude-haiku-4-5-20251001';
922
- mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { config?: { model: string }; onEvent?: (event: { type: string; text?: string }) => void }) => {
923
- expect(options?.config?.model).toBe('claude-haiku-4-5-20251001');
924
- const tokens = ['Override model response.'];
925
- for (const token of tokens) {
926
- options?.onEvent?.({ type: 'text_delta', text: token });
927
- }
928
- return {
929
- content: [{ type: 'text', text: tokens.join('') }],
930
- model: 'claude-haiku-4-5-20251001',
931
- usage: { inputTokens: 100, outputTokens: 50 },
932
- stopReason: 'end_turn',
933
- };
934
- });
935
-
936
- const { orchestrator } = setupOrchestrator();
937
- await orchestrator.handleCallerUtterance('Hello');
938
- orchestrator.destroy();
939
- });
940
-
941
- test('treats empty string calls.model as unset and omits model override', async () => {
942
- mockCallModel = '';
943
- mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { config?: { model?: string }; onEvent?: (event: { type: string; text?: string }) => void }) => {
944
- // Empty string is treated as unset — no model override
945
- expect(options?.config?.model).toBeUndefined();
946
- const tokens = ['Fallback model response.'];
947
- for (const token of tokens) {
948
- options?.onEvent?.({ type: 'text_delta', text: token });
949
- }
950
- return {
951
- content: [{ type: 'text', text: tokens.join('') }],
952
- model: 'claude-opus-4-6',
953
- usage: { inputTokens: 100, outputTokens: 50 },
954
- stopReason: 'end_turn',
955
- };
956
- });
957
-
958
- const { orchestrator } = setupOrchestrator();
959
- await orchestrator.handleCallerUtterance('Hello');
960
- orchestrator.destroy();
961
- });
962
-
963
- test('treats whitespace-only calls.model as unset and omits model override', async () => {
964
- mockCallModel = ' ';
965
- mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { config?: { model?: string }; onEvent?: (event: { type: string; text?: string }) => void }) => {
966
- // Whitespace-only is treated as unset — no model override
967
- expect(options?.config?.model).toBeUndefined();
968
- const tokens = ['Fallback model response.'];
969
- for (const token of tokens) {
970
- options?.onEvent?.({ type: 'text_delta', text: token });
971
- }
972
- return {
973
- content: [{ type: 'text', text: tokens.join('') }],
974
- model: 'claude-opus-4-6',
975
- usage: { inputTokens: 100, outputTokens: 50 },
976
- stopReason: 'end_turn',
977
- };
978
- });
979
-
980
- const { orchestrator } = setupOrchestrator();
981
- await orchestrator.handleCallerUtterance('Hello');
982
- orchestrator.destroy();
983
- });
984
-
985
- // ── handleUserInstruction ─────────────────────────────────────────
986
-
987
- test('handleUserInstruction: injects instruction marker into conversation history and triggers LLM when idle', async () => {
988
- mockSendMessage.mockImplementation(async (messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
989
- const msgs = messages as Array<{ role: string; content: Array<{ type: string; text: string }> }>;
990
- const instructionMsg = msgs.find((m) =>
991
- m.role === 'user' && m.content?.[0]?.text?.includes('[USER_INSTRUCTION:'),
992
- );
993
- expect(instructionMsg).toBeDefined();
994
- expect(instructionMsg!.content[0].text).toContain('[USER_INSTRUCTION: Ask about their weekend plans]');
995
- const tokens = ['Sure, do you have any weekend plans?'];
996
- for (const token of tokens) {
997
- options?.onEvent?.({ type: 'text_delta', text: token });
998
- }
999
- return {
1000
- content: [{ type: 'text', text: tokens.join('') }],
1001
- model: 'claude-sonnet-4-20250514',
1002
- usage: { inputTokens: 100, outputTokens: 50 },
1003
- stopReason: 'end_turn',
1004
- };
1005
- });
1006
-
1007
- const { relay, orchestrator } = setupOrchestrator();
1008
-
1009
- await orchestrator.handleUserInstruction('Ask about their weekend plans');
1010
-
1011
- // Should have streamed a response since orchestrator was idle
1012
- const nonEmptyTokens = relay.sentTokens.filter((t) => t.token.length > 0);
1013
- expect(nonEmptyTokens.length).toBeGreaterThan(0);
1014
-
1015
- orchestrator.destroy();
1016
- });
1017
-
1018
- test('handleUserInstruction: does not break existing answer flow', async () => {
1019
- // Step 1: Caller says something, LLM responds normally
1020
- mockSendMessage.mockImplementation(createMockProviderResponse(['Hello! How can I help you today?']));
1021
- const { session: _session, relay, orchestrator } = setupOrchestrator('Book appointment');
1022
-
1023
- await orchestrator.handleCallerUtterance('Hi there');
1024
-
1025
- // Step 2: Inject an instruction while idle
1026
- mockSendMessage.mockImplementation(async (messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
1027
- const msgs = messages as Array<{ role: string; content: Array<{ type: string; text: string }> }>;
1028
- // Verify the history contains both the original exchange and the instruction
1029
- expect(msgs.length).toBeGreaterThanOrEqual(3); // user utterance + assistant response + instruction
1030
- const instructionMsg = msgs.find((m) =>
1031
- m.role === 'user' && m.content?.[0]?.text?.includes('[USER_INSTRUCTION:'),
1032
- );
1033
- expect(instructionMsg).toBeDefined();
1034
- const tokens = ['Of course, let me mention the weekend special.'];
1035
- for (const token of tokens) {
1036
- options?.onEvent?.({ type: 'text_delta', text: token });
1037
- }
1038
- return {
1039
- content: [{ type: 'text', text: tokens.join('') }],
1040
- model: 'claude-sonnet-4-20250514',
1041
- usage: { inputTokens: 100, outputTokens: 50 },
1042
- stopReason: 'end_turn',
1043
- };
1044
- });
1045
-
1046
- await orchestrator.handleUserInstruction('Mention the weekend special');
1047
-
1048
- // Step 3: Caller speaks again — the flow should continue normally
1049
- mockSendMessage.mockImplementation(createMockProviderResponse(
1050
- ['Great choice! The weekend special is 20% off.'],
1051
- ));
1052
-
1053
- await orchestrator.handleCallerUtterance('Tell me more about that');
1054
-
1055
- // Verify state is idle after the normal flow
1056
- expect(orchestrator.getState()).toBe('idle');
1057
-
1058
- // Verify relay received tokens from all exchanges
1059
- const allText = relay.sentTokens.map((t) => t.token).join('');
1060
- expect(allText).toContain('Hello');
1061
- expect(allText).toContain('weekend special');
1062
-
1063
- orchestrator.destroy();
1064
- });
1065
-
1066
- test('handleUserInstruction: emits user_instruction_relayed event', async () => {
1067
- mockSendMessage.mockImplementation(createMockProviderResponse(['Understood, adjusting approach.']));
1068
-
1069
- const { session, orchestrator } = setupOrchestrator();
1070
-
1071
- await orchestrator.handleUserInstruction('Be more formal in your tone');
1072
-
1073
- const events = getCallEvents(session.id);
1074
- const instructionEvents = events.filter((e) => e.eventType === 'user_instruction_relayed');
1075
- expect(instructionEvents.length).toBe(1);
1076
-
1077
- const payload = JSON.parse(instructionEvents[0].payloadJson);
1078
- expect(payload.instruction).toBe('Be more formal in your tone');
1079
-
1080
- orchestrator.destroy();
1081
- });
1082
-
1083
- test('handleUserInstruction: does not trigger LLM when orchestrator is not idle', async () => {
1084
- // First, trigger ASK_GUARDIAN so orchestrator enters waiting_on_user
1085
- mockSendMessage.mockImplementation(createMockProviderResponse(
1086
- ['Hold on. [ASK_GUARDIAN: What time?]'],
1087
- ));
1088
-
1089
- const { session, orchestrator } = setupOrchestrator();
1090
- await orchestrator.handleCallerUtterance('I need an appointment');
1091
- expect(orchestrator.getState()).toBe('waiting_on_user');
1092
-
1093
- // Track how many times the provider mock is called
1094
- let streamCallCount = 0;
1095
- mockSendMessage.mockImplementation(async () => {
1096
- streamCallCount++;
1097
- return {
1098
- content: [{ type: 'text', text: 'Response after instruction.' }],
1099
- model: 'claude-sonnet-4-20250514',
1100
- usage: { inputTokens: 100, outputTokens: 50 },
1101
- stopReason: 'end_turn',
1102
- };
1103
- });
1104
-
1105
- // Inject instruction while in waiting_on_user state
1106
- await orchestrator.handleUserInstruction('Suggest morning slots');
1107
-
1108
- // The LLM should NOT have been triggered since we're not idle
1109
- expect(streamCallCount).toBe(0);
1110
-
1111
- // But the event should still be recorded
1112
- const events = getCallEvents(session.id);
1113
- const instructionEvents = events.filter((e) => e.eventType === 'user_instruction_relayed');
1114
- expect(instructionEvents.length).toBe(1);
1115
-
1116
- orchestrator.destroy();
1117
- });
1118
-
1119
- // ── System prompt: identity phrasing ────────────────────────────────
1120
-
1121
- test('system prompt contains resolved user reference (default)', async () => {
1122
- mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
1123
- expect(systemPrompt as string).toContain('on behalf of my human');
1124
- const tokens = ['Hello.'];
1125
- for (const token of tokens) {
1126
- options?.onEvent?.({ type: 'text_delta', text: token });
1127
- }
1128
- return {
1129
- content: [{ type: 'text', text: tokens.join('') }],
1130
- model: 'claude-sonnet-4-20250514',
1131
- usage: { inputTokens: 100, outputTokens: 50 },
1132
- stopReason: 'end_turn',
1133
- };
1134
- });
1135
-
1136
- const { orchestrator } = setupOrchestrator();
1137
- await orchestrator.handleCallerUtterance('Hi');
1138
- orchestrator.destroy();
1139
- });
1140
-
1141
- test('system prompt contains resolved user reference when set to a name', async () => {
1142
- mockUserReference = 'John';
1143
- mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
1144
- expect(systemPrompt as string).toContain('on behalf of John');
1145
- const tokens = ['Hello John\'s contact.'];
1146
- for (const token of tokens) {
1147
- options?.onEvent?.({ type: 'text_delta', text: token });
1148
- }
1149
- return {
1150
- content: [{ type: 'text', text: tokens.join('') }],
1151
- model: 'claude-sonnet-4-20250514',
1152
- usage: { inputTokens: 100, outputTokens: 50 },
1153
- stopReason: 'end_turn',
1154
- };
1155
- });
1156
-
1157
- const { orchestrator } = setupOrchestrator();
1158
- await orchestrator.handleCallerUtterance('Hi');
1159
- orchestrator.destroy();
1160
- });
1161
-
1162
- test('system prompt does not hardcode "your user" in the opening line', async () => {
1163
- mockUserReference = 'Alice';
1164
- mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
1165
- expect(systemPrompt as string).not.toContain('on behalf of your user');
1166
- expect(systemPrompt as string).toContain('on behalf of Alice');
1167
- const tokens = ['Hi there.'];
1168
- for (const token of tokens) {
1169
- options?.onEvent?.({ type: 'text_delta', text: token });
1170
- }
1171
- return {
1172
- content: [{ type: 'text', text: tokens.join('') }],
1173
- model: 'claude-sonnet-4-20250514',
1174
- usage: { inputTokens: 100, outputTokens: 50 },
1175
- stopReason: 'end_turn',
1176
- };
1177
- });
1178
-
1179
- const { orchestrator } = setupOrchestrator();
1180
- await orchestrator.handleCallerUtterance('Hello');
1181
- orchestrator.destroy();
1182
- });
1183
-
1184
- test('system prompt includes assistant identity bias rule', async () => {
1185
- mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
1186
- expect(systemPrompt as string).toContain('refer to yourself as an assistant');
1187
- expect(systemPrompt as string).toContain('Avoid the phrase "AI assistant" unless directly asked');
1188
- const tokens = ['Sure thing.'];
1189
- for (const token of tokens) {
1190
- options?.onEvent?.({ type: 'text_delta', text: token });
1191
- }
1192
- return {
1193
- content: [{ type: 'text', text: tokens.join('') }],
1194
- model: 'claude-sonnet-4-20250514',
1195
- usage: { inputTokens: 100, outputTokens: 50 },
1196
- stopReason: 'end_turn',
1197
- };
1198
- });
1199
-
1200
- const { orchestrator } = setupOrchestrator();
1201
- await orchestrator.handleCallerUtterance('Hi');
1202
- orchestrator.destroy();
1203
- });
1204
-
1205
- test('system prompt includes opening-ack guidance to avoid duplicate introductions', async () => {
1206
- mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
1207
- expect(systemPrompt as string).toContain('[CALL_OPENING_ACK]');
1208
- expect(systemPrompt as string).toContain('without re-introducing yourself');
1209
- const tokens = ['Understood.'];
1210
- for (const token of tokens) {
1211
- options?.onEvent?.({ type: 'text_delta', text: token });
1212
- }
1213
- return {
1214
- content: [{ type: 'text', text: tokens.join('') }],
1215
- model: 'claude-sonnet-4-20250514',
1216
- usage: { inputTokens: 100, outputTokens: 50 },
1217
- stopReason: 'end_turn',
1218
- };
1219
- });
1220
-
1221
- const { orchestrator } = setupOrchestrator();
1222
- await orchestrator.handleCallerUtterance('Hi');
1223
- orchestrator.destroy();
1224
- });
1225
-
1226
- test('assistant identity rule appears before disclosure rule in prompt', async () => {
1227
- mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
1228
- const prompt = systemPrompt as string;
1229
- const identityIdx = prompt.indexOf('refer to yourself as an assistant');
1230
- const disclosureIdx = prompt.indexOf('Be concise');
1231
- expect(identityIdx).toBeGreaterThan(-1);
1232
- expect(disclosureIdx).toBeGreaterThan(-1);
1233
- expect(identityIdx).toBeLessThan(disclosureIdx);
1234
- const tokens = ['OK.'];
1235
- for (const token of tokens) {
1236
- options?.onEvent?.({ type: 'text_delta', text: token });
1237
- }
1238
- return {
1239
- content: [{ type: 'text', text: tokens.join('') }],
1240
- model: 'claude-sonnet-4-20250514',
1241
- usage: { inputTokens: 100, outputTokens: 50 },
1242
- stopReason: 'end_turn',
1243
- };
1244
- });
1245
-
1246
- const { orchestrator } = setupOrchestrator();
1247
- await orchestrator.handleCallerUtterance('Test');
1248
- orchestrator.destroy();
1249
- });
1250
-
1251
- test('system prompt uses disclosure text when disclosure is enabled', async () => {
1252
- mockDisclosure = {
1253
- enabled: true,
1254
- text: 'At the very beginning of the call, introduce yourself as an assistant calling on behalf of the person you represent. Do not say "AI assistant".',
1255
- };
1256
- mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
1257
- expect(systemPrompt as string).toContain('introduce yourself as an assistant calling on behalf of the person you represent');
1258
- expect(systemPrompt as string).toContain('Do not say "AI assistant"');
1259
- const tokens = ['Hello, I am calling on behalf of my human.'];
1260
- for (const token of tokens) {
1261
- options?.onEvent?.({ type: 'text_delta', text: token });
1262
- }
1263
- return {
1264
- content: [{ type: 'text', text: tokens.join('') }],
1265
- model: 'claude-sonnet-4-20250514',
1266
- usage: { inputTokens: 100, outputTokens: 50 },
1267
- stopReason: 'end_turn',
1268
- };
1269
- });
1270
-
1271
- const { orchestrator } = setupOrchestrator();
1272
- await orchestrator.handleCallerUtterance('Who is this?');
1273
- orchestrator.destroy();
1274
- });
1275
-
1276
- test('system prompt falls back to "Begin the conversation naturally" when disclosure is disabled', async () => {
1277
- mockDisclosure = { enabled: false, text: '' };
1278
- mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
1279
- expect(systemPrompt as string).toContain('Begin the conversation naturally');
1280
- expect(systemPrompt as string).not.toContain('introduce yourself as an assistant calling on behalf of the person');
1281
- const tokens = ['Hello there.'];
1282
- for (const token of tokens) {
1283
- options?.onEvent?.({ type: 'text_delta', text: token });
1284
- }
1285
- return {
1286
- content: [{ type: 'text', text: tokens.join('') }],
1287
- model: 'claude-sonnet-4-20250514',
1288
- usage: { inputTokens: 100, outputTokens: 50 },
1289
- stopReason: 'end_turn',
1290
- };
1291
- });
1292
-
1293
- const { orchestrator } = setupOrchestrator();
1294
- await orchestrator.handleCallerUtterance('Hi');
1295
- orchestrator.destroy();
1296
- });
1297
-
1298
- test('system prompt does not use "AI assistant" as a self-identity label', async () => {
1299
- mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
1300
- expect(systemPrompt as string).not.toMatch(/(?:you are|call yourself|introduce yourself as).*AI assistant/i);
1301
- const tokens = ['Got it.'];
1302
- for (const token of tokens) {
1303
- options?.onEvent?.({ type: 'text_delta', text: token });
1304
- }
1305
- return {
1306
- content: [{ type: 'text', text: tokens.join('') }],
1307
- model: 'claude-sonnet-4-20250514',
1308
- usage: { inputTokens: 100, outputTokens: 50 },
1309
- stopReason: 'end_turn',
1310
- };
1311
- });
1312
-
1313
- const { orchestrator } = setupOrchestrator();
1314
- await orchestrator.handleCallerUtterance('Hello');
1315
- orchestrator.destroy();
1316
- });
1317
-
1318
- // ── Inbound call orchestration ──────────────────────────────────────
1319
-
1320
- test('inbound call (no task) uses receptionist-style system prompt', async () => {
1321
- mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
1322
- // Should contain inbound-specific language
1323
- expect(systemPrompt as string).toContain('answering an incoming call');
1324
- expect(systemPrompt as string).toContain('find out what they need');
1325
- // Should NOT contain outbound-specific language
1326
- expect(systemPrompt as string).not.toContain('state why you are calling');
1327
- expect(systemPrompt as string).not.toContain('Task:');
1328
- const tokens = ['Hello, how can I help you today?'];
1329
- for (const token of tokens) {
1330
- options?.onEvent?.({ type: 'text_delta', text: token });
1331
- }
1332
- return {
1333
- content: [{ type: 'text', text: tokens.join('') }],
1334
- model: 'claude-sonnet-4-20250514',
1335
- usage: { inputTokens: 100, outputTokens: 50 },
1336
- stopReason: 'end_turn',
1337
- };
1338
- });
1339
-
1340
- // setupOrchestrator with no task creates an inbound-style session
1341
- const { orchestrator } = setupOrchestrator(undefined);
1342
- await orchestrator.handleCallerUtterance('Hi there');
1343
- orchestrator.destroy();
1344
- });
1345
-
1346
- test('outbound call (with task) uses task-driven system prompt', async () => {
1347
- mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
1348
- expect(systemPrompt as string).toContain('Task: Confirm Friday appointment');
1349
- expect(systemPrompt as string).toContain('state why you are calling');
1350
- expect(systemPrompt as string).not.toContain('answering an incoming call');
1351
- const tokens = ['Hi, I am calling about your appointment.'];
1352
- for (const token of tokens) {
1353
- options?.onEvent?.({ type: 'text_delta', text: token });
1354
- }
1355
- return {
1356
- content: [{ type: 'text', text: tokens.join('') }],
1357
- model: 'claude-sonnet-4-20250514',
1358
- usage: { inputTokens: 100, outputTokens: 50 },
1359
- stopReason: 'end_turn',
1360
- };
1361
- });
1362
-
1363
- const { orchestrator } = setupOrchestrator('Confirm Friday appointment');
1364
- await orchestrator.handleCallerUtterance('Hello?');
1365
- orchestrator.destroy();
1366
- });
1367
-
1368
- test('inbound call initial greeting sends receptionist opener', async () => {
1369
- mockSendMessage.mockImplementation(async (messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
1370
- // The system prompt should use inbound framing
1371
- expect(systemPrompt as string).toContain('answering an incoming call');
1372
- // The opening marker should be present
1373
- const msgs = messages as Array<{ role: string; content: Array<{ type: string; text: string }> }>;
1374
- const userMsgs = msgs.filter((m) => m.role === 'user');
1375
- expect(userMsgs.some((m) => m.content?.[0]?.text?.includes('[CALL_OPENING]'))).toBe(true);
1376
- const tokens = ['Hello, this is my human\'s assistant. How can I help you?'];
1377
- for (const token of tokens) {
1378
- options?.onEvent?.({ type: 'text_delta', text: token });
1379
- }
1380
- return {
1381
- content: [{ type: 'text', text: tokens.join('') }],
1382
- model: 'claude-sonnet-4-20250514',
1383
- usage: { inputTokens: 100, outputTokens: 50 },
1384
- stopReason: 'end_turn',
1385
- };
1386
- });
1387
-
1388
- const { relay, orchestrator } = setupOrchestrator(undefined);
1389
- await orchestrator.startInitialGreeting();
1390
-
1391
- const allText = relay.sentTokens.map((t) => t.token).join('');
1392
- expect(allText).toContain('How can I help you');
1393
-
1394
- orchestrator.destroy();
1395
- });
1396
-
1397
- test('inbound call multi-turn conversation uses inbound prompt consistently', async () => {
1398
- let turnNumber = 0;
1399
- mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
1400
- turnNumber++;
1401
- // Every turn should use the inbound system prompt
1402
- expect(systemPrompt as string).toContain('answering an incoming call');
1403
- expect(systemPrompt as string).not.toContain('Task:');
1404
-
1405
- let tokens: string[];
1406
- if (turnNumber === 1) tokens = ['Hello, how can I help you?'];
1407
- else if (turnNumber === 2) tokens = ['Sure, let me help with scheduling.'];
1408
- else tokens = ['Your meeting is set for 3pm.'];
1409
- for (const token of tokens) {
1410
- options?.onEvent?.({ type: 'text_delta', text: token });
1411
- }
1412
- return {
1413
- content: [{ type: 'text', text: tokens.join('') }],
1414
- model: 'claude-sonnet-4-20250514',
1415
- usage: { inputTokens: 100, outputTokens: 50 },
1416
- stopReason: 'end_turn',
1417
- };
1418
- });
1419
-
1420
- const { orchestrator } = setupOrchestrator(undefined);
1421
-
1422
- await orchestrator.startInitialGreeting();
1423
- await orchestrator.handleCallerUtterance('I need to schedule a meeting');
1424
- await orchestrator.handleCallerUtterance('How about 3pm?');
1425
-
1426
- expect(turnNumber).toBe(3);
1427
- orchestrator.destroy();
1428
- });
1429
-
1430
- test('inbound call system prompt includes greet-the-caller guidance for CALL_OPENING', async () => {
1431
- mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
1432
- // Should tell the model to greet warmly and ask how to help
1433
- expect(systemPrompt as string).toContain('greet the caller warmly');
1434
- expect(systemPrompt as string).toContain('how you can help');
1435
- const tokens = ['Hello!'];
1436
- for (const token of tokens) {
1437
- options?.onEvent?.({ type: 'text_delta', text: token });
1438
- }
1439
- return {
1440
- content: [{ type: 'text', text: tokens.join('') }],
1441
- model: 'claude-sonnet-4-20250514',
1442
- usage: { inputTokens: 100, outputTokens: 50 },
1443
- stopReason: 'end_turn',
1444
- };
1445
- });
1446
-
1447
- const { orchestrator } = setupOrchestrator(undefined);
1448
- await orchestrator.handleCallerUtterance('Hi');
1449
- orchestrator.destroy();
1450
- });
1451
-
1452
- test('inbound call system prompt respects disclosure setting', async () => {
1453
- mockDisclosure = {
1454
- enabled: true,
1455
- text: 'Disclose that you are an AI at the start.',
1456
- };
1457
- mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
1458
- expect(systemPrompt as string).toContain('answering an incoming call');
1459
- expect(systemPrompt as string).toContain('Disclose that you are an AI at the start.');
1460
- const tokens = ['Hello, I am an AI assistant.'];
1461
- for (const token of tokens) {
1462
- options?.onEvent?.({ type: 'text_delta', text: token });
1463
- }
1464
- return {
1465
- content: [{ type: 'text', text: tokens.join('') }],
1466
- model: 'claude-sonnet-4-20250514',
1467
- usage: { inputTokens: 100, outputTokens: 50 },
1468
- stopReason: 'end_turn',
1469
- };
1470
- });
1471
-
1472
- const { orchestrator } = setupOrchestrator(undefined);
1473
- await orchestrator.handleCallerUtterance('Who is this?');
1474
- orchestrator.destroy();
1475
- });
1476
-
1477
- test('inbound call persists assistant response to voice conversation', async () => {
1478
- mockSendMessage.mockImplementation(createMockProviderResponse(['I can definitely help you with that.']));
1479
-
1480
- const { session, orchestrator } = setupOrchestrator(undefined);
1481
- await orchestrator.startInitialGreeting();
1482
-
1483
- // Verify assistant transcript was persisted
1484
- const messages = (await import('../memory/conversation-store.js')).getMessages('conv-orch-test');
1485
- const assistantMsgs = messages.filter((m) => m.role === 'assistant');
1486
- expect(assistantMsgs.length).toBeGreaterThan(0);
1487
- const lastAssistant = assistantMsgs[assistantMsgs.length - 1];
1488
- expect(lastAssistant.content).toContain('I can definitely help you with that');
1489
-
1490
- // Verify event was recorded
1491
- const events = getCallEvents(session.id).filter((e) => e.eventType === 'assistant_spoke');
1492
- expect(events.length).toBeGreaterThan(0);
1493
-
1494
- orchestrator.destroy();
1495
- });
1496
- });