@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.
Files changed (73) hide show
  1. package/Dockerfile +6 -6
  2. package/README.md +1 -2
  3. package/package.json +1 -1
  4. package/src/__tests__/approval-routes-http.test.ts +383 -254
  5. package/src/__tests__/call-controller.test.ts +1074 -751
  6. package/src/__tests__/call-routes-http.test.ts +329 -279
  7. package/src/__tests__/channel-approval-routes.test.ts +2 -13
  8. package/src/__tests__/channel-approvals.test.ts +227 -182
  9. package/src/__tests__/channel-guardian.test.ts +1 -0
  10. package/src/__tests__/conversation-attention-telegram.test.ts +157 -114
  11. package/src/__tests__/conversation-routes-guardian-reply.test.ts +164 -104
  12. package/src/__tests__/conversation-routes.test.ts +71 -41
  13. package/src/__tests__/daemon-server-session-init.test.ts +258 -191
  14. package/src/__tests__/deterministic-verification-control-plane.test.ts +183 -134
  15. package/src/__tests__/extract-email.test.ts +42 -0
  16. package/src/__tests__/gateway-only-enforcement.test.ts +467 -368
  17. package/src/__tests__/gateway-only-guard.test.ts +54 -55
  18. package/src/__tests__/gmail-integration.test.ts +48 -46
  19. package/src/__tests__/guardian-action-followup-executor.test.ts +215 -150
  20. package/src/__tests__/guardian-outbound-http.test.ts +334 -208
  21. package/src/__tests__/guardian-routing-invariants.test.ts +680 -613
  22. package/src/__tests__/guardian-routing-state.test.ts +257 -209
  23. package/src/__tests__/guardian-verification-voice-binding.test.ts +47 -40
  24. package/src/__tests__/handle-user-message-secret-resume.test.ts +44 -21
  25. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +269 -195
  26. package/src/__tests__/inbound-invite-redemption.test.ts +194 -151
  27. package/src/__tests__/ingress-reconcile.test.ts +184 -142
  28. package/src/__tests__/non-member-access-request.test.ts +291 -247
  29. package/src/__tests__/notification-telegram-adapter.test.ts +60 -46
  30. package/src/__tests__/pairing-concurrent.test.ts +78 -0
  31. package/src/__tests__/recording-intent-handler.test.ts +422 -291
  32. package/src/__tests__/runtime-attachment-metadata.test.ts +107 -69
  33. package/src/__tests__/runtime-events-sse.test.ts +67 -50
  34. package/src/__tests__/send-endpoint-busy.test.ts +314 -232
  35. package/src/__tests__/session-approval-overrides.test.ts +93 -91
  36. package/src/__tests__/sms-messaging-provider.test.ts +74 -47
  37. package/src/__tests__/trusted-contact-approval-notifier.test.ts +339 -274
  38. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +484 -372
  39. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +261 -239
  40. package/src/__tests__/trusted-contact-multichannel.test.ts +179 -140
  41. package/src/__tests__/twilio-config.test.ts +49 -41
  42. package/src/__tests__/twilio-routes-elevenlabs.test.ts +189 -162
  43. package/src/__tests__/twilio-routes.test.ts +389 -280
  44. package/src/calls/call-controller.ts +1 -1
  45. package/src/calls/guardian-action-sweep.ts +6 -6
  46. package/src/calls/twilio-routes.ts +2 -4
  47. package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +29 -4
  48. package/src/config/bundled-skills/messaging/SKILL.md +5 -4
  49. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +69 -4
  50. package/src/config/env.ts +39 -29
  51. package/src/daemon/handlers/config-inbox.ts +5 -5
  52. package/src/daemon/handlers/skills.ts +18 -10
  53. package/src/daemon/ipc-contract/messages.ts +1 -0
  54. package/src/daemon/ipc-contract/surfaces.ts +7 -1
  55. package/src/daemon/pairing-store.ts +15 -2
  56. package/src/daemon/session-agent-loop-handlers.ts +5 -0
  57. package/src/daemon/session-agent-loop.ts +1 -1
  58. package/src/daemon/session-process.ts +1 -1
  59. package/src/daemon/session-slash.ts +4 -4
  60. package/src/daemon/session-surfaces.ts +42 -2
  61. package/src/runtime/auth/token-service.ts +95 -45
  62. package/src/runtime/channel-retry-sweep.ts +2 -2
  63. package/src/runtime/http-server.ts +8 -7
  64. package/src/runtime/http-types.ts +1 -1
  65. package/src/runtime/routes/conversation-routes.ts +1 -1
  66. package/src/runtime/routes/guardian-bootstrap-routes.ts +3 -2
  67. package/src/runtime/routes/guardian-expiry-sweep.ts +5 -5
  68. package/src/runtime/routes/pairing-routes.ts +4 -1
  69. package/src/sequence/reply-matcher.ts +14 -4
  70. package/src/skills/frontmatter.ts +9 -6
  71. package/src/tools/ui-surface/definitions.ts +3 -1
  72. package/src/util/platform.ts +0 -12
  73. package/docs/architecture/http-token-refresh.md +0 -274
