@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.
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +20 -0
- package/src/__tests__/approval-routes-http.test.ts +704 -0
- package/src/__tests__/call-controller.test.ts +835 -0
- package/src/__tests__/call-state.test.ts +24 -24
- package/src/__tests__/ipc-snapshot.test.ts +14 -0
- package/src/__tests__/relay-server.test.ts +9 -9
- package/src/__tests__/run-orchestrator.test.ts +399 -3
- package/src/__tests__/runtime-runs.test.ts +12 -4
- package/src/__tests__/send-endpoint-busy.test.ts +284 -0
- package/src/__tests__/session-init.benchmark.test.ts +3 -3
- package/src/__tests__/subagent-manager-notify.test.ts +3 -3
- package/src/__tests__/voice-session-bridge.test.ts +869 -0
- package/src/calls/{call-orchestrator.ts → call-controller.ts} +156 -257
- package/src/calls/call-domain.ts +21 -21
- package/src/calls/call-state.ts +12 -12
- package/src/calls/guardian-dispatch.ts +43 -3
- package/src/calls/relay-server.ts +34 -39
- package/src/calls/twilio-routes.ts +3 -3
- package/src/calls/voice-session-bridge.ts +244 -0
- package/src/config/bundled-skills/media-processing/SKILL.md +81 -14
- package/src/config/bundled-skills/media-processing/TOOLS.json +3 -3
- package/src/config/bundled-skills/media-processing/services/preprocess.ts +3 -3
- package/src/config/defaults.ts +5 -0
- package/src/config/notifications-schema.ts +15 -0
- package/src/config/schema.ts +13 -0
- package/src/config/types.ts +1 -0
- package/src/daemon/daemon-control.ts +13 -12
- package/src/daemon/handlers/subagents.ts +10 -3
- package/src/daemon/ipc-contract/notifications.ts +9 -0
- package/src/daemon/ipc-contract-inventory.json +2 -0
- package/src/daemon/ipc-contract.ts +4 -1
- package/src/daemon/lifecycle.ts +100 -1
- package/src/daemon/server.ts +8 -0
- package/src/daemon/session-agent-loop.ts +4 -0
- package/src/daemon/session-process.ts +51 -0
- package/src/daemon/session-runtime-assembly.ts +32 -0
- package/src/daemon/session.ts +5 -0
- package/src/memory/db-init.ts +80 -0
- package/src/memory/guardian-action-store.ts +2 -2
- package/src/memory/migrations/016-memory-segments-indexes.ts +1 -0
- package/src/memory/migrations/019-notification-tables-schema-migration.ts +70 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/migrations/registry.ts +5 -0
- package/src/memory/schema-migration.ts +1 -0
- package/src/memory/schema.ts +59 -1
- package/src/notifications/README.md +134 -0
- package/src/notifications/adapters/macos.ts +55 -0
- package/src/notifications/adapters/telegram.ts +65 -0
- package/src/notifications/broadcaster.ts +175 -0
- package/src/notifications/copy-composer.ts +118 -0
- package/src/notifications/decision-engine.ts +391 -0
- package/src/notifications/decisions-store.ts +158 -0
- package/src/notifications/deliveries-store.ts +130 -0
- package/src/notifications/destination-resolver.ts +54 -0
- package/src/notifications/deterministic-checks.ts +187 -0
- package/src/notifications/emit-signal.ts +191 -0
- package/src/notifications/events-store.ts +145 -0
- package/src/notifications/preference-extractor.ts +223 -0
- package/src/notifications/preference-summary.ts +110 -0
- package/src/notifications/preferences-store.ts +142 -0
- package/src/notifications/runtime-dispatch.ts +100 -0
- package/src/notifications/signal.ts +24 -0
- package/src/notifications/types.ts +75 -0
- package/src/runtime/http-server.ts +15 -0
- package/src/runtime/http-types.ts +22 -0
- package/src/runtime/pending-interactions.ts +73 -0
- package/src/runtime/routes/approval-routes.ts +179 -0
- package/src/runtime/routes/channel-inbound-routes.ts +39 -4
- package/src/runtime/routes/conversation-routes.ts +107 -1
- package/src/runtime/routes/run-routes.ts +1 -1
- package/src/runtime/run-orchestrator.ts +157 -2
- package/src/subagent/manager.ts +6 -6
- package/src/tools/browser/browser-manager.ts +1 -1
- package/src/tools/subagent/message.ts +9 -2
- 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
|
+
});
|