@vellumai/assistant 0.4.16 → 0.4.17
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/Dockerfile +6 -6
- package/README.md +1 -2
- package/package.json +1 -1
- package/src/__tests__/call-controller.test.ts +1074 -751
- package/src/__tests__/call-routes-http.test.ts +329 -279
- package/src/__tests__/channel-approval-routes.test.ts +0 -11
- package/src/__tests__/channel-approvals.test.ts +227 -182
- package/src/__tests__/channel-guardian.test.ts +1 -0
- package/src/__tests__/conversation-attention-telegram.test.ts +157 -114
- package/src/__tests__/conversation-routes-guardian-reply.test.ts +164 -104
- package/src/__tests__/conversation-routes.test.ts +71 -41
- package/src/__tests__/daemon-server-session-init.test.ts +258 -191
- package/src/__tests__/deterministic-verification-control-plane.test.ts +183 -134
- package/src/__tests__/extract-email.test.ts +42 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +467 -368
- package/src/__tests__/gateway-only-guard.test.ts +54 -55
- package/src/__tests__/gmail-integration.test.ts +48 -46
- package/src/__tests__/guardian-action-followup-executor.test.ts +215 -150
- package/src/__tests__/guardian-outbound-http.test.ts +334 -208
- package/src/__tests__/guardian-routing-invariants.test.ts +680 -613
- package/src/__tests__/guardian-routing-state.test.ts +257 -209
- package/src/__tests__/guardian-verification-voice-binding.test.ts +47 -40
- package/src/__tests__/handle-user-message-secret-resume.test.ts +44 -21
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +269 -195
- package/src/__tests__/inbound-invite-redemption.test.ts +194 -151
- package/src/__tests__/ingress-reconcile.test.ts +184 -142
- package/src/__tests__/non-member-access-request.test.ts +291 -247
- package/src/__tests__/notification-telegram-adapter.test.ts +60 -46
- package/src/__tests__/recording-intent-handler.test.ts +422 -291
- package/src/__tests__/runtime-attachment-metadata.test.ts +107 -69
- package/src/__tests__/runtime-events-sse.test.ts +67 -50
- package/src/__tests__/send-endpoint-busy.test.ts +314 -232
- package/src/__tests__/session-approval-overrides.test.ts +93 -91
- package/src/__tests__/sms-messaging-provider.test.ts +74 -47
- package/src/__tests__/trusted-contact-approval-notifier.test.ts +339 -274
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +484 -372
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +261 -239
- package/src/__tests__/trusted-contact-multichannel.test.ts +179 -140
- package/src/__tests__/twilio-config.test.ts +49 -41
- package/src/__tests__/twilio-routes-elevenlabs.test.ts +189 -162
- package/src/__tests__/twilio-routes.test.ts +389 -280
- package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +29 -4
- package/src/config/bundled-skills/messaging/SKILL.md +5 -4
- package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +11 -7
- package/src/config/env.ts +39 -29
- package/src/daemon/handlers/skills.ts +18 -10
- package/src/daemon/ipc-contract/messages.ts +1 -0
- package/src/daemon/ipc-contract/surfaces.ts +7 -1
- package/src/daemon/session-agent-loop-handlers.ts +5 -0
- package/src/daemon/session-agent-loop.ts +1 -1
- package/src/daemon/session-process.ts +1 -1
- package/src/daemon/session-surfaces.ts +42 -2
- package/src/runtime/auth/token-service.ts +74 -47
- package/src/sequence/reply-matcher.ts +10 -6
- package/src/skills/frontmatter.ts +9 -6
- package/src/tools/ui-surface/definitions.ts +2 -1
- package/src/util/platform.ts +0 -12
- package/docs/architecture/http-token-refresh.md +0 -274
|
@@ -1,27 +1,37 @@
|
|
|
1
|
-
import { mkdtempSync, rmSync } from
|
|
2
|
-
import { tmpdir } from
|
|
3
|
-
import { join } from
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
afterAll,
|
|
7
|
+
beforeEach,
|
|
8
|
+
describe,
|
|
9
|
+
expect,
|
|
10
|
+
type Mock,
|
|
11
|
+
mock,
|
|
12
|
+
test,
|
|
13
|
+
} from "bun:test";
|
|
14
|
+
|
|
15
|
+
mock.module("../config/env.js", () => ({ isHttpAuthDisabled: () => true }));
|
|
6
16
|
|
|
7
|
-
const testDir = mkdtempSync(join(tmpdir(),
|
|
17
|
+
const testDir = mkdtempSync(join(tmpdir(), "call-controller-test-"));
|
|
8
18
|
|
|
9
19
|
// ── Platform + logger mocks (must come before any source imports) ────
|
|
10
20
|
|
|
11
|
-
mock.module(
|
|
21
|
+
mock.module("../util/platform.js", () => ({
|
|
12
22
|
getDataDir: () => testDir,
|
|
13
|
-
isMacOS: () => process.platform ===
|
|
14
|
-
isLinux: () => process.platform ===
|
|
15
|
-
isWindows: () => process.platform ===
|
|
16
|
-
getSocketPath: () => join(testDir,
|
|
17
|
-
getPidPath: () => join(testDir,
|
|
18
|
-
getDbPath: () => join(testDir,
|
|
19
|
-
getLogPath: () => join(testDir,
|
|
23
|
+
isMacOS: () => process.platform === "darwin",
|
|
24
|
+
isLinux: () => process.platform === "linux",
|
|
25
|
+
isWindows: () => process.platform === "win32",
|
|
26
|
+
getSocketPath: () => join(testDir, "test.sock"),
|
|
27
|
+
getPidPath: () => join(testDir, "test.pid"),
|
|
28
|
+
getDbPath: () => join(testDir, "test.db"),
|
|
29
|
+
getLogPath: () => join(testDir, "test.log"),
|
|
20
30
|
ensureDataDir: () => {},
|
|
21
31
|
readHttpToken: () => null,
|
|
22
32
|
}));
|
|
23
33
|
|
|
24
|
-
mock.module(
|
|
34
|
+
mock.module("../util/logger.js", () => ({
|
|
25
35
|
getLogger: () =>
|
|
26
36
|
new Proxy({} as Record<string, unknown>, {
|
|
27
37
|
get: () => () => {},
|
|
@@ -30,26 +40,26 @@ mock.module('../util/logger.js', () => ({
|
|
|
30
40
|
|
|
31
41
|
// ── Config mock ─────────────────────────────────────────────────────
|
|
32
42
|
|
|
33
|
-
mock.module(
|
|
43
|
+
mock.module("../config/loader.js", () => ({
|
|
34
44
|
getConfig: () => ({
|
|
35
45
|
ui: {},
|
|
36
|
-
|
|
37
|
-
provider:
|
|
38
|
-
providerOrder: [
|
|
39
|
-
apiKeys: { anthropic:
|
|
46
|
+
|
|
47
|
+
provider: "anthropic",
|
|
48
|
+
providerOrder: ["anthropic"],
|
|
49
|
+
apiKeys: { anthropic: "test-key" },
|
|
40
50
|
calls: {
|
|
41
51
|
enabled: true,
|
|
42
|
-
provider:
|
|
52
|
+
provider: "twilio",
|
|
43
53
|
maxDurationSeconds: 12 * 60,
|
|
44
54
|
userConsultTimeoutSeconds: 90,
|
|
45
55
|
userConsultationTimeoutSeconds: 90,
|
|
46
56
|
silenceTimeoutSeconds: 30,
|
|
47
|
-
disclosure: { enabled: false, text:
|
|
57
|
+
disclosure: { enabled: false, text: "" },
|
|
48
58
|
safety: { denyCategories: [] },
|
|
49
59
|
model: undefined,
|
|
50
60
|
},
|
|
51
61
|
memory: { enabled: false },
|
|
52
|
-
notifications: { decisionModelIntent:
|
|
62
|
+
notifications: { decisionModelIntent: "latency-optimized" },
|
|
53
63
|
}),
|
|
54
64
|
}));
|
|
55
65
|
|
|
@@ -58,7 +68,7 @@ mock.module('../config/loader.js', () => ({
|
|
|
58
68
|
let mockConsultationTimeoutMs = 90_000;
|
|
59
69
|
let mockSilenceTimeoutMs = 30_000;
|
|
60
70
|
|
|
61
|
-
mock.module(
|
|
71
|
+
mock.module("../calls/call-constants.js", () => ({
|
|
62
72
|
getMaxCallDurationMs: () => 12 * 60 * 1000,
|
|
63
73
|
getUserConsultationTimeoutMs: () => mockConsultationTimeoutMs,
|
|
64
74
|
getSilenceTimeoutMs: () => mockSilenceTimeoutMs,
|
|
@@ -85,8 +95,8 @@ function createMockVoiceTurn(tokens: string[]) {
|
|
|
85
95
|
}) => {
|
|
86
96
|
// Check for abort before proceeding
|
|
87
97
|
if (opts.signal?.aborted) {
|
|
88
|
-
const err = new Error(
|
|
89
|
-
err.name =
|
|
98
|
+
const err = new Error("aborted");
|
|
99
|
+
err.name = "AbortError";
|
|
90
100
|
throw err;
|
|
91
101
|
}
|
|
92
102
|
|
|
@@ -107,11 +117,10 @@ function createMockVoiceTurn(tokens: string[]) {
|
|
|
107
117
|
};
|
|
108
118
|
}
|
|
109
119
|
|
|
110
|
-
|
|
111
120
|
let mockStartVoiceTurn: Mock<any>;
|
|
112
121
|
|
|
113
|
-
mock.module(
|
|
114
|
-
mockStartVoiceTurn = mock(createMockVoiceTurn([
|
|
122
|
+
mock.module("../calls/voice-session-bridge.js", () => {
|
|
123
|
+
mockStartVoiceTurn = mock(createMockVoiceTurn(["Hello", " there"]));
|
|
115
124
|
return {
|
|
116
125
|
startVoiceTurn: (...args: unknown[]) => mockStartVoiceTurn(...args),
|
|
117
126
|
setVoiceBridgeDeps: () => {},
|
|
@@ -120,25 +129,23 @@ mock.module('../calls/voice-session-bridge.js', () => {
|
|
|
120
129
|
|
|
121
130
|
// ── Import source modules after all mocks are registered ────────────
|
|
122
131
|
|
|
123
|
-
import { CallController } from
|
|
124
|
-
import {
|
|
125
|
-
getCallController,
|
|
126
|
-
} from '../calls/call-state.js';
|
|
132
|
+
import { CallController } from "../calls/call-controller.js";
|
|
133
|
+
import { getCallController } from "../calls/call-state.js";
|
|
127
134
|
import {
|
|
128
135
|
createCallSession,
|
|
129
136
|
getCallEvents,
|
|
130
137
|
getCallSession,
|
|
131
138
|
getPendingQuestion,
|
|
132
139
|
updateCallSession,
|
|
133
|
-
} from
|
|
134
|
-
import type { RelayConnection } from
|
|
140
|
+
} from "../calls/call-store.js";
|
|
141
|
+
import type { RelayConnection } from "../calls/relay-server.js";
|
|
135
142
|
import {
|
|
136
143
|
getCanonicalGuardianRequest,
|
|
137
144
|
getPendingCanonicalRequestByCallSessionId,
|
|
138
|
-
} from
|
|
139
|
-
import { getMessages } from
|
|
140
|
-
import { getDb, initializeDb, resetDb } from
|
|
141
|
-
import { conversations } from
|
|
145
|
+
} from "../memory/canonical-guardian-store.js";
|
|
146
|
+
import { getMessages } from "../memory/conversation-store.js";
|
|
147
|
+
import { getDb, initializeDb, resetDb } from "../memory/db.js";
|
|
148
|
+
import { conversations } from "../memory/schema.js";
|
|
142
149
|
|
|
143
150
|
initializeDb();
|
|
144
151
|
|
|
@@ -165,15 +172,25 @@ function createMockRelay(): MockRelay {
|
|
|
165
172
|
sentTokens: [] as Array<{ token: string; last: boolean }>,
|
|
166
173
|
_endCalled: false,
|
|
167
174
|
_endReason: undefined as string | undefined,
|
|
168
|
-
_connectionState:
|
|
175
|
+
_connectionState: "connected",
|
|
169
176
|
};
|
|
170
177
|
|
|
171
178
|
return {
|
|
172
|
-
get sentTokens() {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
get
|
|
176
|
-
|
|
179
|
+
get sentTokens() {
|
|
180
|
+
return state.sentTokens;
|
|
181
|
+
},
|
|
182
|
+
get endCalled() {
|
|
183
|
+
return state._endCalled;
|
|
184
|
+
},
|
|
185
|
+
get endReason() {
|
|
186
|
+
return state._endReason;
|
|
187
|
+
},
|
|
188
|
+
get mockConnectionState() {
|
|
189
|
+
return state._connectionState;
|
|
190
|
+
},
|
|
191
|
+
set mockConnectionState(v: string) {
|
|
192
|
+
state._connectionState = v;
|
|
193
|
+
},
|
|
177
194
|
sendTextToken(token: string, last: boolean) {
|
|
178
195
|
state.sentTokens.push({ token, last });
|
|
179
196
|
},
|
|
@@ -194,91 +211,121 @@ function ensureConversation(id: string): void {
|
|
|
194
211
|
if (ensuredConvIds.has(id)) return;
|
|
195
212
|
const db = getDb();
|
|
196
213
|
const now = Date.now();
|
|
197
|
-
db.insert(conversations)
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
214
|
+
db.insert(conversations)
|
|
215
|
+
.values({
|
|
216
|
+
id,
|
|
217
|
+
title: `Test conversation ${id}`,
|
|
218
|
+
createdAt: now,
|
|
219
|
+
updatedAt: now,
|
|
220
|
+
})
|
|
221
|
+
.run();
|
|
203
222
|
ensuredConvIds.add(id);
|
|
204
223
|
}
|
|
205
224
|
|
|
206
225
|
function resetTables() {
|
|
207
226
|
const db = getDb();
|
|
208
|
-
db.run(
|
|
209
|
-
db.run(
|
|
210
|
-
db.run(
|
|
211
|
-
db.run(
|
|
212
|
-
db.run(
|
|
213
|
-
db.run(
|
|
214
|
-
db.run(
|
|
215
|
-
db.run(
|
|
216
|
-
db.run(
|
|
217
|
-
db.run(
|
|
227
|
+
db.run("DELETE FROM canonical_guardian_deliveries");
|
|
228
|
+
db.run("DELETE FROM canonical_guardian_requests");
|
|
229
|
+
db.run("DELETE FROM guardian_action_deliveries");
|
|
230
|
+
db.run("DELETE FROM guardian_action_requests");
|
|
231
|
+
db.run("DELETE FROM call_pending_questions");
|
|
232
|
+
db.run("DELETE FROM call_events");
|
|
233
|
+
db.run("DELETE FROM call_sessions");
|
|
234
|
+
db.run("DELETE FROM tool_invocations");
|
|
235
|
+
db.run("DELETE FROM messages");
|
|
236
|
+
db.run("DELETE FROM conversations");
|
|
218
237
|
ensuredConvIds = new Set();
|
|
219
238
|
}
|
|
220
239
|
|
|
221
240
|
/**
|
|
222
241
|
* Create a call session and a controller wired to a mock relay.
|
|
223
242
|
*/
|
|
224
|
-
function setupController(
|
|
225
|
-
|
|
243
|
+
function setupController(
|
|
244
|
+
task?: string,
|
|
245
|
+
opts?: {
|
|
246
|
+
assistantId?: string;
|
|
247
|
+
guardianContext?: import("../daemon/session-runtime-assembly.js").GuardianRuntimeContext;
|
|
248
|
+
},
|
|
249
|
+
) {
|
|
250
|
+
ensureConversation("conv-ctrl-test");
|
|
226
251
|
const session = createCallSession({
|
|
227
|
-
conversationId:
|
|
228
|
-
provider:
|
|
229
|
-
fromNumber:
|
|
230
|
-
toNumber:
|
|
252
|
+
conversationId: "conv-ctrl-test",
|
|
253
|
+
provider: "twilio",
|
|
254
|
+
fromNumber: "+15551111111",
|
|
255
|
+
toNumber: "+15552222222",
|
|
231
256
|
task,
|
|
232
257
|
});
|
|
233
|
-
updateCallSession(session.id, { status:
|
|
258
|
+
updateCallSession(session.id, { status: "in_progress" });
|
|
234
259
|
const relay = createMockRelay();
|
|
235
|
-
const controller = new CallController(
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
260
|
+
const controller = new CallController(
|
|
261
|
+
session.id,
|
|
262
|
+
relay as unknown as RelayConnection,
|
|
263
|
+
task ?? null,
|
|
264
|
+
{
|
|
265
|
+
assistantId: opts?.assistantId,
|
|
266
|
+
guardianContext: opts?.guardianContext,
|
|
267
|
+
},
|
|
268
|
+
);
|
|
239
269
|
return { session, relay, controller };
|
|
240
270
|
}
|
|
241
271
|
|
|
242
272
|
function getLatestAssistantText(conversationId: string): string | null {
|
|
243
|
-
const msgs = getMessages(conversationId).filter(
|
|
273
|
+
const msgs = getMessages(conversationId).filter(
|
|
274
|
+
(m) => m.role === "assistant",
|
|
275
|
+
);
|
|
244
276
|
if (msgs.length === 0) return null;
|
|
245
277
|
const latest = msgs[msgs.length - 1];
|
|
246
278
|
try {
|
|
247
279
|
const parsed = JSON.parse(latest.content) as unknown;
|
|
248
280
|
if (Array.isArray(parsed)) {
|
|
249
281
|
return parsed
|
|
250
|
-
.filter(
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
282
|
+
.filter(
|
|
283
|
+
(b): b is { type: string; text?: string } =>
|
|
284
|
+
typeof b === "object" && b != null,
|
|
285
|
+
)
|
|
286
|
+
.filter((b) => b.type === "text")
|
|
287
|
+
.map((b) => b.text ?? "")
|
|
288
|
+
.join("");
|
|
254
289
|
}
|
|
255
|
-
if (typeof parsed ===
|
|
256
|
-
} catch {
|
|
290
|
+
if (typeof parsed === "string") return parsed;
|
|
291
|
+
} catch {
|
|
292
|
+
/* fall through */
|
|
293
|
+
}
|
|
257
294
|
return latest.content;
|
|
258
295
|
}
|
|
259
296
|
|
|
260
297
|
function setupControllerWithOrigin(task?: string) {
|
|
261
|
-
ensureConversation(
|
|
262
|
-
ensureConversation(
|
|
298
|
+
ensureConversation("conv-ctrl-voice");
|
|
299
|
+
ensureConversation("conv-ctrl-origin");
|
|
263
300
|
const session = createCallSession({
|
|
264
|
-
conversationId:
|
|
265
|
-
provider:
|
|
266
|
-
fromNumber:
|
|
267
|
-
toNumber:
|
|
301
|
+
conversationId: "conv-ctrl-voice",
|
|
302
|
+
provider: "twilio",
|
|
303
|
+
fromNumber: "+15551111111",
|
|
304
|
+
toNumber: "+15552222222",
|
|
268
305
|
task,
|
|
269
|
-
initiatedFromConversationId:
|
|
306
|
+
initiatedFromConversationId: "conv-ctrl-origin",
|
|
307
|
+
});
|
|
308
|
+
updateCallSession(session.id, {
|
|
309
|
+
status: "in_progress",
|
|
310
|
+
startedAt: Date.now() - 30_000,
|
|
270
311
|
});
|
|
271
|
-
updateCallSession(session.id, { status: 'in_progress', startedAt: Date.now() - 30_000 });
|
|
272
312
|
const relay = createMockRelay();
|
|
273
|
-
const controller = new CallController(
|
|
313
|
+
const controller = new CallController(
|
|
314
|
+
session.id,
|
|
315
|
+
relay as unknown as RelayConnection,
|
|
316
|
+
task ?? null,
|
|
317
|
+
{},
|
|
318
|
+
);
|
|
274
319
|
return { session, relay, controller };
|
|
275
320
|
}
|
|
276
321
|
|
|
277
|
-
describe(
|
|
322
|
+
describe("call-controller", () => {
|
|
278
323
|
beforeEach(() => {
|
|
279
324
|
resetTables();
|
|
280
325
|
// Reset the bridge mock to default behaviour
|
|
281
|
-
mockStartVoiceTurn.mockImplementation(
|
|
326
|
+
mockStartVoiceTurn.mockImplementation(
|
|
327
|
+
createMockVoiceTurn(["Hello", " there"]),
|
|
328
|
+
);
|
|
282
329
|
// Reset consultation timeout to the default (long) value
|
|
283
330
|
mockConsultationTimeoutMs = 90_000;
|
|
284
331
|
mockSilenceTimeoutMs = 30_000;
|
|
@@ -286,11 +333,13 @@ describe('call-controller', () => {
|
|
|
286
333
|
|
|
287
334
|
// ── handleCallerUtterance ─────────────────────────────────────────
|
|
288
335
|
|
|
289
|
-
test(
|
|
290
|
-
mockStartVoiceTurn.mockImplementation(
|
|
336
|
+
test("handleCallerUtterance: streams tokens via sendTextToken", async () => {
|
|
337
|
+
mockStartVoiceTurn.mockImplementation(
|
|
338
|
+
createMockVoiceTurn(["Hi", ", how", " are you?"]),
|
|
339
|
+
);
|
|
291
340
|
const { relay, controller } = setupController();
|
|
292
341
|
|
|
293
|
-
await controller.handleCallerUtterance(
|
|
342
|
+
await controller.handleCallerUtterance("Hello");
|
|
294
343
|
|
|
295
344
|
// Verify tokens were sent to the relay
|
|
296
345
|
const nonEmptyTokens = relay.sentTokens.filter((t) => t.token.length > 0);
|
|
@@ -302,11 +351,13 @@ describe('call-controller', () => {
|
|
|
302
351
|
controller.destroy();
|
|
303
352
|
});
|
|
304
353
|
|
|
305
|
-
test(
|
|
306
|
-
mockStartVoiceTurn.mockImplementation(
|
|
354
|
+
test("handleCallerUtterance: sends last=true at end of turn", async () => {
|
|
355
|
+
mockStartVoiceTurn.mockImplementation(
|
|
356
|
+
createMockVoiceTurn(["Simple response."]),
|
|
357
|
+
);
|
|
307
358
|
const { relay, controller } = setupController();
|
|
308
359
|
|
|
309
|
-
await controller.handleCallerUtterance(
|
|
360
|
+
await controller.handleCallerUtterance("Test");
|
|
310
361
|
|
|
311
362
|
// Find the final empty-string token that marks end of turn
|
|
312
363
|
const endMarkers = relay.sentTokens.filter((t) => t.last === true);
|
|
@@ -315,113 +366,145 @@ describe('call-controller', () => {
|
|
|
315
366
|
controller.destroy();
|
|
316
367
|
});
|
|
317
368
|
|
|
318
|
-
test(
|
|
319
|
-
mockStartVoiceTurn.mockImplementation(
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
369
|
+
test("handleCallerUtterance: includes speaker context in voice turn content", async () => {
|
|
370
|
+
mockStartVoiceTurn.mockImplementation(
|
|
371
|
+
async (opts: {
|
|
372
|
+
content: string;
|
|
373
|
+
onTextDelta: (t: string) => void;
|
|
374
|
+
onComplete: () => void;
|
|
375
|
+
}) => {
|
|
376
|
+
expect(opts.content).toContain(
|
|
377
|
+
'[SPEAKER id="speaker-1" label="Aaron" source="provider" confidence="0.91"]',
|
|
378
|
+
);
|
|
379
|
+
expect(opts.content).toContain("Can you summarize this meeting?");
|
|
380
|
+
opts.onTextDelta("Sure, here is a summary.");
|
|
381
|
+
opts.onComplete();
|
|
382
|
+
return { turnId: "run-1", abort: () => {} };
|
|
383
|
+
},
|
|
384
|
+
);
|
|
326
385
|
|
|
327
386
|
const { controller } = setupController();
|
|
328
387
|
|
|
329
|
-
await controller.handleCallerUtterance(
|
|
330
|
-
speakerId:
|
|
331
|
-
speakerLabel:
|
|
388
|
+
await controller.handleCallerUtterance("Can you summarize this meeting?", {
|
|
389
|
+
speakerId: "speaker-1",
|
|
390
|
+
speakerLabel: "Aaron",
|
|
332
391
|
speakerConfidence: 0.91,
|
|
333
|
-
source:
|
|
392
|
+
source: "provider",
|
|
334
393
|
});
|
|
335
394
|
|
|
336
395
|
controller.destroy();
|
|
337
396
|
});
|
|
338
397
|
|
|
339
|
-
test(
|
|
398
|
+
test("startInitialGreeting: sends CALL_OPENING content and strips control marker from speech", async () => {
|
|
340
399
|
let turnCount = 0;
|
|
341
|
-
mockStartVoiceTurn.mockImplementation(
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
400
|
+
mockStartVoiceTurn.mockImplementation(
|
|
401
|
+
async (opts: {
|
|
402
|
+
content: string;
|
|
403
|
+
onTextDelta: (t: string) => void;
|
|
404
|
+
onComplete: () => void;
|
|
405
|
+
}) => {
|
|
406
|
+
turnCount++;
|
|
407
|
+
expect(opts.content).toContain("[CALL_OPENING]");
|
|
408
|
+
const tokens = [
|
|
409
|
+
"Hi, I am calling about your appointment request. Is now a good time to talk?",
|
|
410
|
+
];
|
|
411
|
+
for (const token of tokens) {
|
|
412
|
+
opts.onTextDelta(token);
|
|
413
|
+
}
|
|
414
|
+
opts.onComplete();
|
|
415
|
+
return { turnId: "run-1", abort: () => {} };
|
|
416
|
+
},
|
|
417
|
+
);
|
|
351
418
|
|
|
352
|
-
const { relay, controller } = setupController(
|
|
419
|
+
const { relay, controller } = setupController("Confirm appointment");
|
|
353
420
|
|
|
354
421
|
await controller.startInitialGreeting();
|
|
355
422
|
await controller.startInitialGreeting(); // should be no-op
|
|
356
423
|
|
|
357
|
-
const allText = relay.sentTokens.map((t) => t.token).join(
|
|
358
|
-
expect(allText).toContain(
|
|
359
|
-
expect(allText).toContain(
|
|
360
|
-
expect(allText).not.toContain(
|
|
424
|
+
const allText = relay.sentTokens.map((t) => t.token).join("");
|
|
425
|
+
expect(allText).toContain("appointment request");
|
|
426
|
+
expect(allText).toContain("good time to talk");
|
|
427
|
+
expect(allText).not.toContain("[CALL_OPENING]");
|
|
361
428
|
expect(turnCount).toBe(1); // idempotent
|
|
362
429
|
|
|
363
430
|
controller.destroy();
|
|
364
431
|
});
|
|
365
432
|
|
|
366
|
-
test(
|
|
433
|
+
test("startInitialGreeting: tags only the first caller response with CALL_OPENING_ACK", async () => {
|
|
367
434
|
let turnCount = 0;
|
|
368
|
-
mockStartVoiceTurn.mockImplementation(
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
435
|
+
mockStartVoiceTurn.mockImplementation(
|
|
436
|
+
async (opts: {
|
|
437
|
+
content: string;
|
|
438
|
+
onTextDelta: (t: string) => void;
|
|
439
|
+
onComplete: () => void;
|
|
440
|
+
}) => {
|
|
441
|
+
turnCount++;
|
|
442
|
+
|
|
443
|
+
let tokens: string[];
|
|
444
|
+
if (turnCount === 1) {
|
|
445
|
+
expect(opts.content).toContain("[CALL_OPENING]");
|
|
446
|
+
tokens = [
|
|
447
|
+
"Hey Noa, it's Credence calling about your joke request. Is now okay for a quick one?",
|
|
448
|
+
];
|
|
449
|
+
} else if (turnCount === 2) {
|
|
450
|
+
expect(opts.content).toContain("[CALL_OPENING_ACK]");
|
|
451
|
+
expect(opts.content).toContain("Yeah. Sure. What's up?");
|
|
452
|
+
tokens = [
|
|
453
|
+
"Great, here's one right away. Why did the scarecrow win an award?",
|
|
454
|
+
];
|
|
455
|
+
} else {
|
|
456
|
+
expect(opts.content).not.toContain("[CALL_OPENING_ACK]");
|
|
457
|
+
expect(opts.content).toContain("Tell me the punchline");
|
|
458
|
+
tokens = ["Because he was outstanding in his field."];
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
for (const token of tokens) {
|
|
462
|
+
opts.onTextDelta(token);
|
|
463
|
+
}
|
|
464
|
+
opts.onComplete();
|
|
465
|
+
return { turnId: `run-${turnCount}`, abort: () => {} };
|
|
466
|
+
},
|
|
467
|
+
);
|
|
391
468
|
|
|
392
|
-
const { controller } = setupController(
|
|
469
|
+
const { controller } = setupController("Tell a joke immediately");
|
|
393
470
|
|
|
394
471
|
await controller.startInitialGreeting();
|
|
395
|
-
await controller.handleCallerUtterance(
|
|
396
|
-
await controller.handleCallerUtterance(
|
|
472
|
+
await controller.handleCallerUtterance("Yeah. Sure. What's up?");
|
|
473
|
+
await controller.handleCallerUtterance("Tell me the punchline");
|
|
397
474
|
|
|
398
475
|
expect(turnCount).toBe(3);
|
|
399
476
|
|
|
400
477
|
controller.destroy();
|
|
401
478
|
});
|
|
402
479
|
|
|
403
|
-
test(
|
|
480
|
+
test("markNextCallerTurnAsOpeningAck: tags the next caller turn with CALL_OPENING_ACK without requiring a prior CALL_OPENING", async () => {
|
|
404
481
|
let turnCount = 0;
|
|
405
|
-
mockStartVoiceTurn.mockImplementation(
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
482
|
+
mockStartVoiceTurn.mockImplementation(
|
|
483
|
+
async (opts: {
|
|
484
|
+
content: string;
|
|
485
|
+
onTextDelta: (t: string) => void;
|
|
486
|
+
onComplete: () => void;
|
|
487
|
+
}) => {
|
|
488
|
+
turnCount++;
|
|
489
|
+
|
|
490
|
+
if (turnCount === 1) {
|
|
491
|
+
// First caller utterance after markNextCallerTurnAsOpeningAck
|
|
492
|
+
expect(opts.content).toContain("[CALL_OPENING_ACK]");
|
|
493
|
+
expect(opts.content).toContain("I want to check my balance");
|
|
494
|
+
for (const token of ["Sure, let me check your balance."]) {
|
|
495
|
+
opts.onTextDelta(token);
|
|
496
|
+
}
|
|
497
|
+
} else {
|
|
498
|
+
// Subsequent utterance should NOT have the marker
|
|
499
|
+
expect(opts.content).not.toContain("[CALL_OPENING_ACK]");
|
|
500
|
+
for (const token of ["Your balance is $42."]) {
|
|
501
|
+
opts.onTextDelta(token);
|
|
502
|
+
}
|
|
420
503
|
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
504
|
+
opts.onComplete();
|
|
505
|
+
return { turnId: `run-${turnCount}`, abort: () => {} };
|
|
506
|
+
},
|
|
507
|
+
);
|
|
425
508
|
|
|
426
509
|
const { controller } = setupController();
|
|
427
510
|
|
|
@@ -429,8 +512,8 @@ describe('call-controller', () => {
|
|
|
429
512
|
// without any prior startInitialGreeting / CALL_OPENING
|
|
430
513
|
controller.markNextCallerTurnAsOpeningAck();
|
|
431
514
|
|
|
432
|
-
await controller.handleCallerUtterance(
|
|
433
|
-
await controller.handleCallerUtterance(
|
|
515
|
+
await controller.handleCallerUtterance("I want to check my balance");
|
|
516
|
+
await controller.handleCallerUtterance("How much exactly?");
|
|
434
517
|
|
|
435
518
|
expect(turnCount).toBe(2);
|
|
436
519
|
|
|
@@ -439,112 +522,123 @@ describe('call-controller', () => {
|
|
|
439
522
|
|
|
440
523
|
// ── ASK_GUARDIAN pattern ──────────────────────────────────────────
|
|
441
524
|
|
|
442
|
-
test(
|
|
443
|
-
mockStartVoiceTurn.mockImplementation(
|
|
444
|
-
[
|
|
445
|
-
|
|
446
|
-
|
|
525
|
+
test("ASK_GUARDIAN pattern: detects pattern, creates pending question, sets session to waiting_on_user", async () => {
|
|
526
|
+
mockStartVoiceTurn.mockImplementation(
|
|
527
|
+
createMockVoiceTurn([
|
|
528
|
+
"Let me check on that. ",
|
|
529
|
+
"[ASK_GUARDIAN: What date works best?]",
|
|
530
|
+
]),
|
|
531
|
+
);
|
|
532
|
+
const { session, relay, controller } = setupController("Book appointment");
|
|
447
533
|
|
|
448
|
-
await controller.handleCallerUtterance(
|
|
534
|
+
await controller.handleCallerUtterance("I need to schedule something");
|
|
449
535
|
|
|
450
536
|
// Verify a pending question was created
|
|
451
537
|
const question = getPendingQuestion(session.id);
|
|
452
538
|
expect(question).not.toBeNull();
|
|
453
|
-
expect(question!.questionText).toBe(
|
|
454
|
-
expect(question!.status).toBe(
|
|
539
|
+
expect(question!.questionText).toBe("What date works best?");
|
|
540
|
+
expect(question!.status).toBe("pending");
|
|
455
541
|
|
|
456
542
|
// Controller state returns to idle (non-blocking); consultation is
|
|
457
543
|
// tracked separately via pendingConsultation.
|
|
458
|
-
expect(controller.getState()).toBe(
|
|
544
|
+
expect(controller.getState()).toBe("idle");
|
|
459
545
|
|
|
460
546
|
// Session status in the store is still set to waiting_on_user for
|
|
461
547
|
// external consumers (e.g. the answer route).
|
|
462
548
|
const updatedSession = getCallSession(session.id);
|
|
463
|
-
expect(updatedSession!.status).toBe(
|
|
549
|
+
expect(updatedSession!.status).toBe("waiting_on_user");
|
|
464
550
|
|
|
465
551
|
// A pending consultation should be active
|
|
466
552
|
expect(controller.getPendingConsultationQuestionId()).not.toBeNull();
|
|
467
553
|
|
|
468
554
|
// The ASK_GUARDIAN marker text should NOT appear in the relay tokens
|
|
469
|
-
const allText = relay.sentTokens.map((t) => t.token).join(
|
|
470
|
-
expect(allText).not.toContain(
|
|
555
|
+
const allText = relay.sentTokens.map((t) => t.token).join("");
|
|
556
|
+
expect(allText).not.toContain("[ASK_GUARDIAN:");
|
|
471
557
|
|
|
472
558
|
controller.destroy();
|
|
473
559
|
});
|
|
474
560
|
|
|
475
|
-
test(
|
|
476
|
-
mockStartVoiceTurn.mockImplementation(
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
561
|
+
test("strips internal context markers from spoken output", async () => {
|
|
562
|
+
mockStartVoiceTurn.mockImplementation(
|
|
563
|
+
createMockVoiceTurn([
|
|
564
|
+
"Thanks for waiting. ",
|
|
565
|
+
"[USER_ANSWERED: The guardian said 3 PM works.] ",
|
|
566
|
+
"[USER_INSTRUCTION: Keep this short.] ",
|
|
567
|
+
"[CALL_OPENING_ACK] ",
|
|
568
|
+
"I can confirm 3 PM works.",
|
|
569
|
+
]),
|
|
570
|
+
);
|
|
483
571
|
const { relay, controller } = setupController();
|
|
484
572
|
|
|
485
|
-
await controller.handleCallerUtterance(
|
|
573
|
+
await controller.handleCallerUtterance("Any update?");
|
|
486
574
|
|
|
487
|
-
const allText = relay.sentTokens.map((t) => t.token).join(
|
|
488
|
-
expect(allText).toContain(
|
|
489
|
-
expect(allText).toContain(
|
|
490
|
-
expect(allText).not.toContain(
|
|
491
|
-
expect(allText).not.toContain(
|
|
492
|
-
expect(allText).not.toContain(
|
|
493
|
-
expect(allText).not.toContain(
|
|
494
|
-
expect(allText).not.toContain(
|
|
495
|
-
expect(allText).not.toContain(
|
|
575
|
+
const allText = relay.sentTokens.map((t) => t.token).join("");
|
|
576
|
+
expect(allText).toContain("Thanks for waiting.");
|
|
577
|
+
expect(allText).toContain("I can confirm 3 PM works.");
|
|
578
|
+
expect(allText).not.toContain("[USER_ANSWERED:");
|
|
579
|
+
expect(allText).not.toContain("[USER_INSTRUCTION:");
|
|
580
|
+
expect(allText).not.toContain("[CALL_OPENING_ACK]");
|
|
581
|
+
expect(allText).not.toContain("USER_ANSWERED");
|
|
582
|
+
expect(allText).not.toContain("USER_INSTRUCTION");
|
|
583
|
+
expect(allText).not.toContain("CALL_OPENING_ACK");
|
|
496
584
|
|
|
497
585
|
controller.destroy();
|
|
498
586
|
});
|
|
499
587
|
|
|
500
588
|
// ── END_CALL pattern ──────────────────────────────────────────────
|
|
501
589
|
|
|
502
|
-
test(
|
|
503
|
-
mockStartVoiceTurn.mockImplementation(
|
|
504
|
-
[
|
|
505
|
-
)
|
|
590
|
+
test("END_CALL pattern: detects marker, calls endSession, updates status to completed", async () => {
|
|
591
|
+
mockStartVoiceTurn.mockImplementation(
|
|
592
|
+
createMockVoiceTurn(["Thank you for calling, goodbye! ", "[END_CALL]"]),
|
|
593
|
+
);
|
|
506
594
|
const { session, relay, controller } = setupController();
|
|
507
595
|
|
|
508
|
-
await controller.handleCallerUtterance(
|
|
596
|
+
await controller.handleCallerUtterance("That is all, thanks");
|
|
509
597
|
|
|
510
598
|
// endSession should have been called
|
|
511
599
|
expect(relay.endCalled).toBe(true);
|
|
512
600
|
|
|
513
601
|
// Session status should be completed
|
|
514
602
|
const updatedSession = getCallSession(session.id);
|
|
515
|
-
expect(updatedSession!.status).toBe(
|
|
603
|
+
expect(updatedSession!.status).toBe("completed");
|
|
516
604
|
expect(updatedSession!.endedAt).not.toBeNull();
|
|
517
605
|
|
|
518
606
|
// The END_CALL marker text should NOT appear in the relay tokens
|
|
519
|
-
const allText = relay.sentTokens.map((t) => t.token).join(
|
|
520
|
-
expect(allText).not.toContain(
|
|
607
|
+
const allText = relay.sentTokens.map((t) => t.token).join("");
|
|
608
|
+
expect(allText).not.toContain("[END_CALL]");
|
|
521
609
|
|
|
522
610
|
controller.destroy();
|
|
523
611
|
});
|
|
524
612
|
|
|
525
613
|
// ── handleUserAnswer ──────────────────────────────────────────────
|
|
526
614
|
|
|
527
|
-
test(
|
|
615
|
+
test("handleUserAnswer: returns true immediately and fires LLM asynchronously", async () => {
|
|
528
616
|
// First utterance triggers ASK_GUARDIAN
|
|
529
|
-
mockStartVoiceTurn.mockImplementation(
|
|
530
|
-
[
|
|
531
|
-
)
|
|
617
|
+
mockStartVoiceTurn.mockImplementation(
|
|
618
|
+
createMockVoiceTurn(["Hold on. [ASK_GUARDIAN: Preferred time?]"]),
|
|
619
|
+
);
|
|
532
620
|
const { relay, controller } = setupController();
|
|
533
621
|
|
|
534
|
-
await controller.handleCallerUtterance(
|
|
622
|
+
await controller.handleCallerUtterance("I need an appointment");
|
|
535
623
|
|
|
536
624
|
// Now provide the answer — reset mock for second turn
|
|
537
|
-
mockStartVoiceTurn.mockImplementation(
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
625
|
+
mockStartVoiceTurn.mockImplementation(
|
|
626
|
+
async (opts: {
|
|
627
|
+
content: string;
|
|
628
|
+
onTextDelta: (t: string) => void;
|
|
629
|
+
onComplete: () => void;
|
|
630
|
+
}) => {
|
|
631
|
+
expect(opts.content).toContain("[USER_ANSWERED: 3pm tomorrow]");
|
|
632
|
+
const tokens = ["Great, I have scheduled for 3pm tomorrow."];
|
|
633
|
+
for (const token of tokens) {
|
|
634
|
+
opts.onTextDelta(token);
|
|
635
|
+
}
|
|
636
|
+
opts.onComplete();
|
|
637
|
+
return { turnId: "run-2", abort: () => {} };
|
|
638
|
+
},
|
|
639
|
+
);
|
|
546
640
|
|
|
547
|
-
const accepted = await controller.handleUserAnswer(
|
|
641
|
+
const accepted = await controller.handleUserAnswer("3pm tomorrow");
|
|
548
642
|
expect(accepted).toBe(true);
|
|
549
643
|
|
|
550
644
|
// handleUserAnswer fires runTurn without awaiting, so give the
|
|
@@ -552,7 +646,9 @@ describe('call-controller', () => {
|
|
|
552
646
|
await new Promise((r) => setTimeout(r, 50));
|
|
553
647
|
|
|
554
648
|
// Should have streamed a response for the answer
|
|
555
|
-
const tokensAfterAnswer = relay.sentTokens.filter((t) =>
|
|
649
|
+
const tokensAfterAnswer = relay.sentTokens.filter((t) =>
|
|
650
|
+
t.token.includes("3pm"),
|
|
651
|
+
);
|
|
556
652
|
expect(tokensAfterAnswer.length).toBeGreaterThan(0);
|
|
557
653
|
|
|
558
654
|
controller.destroy();
|
|
@@ -560,33 +656,42 @@ describe('call-controller', () => {
|
|
|
560
656
|
|
|
561
657
|
// ── Full mid-call question flow ──────────────────────────────────
|
|
562
658
|
|
|
563
|
-
test(
|
|
659
|
+
test("mid-call question flow: unavailable time -> ask user -> user confirms -> resumed call", async () => {
|
|
564
660
|
// Step 1: Caller says "7:30" but it's unavailable. The LLM asks the user.
|
|
565
|
-
mockStartVoiceTurn.mockImplementation(
|
|
566
|
-
[
|
|
567
|
-
|
|
661
|
+
mockStartVoiceTurn.mockImplementation(
|
|
662
|
+
createMockVoiceTurn([
|
|
663
|
+
"I'm sorry, 7:30 is not available. ",
|
|
664
|
+
"[ASK_GUARDIAN: Is 8:00 okay instead?]",
|
|
665
|
+
]),
|
|
666
|
+
);
|
|
568
667
|
|
|
569
|
-
const { session, relay, controller } =
|
|
668
|
+
const { session, relay, controller } =
|
|
669
|
+
setupController("Schedule a haircut");
|
|
570
670
|
|
|
571
|
-
await controller.handleCallerUtterance(
|
|
671
|
+
await controller.handleCallerUtterance("Can I book for 7:30?");
|
|
572
672
|
|
|
573
673
|
// Controller returns to idle (non-blocking); consultation tracked separately
|
|
574
|
-
expect(controller.getState()).toBe(
|
|
674
|
+
expect(controller.getState()).toBe("idle");
|
|
575
675
|
expect(controller.getPendingConsultationQuestionId()).not.toBeNull();
|
|
576
676
|
const question = getPendingQuestion(session.id);
|
|
577
677
|
expect(question).not.toBeNull();
|
|
578
|
-
expect(question!.questionText).toBe(
|
|
678
|
+
expect(question!.questionText).toBe("Is 8:00 okay instead?");
|
|
579
679
|
|
|
580
680
|
// Session status in store reflects consultation state
|
|
581
681
|
const midSession = getCallSession(session.id);
|
|
582
|
-
expect(midSession!.status).toBe(
|
|
682
|
+
expect(midSession!.status).toBe("waiting_on_user");
|
|
583
683
|
|
|
584
684
|
// Step 2: User answers "Yes, 8:00 works"
|
|
585
|
-
mockStartVoiceTurn.mockImplementation(
|
|
586
|
-
[
|
|
587
|
-
|
|
685
|
+
mockStartVoiceTurn.mockImplementation(
|
|
686
|
+
createMockVoiceTurn([
|
|
687
|
+
"Great, I've booked you for 8:00. See you then! ",
|
|
688
|
+
"[END_CALL]",
|
|
689
|
+
]),
|
|
690
|
+
);
|
|
588
691
|
|
|
589
|
-
const accepted = await controller.handleUserAnswer(
|
|
692
|
+
const accepted = await controller.handleUserAnswer(
|
|
693
|
+
"Yes, 8:00 works for me",
|
|
694
|
+
);
|
|
590
695
|
expect(accepted).toBe(true);
|
|
591
696
|
|
|
592
697
|
// Give the fire-and-forget LLM call time to complete
|
|
@@ -594,7 +699,7 @@ describe('call-controller', () => {
|
|
|
594
699
|
|
|
595
700
|
// Step 3: Verify call completed
|
|
596
701
|
const endSession = getCallSession(session.id);
|
|
597
|
-
expect(endSession!.status).toBe(
|
|
702
|
+
expect(endSession!.status).toBe("completed");
|
|
598
703
|
expect(endSession!.endedAt).not.toBeNull();
|
|
599
704
|
|
|
600
705
|
// Verify the END_CALL marker triggered endSession on relay
|
|
@@ -605,33 +710,35 @@ describe('call-controller', () => {
|
|
|
605
710
|
|
|
606
711
|
// ── Error handling ────────────────────────────────────────────────
|
|
607
712
|
|
|
608
|
-
test(
|
|
609
|
-
mockStartVoiceTurn.mockImplementation(
|
|
610
|
-
opts
|
|
611
|
-
|
|
612
|
-
|
|
713
|
+
test("Voice turn error: sends error message to caller and returns to idle", async () => {
|
|
714
|
+
mockStartVoiceTurn.mockImplementation(
|
|
715
|
+
async (opts: { onError: (msg: string) => void }) => {
|
|
716
|
+
opts.onError("API rate limit exceeded");
|
|
717
|
+
return { turnId: "run-err", abort: () => {} };
|
|
718
|
+
},
|
|
719
|
+
);
|
|
613
720
|
|
|
614
721
|
const { relay, controller } = setupController();
|
|
615
722
|
|
|
616
|
-
await controller.handleCallerUtterance(
|
|
723
|
+
await controller.handleCallerUtterance("Hello");
|
|
617
724
|
|
|
618
725
|
// Should have sent an error recovery message
|
|
619
726
|
const errorTokens = relay.sentTokens.filter((t) =>
|
|
620
|
-
t.token.includes(
|
|
727
|
+
t.token.includes("technical issue"),
|
|
621
728
|
);
|
|
622
729
|
expect(errorTokens.length).toBeGreaterThan(0);
|
|
623
730
|
|
|
624
731
|
// State should return to idle after error
|
|
625
|
-
expect(controller.getState()).toBe(
|
|
732
|
+
expect(controller.getState()).toBe("idle");
|
|
626
733
|
|
|
627
734
|
controller.destroy();
|
|
628
735
|
});
|
|
629
736
|
|
|
630
|
-
test(
|
|
737
|
+
test("handleUserAnswer: returns false when no pending consultation exists", async () => {
|
|
631
738
|
const { controller } = setupController();
|
|
632
739
|
|
|
633
740
|
// No consultation is pending — answer should be rejected
|
|
634
|
-
const result = await controller.handleUserAnswer(
|
|
741
|
+
const result = await controller.handleUserAnswer("some answer");
|
|
635
742
|
expect(result).toBe(false);
|
|
636
743
|
|
|
637
744
|
controller.destroy();
|
|
@@ -639,7 +746,7 @@ describe('call-controller', () => {
|
|
|
639
746
|
|
|
640
747
|
// ── handleInterrupt ───────────────────────────────────────────────
|
|
641
748
|
|
|
642
|
-
test(
|
|
749
|
+
test("handleInterrupt: resets state to idle", () => {
|
|
643
750
|
const { controller } = setupController();
|
|
644
751
|
|
|
645
752
|
// Calling handleInterrupt should not throw
|
|
@@ -648,163 +755,197 @@ describe('call-controller', () => {
|
|
|
648
755
|
controller.destroy();
|
|
649
756
|
});
|
|
650
757
|
|
|
651
|
-
test(
|
|
652
|
-
mockStartVoiceTurn.mockImplementation(
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
opts.
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
758
|
+
test("handleInterrupt: sends turn terminator when interrupting active speech", async () => {
|
|
759
|
+
mockStartVoiceTurn.mockImplementation(
|
|
760
|
+
async (opts: {
|
|
761
|
+
signal?: AbortSignal;
|
|
762
|
+
onTextDelta: (t: string) => void;
|
|
763
|
+
onComplete: () => void;
|
|
764
|
+
}) => {
|
|
765
|
+
return new Promise((resolve) => {
|
|
766
|
+
// Simulate a long-running turn that can be aborted
|
|
767
|
+
const timeout = setTimeout(() => {
|
|
768
|
+
opts.onTextDelta("This should be interrupted");
|
|
769
|
+
opts.onComplete();
|
|
770
|
+
resolve({ turnId: "run-1", abort: () => {} });
|
|
771
|
+
}, 1000);
|
|
772
|
+
|
|
773
|
+
opts.signal?.addEventListener(
|
|
774
|
+
"abort",
|
|
775
|
+
() => {
|
|
776
|
+
clearTimeout(timeout);
|
|
777
|
+
// In the real system, generation_cancelled triggers
|
|
778
|
+
// onComplete via the event sink. The AbortSignal listener
|
|
779
|
+
// in call-controller also resolves turnComplete defensively.
|
|
780
|
+
opts.onComplete();
|
|
781
|
+
resolve({ turnId: "run-1", abort: () => {} });
|
|
782
|
+
},
|
|
783
|
+
{ once: true },
|
|
784
|
+
);
|
|
785
|
+
});
|
|
786
|
+
},
|
|
787
|
+
);
|
|
671
788
|
|
|
672
789
|
const { relay, controller } = setupController();
|
|
673
|
-
const turnPromise = controller.handleCallerUtterance(
|
|
790
|
+
const turnPromise = controller.handleCallerUtterance("Start speaking");
|
|
674
791
|
await new Promise((r) => setTimeout(r, 5));
|
|
675
792
|
controller.handleInterrupt();
|
|
676
793
|
await turnPromise;
|
|
677
794
|
|
|
678
|
-
const endTurnMarkers = relay.sentTokens.filter(
|
|
795
|
+
const endTurnMarkers = relay.sentTokens.filter(
|
|
796
|
+
(t) => t.token === "" && t.last === true,
|
|
797
|
+
);
|
|
679
798
|
expect(endTurnMarkers.length).toBeGreaterThan(0);
|
|
680
799
|
|
|
681
800
|
controller.destroy();
|
|
682
801
|
});
|
|
683
802
|
|
|
684
|
-
test(
|
|
803
|
+
test("handleInterrupt: turnComplete settles even when event sink callbacks are not called", async () => {
|
|
685
804
|
// Simulate a turn that never calls onComplete or onError on abort —
|
|
686
805
|
// the defensive AbortSignal listener in runTurn() should settle the promise.
|
|
687
|
-
mockStartVoiceTurn.mockImplementation(
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
806
|
+
mockStartVoiceTurn.mockImplementation(
|
|
807
|
+
async (opts: {
|
|
808
|
+
signal?: AbortSignal;
|
|
809
|
+
onTextDelta: (t: string) => void;
|
|
810
|
+
onComplete: () => void;
|
|
811
|
+
}) => {
|
|
812
|
+
return new Promise((resolve) => {
|
|
813
|
+
const timeout = setTimeout(() => {
|
|
814
|
+
opts.onTextDelta("Long running turn");
|
|
815
|
+
opts.onComplete();
|
|
816
|
+
resolve({ turnId: "run-1", abort: () => {} });
|
|
817
|
+
}, 5000);
|
|
818
|
+
|
|
819
|
+
opts.signal?.addEventListener(
|
|
820
|
+
"abort",
|
|
821
|
+
() => {
|
|
822
|
+
clearTimeout(timeout);
|
|
823
|
+
// Intentionally do NOT call onComplete — simulates the old
|
|
824
|
+
// broken path where generation_cancelled was not forwarded.
|
|
825
|
+
resolve({ turnId: "run-1", abort: () => {} });
|
|
826
|
+
},
|
|
827
|
+
{ once: true },
|
|
828
|
+
);
|
|
829
|
+
});
|
|
830
|
+
},
|
|
831
|
+
);
|
|
703
832
|
|
|
704
833
|
const { controller } = setupController();
|
|
705
|
-
const turnPromise = controller.handleCallerUtterance(
|
|
834
|
+
const turnPromise = controller.handleCallerUtterance("Start speaking");
|
|
706
835
|
await new Promise((r) => setTimeout(r, 5));
|
|
707
836
|
controller.handleInterrupt();
|
|
708
837
|
|
|
709
838
|
// Should not hang — the AbortSignal listener resolves the promise
|
|
710
839
|
await turnPromise;
|
|
711
840
|
|
|
712
|
-
expect(controller.getState()).toBe(
|
|
841
|
+
expect(controller.getState()).toBe("idle");
|
|
713
842
|
|
|
714
843
|
controller.destroy();
|
|
715
844
|
});
|
|
716
845
|
|
|
717
846
|
// ── Guardian context pass-through ──────────────────────────────────
|
|
718
847
|
|
|
719
|
-
test(
|
|
848
|
+
test("handleCallerUtterance: passes guardian context to startVoiceTurn", async () => {
|
|
720
849
|
const guardianCtx = {
|
|
721
|
-
sourceChannel:
|
|
722
|
-
trustClass:
|
|
723
|
-
guardianExternalUserId:
|
|
724
|
-
guardianChatId:
|
|
725
|
-
requesterExternalUserId:
|
|
850
|
+
sourceChannel: "voice" as const,
|
|
851
|
+
trustClass: "trusted_contact" as const,
|
|
852
|
+
guardianExternalUserId: "+15550009999",
|
|
853
|
+
guardianChatId: "+15550009999",
|
|
854
|
+
requesterExternalUserId: "+15550002222",
|
|
726
855
|
};
|
|
727
856
|
|
|
728
857
|
let capturedGuardianContext: unknown = undefined;
|
|
729
|
-
mockStartVoiceTurn.mockImplementation(
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
858
|
+
mockStartVoiceTurn.mockImplementation(
|
|
859
|
+
async (opts: {
|
|
860
|
+
guardianContext?: unknown;
|
|
861
|
+
onTextDelta: (t: string) => void;
|
|
862
|
+
onComplete: () => void;
|
|
863
|
+
}) => {
|
|
864
|
+
capturedGuardianContext = opts.guardianContext;
|
|
865
|
+
opts.onTextDelta("Hello.");
|
|
866
|
+
opts.onComplete();
|
|
867
|
+
return { turnId: "run-gc", abort: () => {} };
|
|
868
|
+
},
|
|
869
|
+
);
|
|
739
870
|
|
|
740
|
-
const { controller } = setupController(undefined, {
|
|
871
|
+
const { controller } = setupController(undefined, {
|
|
872
|
+
guardianContext: guardianCtx,
|
|
873
|
+
});
|
|
741
874
|
|
|
742
|
-
await controller.handleCallerUtterance(
|
|
875
|
+
await controller.handleCallerUtterance("Hello");
|
|
743
876
|
|
|
744
877
|
expect(capturedGuardianContext).toEqual(guardianCtx);
|
|
745
878
|
|
|
746
879
|
controller.destroy();
|
|
747
880
|
});
|
|
748
881
|
|
|
749
|
-
test(
|
|
882
|
+
test("handleCallerUtterance: passes assistantId to startVoiceTurn", async () => {
|
|
750
883
|
let capturedAssistantId: string | undefined;
|
|
751
|
-
mockStartVoiceTurn.mockImplementation(
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
884
|
+
mockStartVoiceTurn.mockImplementation(
|
|
885
|
+
async (opts: {
|
|
886
|
+
assistantId?: string;
|
|
887
|
+
onTextDelta: (t: string) => void;
|
|
888
|
+
onComplete: () => void;
|
|
889
|
+
}) => {
|
|
890
|
+
capturedAssistantId = opts.assistantId;
|
|
891
|
+
opts.onTextDelta("Hello.");
|
|
892
|
+
opts.onComplete();
|
|
893
|
+
return { turnId: "run-aid", abort: () => {} };
|
|
894
|
+
},
|
|
895
|
+
);
|
|
761
896
|
|
|
762
|
-
const { controller } = setupController(undefined, {
|
|
897
|
+
const { controller } = setupController(undefined, {
|
|
898
|
+
assistantId: "my-assistant",
|
|
899
|
+
});
|
|
763
900
|
|
|
764
|
-
await controller.handleCallerUtterance(
|
|
901
|
+
await controller.handleCallerUtterance("Hello");
|
|
765
902
|
|
|
766
|
-
expect(capturedAssistantId).toBe(
|
|
903
|
+
expect(capturedAssistantId).toBe("my-assistant");
|
|
767
904
|
|
|
768
905
|
controller.destroy();
|
|
769
906
|
});
|
|
770
907
|
|
|
771
|
-
test(
|
|
908
|
+
test("setGuardianContext: subsequent turns use updated guardian context", async () => {
|
|
772
909
|
const initialCtx = {
|
|
773
|
-
sourceChannel:
|
|
774
|
-
trustClass:
|
|
775
|
-
denialReason:
|
|
910
|
+
sourceChannel: "voice" as const,
|
|
911
|
+
trustClass: "unknown" as const,
|
|
912
|
+
denialReason: "no_binding" as const,
|
|
776
913
|
};
|
|
777
914
|
|
|
778
915
|
const upgradedCtx = {
|
|
779
|
-
sourceChannel:
|
|
780
|
-
trustClass:
|
|
781
|
-
guardianExternalUserId:
|
|
782
|
-
guardianChatId:
|
|
916
|
+
sourceChannel: "voice" as const,
|
|
917
|
+
trustClass: "guardian" as const,
|
|
918
|
+
guardianExternalUserId: "+15550003333",
|
|
919
|
+
guardianChatId: "+15550003333",
|
|
783
920
|
};
|
|
784
921
|
|
|
785
922
|
const capturedContexts: unknown[] = [];
|
|
786
|
-
mockStartVoiceTurn.mockImplementation(
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
923
|
+
mockStartVoiceTurn.mockImplementation(
|
|
924
|
+
async (opts: {
|
|
925
|
+
guardianContext?: unknown;
|
|
926
|
+
onTextDelta: (t: string) => void;
|
|
927
|
+
onComplete: () => void;
|
|
928
|
+
}) => {
|
|
929
|
+
capturedContexts.push(opts.guardianContext);
|
|
930
|
+
opts.onTextDelta("Response.");
|
|
931
|
+
opts.onComplete();
|
|
932
|
+
return { turnId: `run-${capturedContexts.length}`, abort: () => {} };
|
|
933
|
+
},
|
|
934
|
+
);
|
|
796
935
|
|
|
797
|
-
const { controller } = setupController(undefined, {
|
|
936
|
+
const { controller } = setupController(undefined, {
|
|
937
|
+
guardianContext: initialCtx,
|
|
938
|
+
});
|
|
798
939
|
|
|
799
940
|
// First turn: unverified
|
|
800
|
-
await controller.handleCallerUtterance(
|
|
941
|
+
await controller.handleCallerUtterance("Hello");
|
|
801
942
|
expect(capturedContexts[0]).toEqual(initialCtx);
|
|
802
943
|
|
|
803
944
|
// Simulate guardian verification succeeding
|
|
804
945
|
controller.setGuardianContext(upgradedCtx);
|
|
805
946
|
|
|
806
947
|
// Second turn: should use upgraded guardian context
|
|
807
|
-
await controller.handleCallerUtterance(
|
|
948
|
+
await controller.handleCallerUtterance("I verified");
|
|
808
949
|
expect(capturedContexts[1]).toEqual(upgradedCtx);
|
|
809
950
|
|
|
810
951
|
controller.destroy();
|
|
@@ -812,7 +953,7 @@ describe('call-controller', () => {
|
|
|
812
953
|
|
|
813
954
|
// ── destroy ───────────────────────────────────────────────────────
|
|
814
955
|
|
|
815
|
-
test(
|
|
956
|
+
test("destroy: unregisters controller", () => {
|
|
816
957
|
const { session, controller } = setupController();
|
|
817
958
|
|
|
818
959
|
// Controller should be registered
|
|
@@ -824,7 +965,7 @@ describe('call-controller', () => {
|
|
|
824
965
|
expect(getCallController(session.id)).toBeUndefined();
|
|
825
966
|
});
|
|
826
967
|
|
|
827
|
-
test(
|
|
968
|
+
test("destroy: can be called multiple times without error", () => {
|
|
828
969
|
const { controller } = setupController();
|
|
829
970
|
|
|
830
971
|
controller.destroy();
|
|
@@ -832,27 +973,37 @@ describe('call-controller', () => {
|
|
|
832
973
|
expect(() => controller.destroy()).not.toThrow();
|
|
833
974
|
});
|
|
834
975
|
|
|
835
|
-
test(
|
|
976
|
+
test("destroy: during active turn does not trigger post-turn side effects", async () => {
|
|
836
977
|
// Simulate a turn that completes after destroy() is called
|
|
837
|
-
mockStartVoiceTurn.mockImplementation(
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
978
|
+
mockStartVoiceTurn.mockImplementation(
|
|
979
|
+
async (opts: {
|
|
980
|
+
signal?: AbortSignal;
|
|
981
|
+
onTextDelta: (t: string) => void;
|
|
982
|
+
onComplete: () => void;
|
|
983
|
+
}) => {
|
|
984
|
+
return new Promise((resolve) => {
|
|
985
|
+
const timeout = setTimeout(() => {
|
|
986
|
+
opts.onTextDelta("This is a long response");
|
|
987
|
+
opts.onComplete();
|
|
988
|
+
resolve({ turnId: "run-1", abort: () => {} });
|
|
989
|
+
}, 1000);
|
|
990
|
+
|
|
991
|
+
opts.signal?.addEventListener(
|
|
992
|
+
"abort",
|
|
993
|
+
() => {
|
|
994
|
+
clearTimeout(timeout);
|
|
995
|
+
// The defensive abort listener in runTurn resolves turnComplete
|
|
996
|
+
opts.onComplete();
|
|
997
|
+
resolve({ turnId: "run-1", abort: () => {} });
|
|
998
|
+
},
|
|
999
|
+
{ once: true },
|
|
1000
|
+
);
|
|
1001
|
+
});
|
|
1002
|
+
},
|
|
1003
|
+
);
|
|
853
1004
|
|
|
854
1005
|
const { relay, controller } = setupController();
|
|
855
|
-
const turnPromise = controller.handleCallerUtterance(
|
|
1006
|
+
const turnPromise = controller.handleCallerUtterance("Start speaking");
|
|
856
1007
|
|
|
857
1008
|
// Let the turn start
|
|
858
1009
|
await new Promise((r) => setTimeout(r, 5));
|
|
@@ -866,7 +1017,9 @@ describe('call-controller', () => {
|
|
|
866
1017
|
// Verify that NO spurious post-turn side effects occurred after destroy:
|
|
867
1018
|
// - No final empty-string sendTextToken('', true) call after abort
|
|
868
1019
|
// The only end marker should be from handleInterrupt, not from post-turn logic
|
|
869
|
-
const endMarkers = relay.sentTokens.filter(
|
|
1020
|
+
const endMarkers = relay.sentTokens.filter(
|
|
1021
|
+
(t) => t.token === "" && t.last === true,
|
|
1022
|
+
);
|
|
870
1023
|
|
|
871
1024
|
// destroy() increments llmRunVersion, so isCurrentRun() returns false
|
|
872
1025
|
// for the aborted turn, preventing post-turn side effects including
|
|
@@ -876,20 +1029,28 @@ describe('call-controller', () => {
|
|
|
876
1029
|
|
|
877
1030
|
// ── handleUserInstruction ─────────────────────────────────────────
|
|
878
1031
|
|
|
879
|
-
test(
|
|
880
|
-
mockStartVoiceTurn.mockImplementation(
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
1032
|
+
test("handleUserInstruction: injects instruction marker and triggers turn when idle", async () => {
|
|
1033
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1034
|
+
async (opts: {
|
|
1035
|
+
content: string;
|
|
1036
|
+
onTextDelta: (t: string) => void;
|
|
1037
|
+
onComplete: () => void;
|
|
1038
|
+
}) => {
|
|
1039
|
+
expect(opts.content).toContain(
|
|
1040
|
+
"[USER_INSTRUCTION: Ask about their weekend plans]",
|
|
1041
|
+
);
|
|
1042
|
+
const tokens = ["Sure, do you have any weekend plans?"];
|
|
1043
|
+
for (const token of tokens) {
|
|
1044
|
+
opts.onTextDelta(token);
|
|
1045
|
+
}
|
|
1046
|
+
opts.onComplete();
|
|
1047
|
+
return { turnId: "run-instr", abort: () => {} };
|
|
1048
|
+
},
|
|
1049
|
+
);
|
|
889
1050
|
|
|
890
1051
|
const { relay, controller } = setupController();
|
|
891
1052
|
|
|
892
|
-
await controller.handleUserInstruction(
|
|
1053
|
+
await controller.handleUserInstruction("Ask about their weekend plans");
|
|
893
1054
|
|
|
894
1055
|
// Should have streamed a response since controller was idle
|
|
895
1056
|
const nonEmptyTokens = relay.sentTokens.filter((t) => t.token.length > 0);
|
|
@@ -898,106 +1059,125 @@ describe('call-controller', () => {
|
|
|
898
1059
|
controller.destroy();
|
|
899
1060
|
});
|
|
900
1061
|
|
|
901
|
-
test(
|
|
902
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1062
|
+
test("handleUserInstruction: emits user_instruction_relayed event", async () => {
|
|
1063
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1064
|
+
createMockVoiceTurn(["Understood, adjusting approach."]),
|
|
1065
|
+
);
|
|
903
1066
|
|
|
904
1067
|
const { session, controller } = setupController();
|
|
905
1068
|
|
|
906
|
-
await controller.handleUserInstruction(
|
|
1069
|
+
await controller.handleUserInstruction("Be more formal in your tone");
|
|
907
1070
|
|
|
908
1071
|
const events = getCallEvents(session.id);
|
|
909
|
-
const instructionEvents = events.filter(
|
|
1072
|
+
const instructionEvents = events.filter(
|
|
1073
|
+
(e) => e.eventType === "user_instruction_relayed",
|
|
1074
|
+
);
|
|
910
1075
|
expect(instructionEvents.length).toBe(1);
|
|
911
1076
|
|
|
912
1077
|
const payload = JSON.parse(instructionEvents[0].payloadJson);
|
|
913
|
-
expect(payload.instruction).toBe(
|
|
1078
|
+
expect(payload.instruction).toBe("Be more formal in your tone");
|
|
914
1079
|
|
|
915
1080
|
controller.destroy();
|
|
916
1081
|
});
|
|
917
1082
|
|
|
918
1083
|
// ── Non-blocking consultation: caller follow-up during pending consultation ──
|
|
919
1084
|
|
|
920
|
-
test(
|
|
1085
|
+
test("handleCallerUtterance: triggers normal turn while consultation is pending (non-blocking)", async () => {
|
|
921
1086
|
// Trigger ASK_GUARDIAN to start a consultation
|
|
922
|
-
mockStartVoiceTurn.mockImplementation(
|
|
923
|
-
[
|
|
924
|
-
)
|
|
1087
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1088
|
+
createMockVoiceTurn(["Hold on. [ASK_GUARDIAN: What time works?]"]),
|
|
1089
|
+
);
|
|
925
1090
|
const { controller } = setupController();
|
|
926
|
-
await controller.handleCallerUtterance(
|
|
1091
|
+
await controller.handleCallerUtterance("Book me in");
|
|
927
1092
|
// Controller returns to idle; consultation tracked separately
|
|
928
|
-
expect(controller.getState()).toBe(
|
|
1093
|
+
expect(controller.getState()).toBe("idle");
|
|
929
1094
|
expect(controller.getPendingConsultationQuestionId()).not.toBeNull();
|
|
930
1095
|
|
|
931
1096
|
// Track calls to startVoiceTurn from this point
|
|
932
1097
|
let turnCallCount = 0;
|
|
933
|
-
mockStartVoiceTurn.mockImplementation(
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
1098
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1099
|
+
async (opts: {
|
|
1100
|
+
onTextDelta: (t: string) => void;
|
|
1101
|
+
onComplete: () => void;
|
|
1102
|
+
}) => {
|
|
1103
|
+
turnCallCount++;
|
|
1104
|
+
opts.onTextDelta("Sure, let me help with that.");
|
|
1105
|
+
opts.onComplete();
|
|
1106
|
+
return { turnId: "run-followup", abort: () => {} };
|
|
1107
|
+
},
|
|
1108
|
+
);
|
|
939
1109
|
|
|
940
1110
|
// Caller speaks while consultation is pending — should trigger a normal turn
|
|
941
|
-
await controller.handleCallerUtterance(
|
|
1111
|
+
await controller.handleCallerUtterance("Hello? Are you still there?");
|
|
942
1112
|
expect(turnCallCount).toBe(1);
|
|
943
1113
|
// Controller returns to idle after the turn completes
|
|
944
|
-
expect(controller.getState()).toBe(
|
|
1114
|
+
expect(controller.getState()).toBe("idle");
|
|
945
1115
|
// Consultation should still be pending
|
|
946
1116
|
expect(controller.getPendingConsultationQuestionId()).not.toBeNull();
|
|
947
1117
|
|
|
948
1118
|
controller.destroy();
|
|
949
1119
|
});
|
|
950
1120
|
|
|
951
|
-
test(
|
|
1121
|
+
test("guardian answer arriving while controller idle: queued as instruction and flushed immediately", async () => {
|
|
952
1122
|
// Trigger ASK_GUARDIAN to start a consultation
|
|
953
|
-
mockStartVoiceTurn.mockImplementation(
|
|
954
|
-
[
|
|
955
|
-
)
|
|
1123
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1124
|
+
createMockVoiceTurn(["Checking. [ASK_GUARDIAN: Confirm appointment?]"]),
|
|
1125
|
+
);
|
|
956
1126
|
const { controller } = setupController();
|
|
957
|
-
await controller.handleCallerUtterance(
|
|
958
|
-
expect(controller.getState()).toBe(
|
|
1127
|
+
await controller.handleCallerUtterance("I want to schedule");
|
|
1128
|
+
expect(controller.getState()).toBe("idle");
|
|
959
1129
|
expect(controller.getPendingConsultationQuestionId()).not.toBeNull();
|
|
960
1130
|
|
|
961
1131
|
// Set up mock for the answer turn
|
|
962
1132
|
const turnContents: string[] = [];
|
|
963
|
-
mockStartVoiceTurn.mockImplementation(
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
1133
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1134
|
+
async (opts: {
|
|
1135
|
+
content: string;
|
|
1136
|
+
onTextDelta: (t: string) => void;
|
|
1137
|
+
onComplete: () => void;
|
|
1138
|
+
}) => {
|
|
1139
|
+
turnContents.push(opts.content);
|
|
1140
|
+
opts.onTextDelta("Confirmed.");
|
|
1141
|
+
opts.onComplete();
|
|
1142
|
+
return { turnId: `run-${turnContents.length}`, abort: () => {} };
|
|
1143
|
+
},
|
|
1144
|
+
);
|
|
969
1145
|
|
|
970
|
-
const accepted = await controller.handleUserAnswer(
|
|
1146
|
+
const accepted = await controller.handleUserAnswer("Yes, confirmed");
|
|
971
1147
|
expect(accepted).toBe(true);
|
|
972
1148
|
|
|
973
1149
|
// Give fire-and-forget turns time to complete
|
|
974
1150
|
await new Promise((r) => setTimeout(r, 100));
|
|
975
1151
|
|
|
976
1152
|
// The answer turn should have fired with the USER_ANSWERED marker
|
|
977
|
-
expect(
|
|
1153
|
+
expect(
|
|
1154
|
+
turnContents.some((c) => c.includes("[USER_ANSWERED: Yes, confirmed]")),
|
|
1155
|
+
).toBe(true);
|
|
978
1156
|
// Consultation should now be cleared
|
|
979
1157
|
expect(controller.getPendingConsultationQuestionId()).toBeNull();
|
|
980
1158
|
|
|
981
1159
|
controller.destroy();
|
|
982
1160
|
});
|
|
983
1161
|
|
|
984
|
-
test(
|
|
1162
|
+
test("no duplicate guardian dispatch: repeated informational ASK_GUARDIAN coalesces with existing consultation", async () => {
|
|
985
1163
|
// Trigger ASK_GUARDIAN to start first consultation
|
|
986
|
-
mockStartVoiceTurn.mockImplementation(
|
|
987
|
-
[
|
|
988
|
-
)
|
|
1164
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1165
|
+
createMockVoiceTurn(["Let me ask. [ASK_GUARDIAN: Preferred date?]"]),
|
|
1166
|
+
);
|
|
989
1167
|
const { session, controller } = setupController();
|
|
990
|
-
await controller.handleCallerUtterance(
|
|
991
|
-
expect(controller.getState()).toBe(
|
|
1168
|
+
await controller.handleCallerUtterance("Schedule please");
|
|
1169
|
+
expect(controller.getState()).toBe("idle");
|
|
992
1170
|
const firstQuestionId = controller.getPendingConsultationQuestionId();
|
|
993
1171
|
expect(firstQuestionId).not.toBeNull();
|
|
994
1172
|
|
|
995
1173
|
// Model emits another informational ASK_GUARDIAN in a subsequent turn —
|
|
996
1174
|
// should coalesce (same tool scope: both lack tool metadata)
|
|
997
|
-
mockStartVoiceTurn.mockImplementation(
|
|
998
|
-
[
|
|
999
|
-
|
|
1000
|
-
|
|
1175
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1176
|
+
createMockVoiceTurn([
|
|
1177
|
+
"Actually let me re-check. [ASK_GUARDIAN: Preferred date again?]",
|
|
1178
|
+
]),
|
|
1179
|
+
);
|
|
1180
|
+
await controller.handleCallerUtterance("Hello?");
|
|
1001
1181
|
|
|
1002
1182
|
// Consultation should be coalesced — same question ID retained
|
|
1003
1183
|
const secondQuestionId = controller.getPendingConsultationQuestionId();
|
|
@@ -1006,64 +1186,75 @@ describe('call-controller', () => {
|
|
|
1006
1186
|
|
|
1007
1187
|
// The session status should still be waiting_on_user
|
|
1008
1188
|
const updatedSession = getCallSession(session.id);
|
|
1009
|
-
expect(updatedSession!.status).toBe(
|
|
1189
|
+
expect(updatedSession!.status).toBe("waiting_on_user");
|
|
1010
1190
|
|
|
1011
1191
|
controller.destroy();
|
|
1012
1192
|
});
|
|
1013
1193
|
|
|
1014
|
-
test(
|
|
1194
|
+
test("handleUserAnswer: returns false when no pending consultation (stale/duplicate guard)", async () => {
|
|
1015
1195
|
const { controller } = setupController();
|
|
1016
1196
|
|
|
1017
1197
|
// No consultation pending — idle state, answer rejected
|
|
1018
|
-
expect(await controller.handleUserAnswer(
|
|
1198
|
+
expect(await controller.handleUserAnswer("some answer")).toBe(false);
|
|
1019
1199
|
|
|
1020
1200
|
// Start a turn to enter processing state — still no consultation
|
|
1021
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1201
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1202
|
+
async (opts: {
|
|
1203
|
+
onTextDelta: (t: string) => void;
|
|
1204
|
+
onComplete: () => void;
|
|
1205
|
+
}) => {
|
|
1206
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
1207
|
+
opts.onTextDelta("Response.");
|
|
1208
|
+
opts.onComplete();
|
|
1209
|
+
return { turnId: "run-proc", abort: () => {} };
|
|
1210
|
+
},
|
|
1211
|
+
);
|
|
1212
|
+
const turnPromise = controller.handleCallerUtterance("Test");
|
|
1028
1213
|
await new Promise((r) => setTimeout(r, 10));
|
|
1029
1214
|
// No consultation → answer rejected regardless of controller state
|
|
1030
|
-
expect(await controller.handleUserAnswer(
|
|
1215
|
+
expect(await controller.handleUserAnswer("stale answer")).toBe(false);
|
|
1031
1216
|
|
|
1032
1217
|
// Clean up
|
|
1033
1218
|
await turnPromise.catch(() => {});
|
|
1034
1219
|
controller.destroy();
|
|
1035
1220
|
});
|
|
1036
1221
|
|
|
1037
|
-
test(
|
|
1222
|
+
test("duplicate answer to same consultation: first accepted, second rejected", async () => {
|
|
1038
1223
|
// Trigger ASK_GUARDIAN consultation
|
|
1039
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1040
|
-
[
|
|
1041
|
-
)
|
|
1224
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1225
|
+
createMockVoiceTurn(["Hold on. [ASK_GUARDIAN: What time?]"]),
|
|
1226
|
+
);
|
|
1042
1227
|
const { controller } = setupController();
|
|
1043
|
-
await controller.handleCallerUtterance(
|
|
1228
|
+
await controller.handleCallerUtterance("Book me");
|
|
1044
1229
|
expect(controller.getPendingConsultationQuestionId()).not.toBeNull();
|
|
1045
1230
|
|
|
1046
1231
|
// Set up mock for the answer turn
|
|
1047
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1048
|
-
opts
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1232
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1233
|
+
async (opts: {
|
|
1234
|
+
content: string;
|
|
1235
|
+
onTextDelta: (t: string) => void;
|
|
1236
|
+
onComplete: () => void;
|
|
1237
|
+
}) => {
|
|
1238
|
+
opts.onTextDelta("Got it.");
|
|
1239
|
+
opts.onComplete();
|
|
1240
|
+
return { turnId: "run-answer", abort: () => {} };
|
|
1241
|
+
},
|
|
1242
|
+
);
|
|
1052
1243
|
|
|
1053
1244
|
// First answer is accepted
|
|
1054
|
-
const first = await controller.handleUserAnswer(
|
|
1245
|
+
const first = await controller.handleUserAnswer("3pm");
|
|
1055
1246
|
expect(first).toBe(true);
|
|
1056
1247
|
expect(controller.getPendingConsultationQuestionId()).toBeNull();
|
|
1057
1248
|
|
|
1058
1249
|
// Second answer is rejected — consultation already consumed
|
|
1059
|
-
const second = await controller.handleUserAnswer(
|
|
1250
|
+
const second = await controller.handleUserAnswer("4pm");
|
|
1060
1251
|
expect(second).toBe(false);
|
|
1061
1252
|
|
|
1062
1253
|
await new Promise((r) => setTimeout(r, 50));
|
|
1063
1254
|
controller.destroy();
|
|
1064
1255
|
});
|
|
1065
1256
|
|
|
1066
|
-
test(
|
|
1257
|
+
test("handleUserInstruction: queues when processing, but triggers when idle", async () => {
|
|
1067
1258
|
// Track content passed to each voice turn invocation
|
|
1068
1259
|
const turnContents: string[] = [];
|
|
1069
1260
|
let turnCount = 0;
|
|
@@ -1071,32 +1262,40 @@ describe('call-controller', () => {
|
|
|
1071
1262
|
// Start a slow turn to put controller in processing/speaking state.
|
|
1072
1263
|
// After the first turn completes, the mock switches to a fast handler
|
|
1073
1264
|
// that captures content so we can verify the flushed instruction.
|
|
1074
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1265
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1266
|
+
async (opts: {
|
|
1267
|
+
content: string;
|
|
1268
|
+
onTextDelta: (t: string) => void;
|
|
1269
|
+
onComplete: () => void;
|
|
1270
|
+
}) => {
|
|
1271
|
+
turnCount++;
|
|
1272
|
+
if (turnCount === 1) {
|
|
1273
|
+
// First turn: slow, simulates processing state
|
|
1274
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
1275
|
+
opts.onTextDelta("Response.");
|
|
1276
|
+
opts.onComplete();
|
|
1277
|
+
return { turnId: "run-1", abort: () => {} };
|
|
1278
|
+
}
|
|
1279
|
+
// Subsequent turns: capture content and complete immediately
|
|
1280
|
+
turnContents.push(opts.content);
|
|
1281
|
+
opts.onTextDelta("Noted.");
|
|
1080
1282
|
opts.onComplete();
|
|
1081
|
-
return { turnId:
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
|
-
turnContents.push(opts.content);
|
|
1085
|
-
opts.onTextDelta('Noted.');
|
|
1086
|
-
opts.onComplete();
|
|
1087
|
-
return { turnId: `run-${turnCount}`, abort: () => {} };
|
|
1088
|
-
});
|
|
1283
|
+
return { turnId: `run-${turnCount}`, abort: () => {} };
|
|
1284
|
+
},
|
|
1285
|
+
);
|
|
1089
1286
|
|
|
1090
1287
|
const { session, controller } = setupController();
|
|
1091
|
-
const turnPromise = controller.handleCallerUtterance(
|
|
1288
|
+
const turnPromise = controller.handleCallerUtterance("Hello");
|
|
1092
1289
|
await new Promise((r) => setTimeout(r, 10));
|
|
1093
1290
|
|
|
1094
1291
|
// Inject instruction while processing — should be queued
|
|
1095
|
-
await controller.handleUserInstruction(
|
|
1292
|
+
await controller.handleUserInstruction("Suggest morning slots");
|
|
1096
1293
|
|
|
1097
1294
|
// Event should be recorded even when queued
|
|
1098
1295
|
const events = getCallEvents(session.id);
|
|
1099
|
-
const instructionEvents = events.filter(
|
|
1296
|
+
const instructionEvents = events.filter(
|
|
1297
|
+
(e) => e.eventType === "user_instruction_relayed",
|
|
1298
|
+
);
|
|
1100
1299
|
expect(instructionEvents.length).toBe(1);
|
|
1101
1300
|
|
|
1102
1301
|
// Wait for the first turn to finish (instructions flushed at turn boundary)
|
|
@@ -1107,47 +1306,61 @@ describe('call-controller', () => {
|
|
|
1107
1306
|
|
|
1108
1307
|
// The queued instruction should have been flushed into a new turn
|
|
1109
1308
|
expect(turnContents.length).toBeGreaterThanOrEqual(1);
|
|
1110
|
-
expect(
|
|
1309
|
+
expect(
|
|
1310
|
+
turnContents.some((c) =>
|
|
1311
|
+
c.includes("[USER_INSTRUCTION: Suggest morning slots]"),
|
|
1312
|
+
),
|
|
1313
|
+
).toBe(true);
|
|
1111
1314
|
|
|
1112
1315
|
// Controller should return to idle after the flush turn completes
|
|
1113
|
-
expect(controller.getState()).toBe(
|
|
1316
|
+
expect(controller.getState()).toBe("idle");
|
|
1114
1317
|
|
|
1115
1318
|
controller.destroy();
|
|
1116
1319
|
});
|
|
1117
1320
|
|
|
1118
1321
|
// ── Post-end-call drain guard ───────────────────────────────────
|
|
1119
1322
|
|
|
1120
|
-
test(
|
|
1323
|
+
test("handleUserAnswer: answer turn ends call with END_CALL, no further turns after completion", async () => {
|
|
1121
1324
|
// Trigger ASK_GUARDIAN to start a consultation
|
|
1122
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1123
|
-
[
|
|
1124
|
-
)
|
|
1325
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1326
|
+
createMockVoiceTurn(["Checking. [ASK_GUARDIAN: Confirm cancellation?]"]),
|
|
1327
|
+
);
|
|
1125
1328
|
const { session, relay, controller } = setupController();
|
|
1126
|
-
await controller.handleCallerUtterance(
|
|
1127
|
-
expect(controller.getState()).toBe(
|
|
1329
|
+
await controller.handleCallerUtterance("I want to cancel");
|
|
1330
|
+
expect(controller.getState()).toBe("idle");
|
|
1128
1331
|
expect(controller.getPendingConsultationQuestionId()).not.toBeNull();
|
|
1129
1332
|
|
|
1130
1333
|
// Set up mock so the answer turn ends the call with [END_CALL]
|
|
1131
1334
|
const turnContents: string[] = [];
|
|
1132
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1335
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1336
|
+
async (opts: {
|
|
1337
|
+
content: string;
|
|
1338
|
+
onTextDelta: (t: string) => void;
|
|
1339
|
+
onComplete: () => void;
|
|
1340
|
+
}) => {
|
|
1341
|
+
turnContents.push(opts.content);
|
|
1342
|
+
opts.onTextDelta(
|
|
1343
|
+
"Alright, your appointment is cancelled. Goodbye! [END_CALL]",
|
|
1344
|
+
);
|
|
1345
|
+
opts.onComplete();
|
|
1346
|
+
return { turnId: `run-${turnContents.length}`, abort: () => {} };
|
|
1347
|
+
},
|
|
1348
|
+
);
|
|
1138
1349
|
|
|
1139
|
-
const accepted = await controller.handleUserAnswer(
|
|
1350
|
+
const accepted = await controller.handleUserAnswer("Yes, cancel it");
|
|
1140
1351
|
expect(accepted).toBe(true);
|
|
1141
1352
|
|
|
1142
1353
|
// Give fire-and-forget turns time to complete
|
|
1143
1354
|
await new Promise((r) => setTimeout(r, 100));
|
|
1144
1355
|
|
|
1145
1356
|
// The answer turn should have fired
|
|
1146
|
-
expect(
|
|
1357
|
+
expect(
|
|
1358
|
+
turnContents.some((c) => c.includes("[USER_ANSWERED: Yes, cancel it]")),
|
|
1359
|
+
).toBe(true);
|
|
1147
1360
|
|
|
1148
1361
|
// Call should be completed
|
|
1149
1362
|
const updatedSession = getCallSession(session.id);
|
|
1150
|
-
expect(updatedSession!.status).toBe(
|
|
1363
|
+
expect(updatedSession!.status).toBe("completed");
|
|
1151
1364
|
expect(relay.endCalled).toBe(true);
|
|
1152
1365
|
|
|
1153
1366
|
controller.destroy();
|
|
@@ -1155,27 +1368,35 @@ describe('call-controller', () => {
|
|
|
1155
1368
|
|
|
1156
1369
|
// ── Consultation timeout with generated turn ─────────────────────
|
|
1157
1370
|
|
|
1158
|
-
test(
|
|
1371
|
+
test("consultation timeout: fires generated turn with GUARDIAN_TIMEOUT instruction instead of hardcoded text", async () => {
|
|
1159
1372
|
// Use a short consultation timeout so we can wait for it in the test
|
|
1160
1373
|
mockConsultationTimeoutMs = 50;
|
|
1161
1374
|
|
|
1162
1375
|
// Trigger ASK_GUARDIAN to start a consultation
|
|
1163
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1164
|
-
[
|
|
1165
|
-
)
|
|
1376
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1377
|
+
createMockVoiceTurn(["Let me check. [ASK_GUARDIAN: What time works?]"]),
|
|
1378
|
+
);
|
|
1166
1379
|
const { session, relay, controller } = setupController();
|
|
1167
|
-
await controller.handleCallerUtterance(
|
|
1168
|
-
expect(controller.getState()).toBe(
|
|
1380
|
+
await controller.handleCallerUtterance("Book me in");
|
|
1381
|
+
expect(controller.getState()).toBe("idle");
|
|
1169
1382
|
expect(controller.getPendingConsultationQuestionId()).not.toBeNull();
|
|
1170
1383
|
|
|
1171
1384
|
// Set up mock to capture what content the timeout turn receives
|
|
1172
1385
|
const turnContents: string[] = [];
|
|
1173
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1386
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1387
|
+
async (opts: {
|
|
1388
|
+
content: string;
|
|
1389
|
+
onTextDelta: (t: string) => void;
|
|
1390
|
+
onComplete: () => void;
|
|
1391
|
+
}) => {
|
|
1392
|
+
turnContents.push(opts.content);
|
|
1393
|
+
opts.onTextDelta(
|
|
1394
|
+
"I'm sorry, I wasn't able to reach them. Would you like a callback?",
|
|
1395
|
+
);
|
|
1396
|
+
opts.onComplete();
|
|
1397
|
+
return { turnId: `run-${turnContents.length}`, abort: () => {} };
|
|
1398
|
+
},
|
|
1399
|
+
);
|
|
1179
1400
|
|
|
1180
1401
|
// Wait for the short consultation timeout to fire
|
|
1181
1402
|
await new Promise((r) => setTimeout(r, 200));
|
|
@@ -1184,50 +1405,60 @@ describe('call-controller', () => {
|
|
|
1184
1405
|
// The instruction starts with '[' so flushPendingInstructions passes it through
|
|
1185
1406
|
// without wrapping it in [USER_INSTRUCTION:].
|
|
1186
1407
|
expect(turnContents.length).toBe(1);
|
|
1187
|
-
expect(turnContents[0]).toContain(
|
|
1188
|
-
expect(turnContents[0]).toContain(
|
|
1408
|
+
expect(turnContents[0]).toContain("[GUARDIAN_TIMEOUT]");
|
|
1409
|
+
expect(turnContents[0]).toContain("What time works?");
|
|
1189
1410
|
|
|
1190
1411
|
// No hardcoded timeout text should appear in relay tokens
|
|
1191
|
-
const allText = relay.sentTokens.map((t) => t.token).join(
|
|
1192
|
-
expect(allText).not.toContain(
|
|
1193
|
-
|
|
1412
|
+
const allText = relay.sentTokens.map((t) => t.token).join("");
|
|
1413
|
+
expect(allText).not.toContain(
|
|
1414
|
+
"I'm sorry, I wasn't able to get that information in time",
|
|
1415
|
+
);
|
|
1416
|
+
expect(allText).toContain("callback");
|
|
1194
1417
|
|
|
1195
1418
|
// Session should be back in progress
|
|
1196
1419
|
const updatedSession = getCallSession(session.id);
|
|
1197
|
-
expect(updatedSession!.status).toBe(
|
|
1420
|
+
expect(updatedSession!.status).toBe("in_progress");
|
|
1198
1421
|
|
|
1199
1422
|
controller.destroy();
|
|
1200
1423
|
});
|
|
1201
1424
|
|
|
1202
|
-
test(
|
|
1425
|
+
test("consultation timeout: timeout instruction fires even when controller is idle", async () => {
|
|
1203
1426
|
// Use a short consultation timeout so we can wait for it in the test
|
|
1204
1427
|
mockConsultationTimeoutMs = 50;
|
|
1205
1428
|
|
|
1206
1429
|
// Trigger ASK_GUARDIAN to start a consultation
|
|
1207
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1208
|
-
[
|
|
1209
|
-
)
|
|
1430
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1431
|
+
createMockVoiceTurn(["Let me check. [ASK_GUARDIAN: What time works?]"]),
|
|
1432
|
+
);
|
|
1210
1433
|
const { controller } = setupController();
|
|
1211
|
-
await controller.handleCallerUtterance(
|
|
1212
|
-
expect(controller.getState()).toBe(
|
|
1434
|
+
await controller.handleCallerUtterance("Book me in");
|
|
1435
|
+
expect(controller.getState()).toBe("idle");
|
|
1213
1436
|
expect(controller.getPendingConsultationQuestionId()).not.toBeNull();
|
|
1214
1437
|
|
|
1215
1438
|
// Set up mock to capture what content the timeout turn receives
|
|
1216
1439
|
const turnContents: string[] = [];
|
|
1217
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1440
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1441
|
+
async (opts: {
|
|
1442
|
+
content: string;
|
|
1443
|
+
onTextDelta: (t: string) => void;
|
|
1444
|
+
onComplete: () => void;
|
|
1445
|
+
}) => {
|
|
1446
|
+
turnContents.push(opts.content);
|
|
1447
|
+
opts.onTextDelta("Got it, I was unable to reach them.");
|
|
1448
|
+
opts.onComplete();
|
|
1449
|
+
return { turnId: `run-${turnContents.length}`, abort: () => {} };
|
|
1450
|
+
},
|
|
1451
|
+
);
|
|
1223
1452
|
|
|
1224
1453
|
// Wait for the short consultation timeout to fire
|
|
1225
1454
|
await new Promise((r) => setTimeout(r, 200));
|
|
1226
1455
|
|
|
1227
1456
|
// The timeout instruction turn should have fired
|
|
1228
|
-
const timeoutTurns = turnContents.filter((c) =>
|
|
1457
|
+
const timeoutTurns = turnContents.filter((c) =>
|
|
1458
|
+
c.includes("[GUARDIAN_TIMEOUT]"),
|
|
1459
|
+
);
|
|
1229
1460
|
expect(timeoutTurns.length).toBe(1);
|
|
1230
|
-
expect(timeoutTurns[0]).toContain(
|
|
1461
|
+
expect(timeoutTurns[0]).toContain("What time works?");
|
|
1231
1462
|
|
|
1232
1463
|
// Consultation should be cleared after timeout
|
|
1233
1464
|
expect(controller.getPendingConsultationQuestionId()).toBeNull();
|
|
@@ -1235,30 +1466,34 @@ describe('call-controller', () => {
|
|
|
1235
1466
|
controller.destroy();
|
|
1236
1467
|
});
|
|
1237
1468
|
|
|
1238
|
-
test(
|
|
1469
|
+
test("consultation timeout: marks linked guardian action request as timed out", async () => {
|
|
1239
1470
|
mockConsultationTimeoutMs = 50;
|
|
1240
1471
|
|
|
1241
1472
|
// Trigger ASK_GUARDIAN to start a consultation
|
|
1242
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1243
|
-
[
|
|
1244
|
-
)
|
|
1473
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1474
|
+
createMockVoiceTurn(["Let me check. [ASK_GUARDIAN: What time works?]"]),
|
|
1475
|
+
);
|
|
1245
1476
|
const { session, controller } = setupController();
|
|
1246
|
-
await controller.handleCallerUtterance(
|
|
1247
|
-
expect(controller.getState()).toBe(
|
|
1477
|
+
await controller.handleCallerUtterance("Book me in");
|
|
1478
|
+
expect(controller.getState()).toBe("idle");
|
|
1248
1479
|
expect(controller.getPendingConsultationQuestionId()).not.toBeNull();
|
|
1249
1480
|
|
|
1250
1481
|
// Give the async dispatchGuardianQuestion a tick to create the request
|
|
1251
1482
|
await new Promise((r) => setTimeout(r, 10));
|
|
1252
1483
|
|
|
1253
1484
|
// Verify a guardian action request was created
|
|
1254
|
-
const pendingRequest = getPendingCanonicalRequestByCallSessionId(
|
|
1485
|
+
const pendingRequest = getPendingCanonicalRequestByCallSessionId(
|
|
1486
|
+
session.id,
|
|
1487
|
+
);
|
|
1255
1488
|
expect(pendingRequest).not.toBeNull();
|
|
1256
|
-
expect(pendingRequest!.status).toBe(
|
|
1489
|
+
expect(pendingRequest!.status).toBe("pending");
|
|
1257
1490
|
|
|
1258
1491
|
// Set up mock for the timeout-generated turn
|
|
1259
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1260
|
-
[
|
|
1261
|
-
|
|
1492
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1493
|
+
createMockVoiceTurn([
|
|
1494
|
+
"I'm sorry, I couldn't reach them. Would you like a callback?",
|
|
1495
|
+
]),
|
|
1496
|
+
);
|
|
1262
1497
|
|
|
1263
1498
|
// Wait for the consultation timeout
|
|
1264
1499
|
await new Promise((r) => setTimeout(r, 200));
|
|
@@ -1266,11 +1501,13 @@ describe('call-controller', () => {
|
|
|
1266
1501
|
// The canonical guardian request should now be expired
|
|
1267
1502
|
const timedOutRequest = getCanonicalGuardianRequest(pendingRequest!.id);
|
|
1268
1503
|
expect(timedOutRequest).not.toBeNull();
|
|
1269
|
-
expect(timedOutRequest!.status).toBe(
|
|
1504
|
+
expect(timedOutRequest!.status).toBe("expired");
|
|
1270
1505
|
|
|
1271
1506
|
// Event should be recorded
|
|
1272
1507
|
const events = getCallEvents(session.id);
|
|
1273
|
-
const timeoutEvents = events.filter(
|
|
1508
|
+
const timeoutEvents = events.filter(
|
|
1509
|
+
(e) => e.eventType === "guardian_consultation_timed_out",
|
|
1510
|
+
);
|
|
1274
1511
|
expect(timeoutEvents.length).toBe(1);
|
|
1275
1512
|
|
|
1276
1513
|
controller.destroy();
|
|
@@ -1278,59 +1515,75 @@ describe('call-controller', () => {
|
|
|
1278
1515
|
|
|
1279
1516
|
// ── Guardian unavailable skip after timeout ────────────────────────
|
|
1280
1517
|
|
|
1281
|
-
test(
|
|
1518
|
+
test("ASK_GUARDIAN after timeout: skips wait and injects GUARDIAN_UNAVAILABLE instruction", async () => {
|
|
1282
1519
|
mockConsultationTimeoutMs = 50;
|
|
1283
1520
|
|
|
1284
1521
|
// Step 1: Trigger ASK_GUARDIAN to start a consultation
|
|
1285
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1286
|
-
[
|
|
1287
|
-
)
|
|
1522
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1523
|
+
createMockVoiceTurn(["Let me check. [ASK_GUARDIAN: What time works?]"]),
|
|
1524
|
+
);
|
|
1288
1525
|
const { session, controller } = setupController();
|
|
1289
|
-
await controller.handleCallerUtterance(
|
|
1290
|
-
expect(controller.getState()).toBe(
|
|
1526
|
+
await controller.handleCallerUtterance("Book me in");
|
|
1527
|
+
expect(controller.getState()).toBe("idle");
|
|
1291
1528
|
expect(controller.getPendingConsultationQuestionId()).not.toBeNull();
|
|
1292
1529
|
|
|
1293
1530
|
// Step 2: Set up mock for timeout-generated turn
|
|
1294
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1295
|
-
[
|
|
1296
|
-
|
|
1531
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1532
|
+
createMockVoiceTurn([
|
|
1533
|
+
"I'm sorry, I couldn't reach them. Would you like a callback?",
|
|
1534
|
+
]),
|
|
1535
|
+
);
|
|
1297
1536
|
|
|
1298
1537
|
// Wait for the consultation timeout
|
|
1299
1538
|
await new Promise((r) => setTimeout(r, 200));
|
|
1300
|
-
expect(controller.getState()).toBe(
|
|
1539
|
+
expect(controller.getState()).toBe("idle");
|
|
1301
1540
|
|
|
1302
1541
|
// Step 3: Model tries ASK_GUARDIAN again in a subsequent turn
|
|
1303
1542
|
const turnContents: string[] = [];
|
|
1304
1543
|
let turnCount = 0;
|
|
1305
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1544
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1545
|
+
async (opts: {
|
|
1546
|
+
content: string;
|
|
1547
|
+
onTextDelta: (t: string) => void;
|
|
1548
|
+
onComplete: () => void;
|
|
1549
|
+
}) => {
|
|
1550
|
+
turnCount++;
|
|
1551
|
+
turnContents.push(opts.content);
|
|
1552
|
+
if (turnCount === 1) {
|
|
1553
|
+
// First turn: model emits ASK_GUARDIAN again
|
|
1554
|
+
opts.onTextDelta(
|
|
1555
|
+
"Let me check on that. [ASK_GUARDIAN: What about 3pm?]",
|
|
1556
|
+
);
|
|
1557
|
+
} else {
|
|
1558
|
+
// Second turn: model should handle the GUARDIAN_UNAVAILABLE instruction
|
|
1559
|
+
opts.onTextDelta(
|
|
1560
|
+
"Unfortunately I can't reach them. Anything else I can help with?",
|
|
1561
|
+
);
|
|
1562
|
+
}
|
|
1563
|
+
opts.onComplete();
|
|
1564
|
+
return { turnId: `run-${turnCount}`, abort: () => {} };
|
|
1565
|
+
},
|
|
1566
|
+
);
|
|
1318
1567
|
|
|
1319
|
-
await controller.handleCallerUtterance(
|
|
1568
|
+
await controller.handleCallerUtterance("Can we try another time?");
|
|
1320
1569
|
|
|
1321
1570
|
// Give the queued instruction flush time to fire
|
|
1322
1571
|
await new Promise((r) => setTimeout(r, 100));
|
|
1323
1572
|
|
|
1324
1573
|
// The second turn should contain the GUARDIAN_UNAVAILABLE instruction
|
|
1325
1574
|
expect(turnCount).toBeGreaterThanOrEqual(2);
|
|
1326
|
-
expect(turnContents.some((c) => c.includes(
|
|
1575
|
+
expect(turnContents.some((c) => c.includes("[GUARDIAN_UNAVAILABLE]"))).toBe(
|
|
1576
|
+
true,
|
|
1577
|
+
);
|
|
1327
1578
|
// Controller remains idle; no new consultation created
|
|
1328
|
-
expect(controller.getState()).toBe(
|
|
1579
|
+
expect(controller.getState()).toBe("idle");
|
|
1329
1580
|
expect(controller.getPendingConsultationQuestionId()).toBeNull();
|
|
1330
1581
|
|
|
1331
1582
|
// The skip should be recorded as an event
|
|
1332
1583
|
const events = getCallEvents(session.id);
|
|
1333
|
-
const skipEvents = events.filter(
|
|
1584
|
+
const skipEvents = events.filter(
|
|
1585
|
+
(e) => e.eventType === "guardian_unavailable_skipped",
|
|
1586
|
+
);
|
|
1334
1587
|
expect(skipEvents.length).toBe(1);
|
|
1335
1588
|
|
|
1336
1589
|
controller.destroy();
|
|
@@ -1338,169 +1591,193 @@ describe('call-controller', () => {
|
|
|
1338
1591
|
|
|
1339
1592
|
// ── Structured tool-approval ASK_GUARDIAN_APPROVAL ──────────────────
|
|
1340
1593
|
|
|
1341
|
-
test(
|
|
1594
|
+
test("ASK_GUARDIAN_APPROVAL: persists toolName and inputDigest on guardian action request", async () => {
|
|
1342
1595
|
const approvalPayload = JSON.stringify({
|
|
1343
|
-
question:
|
|
1344
|
-
toolName:
|
|
1345
|
-
input: { to:
|
|
1596
|
+
question: "Allow send_email to bob@example.com?",
|
|
1597
|
+
toolName: "send_email",
|
|
1598
|
+
input: { to: "bob@example.com", subject: "Hello" },
|
|
1346
1599
|
});
|
|
1347
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1348
|
-
[
|
|
1349
|
-
|
|
1350
|
-
|
|
1600
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1601
|
+
createMockVoiceTurn([
|
|
1602
|
+
`Let me check with your guardian. [ASK_GUARDIAN_APPROVAL: ${approvalPayload}]`,
|
|
1603
|
+
]),
|
|
1604
|
+
);
|
|
1605
|
+
const { session, relay, controller } = setupController("Send an email");
|
|
1351
1606
|
|
|
1352
|
-
await controller.handleCallerUtterance(
|
|
1607
|
+
await controller.handleCallerUtterance("Send an email to Bob");
|
|
1353
1608
|
|
|
1354
1609
|
// Give the async dispatchGuardianQuestion a tick to create the request
|
|
1355
1610
|
await new Promise((r) => setTimeout(r, 50));
|
|
1356
1611
|
|
|
1357
1612
|
// Controller returns to idle (non-blocking); consultation tracked separately
|
|
1358
|
-
expect(controller.getState()).toBe(
|
|
1613
|
+
expect(controller.getState()).toBe("idle");
|
|
1359
1614
|
expect(controller.getPendingConsultationQuestionId()).not.toBeNull();
|
|
1360
1615
|
|
|
1361
1616
|
// Verify a pending question was created with the correct text
|
|
1362
1617
|
const question = getPendingQuestion(session.id);
|
|
1363
1618
|
expect(question).not.toBeNull();
|
|
1364
|
-
expect(question!.questionText).toBe(
|
|
1619
|
+
expect(question!.questionText).toBe("Allow send_email to bob@example.com?");
|
|
1365
1620
|
|
|
1366
1621
|
// Verify the guardian action request has tool metadata
|
|
1367
|
-
const pendingRequest = getPendingCanonicalRequestByCallSessionId(
|
|
1622
|
+
const pendingRequest = getPendingCanonicalRequestByCallSessionId(
|
|
1623
|
+
session.id,
|
|
1624
|
+
);
|
|
1368
1625
|
expect(pendingRequest).not.toBeNull();
|
|
1369
|
-
expect(pendingRequest!.toolName).toBe(
|
|
1626
|
+
expect(pendingRequest!.toolName).toBe("send_email");
|
|
1370
1627
|
expect(pendingRequest!.inputDigest).not.toBeNull();
|
|
1371
1628
|
expect(pendingRequest!.inputDigest!.length).toBe(64); // SHA-256 hex = 64 chars
|
|
1372
1629
|
|
|
1373
1630
|
// The ASK_GUARDIAN_APPROVAL marker should NOT appear in the relay tokens
|
|
1374
|
-
const allText = relay.sentTokens.map((t) => t.token).join(
|
|
1375
|
-
expect(allText).not.toContain(
|
|
1376
|
-
expect(allText).not.toContain(
|
|
1631
|
+
const allText = relay.sentTokens.map((t) => t.token).join("");
|
|
1632
|
+
expect(allText).not.toContain("[ASK_GUARDIAN_APPROVAL:");
|
|
1633
|
+
expect(allText).not.toContain("send_email");
|
|
1377
1634
|
|
|
1378
1635
|
controller.destroy();
|
|
1379
1636
|
});
|
|
1380
1637
|
|
|
1381
|
-
test(
|
|
1638
|
+
test("ASK_GUARDIAN_APPROVAL: computes deterministic digest for same tool+input", async () => {
|
|
1382
1639
|
const approvalPayload = JSON.stringify({
|
|
1383
|
-
question:
|
|
1384
|
-
toolName:
|
|
1385
|
-
input: { subject:
|
|
1640
|
+
question: "Allow send_email?",
|
|
1641
|
+
toolName: "send_email",
|
|
1642
|
+
input: { subject: "Hello", to: "bob@example.com" },
|
|
1386
1643
|
});
|
|
1387
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1388
|
-
[
|
|
1389
|
-
|
|
1390
|
-
|
|
1644
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1645
|
+
createMockVoiceTurn([
|
|
1646
|
+
`Checking. [ASK_GUARDIAN_APPROVAL: ${approvalPayload}]`,
|
|
1647
|
+
]),
|
|
1648
|
+
);
|
|
1649
|
+
const { session, controller } = setupController("Send email");
|
|
1391
1650
|
|
|
1392
|
-
await controller.handleCallerUtterance(
|
|
1651
|
+
await controller.handleCallerUtterance("Send it");
|
|
1393
1652
|
await new Promise((r) => setTimeout(r, 50));
|
|
1394
1653
|
|
|
1395
1654
|
const request1 = getPendingCanonicalRequestByCallSessionId(session.id);
|
|
1396
1655
|
expect(request1).not.toBeNull();
|
|
1397
1656
|
|
|
1398
1657
|
// Compute expected digest independently using the same utility
|
|
1399
|
-
const { computeToolApprovalDigest } =
|
|
1400
|
-
|
|
1658
|
+
const { computeToolApprovalDigest } =
|
|
1659
|
+
await import("../security/tool-approval-digest.js");
|
|
1660
|
+
const expectedDigest = computeToolApprovalDigest("send_email", {
|
|
1661
|
+
subject: "Hello",
|
|
1662
|
+
to: "bob@example.com",
|
|
1663
|
+
});
|
|
1401
1664
|
expect(request1!.inputDigest).toBe(expectedDigest);
|
|
1402
1665
|
|
|
1403
1666
|
controller.destroy();
|
|
1404
1667
|
});
|
|
1405
1668
|
|
|
1406
|
-
test(
|
|
1407
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1408
|
-
[
|
|
1409
|
-
|
|
1410
|
-
|
|
1669
|
+
test("informational ASK_GUARDIAN: does NOT persist tool metadata (null toolName/inputDigest)", async () => {
|
|
1670
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1671
|
+
createMockVoiceTurn([
|
|
1672
|
+
"Let me check. [ASK_GUARDIAN: What date works best?]",
|
|
1673
|
+
]),
|
|
1674
|
+
);
|
|
1675
|
+
const { session, controller } = setupController("Book appointment");
|
|
1411
1676
|
|
|
1412
|
-
await controller.handleCallerUtterance(
|
|
1677
|
+
await controller.handleCallerUtterance("I need to schedule something");
|
|
1413
1678
|
await new Promise((r) => setTimeout(r, 50));
|
|
1414
1679
|
|
|
1415
1680
|
// Verify the guardian action request has NO tool metadata
|
|
1416
|
-
const pendingRequest = getPendingCanonicalRequestByCallSessionId(
|
|
1681
|
+
const pendingRequest = getPendingCanonicalRequestByCallSessionId(
|
|
1682
|
+
session.id,
|
|
1683
|
+
);
|
|
1417
1684
|
expect(pendingRequest).not.toBeNull();
|
|
1418
1685
|
expect(pendingRequest!.toolName).toBeNull();
|
|
1419
1686
|
expect(pendingRequest!.inputDigest).toBeNull();
|
|
1420
|
-
expect(pendingRequest!.questionText).toBe(
|
|
1687
|
+
expect(pendingRequest!.questionText).toBe("What date works best?");
|
|
1421
1688
|
|
|
1422
1689
|
controller.destroy();
|
|
1423
1690
|
});
|
|
1424
1691
|
|
|
1425
|
-
test(
|
|
1692
|
+
test("ASK_GUARDIAN_APPROVAL: strips marker from TTS output", async () => {
|
|
1426
1693
|
const approvalPayload = JSON.stringify({
|
|
1427
|
-
question:
|
|
1428
|
-
toolName:
|
|
1429
|
-
input: { date:
|
|
1694
|
+
question: "Allow calendar_create?",
|
|
1695
|
+
toolName: "calendar_create",
|
|
1696
|
+
input: { date: "2026-03-01", title: "Meeting" },
|
|
1430
1697
|
});
|
|
1431
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1698
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1699
|
+
createMockVoiceTurn([
|
|
1700
|
+
"Let me get approval for that. ",
|
|
1701
|
+
`[ASK_GUARDIAN_APPROVAL: ${approvalPayload}]`,
|
|
1702
|
+
" Thank you.",
|
|
1703
|
+
]),
|
|
1704
|
+
);
|
|
1705
|
+
const { relay, controller } = setupController("Create event");
|
|
1437
1706
|
|
|
1438
|
-
await controller.handleCallerUtterance(
|
|
1707
|
+
await controller.handleCallerUtterance("Create a meeting");
|
|
1439
1708
|
|
|
1440
|
-
const allText = relay.sentTokens.map((t) => t.token).join(
|
|
1441
|
-
expect(allText).toContain(
|
|
1442
|
-
expect(allText).not.toContain(
|
|
1443
|
-
expect(allText).not.toContain(
|
|
1444
|
-
expect(allText).not.toContain(
|
|
1709
|
+
const allText = relay.sentTokens.map((t) => t.token).join("");
|
|
1710
|
+
expect(allText).toContain("Let me get approval");
|
|
1711
|
+
expect(allText).not.toContain("[ASK_GUARDIAN_APPROVAL:");
|
|
1712
|
+
expect(allText).not.toContain("calendar_create");
|
|
1713
|
+
expect(allText).not.toContain("inputDigest");
|
|
1445
1714
|
|
|
1446
1715
|
controller.destroy();
|
|
1447
1716
|
});
|
|
1448
1717
|
|
|
1449
|
-
test(
|
|
1718
|
+
test("ASK_GUARDIAN_APPROVAL: handles JSON payloads containing }] in string values", async () => {
|
|
1450
1719
|
// The `}]` sequence inside a JSON string value previously caused the
|
|
1451
1720
|
// non-greedy regex to terminate early, truncating the JSON and leaking
|
|
1452
1721
|
// partial data into TTS output.
|
|
1453
1722
|
const approvalPayload = JSON.stringify({
|
|
1454
|
-
question:
|
|
1455
|
-
toolName:
|
|
1456
|
-
input: { msg:
|
|
1723
|
+
question: "Allow send_message?",
|
|
1724
|
+
toolName: "send_message",
|
|
1725
|
+
input: { msg: "test}]more", nested: { key: "value with }] braces" } },
|
|
1457
1726
|
});
|
|
1458
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1459
|
-
[
|
|
1460
|
-
|
|
1461
|
-
|
|
1727
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1728
|
+
createMockVoiceTurn([
|
|
1729
|
+
`Let me check. [ASK_GUARDIAN_APPROVAL: ${approvalPayload}]`,
|
|
1730
|
+
]),
|
|
1731
|
+
);
|
|
1732
|
+
const { session, relay, controller } = setupController("Send a message");
|
|
1462
1733
|
|
|
1463
|
-
await controller.handleCallerUtterance(
|
|
1734
|
+
await controller.handleCallerUtterance("Send it");
|
|
1464
1735
|
await new Promise((r) => setTimeout(r, 50));
|
|
1465
1736
|
|
|
1466
1737
|
// Controller returns to idle (non-blocking); consultation tracked separately
|
|
1467
|
-
expect(controller.getState()).toBe(
|
|
1738
|
+
expect(controller.getState()).toBe("idle");
|
|
1468
1739
|
expect(controller.getPendingConsultationQuestionId()).not.toBeNull();
|
|
1469
1740
|
const question = getPendingQuestion(session.id);
|
|
1470
1741
|
expect(question).not.toBeNull();
|
|
1471
|
-
expect(question!.questionText).toBe(
|
|
1742
|
+
expect(question!.questionText).toBe("Allow send_message?");
|
|
1472
1743
|
|
|
1473
1744
|
// Verify tool metadata was parsed correctly
|
|
1474
|
-
const pendingRequest = getPendingCanonicalRequestByCallSessionId(
|
|
1745
|
+
const pendingRequest = getPendingCanonicalRequestByCallSessionId(
|
|
1746
|
+
session.id,
|
|
1747
|
+
);
|
|
1475
1748
|
expect(pendingRequest).not.toBeNull();
|
|
1476
|
-
expect(pendingRequest!.toolName).toBe(
|
|
1749
|
+
expect(pendingRequest!.toolName).toBe("send_message");
|
|
1477
1750
|
expect(pendingRequest!.inputDigest).not.toBeNull();
|
|
1478
1751
|
|
|
1479
1752
|
// No partial JSON or marker text should leak into TTS output
|
|
1480
|
-
const allText = relay.sentTokens.map((t) => t.token).join(
|
|
1481
|
-
expect(allText).not.toContain(
|
|
1482
|
-
expect(allText).not.toContain(
|
|
1483
|
-
expect(allText).not.toContain(
|
|
1484
|
-
expect(allText).not.toContain(
|
|
1485
|
-
expect(allText).toContain(
|
|
1753
|
+
const allText = relay.sentTokens.map((t) => t.token).join("");
|
|
1754
|
+
expect(allText).not.toContain("[ASK_GUARDIAN_APPROVAL:");
|
|
1755
|
+
expect(allText).not.toContain("send_message");
|
|
1756
|
+
expect(allText).not.toContain("}]");
|
|
1757
|
+
expect(allText).not.toContain("test}]more");
|
|
1758
|
+
expect(allText).toContain("Let me check.");
|
|
1486
1759
|
|
|
1487
1760
|
controller.destroy();
|
|
1488
1761
|
});
|
|
1489
1762
|
|
|
1490
|
-
test(
|
|
1763
|
+
test("ASK_GUARDIAN_APPROVAL with malformed JSON: falls through to informational ASK_GUARDIAN", async () => {
|
|
1491
1764
|
// Malformed JSON in the approval marker — should be ignored, and if there's
|
|
1492
1765
|
// also an informational ASK_GUARDIAN marker, it should be used instead
|
|
1493
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1494
|
-
[
|
|
1495
|
-
|
|
1496
|
-
|
|
1766
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1767
|
+
createMockVoiceTurn([
|
|
1768
|
+
"Checking. [ASK_GUARDIAN_APPROVAL: {invalid json}] [ASK_GUARDIAN: Fallback question?]",
|
|
1769
|
+
]),
|
|
1770
|
+
);
|
|
1771
|
+
const { session, controller } = setupController("Test fallback");
|
|
1497
1772
|
|
|
1498
|
-
await controller.handleCallerUtterance(
|
|
1773
|
+
await controller.handleCallerUtterance("Do something");
|
|
1499
1774
|
await new Promise((r) => setTimeout(r, 50));
|
|
1500
1775
|
|
|
1501
|
-
const pendingRequest = getPendingCanonicalRequestByCallSessionId(
|
|
1776
|
+
const pendingRequest = getPendingCanonicalRequestByCallSessionId(
|
|
1777
|
+
session.id,
|
|
1778
|
+
);
|
|
1502
1779
|
expect(pendingRequest).not.toBeNull();
|
|
1503
|
-
expect(pendingRequest!.questionText).toBe(
|
|
1780
|
+
expect(pendingRequest!.questionText).toBe("Fallback question?");
|
|
1504
1781
|
// Tool metadata should be null since the approval marker was malformed
|
|
1505
1782
|
expect(pendingRequest!.toolName).toBeNull();
|
|
1506
1783
|
expect(pendingRequest!.inputDigest).toBeNull();
|
|
@@ -1510,37 +1787,47 @@ describe('call-controller', () => {
|
|
|
1510
1787
|
|
|
1511
1788
|
// ── Non-blocking race safety ───────────────────────────────────────
|
|
1512
1789
|
|
|
1513
|
-
test(
|
|
1790
|
+
test("guardian answer during processing/speaking: queued in pendingInstructions and applied at next turn boundary", async () => {
|
|
1514
1791
|
// Trigger ASK_GUARDIAN to start a consultation
|
|
1515
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1516
|
-
[
|
|
1517
|
-
)
|
|
1792
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1793
|
+
createMockVoiceTurn(["Checking. [ASK_GUARDIAN: Confirm appointment?]"]),
|
|
1794
|
+
);
|
|
1518
1795
|
const { controller } = setupController();
|
|
1519
|
-
await controller.handleCallerUtterance(
|
|
1520
|
-
expect(controller.getState()).toBe(
|
|
1796
|
+
await controller.handleCallerUtterance("I want to schedule");
|
|
1797
|
+
expect(controller.getState()).toBe("idle");
|
|
1521
1798
|
expect(controller.getPendingConsultationQuestionId()).not.toBeNull();
|
|
1522
1799
|
|
|
1523
1800
|
// Start a new turn (caller follow-up) to put controller in processing state
|
|
1524
1801
|
let firstTurnResolve: (() => void) | null = null;
|
|
1525
1802
|
const turnContents: string[] = [];
|
|
1526
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
}
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1803
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1804
|
+
async (opts: {
|
|
1805
|
+
content: string;
|
|
1806
|
+
onTextDelta: (t: string) => void;
|
|
1807
|
+
onComplete: () => void;
|
|
1808
|
+
}) => {
|
|
1809
|
+
turnContents.push(opts.content);
|
|
1810
|
+
if (!firstTurnResolve) {
|
|
1811
|
+
// First turn: pause to simulate processing state
|
|
1812
|
+
await new Promise<void>((resolve) => {
|
|
1813
|
+
firstTurnResolve = resolve;
|
|
1814
|
+
});
|
|
1815
|
+
}
|
|
1816
|
+
opts.onTextDelta("Response.");
|
|
1817
|
+
opts.onComplete();
|
|
1818
|
+
return { turnId: `run-${turnContents.length}`, abort: () => {} };
|
|
1819
|
+
},
|
|
1820
|
+
);
|
|
1536
1821
|
|
|
1537
1822
|
// Start a caller turn that will pause mid-processing
|
|
1538
|
-
const callerTurnPromise = controller.handleCallerUtterance(
|
|
1823
|
+
const callerTurnPromise = controller.handleCallerUtterance(
|
|
1824
|
+
"Are you still there?",
|
|
1825
|
+
);
|
|
1539
1826
|
await new Promise((r) => setTimeout(r, 10));
|
|
1540
|
-
expect(controller.getState()).toBe(
|
|
1827
|
+
expect(controller.getState()).toBe("speaking");
|
|
1541
1828
|
|
|
1542
1829
|
// Answer arrives while the controller is processing/speaking
|
|
1543
|
-
const accepted = await controller.handleUserAnswer(
|
|
1830
|
+
const accepted = await controller.handleUserAnswer("3pm works");
|
|
1544
1831
|
expect(accepted).toBe(true);
|
|
1545
1832
|
// Consultation is consumed immediately
|
|
1546
1833
|
expect(controller.getPendingConsultationQuestionId()).toBeNull();
|
|
@@ -1553,26 +1840,28 @@ describe('call-controller', () => {
|
|
|
1553
1840
|
await new Promise((r) => setTimeout(r, 100));
|
|
1554
1841
|
|
|
1555
1842
|
// The queued USER_ANSWERED instruction should have been applied
|
|
1556
|
-
expect(
|
|
1843
|
+
expect(
|
|
1844
|
+
turnContents.some((c) => c.includes("[USER_ANSWERED: 3pm works]")),
|
|
1845
|
+
).toBe(true);
|
|
1557
1846
|
|
|
1558
1847
|
controller.destroy();
|
|
1559
1848
|
});
|
|
1560
1849
|
|
|
1561
|
-
test(
|
|
1850
|
+
test("timeout + late answer: after timeout, a late answer is rejected as stale", async () => {
|
|
1562
1851
|
mockConsultationTimeoutMs = 50;
|
|
1563
1852
|
|
|
1564
1853
|
// Trigger ASK_GUARDIAN to start a consultation
|
|
1565
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1566
|
-
[
|
|
1567
|
-
)
|
|
1854
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1855
|
+
createMockVoiceTurn(["Let me check. [ASK_GUARDIAN: What time works?]"]),
|
|
1856
|
+
);
|
|
1568
1857
|
const { controller } = setupController();
|
|
1569
|
-
await controller.handleCallerUtterance(
|
|
1858
|
+
await controller.handleCallerUtterance("Book me in");
|
|
1570
1859
|
expect(controller.getPendingConsultationQuestionId()).not.toBeNull();
|
|
1571
1860
|
|
|
1572
1861
|
// Set up mock for the timeout-generated turn
|
|
1573
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1574
|
-
[
|
|
1575
|
-
)
|
|
1862
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1863
|
+
createMockVoiceTurn(["Sorry, I could not reach them."]),
|
|
1864
|
+
);
|
|
1576
1865
|
|
|
1577
1866
|
// Wait for the consultation timeout to expire the consultation
|
|
1578
1867
|
await new Promise((r) => setTimeout(r, 200));
|
|
@@ -1581,55 +1870,61 @@ describe('call-controller', () => {
|
|
|
1581
1870
|
expect(controller.getPendingConsultationQuestionId()).toBeNull();
|
|
1582
1871
|
|
|
1583
1872
|
// A late answer should be rejected
|
|
1584
|
-
const lateResult = await controller.handleUserAnswer(
|
|
1873
|
+
const lateResult = await controller.handleUserAnswer("3pm is fine");
|
|
1585
1874
|
expect(lateResult).toBe(false);
|
|
1586
1875
|
|
|
1587
1876
|
controller.destroy();
|
|
1588
1877
|
});
|
|
1589
1878
|
|
|
1590
|
-
test(
|
|
1879
|
+
test("caller follow-up processed normally while consultation pending", async () => {
|
|
1591
1880
|
// Trigger ASK_GUARDIAN to start a consultation
|
|
1592
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1593
|
-
[
|
|
1594
|
-
)
|
|
1881
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1882
|
+
createMockVoiceTurn(["Let me check. [ASK_GUARDIAN: What date?]"]),
|
|
1883
|
+
);
|
|
1595
1884
|
const { relay, controller } = setupController();
|
|
1596
|
-
await controller.handleCallerUtterance(
|
|
1885
|
+
await controller.handleCallerUtterance("Schedule something");
|
|
1597
1886
|
expect(controller.getPendingConsultationQuestionId()).not.toBeNull();
|
|
1598
1887
|
|
|
1599
1888
|
// Caller follows up while consultation is pending
|
|
1600
1889
|
const turnContents: string[] = [];
|
|
1601
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1890
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1891
|
+
async (opts: {
|
|
1892
|
+
content: string;
|
|
1893
|
+
onTextDelta: (t: string) => void;
|
|
1894
|
+
onComplete: () => void;
|
|
1895
|
+
}) => {
|
|
1896
|
+
turnContents.push(opts.content);
|
|
1897
|
+
opts.onTextDelta("Of course, what else can I help with?");
|
|
1898
|
+
opts.onComplete();
|
|
1899
|
+
return { turnId: `run-${turnContents.length}`, abort: () => {} };
|
|
1900
|
+
},
|
|
1901
|
+
);
|
|
1607
1902
|
|
|
1608
|
-
await controller.handleCallerUtterance(
|
|
1903
|
+
await controller.handleCallerUtterance("Can you also check availability?");
|
|
1609
1904
|
|
|
1610
1905
|
// The follow-up should trigger a normal turn (non-blocking)
|
|
1611
1906
|
expect(turnContents.length).toBe(1);
|
|
1612
|
-
expect(turnContents[0]).toContain(
|
|
1907
|
+
expect(turnContents[0]).toContain("Can you also check availability?");
|
|
1613
1908
|
|
|
1614
1909
|
// Consultation should still be pending
|
|
1615
1910
|
expect(controller.getPendingConsultationQuestionId()).not.toBeNull();
|
|
1616
1911
|
|
|
1617
1912
|
// Response should appear in relay
|
|
1618
|
-
const allText = relay.sentTokens.map((t) => t.token).join(
|
|
1619
|
-
expect(allText).toContain(
|
|
1913
|
+
const allText = relay.sentTokens.map((t) => t.token).join("");
|
|
1914
|
+
expect(allText).toContain("what else can I help with");
|
|
1620
1915
|
|
|
1621
1916
|
controller.destroy();
|
|
1622
1917
|
});
|
|
1623
1918
|
|
|
1624
1919
|
// ── Consultation coalescing (Incident C) ────────────────────────────
|
|
1625
1920
|
|
|
1626
|
-
test(
|
|
1921
|
+
test("coalescing: repeated identical informational ASK_GUARDIAN does not create a new request", async () => {
|
|
1627
1922
|
// Trigger first ASK_GUARDIAN
|
|
1628
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1629
|
-
[
|
|
1630
|
-
)
|
|
1923
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1924
|
+
createMockVoiceTurn(["Let me ask. [ASK_GUARDIAN: Preferred date?]"]),
|
|
1925
|
+
);
|
|
1631
1926
|
const { session, controller } = setupController();
|
|
1632
|
-
await controller.handleCallerUtterance(
|
|
1927
|
+
await controller.handleCallerUtterance("Schedule please");
|
|
1633
1928
|
await new Promise((r) => setTimeout(r, 50));
|
|
1634
1929
|
|
|
1635
1930
|
const firstQuestionId = controller.getPendingConsultationQuestionId();
|
|
@@ -1638,40 +1933,46 @@ describe('call-controller', () => {
|
|
|
1638
1933
|
expect(firstRequest).not.toBeNull();
|
|
1639
1934
|
|
|
1640
1935
|
// Repeated ASK_GUARDIAN with same informational question (no tool metadata)
|
|
1641
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1642
|
-
[
|
|
1643
|
-
)
|
|
1644
|
-
await controller.handleCallerUtterance(
|
|
1936
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1937
|
+
createMockVoiceTurn(["Still checking. [ASK_GUARDIAN: Preferred date?]"]),
|
|
1938
|
+
);
|
|
1939
|
+
await controller.handleCallerUtterance("Hello? Still there?");
|
|
1645
1940
|
await new Promise((r) => setTimeout(r, 50));
|
|
1646
1941
|
|
|
1647
1942
|
// Should coalesce: same consultation ID, same request
|
|
1648
1943
|
expect(controller.getPendingConsultationQuestionId()).toBe(firstQuestionId);
|
|
1649
|
-
const currentRequest = getPendingCanonicalRequestByCallSessionId(
|
|
1944
|
+
const currentRequest = getPendingCanonicalRequestByCallSessionId(
|
|
1945
|
+
session.id,
|
|
1946
|
+
);
|
|
1650
1947
|
expect(currentRequest).not.toBeNull();
|
|
1651
1948
|
expect(currentRequest!.id).toBe(firstRequest!.id);
|
|
1652
|
-
expect(currentRequest!.status).toBe(
|
|
1949
|
+
expect(currentRequest!.status).toBe("pending");
|
|
1653
1950
|
|
|
1654
1951
|
// Coalesce event should be recorded
|
|
1655
1952
|
const events = getCallEvents(session.id);
|
|
1656
|
-
const coalesceEvents = events.filter(
|
|
1953
|
+
const coalesceEvents = events.filter(
|
|
1954
|
+
(e) => e.eventType === "guardian_consult_coalesced",
|
|
1955
|
+
);
|
|
1657
1956
|
expect(coalesceEvents.length).toBe(1);
|
|
1658
1957
|
|
|
1659
1958
|
controller.destroy();
|
|
1660
1959
|
});
|
|
1661
1960
|
|
|
1662
|
-
test(
|
|
1961
|
+
test("coalescing: repeated ASK_GUARDIAN_APPROVAL with same tool/input does not create a new request", async () => {
|
|
1663
1962
|
const approvalPayload = JSON.stringify({
|
|
1664
|
-
question:
|
|
1665
|
-
toolName:
|
|
1666
|
-
input: { to:
|
|
1963
|
+
question: "Allow send_email to bob@example.com?",
|
|
1964
|
+
toolName: "send_email",
|
|
1965
|
+
input: { to: "bob@example.com", subject: "Hello" },
|
|
1667
1966
|
});
|
|
1668
1967
|
|
|
1669
1968
|
// First ASK_GUARDIAN_APPROVAL
|
|
1670
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1671
|
-
[
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1969
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1970
|
+
createMockVoiceTurn([
|
|
1971
|
+
`Checking. [ASK_GUARDIAN_APPROVAL: ${approvalPayload}]`,
|
|
1972
|
+
]),
|
|
1973
|
+
);
|
|
1974
|
+
const { session, controller } = setupController("Send email");
|
|
1975
|
+
await controller.handleCallerUtterance("Send email to Bob");
|
|
1675
1976
|
await new Promise((r) => setTimeout(r, 50));
|
|
1676
1977
|
|
|
1677
1978
|
const firstQuestionId = controller.getPendingConsultationQuestionId();
|
|
@@ -1680,102 +1981,120 @@ describe('call-controller', () => {
|
|
|
1680
1981
|
expect(firstRequest).not.toBeNull();
|
|
1681
1982
|
|
|
1682
1983
|
// Repeated ASK_GUARDIAN_APPROVAL with same tool/input
|
|
1683
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1684
|
-
[
|
|
1685
|
-
|
|
1686
|
-
|
|
1984
|
+
mockStartVoiceTurn.mockImplementation(
|
|
1985
|
+
createMockVoiceTurn([
|
|
1986
|
+
`Still checking. [ASK_GUARDIAN_APPROVAL: ${approvalPayload}]`,
|
|
1987
|
+
]),
|
|
1988
|
+
);
|
|
1989
|
+
await controller.handleCallerUtterance("Can you send it already?");
|
|
1687
1990
|
await new Promise((r) => setTimeout(r, 50));
|
|
1688
1991
|
|
|
1689
1992
|
// Should coalesce: same consultation, same request
|
|
1690
1993
|
expect(controller.getPendingConsultationQuestionId()).toBe(firstQuestionId);
|
|
1691
|
-
const currentRequest = getPendingCanonicalRequestByCallSessionId(
|
|
1994
|
+
const currentRequest = getPendingCanonicalRequestByCallSessionId(
|
|
1995
|
+
session.id,
|
|
1996
|
+
);
|
|
1692
1997
|
expect(currentRequest!.id).toBe(firstRequest!.id);
|
|
1693
|
-
expect(currentRequest!.status).toBe(
|
|
1998
|
+
expect(currentRequest!.status).toBe("pending");
|
|
1694
1999
|
|
|
1695
2000
|
controller.destroy();
|
|
1696
2001
|
});
|
|
1697
2002
|
|
|
1698
|
-
test(
|
|
2003
|
+
test("supersession: materially different tool triggers new request with superseded metadata", async () => {
|
|
1699
2004
|
const firstPayload = JSON.stringify({
|
|
1700
|
-
question:
|
|
1701
|
-
toolName:
|
|
1702
|
-
input: { to:
|
|
2005
|
+
question: "Allow send_email?",
|
|
2006
|
+
toolName: "send_email",
|
|
2007
|
+
input: { to: "bob@example.com" },
|
|
1703
2008
|
});
|
|
1704
2009
|
|
|
1705
2010
|
// First ASK_GUARDIAN_APPROVAL for send_email
|
|
1706
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1707
|
-
[
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
2011
|
+
mockStartVoiceTurn.mockImplementation(
|
|
2012
|
+
createMockVoiceTurn([
|
|
2013
|
+
`Checking. [ASK_GUARDIAN_APPROVAL: ${firstPayload}]`,
|
|
2014
|
+
]),
|
|
2015
|
+
);
|
|
2016
|
+
const { session, controller } = setupController("Process request");
|
|
2017
|
+
await controller.handleCallerUtterance("Send email");
|
|
1711
2018
|
await new Promise((r) => setTimeout(r, 50));
|
|
1712
2019
|
|
|
1713
2020
|
const firstRequest = getPendingCanonicalRequestByCallSessionId(session.id);
|
|
1714
2021
|
expect(firstRequest).not.toBeNull();
|
|
1715
|
-
expect(firstRequest!.toolName).toBe(
|
|
2022
|
+
expect(firstRequest!.toolName).toBe("send_email");
|
|
1716
2023
|
|
|
1717
2024
|
// Different tool — should supersede
|
|
1718
2025
|
const secondPayload = JSON.stringify({
|
|
1719
|
-
question:
|
|
1720
|
-
toolName:
|
|
1721
|
-
input: { date:
|
|
2026
|
+
question: "Allow calendar_create?",
|
|
2027
|
+
toolName: "calendar_create",
|
|
2028
|
+
input: { date: "2026-03-01" },
|
|
1722
2029
|
});
|
|
1723
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1724
|
-
[
|
|
1725
|
-
|
|
1726
|
-
|
|
2030
|
+
mockStartVoiceTurn.mockImplementation(
|
|
2031
|
+
createMockVoiceTurn([
|
|
2032
|
+
`Actually, let me do this. [ASK_GUARDIAN_APPROVAL: ${secondPayload}]`,
|
|
2033
|
+
]),
|
|
2034
|
+
);
|
|
2035
|
+
await controller.handleCallerUtterance(
|
|
2036
|
+
"Actually, create a calendar event instead",
|
|
2037
|
+
);
|
|
1727
2038
|
await new Promise((r) => setTimeout(r, 100));
|
|
1728
2039
|
|
|
1729
2040
|
// New consultation should be active
|
|
1730
2041
|
const secondRequest = getPendingCanonicalRequestByCallSessionId(session.id);
|
|
1731
2042
|
expect(secondRequest).not.toBeNull();
|
|
1732
2043
|
expect(secondRequest!.id).not.toBe(firstRequest!.id);
|
|
1733
|
-
expect(secondRequest!.toolName).toBe(
|
|
2044
|
+
expect(secondRequest!.toolName).toBe("calendar_create");
|
|
1734
2045
|
|
|
1735
2046
|
// Old request should be expired (superseded by the new one)
|
|
1736
2047
|
const expiredRequest = getCanonicalGuardianRequest(firstRequest!.id);
|
|
1737
2048
|
expect(expiredRequest).not.toBeNull();
|
|
1738
|
-
expect(expiredRequest!.status).toBe(
|
|
2049
|
+
expect(expiredRequest!.status).toBe("expired");
|
|
1739
2050
|
|
|
1740
2051
|
controller.destroy();
|
|
1741
2052
|
});
|
|
1742
2053
|
|
|
1743
|
-
test(
|
|
2054
|
+
test("tool metadata continuity: re-ask without structured metadata inherits tool scope from prior consultation", async () => {
|
|
1744
2055
|
const approvalPayload = JSON.stringify({
|
|
1745
|
-
question:
|
|
1746
|
-
toolName:
|
|
1747
|
-
input: { to:
|
|
2056
|
+
question: "Allow send_email?",
|
|
2057
|
+
toolName: "send_email",
|
|
2058
|
+
input: { to: "bob@example.com", subject: "Hello" },
|
|
1748
2059
|
});
|
|
1749
2060
|
|
|
1750
2061
|
// First ask with structured tool metadata
|
|
1751
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1752
|
-
[
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
2062
|
+
mockStartVoiceTurn.mockImplementation(
|
|
2063
|
+
createMockVoiceTurn([
|
|
2064
|
+
`Let me check. [ASK_GUARDIAN_APPROVAL: ${approvalPayload}]`,
|
|
2065
|
+
]),
|
|
2066
|
+
);
|
|
2067
|
+
const { session, controller } = setupController("Send email");
|
|
2068
|
+
await controller.handleCallerUtterance("Send email to Bob");
|
|
1756
2069
|
await new Promise((r) => setTimeout(r, 50));
|
|
1757
2070
|
|
|
1758
2071
|
const firstRequest = getPendingCanonicalRequestByCallSessionId(session.id);
|
|
1759
2072
|
expect(firstRequest).not.toBeNull();
|
|
1760
|
-
expect(firstRequest!.toolName).toBe(
|
|
2073
|
+
expect(firstRequest!.toolName).toBe("send_email");
|
|
1761
2074
|
|
|
1762
2075
|
// Re-ask with informational ASK_GUARDIAN (no structured metadata).
|
|
1763
2076
|
// Since the tool metadata matches the existing consultation (inherited),
|
|
1764
2077
|
// this should coalesce rather than supersede.
|
|
1765
|
-
mockStartVoiceTurn.mockImplementation(
|
|
1766
|
-
[
|
|
1767
|
-
|
|
1768
|
-
|
|
2078
|
+
mockStartVoiceTurn.mockImplementation(
|
|
2079
|
+
createMockVoiceTurn([
|
|
2080
|
+
"Checking again. [ASK_GUARDIAN: Can I send that email?]",
|
|
2081
|
+
]),
|
|
2082
|
+
);
|
|
2083
|
+
await controller.handleCallerUtterance("Can you hurry up?");
|
|
1769
2084
|
await new Promise((r) => setTimeout(r, 50));
|
|
1770
2085
|
|
|
1771
2086
|
// Should coalesce: the inherited tool metadata matches the existing consultation
|
|
1772
|
-
const currentRequest = getPendingCanonicalRequestByCallSessionId(
|
|
2087
|
+
const currentRequest = getPendingCanonicalRequestByCallSessionId(
|
|
2088
|
+
session.id,
|
|
2089
|
+
);
|
|
1773
2090
|
expect(currentRequest!.id).toBe(firstRequest!.id);
|
|
1774
|
-
expect(currentRequest!.status).toBe(
|
|
2091
|
+
expect(currentRequest!.status).toBe("pending");
|
|
1775
2092
|
|
|
1776
2093
|
// Coalesce event should be recorded
|
|
1777
2094
|
const events = getCallEvents(session.id);
|
|
1778
|
-
const coalesceEvents = events.filter(
|
|
2095
|
+
const coalesceEvents = events.filter(
|
|
2096
|
+
(e) => e.eventType === "guardian_consult_coalesced",
|
|
2097
|
+
);
|
|
1779
2098
|
expect(coalesceEvents.length).toBe(1);
|
|
1780
2099
|
|
|
1781
2100
|
controller.destroy();
|
|
@@ -1788,21 +2107,21 @@ describe('call-controller', () => {
|
|
|
1788
2107
|
const { relay, controller } = setupController();
|
|
1789
2108
|
|
|
1790
2109
|
// Simulate guardian wait state on the relay
|
|
1791
|
-
relay.mockConnectionState =
|
|
2110
|
+
relay.mockConnectionState = "awaiting_guardian_decision";
|
|
1792
2111
|
|
|
1793
2112
|
// Wait for the silence timeout to fire
|
|
1794
2113
|
await new Promise((r) => setTimeout(r, 200));
|
|
1795
2114
|
|
|
1796
2115
|
// "Are you still there?" should NOT have been sent
|
|
1797
2116
|
const silenceTokens = relay.sentTokens.filter((t) =>
|
|
1798
|
-
t.token.includes(
|
|
2117
|
+
t.token.includes("Are you still there?"),
|
|
1799
2118
|
);
|
|
1800
2119
|
expect(silenceTokens.length).toBe(0);
|
|
1801
2120
|
|
|
1802
2121
|
controller.destroy();
|
|
1803
2122
|
});
|
|
1804
2123
|
|
|
1805
|
-
test(
|
|
2124
|
+
test("silence timeout fires normally when not in guardian wait", async () => {
|
|
1806
2125
|
mockSilenceTimeoutMs = 50; // Short timeout for testing
|
|
1807
2126
|
const { relay, controller } = setupController();
|
|
1808
2127
|
|
|
@@ -1813,7 +2132,7 @@ describe('call-controller', () => {
|
|
|
1813
2132
|
|
|
1814
2133
|
// "Are you still there?" SHOULD have been sent
|
|
1815
2134
|
const silenceTokens = relay.sentTokens.filter((t) =>
|
|
1816
|
-
t.token.includes(
|
|
2135
|
+
t.token.includes("Are you still there?"),
|
|
1817
2136
|
);
|
|
1818
2137
|
expect(silenceTokens.length).toBe(1);
|
|
1819
2138
|
|
|
@@ -1822,23 +2141,25 @@ describe('call-controller', () => {
|
|
|
1822
2141
|
|
|
1823
2142
|
// ── Pointer message regression tests ─────────────────────────────
|
|
1824
2143
|
|
|
1825
|
-
test(
|
|
1826
|
-
mockStartVoiceTurn.mockImplementation(
|
|
2144
|
+
test("END_CALL marker writes completed pointer to origin conversation", async () => {
|
|
2145
|
+
mockStartVoiceTurn.mockImplementation(
|
|
2146
|
+
createMockVoiceTurn(["Goodbye! [END_CALL]"]),
|
|
2147
|
+
);
|
|
1827
2148
|
const { controller } = setupControllerWithOrigin();
|
|
1828
2149
|
|
|
1829
|
-
await controller.handleCallerUtterance(
|
|
2150
|
+
await controller.handleCallerUtterance("Bye");
|
|
1830
2151
|
// Allow async pointer write to flush
|
|
1831
2152
|
await new Promise((r) => setTimeout(r, 100));
|
|
1832
2153
|
|
|
1833
|
-
const text = getLatestAssistantText(
|
|
2154
|
+
const text = getLatestAssistantText("conv-ctrl-origin");
|
|
1834
2155
|
expect(text).not.toBeNull();
|
|
1835
|
-
expect(text!).toContain(
|
|
1836
|
-
expect(text!).toContain(
|
|
2156
|
+
expect(text!).toContain("+15552222222");
|
|
2157
|
+
expect(text!).toContain("completed");
|
|
1837
2158
|
|
|
1838
2159
|
controller.destroy();
|
|
1839
2160
|
});
|
|
1840
2161
|
|
|
1841
|
-
test(
|
|
2162
|
+
test("max duration timeout writes completed pointer to origin conversation", async () => {
|
|
1842
2163
|
// Use a very short max duration to trigger the timeout quickly.
|
|
1843
2164
|
// The real MAX_CALL_DURATION_MS mock is 12 minutes; override via
|
|
1844
2165
|
// call-constants mock (already set to 12*60*1000). Instead, we
|
|
@@ -1848,16 +2169,18 @@ describe('call-controller', () => {
|
|
|
1848
2169
|
// For this test, we check that when the session has an
|
|
1849
2170
|
// initiatedFromConversationId and startedAt, the completion pointer
|
|
1850
2171
|
// is written with a duration.
|
|
1851
|
-
mockStartVoiceTurn.mockImplementation(
|
|
2172
|
+
mockStartVoiceTurn.mockImplementation(
|
|
2173
|
+
createMockVoiceTurn(["Goodbye! [END_CALL]"]),
|
|
2174
|
+
);
|
|
1852
2175
|
const { controller } = setupControllerWithOrigin();
|
|
1853
2176
|
|
|
1854
|
-
await controller.handleCallerUtterance(
|
|
2177
|
+
await controller.handleCallerUtterance("End call");
|
|
1855
2178
|
await new Promise((r) => setTimeout(r, 100));
|
|
1856
2179
|
|
|
1857
|
-
const text = getLatestAssistantText(
|
|
2180
|
+
const text = getLatestAssistantText("conv-ctrl-origin");
|
|
1858
2181
|
expect(text).not.toBeNull();
|
|
1859
|
-
expect(text!).toContain(
|
|
1860
|
-
expect(text!).toContain(
|
|
2182
|
+
expect(text!).toContain("+15552222222");
|
|
2183
|
+
expect(text!).toContain("completed");
|
|
1861
2184
|
|
|
1862
2185
|
controller.destroy();
|
|
1863
2186
|
});
|