@vellumai/assistant 0.4.15 → 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__/approval-routes-http.test.ts +383 -254
- 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 +2 -13
- 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__/pairing-concurrent.test.ts +78 -0
- 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/calls/call-controller.ts +1 -1
- package/src/calls/guardian-action-sweep.ts +6 -6
- package/src/calls/twilio-routes.ts +2 -4
- 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 +69 -4
- package/src/config/env.ts +39 -29
- package/src/daemon/handlers/config-inbox.ts +5 -5
- 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/pairing-store.ts +15 -2
- 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-slash.ts +4 -4
- package/src/daemon/session-surfaces.ts +42 -2
- package/src/runtime/auth/token-service.ts +95 -45
- package/src/runtime/channel-retry-sweep.ts +2 -2
- package/src/runtime/http-server.ts +8 -7
- package/src/runtime/http-types.ts +1 -1
- package/src/runtime/routes/conversation-routes.ts +1 -1
- package/src/runtime/routes/guardian-bootstrap-routes.ts +3 -2
- package/src/runtime/routes/guardian-expiry-sweep.ts +5 -5
- package/src/runtime/routes/pairing-routes.ts +4 -1
- package/src/sequence/reply-matcher.ts +14 -4
- package/src/skills/frontmatter.ts +9 -6
- package/src/tools/ui-surface/definitions.ts +3 -1
- package/src/util/platform.ts +0 -12
- package/docs/architecture/http-token-refresh.md +0 -274
|
@@ -1,25 +1,27 @@
|
|
|
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 { afterAll, beforeEach, describe, expect, mock, test } from
|
|
5
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
6
6
|
|
|
7
|
-
const testDir = mkdtempSync(
|
|
7
|
+
const testDir = mkdtempSync(
|
|
8
|
+
join(tmpdir(), "guardian-action-followup-executor-test-"),
|
|
9
|
+
);
|
|
8
10
|
|
|
9
|
-
mock.module(
|
|
11
|
+
mock.module("../util/platform.js", () => ({
|
|
10
12
|
getDataDir: () => testDir,
|
|
11
|
-
isMacOS: () => process.platform ===
|
|
12
|
-
isLinux: () => process.platform ===
|
|
13
|
-
isWindows: () => process.platform ===
|
|
14
|
-
getSocketPath: () => join(testDir,
|
|
15
|
-
getPidPath: () => join(testDir,
|
|
16
|
-
getDbPath: () => join(testDir,
|
|
17
|
-
getLogPath: () => join(testDir,
|
|
13
|
+
isMacOS: () => process.platform === "darwin",
|
|
14
|
+
isLinux: () => process.platform === "linux",
|
|
15
|
+
isWindows: () => process.platform === "win32",
|
|
16
|
+
getSocketPath: () => join(testDir, "test.sock"),
|
|
17
|
+
getPidPath: () => join(testDir, "test.pid"),
|
|
18
|
+
getDbPath: () => join(testDir, "test.db"),
|
|
19
|
+
getLogPath: () => join(testDir, "test.log"),
|
|
18
20
|
ensureDataDir: () => {},
|
|
19
|
-
readHttpToken: () =>
|
|
21
|
+
readHttpToken: () => "test-token",
|
|
20
22
|
}));
|
|
21
23
|
|
|
22
|
-
mock.module(
|
|
24
|
+
mock.module("../util/logger.js", () => ({
|
|
23
25
|
getLogger: () =>
|
|
24
26
|
new Proxy({} as Record<string, unknown>, {
|
|
25
27
|
get: () => () => {},
|
|
@@ -28,40 +30,69 @@ mock.module('../util/logger.js', () => ({
|
|
|
28
30
|
|
|
29
31
|
// Track SMS deliveries and call starts for assertions
|
|
30
32
|
const deliveredSms: Array<{ url: string; chatId: string; text: string }> = [];
|
|
31
|
-
const startedCalls: Array<{
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
const startedCalls: Array<{
|
|
34
|
+
phoneNumber: string;
|
|
35
|
+
task: string;
|
|
36
|
+
conversationId: string;
|
|
37
|
+
}> = [];
|
|
38
|
+
let mockStartCallResult:
|
|
39
|
+
| {
|
|
40
|
+
ok: true;
|
|
41
|
+
session: { id: string };
|
|
42
|
+
callSid: string;
|
|
43
|
+
callerIdentityMode: string;
|
|
44
|
+
}
|
|
45
|
+
| { ok: false; error: string } = {
|
|
46
|
+
ok: true,
|
|
47
|
+
session: { id: "mock-call-session" },
|
|
48
|
+
callSid: "CA-mock",
|
|
49
|
+
callerIdentityMode: "assistant_number",
|
|
34
50
|
};
|
|
35
51
|
|
|
36
|
-
mock.module(
|
|
37
|
-
deliverChannelReply: async (
|
|
38
|
-
|
|
52
|
+
mock.module("../runtime/gateway-client.js", () => ({
|
|
53
|
+
deliverChannelReply: async (
|
|
54
|
+
url: string,
|
|
55
|
+
payload: { chatId: string; text?: string },
|
|
56
|
+
) => {
|
|
57
|
+
deliveredSms.push({
|
|
58
|
+
url,
|
|
59
|
+
chatId: payload.chatId,
|
|
60
|
+
text: payload.text ?? "",
|
|
61
|
+
});
|
|
39
62
|
},
|
|
40
63
|
}));
|
|
41
64
|
|
|
42
|
-
mock.module(
|
|
43
|
-
startCall: async (input: {
|
|
65
|
+
mock.module("../calls/call-domain.js", () => ({
|
|
66
|
+
startCall: async (input: {
|
|
67
|
+
phoneNumber: string;
|
|
68
|
+
task: string;
|
|
69
|
+
conversationId: string;
|
|
70
|
+
}) => {
|
|
44
71
|
startedCalls.push(input);
|
|
45
72
|
return mockStartCallResult;
|
|
46
73
|
},
|
|
47
74
|
}));
|
|
48
75
|
|
|
49
|
-
mock.module(
|
|
50
|
-
|
|
76
|
+
mock.module("../config/env.js", () => ({
|
|
77
|
+
isHttpAuthDisabled: () => true,
|
|
78
|
+
getGatewayInternalBaseUrl: () => "http://127.0.0.1:7830",
|
|
51
79
|
}));
|
|
52
80
|
|
|
53
81
|
// Mock conversation-key-store for call_back conversation creation
|
|
54
82
|
let conversationCounter = 0;
|
|
55
|
-
mock.module(
|
|
83
|
+
mock.module("../memory/conversation-key-store.js", () => ({
|
|
56
84
|
getOrCreateConversation: () => ({
|
|
57
85
|
conversationId: `mock-conv-${++conversationCounter}`,
|
|
58
86
|
created: true,
|
|
59
87
|
}),
|
|
60
88
|
}));
|
|
61
89
|
|
|
62
|
-
import {
|
|
63
|
-
|
|
64
|
-
|
|
90
|
+
import {
|
|
91
|
+
createCallSession,
|
|
92
|
+
createPendingQuestion,
|
|
93
|
+
} from "../calls/call-store.js";
|
|
94
|
+
import { initializeDb, resetDb } from "../memory/db.js";
|
|
95
|
+
import { getDb } from "../memory/db.js";
|
|
65
96
|
import {
|
|
66
97
|
createGuardianActionDelivery,
|
|
67
98
|
createGuardianActionRequest,
|
|
@@ -70,38 +101,43 @@ import {
|
|
|
70
101
|
progressFollowupState,
|
|
71
102
|
startFollowupFromExpiredRequest,
|
|
72
103
|
updateDeliveryStatus,
|
|
73
|
-
} from
|
|
74
|
-
import { conversations } from
|
|
75
|
-
import { executeFollowupAction } from
|
|
76
|
-
import { resolveCounterparty } from
|
|
104
|
+
} from "../memory/guardian-action-store.js";
|
|
105
|
+
import { conversations } from "../memory/schema.js";
|
|
106
|
+
import { executeFollowupAction } from "../runtime/guardian-action-followup-executor.js";
|
|
107
|
+
import { resolveCounterparty } from "../runtime/guardian-action-followup-executor.js";
|
|
77
108
|
|
|
78
109
|
initializeDb();
|
|
79
110
|
|
|
80
111
|
function ensureConversation(id: string): void {
|
|
81
112
|
const db = getDb();
|
|
82
113
|
const now = Date.now();
|
|
83
|
-
db.insert(conversations)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
114
|
+
db.insert(conversations)
|
|
115
|
+
.values({
|
|
116
|
+
id,
|
|
117
|
+
title: `Conversation ${id}`,
|
|
118
|
+
createdAt: now,
|
|
119
|
+
updatedAt: now,
|
|
120
|
+
})
|
|
121
|
+
.run();
|
|
89
122
|
}
|
|
90
123
|
|
|
91
124
|
function resetTables(): void {
|
|
92
125
|
const db = getDb();
|
|
93
|
-
db.run(
|
|
94
|
-
db.run(
|
|
95
|
-
db.run(
|
|
96
|
-
db.run(
|
|
97
|
-
db.run(
|
|
98
|
-
db.run(
|
|
99
|
-
db.run(
|
|
126
|
+
db.run("DELETE FROM guardian_action_deliveries");
|
|
127
|
+
db.run("DELETE FROM guardian_action_requests");
|
|
128
|
+
db.run("DELETE FROM call_pending_questions");
|
|
129
|
+
db.run("DELETE FROM call_events");
|
|
130
|
+
db.run("DELETE FROM call_sessions");
|
|
131
|
+
db.run("DELETE FROM messages");
|
|
132
|
+
db.run("DELETE FROM conversations");
|
|
100
133
|
deliveredSms.length = 0;
|
|
101
134
|
startedCalls.length = 0;
|
|
102
135
|
conversationCounter = 0;
|
|
103
136
|
mockStartCallResult = {
|
|
104
|
-
ok: true,
|
|
137
|
+
ok: true,
|
|
138
|
+
session: { id: "mock-call-session" },
|
|
139
|
+
callSid: "CA-mock",
|
|
140
|
+
callerIdentityMode: "assistant_number",
|
|
105
141
|
};
|
|
106
142
|
}
|
|
107
143
|
|
|
@@ -109,18 +145,21 @@ function resetTables(): void {
|
|
|
109
145
|
* Create a request in `dispatching` state ready for the executor.
|
|
110
146
|
* The call session has fromNumber='+15550001111' (the counterparty).
|
|
111
147
|
*/
|
|
112
|
-
function createDispatchingRequest(
|
|
148
|
+
function createDispatchingRequest(
|
|
149
|
+
convId: string,
|
|
150
|
+
action: "call_back" | "message_back",
|
|
151
|
+
) {
|
|
113
152
|
ensureConversation(convId);
|
|
114
153
|
const session = createCallSession({
|
|
115
154
|
conversationId: convId,
|
|
116
|
-
provider:
|
|
117
|
-
fromNumber:
|
|
118
|
-
toNumber:
|
|
155
|
+
provider: "twilio",
|
|
156
|
+
fromNumber: "+15550001111",
|
|
157
|
+
toNumber: "+15550002222",
|
|
119
158
|
});
|
|
120
|
-
const pq = createPendingQuestion(session.id,
|
|
159
|
+
const pq = createPendingQuestion(session.id, "What is the gate code?");
|
|
121
160
|
const request = createGuardianActionRequest({
|
|
122
|
-
kind:
|
|
123
|
-
sourceChannel:
|
|
161
|
+
kind: "ask_guardian",
|
|
162
|
+
sourceChannel: "voice",
|
|
124
163
|
sourceConversationId: convId,
|
|
125
164
|
callSessionId: session.id,
|
|
126
165
|
pendingQuestionId: pq.id,
|
|
@@ -132,248 +171,274 @@ function createDispatchingRequest(convId: string, action: 'call_back' | 'message
|
|
|
132
171
|
ensureConversation(deliveryConvId);
|
|
133
172
|
const delivery = createGuardianActionDelivery({
|
|
134
173
|
requestId: request.id,
|
|
135
|
-
destinationChannel:
|
|
136
|
-
destinationChatId:
|
|
137
|
-
destinationExternalUserId:
|
|
174
|
+
destinationChannel: "telegram",
|
|
175
|
+
destinationChatId: "chat-123",
|
|
176
|
+
destinationExternalUserId: "user-456",
|
|
138
177
|
destinationConversationId: deliveryConvId,
|
|
139
178
|
});
|
|
140
|
-
updateDeliveryStatus(delivery.id,
|
|
179
|
+
updateDeliveryStatus(delivery.id, "sent");
|
|
141
180
|
|
|
142
181
|
// Expire the request
|
|
143
|
-
markTimedOutWithReason(request.id,
|
|
182
|
+
markTimedOutWithReason(request.id, "call_timeout");
|
|
144
183
|
|
|
145
184
|
// Start follow-up
|
|
146
|
-
startFollowupFromExpiredRequest(request.id,
|
|
185
|
+
startFollowupFromExpiredRequest(request.id, "The gate code is 1234");
|
|
147
186
|
|
|
148
187
|
// Progress to dispatching with the given action
|
|
149
|
-
progressFollowupState(request.id,
|
|
188
|
+
progressFollowupState(request.id, "dispatching", action);
|
|
150
189
|
|
|
151
|
-
return {
|
|
190
|
+
return {
|
|
191
|
+
request: getGuardianActionRequest(request.id)!,
|
|
192
|
+
delivery,
|
|
193
|
+
callSession: session,
|
|
194
|
+
};
|
|
152
195
|
}
|
|
153
196
|
|
|
154
|
-
describe(
|
|
197
|
+
describe("guardian-action-followup-executor", () => {
|
|
155
198
|
beforeEach(() => {
|
|
156
199
|
resetTables();
|
|
157
200
|
});
|
|
158
201
|
|
|
159
202
|
afterAll(() => {
|
|
160
203
|
resetDb();
|
|
161
|
-
try {
|
|
204
|
+
try {
|
|
205
|
+
rmSync(testDir, { recursive: true });
|
|
206
|
+
} catch {
|
|
207
|
+
/* best effort */
|
|
208
|
+
}
|
|
162
209
|
});
|
|
163
210
|
|
|
164
211
|
// ── Counterparty resolution ─────────────────────────────────────────
|
|
165
212
|
|
|
166
|
-
describe(
|
|
167
|
-
test(
|
|
168
|
-
ensureConversation(
|
|
213
|
+
describe("resolveCounterparty", () => {
|
|
214
|
+
test("resolves fromNumber as counterparty for inbound call", () => {
|
|
215
|
+
ensureConversation("cp-test-1");
|
|
169
216
|
const session = createCallSession({
|
|
170
|
-
conversationId:
|
|
171
|
-
provider:
|
|
172
|
-
fromNumber:
|
|
173
|
-
toNumber:
|
|
217
|
+
conversationId: "cp-test-1",
|
|
218
|
+
provider: "twilio",
|
|
219
|
+
fromNumber: "+15550001111",
|
|
220
|
+
toNumber: "+15550002222",
|
|
174
221
|
});
|
|
175
222
|
|
|
176
223
|
const result = resolveCounterparty(session.id);
|
|
177
224
|
expect(result).not.toBeNull();
|
|
178
|
-
expect(result!.phoneNumber).toBe(
|
|
179
|
-
expect(result!.displayIdentifier).toBe(
|
|
225
|
+
expect(result!.phoneNumber).toBe("+15550001111");
|
|
226
|
+
expect(result!.displayIdentifier).toBe("+15550001111");
|
|
180
227
|
});
|
|
181
228
|
|
|
182
|
-
test(
|
|
183
|
-
ensureConversation(
|
|
229
|
+
test("resolves toNumber as counterparty for outbound call", () => {
|
|
230
|
+
ensureConversation("cp-test-outbound");
|
|
184
231
|
const session = createCallSession({
|
|
185
|
-
conversationId:
|
|
186
|
-
provider:
|
|
187
|
-
fromNumber:
|
|
188
|
-
toNumber:
|
|
189
|
-
initiatedFromConversationId:
|
|
232
|
+
conversationId: "cp-test-outbound",
|
|
233
|
+
provider: "twilio",
|
|
234
|
+
fromNumber: "+15550002222", // assistant's number
|
|
235
|
+
toNumber: "+15550001111", // callee (the counterparty)
|
|
236
|
+
initiatedFromConversationId: "cp-test-outbound", // signals outbound
|
|
190
237
|
});
|
|
191
238
|
|
|
192
239
|
const result = resolveCounterparty(session.id);
|
|
193
240
|
expect(result).not.toBeNull();
|
|
194
|
-
expect(result!.phoneNumber).toBe(
|
|
195
|
-
expect(result!.displayIdentifier).toBe(
|
|
241
|
+
expect(result!.phoneNumber).toBe("+15550001111");
|
|
242
|
+
expect(result!.displayIdentifier).toBe("+15550001111");
|
|
196
243
|
});
|
|
197
244
|
|
|
198
|
-
test(
|
|
199
|
-
const result = resolveCounterparty(
|
|
245
|
+
test("returns null for nonexistent call session", () => {
|
|
246
|
+
const result = resolveCounterparty("nonexistent-session-id");
|
|
200
247
|
expect(result).toBeNull();
|
|
201
248
|
});
|
|
202
249
|
});
|
|
203
250
|
|
|
204
251
|
// ── message_back execution ──────────────────────────────────────────
|
|
205
252
|
|
|
206
|
-
describe(
|
|
207
|
-
test(
|
|
208
|
-
const { request } = createDispatchingRequest(
|
|
253
|
+
describe("message_back", () => {
|
|
254
|
+
test("sends SMS to counterparty and finalizes as completed", async () => {
|
|
255
|
+
const { request } = createDispatchingRequest(
|
|
256
|
+
"exec-msg-1",
|
|
257
|
+
"message_back",
|
|
258
|
+
);
|
|
209
259
|
|
|
210
|
-
const result = await executeFollowupAction(request.id,
|
|
260
|
+
const result = await executeFollowupAction(request.id, "message_back");
|
|
211
261
|
|
|
212
262
|
expect(result.ok).toBe(true);
|
|
213
|
-
expect(result.action).toBe(
|
|
263
|
+
expect(result.action).toBe("message_back");
|
|
214
264
|
expect(result.guardianReplyText.length).toBeGreaterThan(0);
|
|
215
265
|
|
|
216
266
|
// Verify SMS was sent to the counterparty
|
|
217
267
|
expect(deliveredSms.length).toBe(1);
|
|
218
|
-
expect(deliveredSms[0].chatId).toBe(
|
|
219
|
-
expect(deliveredSms[0].url).toContain(
|
|
268
|
+
expect(deliveredSms[0].chatId).toBe("+15550001111");
|
|
269
|
+
expect(deliveredSms[0].url).toContain("/deliver/sms");
|
|
220
270
|
expect(deliveredSms[0].text.length).toBeGreaterThan(0);
|
|
221
271
|
|
|
222
272
|
// Verify follow-up state is completed
|
|
223
273
|
const updated = getGuardianActionRequest(request.id);
|
|
224
|
-
expect(updated!.followupState).toBe(
|
|
274
|
+
expect(updated!.followupState).toBe("completed");
|
|
225
275
|
expect(updated!.followupCompletedAt).toBeGreaterThan(0);
|
|
226
276
|
});
|
|
227
277
|
|
|
228
|
-
test(
|
|
229
|
-
const { request } = createDispatchingRequest(
|
|
278
|
+
test("confirmation text mentions the phone number", async () => {
|
|
279
|
+
const { request } = createDispatchingRequest(
|
|
280
|
+
"exec-msg-2",
|
|
281
|
+
"message_back",
|
|
282
|
+
);
|
|
230
283
|
|
|
231
|
-
const result = await executeFollowupAction(request.id,
|
|
284
|
+
const result = await executeFollowupAction(request.id, "message_back");
|
|
232
285
|
|
|
233
286
|
expect(result.ok).toBe(true);
|
|
234
287
|
// The fallback template includes the phone number
|
|
235
|
-
expect(result.guardianReplyText).toContain(
|
|
288
|
+
expect(result.guardianReplyText).toContain("+15550001111");
|
|
236
289
|
});
|
|
237
290
|
});
|
|
238
291
|
|
|
239
292
|
// ── call_back execution ─────────────────────────────────────────────
|
|
240
293
|
|
|
241
|
-
describe(
|
|
242
|
-
test(
|
|
243
|
-
const { request } = createDispatchingRequest(
|
|
294
|
+
describe("call_back", () => {
|
|
295
|
+
test("starts outbound call to counterparty and finalizes as completed", async () => {
|
|
296
|
+
const { request } = createDispatchingRequest("exec-call-1", "call_back");
|
|
244
297
|
|
|
245
|
-
const result = await executeFollowupAction(request.id,
|
|
298
|
+
const result = await executeFollowupAction(request.id, "call_back");
|
|
246
299
|
|
|
247
300
|
expect(result.ok).toBe(true);
|
|
248
|
-
expect(result.action).toBe(
|
|
301
|
+
expect(result.action).toBe("call_back");
|
|
249
302
|
expect(result.guardianReplyText.length).toBeGreaterThan(0);
|
|
250
303
|
|
|
251
304
|
// Verify call was started to the counterparty
|
|
252
305
|
expect(startedCalls.length).toBe(1);
|
|
253
|
-
expect(startedCalls[0].phoneNumber).toBe(
|
|
254
|
-
expect(startedCalls[0].task).toContain(
|
|
306
|
+
expect(startedCalls[0].phoneNumber).toBe("+15550001111");
|
|
307
|
+
expect(startedCalls[0].task).toContain("gate code");
|
|
255
308
|
|
|
256
309
|
// Verify follow-up state is completed
|
|
257
310
|
const updated = getGuardianActionRequest(request.id);
|
|
258
|
-
expect(updated!.followupState).toBe(
|
|
311
|
+
expect(updated!.followupState).toBe("completed");
|
|
259
312
|
expect(updated!.followupCompletedAt).toBeGreaterThan(0);
|
|
260
313
|
});
|
|
261
314
|
|
|
262
|
-
test(
|
|
263
|
-
const { request } = createDispatchingRequest(
|
|
315
|
+
test("confirmation text mentions calling back", async () => {
|
|
316
|
+
const { request } = createDispatchingRequest("exec-call-2", "call_back");
|
|
264
317
|
|
|
265
|
-
const result = await executeFollowupAction(request.id,
|
|
318
|
+
const result = await executeFollowupAction(request.id, "call_back");
|
|
266
319
|
|
|
267
320
|
expect(result.ok).toBe(true);
|
|
268
|
-
expect(result.guardianReplyText).toContain(
|
|
321
|
+
expect(result.guardianReplyText).toContain("calling");
|
|
269
322
|
});
|
|
270
323
|
|
|
271
|
-
test(
|
|
272
|
-
mockStartCallResult = { ok: false, error:
|
|
273
|
-
const { request } = createDispatchingRequest(
|
|
324
|
+
test("failed call start finalizes as failed with error message", async () => {
|
|
325
|
+
mockStartCallResult = { ok: false, error: "Twilio account suspended" };
|
|
326
|
+
const { request } = createDispatchingRequest(
|
|
327
|
+
"exec-call-fail-1",
|
|
328
|
+
"call_back",
|
|
329
|
+
);
|
|
274
330
|
|
|
275
|
-
const result = await executeFollowupAction(request.id,
|
|
331
|
+
const result = await executeFollowupAction(request.id, "call_back");
|
|
276
332
|
|
|
277
333
|
expect(result.ok).toBe(false);
|
|
278
334
|
if (!result.ok) {
|
|
279
|
-
expect(result.error).toContain(
|
|
335
|
+
expect(result.error).toContain("Twilio account suspended");
|
|
280
336
|
}
|
|
281
337
|
expect(result.guardianReplyText.length).toBeGreaterThan(0);
|
|
282
338
|
|
|
283
339
|
// Verify follow-up state is failed
|
|
284
340
|
const updated = getGuardianActionRequest(request.id);
|
|
285
|
-
expect(updated!.followupState).toBe(
|
|
341
|
+
expect(updated!.followupState).toBe("failed");
|
|
286
342
|
expect(updated!.followupCompletedAt).toBeGreaterThan(0);
|
|
287
343
|
});
|
|
288
344
|
});
|
|
289
345
|
|
|
290
346
|
// ── Error handling ──────────────────────────────────────────────────
|
|
291
347
|
|
|
292
|
-
describe(
|
|
293
|
-
test(
|
|
294
|
-
const result = await executeFollowupAction(
|
|
348
|
+
describe("error handling", () => {
|
|
349
|
+
test("nonexistent request returns failure with error message", async () => {
|
|
350
|
+
const result = await executeFollowupAction("nonexistent-id", "call_back");
|
|
295
351
|
|
|
296
352
|
expect(result.ok).toBe(false);
|
|
297
353
|
if (!result.ok) {
|
|
298
|
-
expect(result.error).toContain(
|
|
354
|
+
expect(result.error).toContain("not found");
|
|
299
355
|
}
|
|
300
356
|
expect(result.guardianReplyText.length).toBeGreaterThan(0);
|
|
301
357
|
});
|
|
302
358
|
|
|
303
|
-
test(
|
|
359
|
+
test("request not in dispatching state returns failure", async () => {
|
|
304
360
|
// Create a request in awaiting_guardian_choice (not dispatching)
|
|
305
|
-
ensureConversation(
|
|
361
|
+
ensureConversation("exec-wrong-state");
|
|
306
362
|
const session = createCallSession({
|
|
307
|
-
conversationId:
|
|
308
|
-
provider:
|
|
309
|
-
fromNumber:
|
|
310
|
-
toNumber:
|
|
363
|
+
conversationId: "exec-wrong-state",
|
|
364
|
+
provider: "twilio",
|
|
365
|
+
fromNumber: "+15550001111",
|
|
366
|
+
toNumber: "+15550002222",
|
|
311
367
|
});
|
|
312
|
-
const pq = createPendingQuestion(session.id,
|
|
368
|
+
const pq = createPendingQuestion(session.id, "Question?");
|
|
313
369
|
const request = createGuardianActionRequest({
|
|
314
|
-
kind:
|
|
315
|
-
sourceChannel:
|
|
316
|
-
sourceConversationId:
|
|
370
|
+
kind: "ask_guardian",
|
|
371
|
+
sourceChannel: "voice",
|
|
372
|
+
sourceConversationId: "exec-wrong-state",
|
|
317
373
|
callSessionId: session.id,
|
|
318
374
|
pendingQuestionId: pq.id,
|
|
319
375
|
questionText: pq.questionText,
|
|
320
376
|
expiresAt: Date.now() - 10_000,
|
|
321
377
|
});
|
|
322
|
-
markTimedOutWithReason(request.id,
|
|
323
|
-
startFollowupFromExpiredRequest(request.id,
|
|
378
|
+
markTimedOutWithReason(request.id, "call_timeout");
|
|
379
|
+
startFollowupFromExpiredRequest(request.id, "Answer");
|
|
324
380
|
// Still in awaiting_guardian_choice — do NOT progress to dispatching
|
|
325
381
|
|
|
326
|
-
const result = await executeFollowupAction(request.id,
|
|
382
|
+
const result = await executeFollowupAction(request.id, "call_back");
|
|
327
383
|
|
|
328
384
|
expect(result.ok).toBe(false);
|
|
329
385
|
if (!result.ok) {
|
|
330
|
-
expect(result.error).toContain(
|
|
386
|
+
expect(result.error).toContain("Invalid followup state");
|
|
331
387
|
}
|
|
332
388
|
});
|
|
333
389
|
|
|
334
|
-
test(
|
|
335
|
-
const { request } = createDispatchingRequest(
|
|
390
|
+
test("follow-up states terminate correctly on success", async () => {
|
|
391
|
+
const { request } = createDispatchingRequest(
|
|
392
|
+
"exec-state-1",
|
|
393
|
+
"message_back",
|
|
394
|
+
);
|
|
336
395
|
|
|
337
|
-
await executeFollowupAction(request.id,
|
|
396
|
+
await executeFollowupAction(request.id, "message_back");
|
|
338
397
|
|
|
339
398
|
const updated = getGuardianActionRequest(request.id);
|
|
340
|
-
expect(updated!.followupState).toBe(
|
|
399
|
+
expect(updated!.followupState).toBe("completed");
|
|
341
400
|
expect(updated!.followupCompletedAt).not.toBeNull();
|
|
342
401
|
});
|
|
343
402
|
|
|
344
|
-
test(
|
|
345
|
-
mockStartCallResult = { ok: false, error:
|
|
346
|
-
const { request } = createDispatchingRequest(
|
|
403
|
+
test("follow-up states terminate correctly on failure", async () => {
|
|
404
|
+
mockStartCallResult = { ok: false, error: "Provider error" };
|
|
405
|
+
const { request } = createDispatchingRequest("exec-state-2", "call_back");
|
|
347
406
|
|
|
348
|
-
await executeFollowupAction(request.id,
|
|
407
|
+
await executeFollowupAction(request.id, "call_back");
|
|
349
408
|
|
|
350
409
|
const updated = getGuardianActionRequest(request.id);
|
|
351
|
-
expect(updated!.followupState).toBe(
|
|
410
|
+
expect(updated!.followupState).toBe("failed");
|
|
352
411
|
expect(updated!.followupCompletedAt).not.toBeNull();
|
|
353
412
|
});
|
|
354
413
|
});
|
|
355
414
|
|
|
356
415
|
// ── Outbound SMS content ────────────────────────────────────────────
|
|
357
416
|
|
|
358
|
-
describe(
|
|
359
|
-
test(
|
|
360
|
-
const { request } = createDispatchingRequest(
|
|
417
|
+
describe("outbound SMS content", () => {
|
|
418
|
+
test("SMS text includes the original question", async () => {
|
|
419
|
+
const { request } = createDispatchingRequest(
|
|
420
|
+
"exec-sms-content-1",
|
|
421
|
+
"message_back",
|
|
422
|
+
);
|
|
361
423
|
|
|
362
|
-
await executeFollowupAction(request.id,
|
|
424
|
+
await executeFollowupAction(request.id, "message_back");
|
|
363
425
|
|
|
364
426
|
expect(deliveredSms.length).toBe(1);
|
|
365
427
|
// The deterministic fallback for 'outbound_message_copy' includes the question
|
|
366
|
-
expect(deliveredSms[0].text).toContain(
|
|
428
|
+
expect(deliveredSms[0].text).toContain("gate code");
|
|
367
429
|
});
|
|
368
430
|
|
|
369
|
-
test(
|
|
370
|
-
const { request } = createDispatchingRequest(
|
|
431
|
+
test("SMS text includes the guardian late answer", async () => {
|
|
432
|
+
const { request } = createDispatchingRequest(
|
|
433
|
+
"exec-sms-content-2",
|
|
434
|
+
"message_back",
|
|
435
|
+
);
|
|
371
436
|
|
|
372
|
-
await executeFollowupAction(request.id,
|
|
437
|
+
await executeFollowupAction(request.id, "message_back");
|
|
373
438
|
|
|
374
439
|
expect(deliveredSms.length).toBe(1);
|
|
375
440
|
// The fallback must relay the guardian's answer to the caller
|
|
376
|
-
expect(deliveredSms[0].text).toContain(
|
|
441
|
+
expect(deliveredSms[0].text).toContain("1234");
|
|
377
442
|
});
|
|
378
443
|
});
|
|
379
444
|
});
|