@@ -1,25 +1,27 @@
1
- import { mkdtempSync, rmSync } from 'node:fs';
2
- import { tmpdir } from 'node:os';
3
- import { join } from 'node:path';
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 'bun:test';
5
+ import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
6
6
 
7
- const testDir = mkdtempSync(join(tmpdir(), 'guardian-action-followup-executor-test-'));
7
+ const testDir = mkdtempSync(
8
+ join(tmpdir(), "guardian-action-followup-executor-test-"),
9
+ );
8
10
 
9
- mock.module('../util/platform.js', () => ({
11
+ mock.module("../util/platform.js", () => ({
10
12
  getDataDir: () => testDir,
11
- isMacOS: () => process.platform === 'darwin',
12
- isLinux: () => process.platform === 'linux',
13
- isWindows: () => process.platform === 'win32',
14
- getSocketPath: () => join(testDir, 'test.sock'),
15
- getPidPath: () => join(testDir, 'test.pid'),
16
- getDbPath: () => join(testDir, 'test.db'),
17
- getLogPath: () => join(testDir, 'test.log'),
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: () => 'test-token',
21
+ readHttpToken: () => "test-token",
20
22
  }));
21
23
 
22
- mock.module('../util/logger.js', () => ({
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<{ phoneNumber: string; task: string; conversationId: string }> = [];
32
- let mockStartCallResult: { ok: true; session: { id: string }; callSid: string; callerIdentityMode: string } | { ok: false; error: string } = {
33
- ok: true, session: { id: 'mock-call-session' }, callSid: 'CA-mock', callerIdentityMode: 'assistant_number',
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('../runtime/gateway-client.js', () => ({
37
- deliverChannelReply: async (url: string, payload: { chatId: string; text?: string }) => {
38
- deliveredSms.push({ url, chatId: payload.chatId, text: payload.text ?? '' });
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('../calls/call-domain.js', () => ({
43
- startCall: async (input: { phoneNumber: string; task: string; conversationId: string }) => {
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('../config/env.js', () => ({
50
- getGatewayInternalBaseUrl: () => 'http://127.0.0.1:7830',
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('../memory/conversation-key-store.js', () => ({
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 { createCallSession, createPendingQuestion } from '../calls/call-store.js';
63
- import { initializeDb, resetDb } from '../memory/db.js';
64
- import { getDb } from '../memory/db.js';
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 '../memory/guardian-action-store.js';
74
- import { conversations } from '../memory/schema.js';
75
- import { executeFollowupAction } from '../runtime/guardian-action-followup-executor.js';
76
- import { resolveCounterparty } from '../runtime/guardian-action-followup-executor.js';
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).values({
84
- id,
85
- title: `Conversation ${id}`,
86
- createdAt: now,
87
- updatedAt: now,
88
- }).run();
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('DELETE FROM guardian_action_deliveries');
94
- db.run('DELETE FROM guardian_action_requests');
95
- db.run('DELETE FROM call_pending_questions');
96
- db.run('DELETE FROM call_events');
97
- db.run('DELETE FROM call_sessions');
98
- db.run('DELETE FROM messages');
99
- db.run('DELETE FROM conversations');
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, session: { id: 'mock-call-session' }, callSid: 'CA-mock', callerIdentityMode: 'assistant_number',
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(convId: string, action: 'call_back' | 'message_back') {
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: 'twilio',
117
- fromNumber: '+15550001111',
118
- toNumber: '+15550002222',
155
+ provider: "twilio",
156
+ fromNumber: "+15550001111",
157
+ toNumber: "+15550002222",
119
158
  });
120
- const pq = createPendingQuestion(session.id, 'What is the gate code?');
159
+ const pq = createPendingQuestion(session.id, "What is the gate code?");
121
160
  const request = createGuardianActionRequest({
122
- kind: 'ask_guardian',
123
- sourceChannel: 'voice',
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: 'telegram',
136
- destinationChatId: 'chat-123',
137
- destinationExternalUserId: 'user-456',
174
+ destinationChannel: "telegram",
175
+ destinationChatId: "chat-123",
176
+ destinationExternalUserId: "user-456",
138
177
  destinationConversationId: deliveryConvId,
139
178
  });
140
- updateDeliveryStatus(delivery.id, 'sent');
179
+ updateDeliveryStatus(delivery.id, "sent");
141
180
 
142
181
  // Expire the request
143
- markTimedOutWithReason(request.id, 'call_timeout');
182
+ markTimedOutWithReason(request.id, "call_timeout");
144
183
 
145
184
  // Start follow-up
146
- startFollowupFromExpiredRequest(request.id, 'The gate code is 1234');
185
+ startFollowupFromExpiredRequest(request.id, "The gate code is 1234");
147
186
 
148
187
  // Progress to dispatching with the given action
149
- progressFollowupState(request.id, 'dispatching', action);
188
+ progressFollowupState(request.id, "dispatching", action);
150
189
 
151
- return { request: getGuardianActionRequest(request.id)!, delivery, callSession: session };
190
+ return {
191
+ request: getGuardianActionRequest(request.id)!,
192
+ delivery,
193
+ callSession: session,
194
+ };
152
195
  }
153
196
 
154
- describe('guardian-action-followup-executor', () => {
197
+ describe("guardian-action-followup-executor", () => {
155
198
  beforeEach(() => {
156
199
  resetTables();
157
200
  });
158
201
 
159
202
  afterAll(() => {
160
203
  resetDb();
161
- try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
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('resolveCounterparty', () => {
167
- test('resolves fromNumber as counterparty for inbound call', () => {
168
- ensureConversation('cp-test-1');
213
+ describe("resolveCounterparty", () => {
214
+ test("resolves fromNumber as counterparty for inbound call", () => {
215
+ ensureConversation("cp-test-1");
169
216
  const session = createCallSession({
170
- conversationId: 'cp-test-1',
171
- provider: 'twilio',
172
- fromNumber: '+15550001111',
173
- toNumber: '+15550002222',
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('+15550001111');
179
- expect(result!.displayIdentifier).toBe('+15550001111');
225
+ expect(result!.phoneNumber).toBe("+15550001111");
226
+ expect(result!.displayIdentifier).toBe("+15550001111");
180
227
  });
181
228
 
182
- test('resolves toNumber as counterparty for outbound call', () => {
183
- ensureConversation('cp-test-outbound');
229
+ test("resolves toNumber as counterparty for outbound call", () => {
230
+ ensureConversation("cp-test-outbound");
184
231
  const session = createCallSession({
185
- conversationId: 'cp-test-outbound',
186
- provider: 'twilio',
187
- fromNumber: '+15550002222', // assistant's number
188
- toNumber: '+15550001111', // callee (the counterparty)
189
- initiatedFromConversationId: 'cp-test-outbound', // signals outbound
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('+15550001111');
195
- expect(result!.displayIdentifier).toBe('+15550001111');
241
+ expect(result!.phoneNumber).toBe("+15550001111");
242
+ expect(result!.displayIdentifier).toBe("+15550001111");
196
243
  });
197
244
 
198
- test('returns null for nonexistent call session', () => {
199
- const result = resolveCounterparty('nonexistent-session-id');
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('message_back', () => {
207
- test('sends SMS to counterparty and finalizes as completed', async () => {
208
- const { request } = createDispatchingRequest('exec-msg-1', 'message_back');
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, 'message_back');
260
+ const result = await executeFollowupAction(request.id, "message_back");
211
261
 
212
262
  expect(result.ok).toBe(true);
213
- expect(result.action).toBe('message_back');
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('+15550001111');
219
- expect(deliveredSms[0].url).toContain('/deliver/sms');
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('completed');
274
+ expect(updated!.followupState).toBe("completed");
225
275
  expect(updated!.followupCompletedAt).toBeGreaterThan(0);
226
276
  });
227
277
 
228
- test('confirmation text mentions the phone number', async () => {
229
- const { request } = createDispatchingRequest('exec-msg-2', 'message_back');
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, 'message_back');
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('+15550001111');
288
+ expect(result.guardianReplyText).toContain("+15550001111");
236
289
  });
237
290
  });
238
291
 
239
292
  // ── call_back execution ─────────────────────────────────────────────
240
293
 
241
- describe('call_back', () => {
242
- test('starts outbound call to counterparty and finalizes as completed', async () => {
243
- const { request } = createDispatchingRequest('exec-call-1', 'call_back');
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, 'call_back');
298
+ const result = await executeFollowupAction(request.id, "call_back");
246
299
 
247
300
  expect(result.ok).toBe(true);
248
- expect(result.action).toBe('call_back');
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('+15550001111');
254
- expect(startedCalls[0].task).toContain('gate code');
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('completed');
311
+ expect(updated!.followupState).toBe("completed");
259
312
  expect(updated!.followupCompletedAt).toBeGreaterThan(0);
260
313
  });
261
314
 
262
- test('confirmation text mentions calling back', async () => {
263
- const { request } = createDispatchingRequest('exec-call-2', 'call_back');
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, 'call_back');
318
+ const result = await executeFollowupAction(request.id, "call_back");
266
319
 
267
320
  expect(result.ok).toBe(true);
268
- expect(result.guardianReplyText).toContain('calling');
321
+ expect(result.guardianReplyText).toContain("calling");
269
322
  });
270
323
 
271
- test('failed call start finalizes as failed with error message', async () => {
272
- mockStartCallResult = { ok: false, error: 'Twilio account suspended' };
273
- const { request } = createDispatchingRequest('exec-call-fail-1', 'call_back');
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, 'call_back');
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('Twilio account suspended');
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('failed');
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('error handling', () => {
293
- test('nonexistent request returns failure with error message', async () => {
294
- const result = await executeFollowupAction('nonexistent-id', 'call_back');
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('not found');
354
+ expect(result.error).toContain("not found");
299
355
  }
300
356
  expect(result.guardianReplyText.length).toBeGreaterThan(0);
301
357
  });
302
358
 
303
- test('request not in dispatching state returns failure', async () => {
359
+ test("request not in dispatching state returns failure", async () => {
304
360
  // Create a request in awaiting_guardian_choice (not dispatching)
305
- ensureConversation('exec-wrong-state');
361
+ ensureConversation("exec-wrong-state");
306
362
  const session = createCallSession({
307
- conversationId: 'exec-wrong-state',
308
- provider: 'twilio',
309
- fromNumber: '+15550001111',
310
- toNumber: '+15550002222',
363
+ conversationId: "exec-wrong-state",
364
+ provider: "twilio",
365
+ fromNumber: "+15550001111",
366
+ toNumber: "+15550002222",
311
367
  });
312
- const pq = createPendingQuestion(session.id, 'Question?');
368
+ const pq = createPendingQuestion(session.id, "Question?");
313
369
  const request = createGuardianActionRequest({
314
- kind: 'ask_guardian',
315
- sourceChannel: 'voice',
316
- sourceConversationId: 'exec-wrong-state',
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, 'call_timeout');
323
- startFollowupFromExpiredRequest(request.id, 'Answer');
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, 'call_back');
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('Invalid followup state');
386
+ expect(result.error).toContain("Invalid followup state");
331
387
  }
332
388
  });
333
389
 
334
- test('follow-up states terminate correctly on success', async () => {
335
- const { request } = createDispatchingRequest('exec-state-1', 'message_back');
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, 'message_back');
396
+ await executeFollowupAction(request.id, "message_back");
338
397
 
339
398
  const updated = getGuardianActionRequest(request.id);
340
- expect(updated!.followupState).toBe('completed');
399
+ expect(updated!.followupState).toBe("completed");
341
400
  expect(updated!.followupCompletedAt).not.toBeNull();
342
401
  });
343
402
 
344
- test('follow-up states terminate correctly on failure', async () => {
345
- mockStartCallResult = { ok: false, error: 'Provider error' };
346
- const { request } = createDispatchingRequest('exec-state-2', 'call_back');
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, 'call_back');
407
+ await executeFollowupAction(request.id, "call_back");
349
408
 
350
409
  const updated = getGuardianActionRequest(request.id);
351
- expect(updated!.followupState).toBe('failed');
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('outbound SMS content', () => {
359
- test('SMS text includes the original question', async () => {
360
- const { request } = createDispatchingRequest('exec-sms-content-1', 'message_back');
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, 'message_back');
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('gate code');
428
+ expect(deliveredSms[0].text).toContain("gate code");
367
429
  });
368
430
 
369
- test('SMS text includes the guardian late answer', async () => {
370
- const { request } = createDispatchingRequest('exec-sms-content-2', 'message_back');
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, 'message_back');
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('1234');
441
+ expect(deliveredSms[0].text).toContain("1234");
377
442
  });
378
443
  });
379
444
  });