@vellumai/assistant 0.4.3 → 0.4.4

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 (183) hide show
  1. package/.env.example +3 -0
  2. package/ARCHITECTURE.md +40 -3
  3. package/README.md +43 -35
  4. package/package.json +1 -1
  5. package/scripts/ipc/generate-swift.ts +1 -0
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
  7. package/src/__tests__/actor-token-service.test.ts +1099 -0
  8. package/src/__tests__/agent-loop.test.ts +51 -0
  9. package/src/__tests__/approval-routes-http.test.ts +2 -0
  10. package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
  11. package/src/__tests__/assistant-id-boundary-guard.test.ts +125 -0
  12. package/src/__tests__/call-controller.test.ts +49 -0
  13. package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
  14. package/src/__tests__/call-pointer-messages.test.ts +93 -3
  15. package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
  16. package/src/__tests__/callback-handoff-copy.test.ts +186 -0
  17. package/src/__tests__/channel-approval-routes.test.ts +133 -12
  18. package/src/__tests__/channel-guardian.test.ts +0 -87
  19. package/src/__tests__/channel-readiness-service.test.ts +10 -16
  20. package/src/__tests__/checker.test.ts +33 -12
  21. package/src/__tests__/config-schema.test.ts +4 -0
  22. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
  23. package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
  24. package/src/__tests__/conversation-routes.test.ts +12 -3
  25. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  26. package/src/__tests__/daemon-server-session-init.test.ts +4 -0
  27. package/src/__tests__/guardian-actions-endpoint.test.ts +19 -14
  28. package/src/__tests__/guardian-dispatch.test.ts +8 -0
  29. package/src/__tests__/guardian-outbound-http.test.ts +4 -4
  30. package/src/__tests__/guardian-question-mode.test.ts +200 -0
  31. package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
  32. package/src/__tests__/guardian-routing-state.test.ts +525 -0
  33. package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
  34. package/src/__tests__/handlers-telegram-config.test.ts +0 -83
  35. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
  36. package/src/__tests__/headless-browser-navigate.test.ts +2 -0
  37. package/src/__tests__/ipc-snapshot.test.ts +18 -51
  38. package/src/__tests__/non-member-access-request.test.ts +131 -8
  39. package/src/__tests__/notification-decision-fallback.test.ts +129 -4
  40. package/src/__tests__/notification-decision-strategy.test.ts +62 -2
  41. package/src/__tests__/notification-guardian-path.test.ts +3 -0
  42. package/src/__tests__/recording-intent-handler.test.ts +1 -0
  43. package/src/__tests__/relay-server.test.ts +841 -39
  44. package/src/__tests__/send-endpoint-busy.test.ts +5 -0
  45. package/src/__tests__/session-agent-loop.test.ts +1 -0
  46. package/src/__tests__/session-confirmation-signals.test.ts +523 -0
  47. package/src/__tests__/session-init.benchmark.test.ts +0 -1
  48. package/src/__tests__/session-surfaces-task-progress.test.ts +1 -1
  49. package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
  50. package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
  51. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
  52. package/src/__tests__/tool-executor.test.ts +21 -2
  53. package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
  54. package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
  55. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
  56. package/src/__tests__/twilio-config.test.ts +2 -13
  57. package/src/agent/loop.ts +1 -1
  58. package/src/approvals/guardian-decision-primitive.ts +10 -2
  59. package/src/approvals/guardian-request-resolvers.ts +128 -9
  60. package/src/calls/call-constants.ts +21 -0
  61. package/src/calls/call-controller.ts +9 -2
  62. package/src/calls/call-domain.ts +28 -7
  63. package/src/calls/call-pointer-message-composer.ts +154 -0
  64. package/src/calls/call-pointer-messages.ts +106 -27
  65. package/src/calls/guardian-dispatch.ts +4 -2
  66. package/src/calls/relay-server.ts +424 -12
  67. package/src/calls/twilio-config.ts +4 -11
  68. package/src/calls/twilio-routes.ts +1 -1
  69. package/src/calls/types.ts +3 -1
  70. package/src/cli.ts +5 -4
  71. package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
  72. package/src/config/bundled-skills/app-builder/SKILL.md +146 -10
  73. package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
  74. package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
  75. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
  76. package/src/config/bundled-skills/messaging/SKILL.md +61 -12
  77. package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
  78. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
  79. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
  80. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
  81. package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
  82. package/src/config/bundled-skills/twitter/SKILL.md +3 -3
  83. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +1 -0
  84. package/src/config/calls-schema.ts +24 -0
  85. package/src/config/env.ts +22 -0
  86. package/src/config/feature-flag-registry.json +8 -0
  87. package/src/config/schema.ts +2 -2
  88. package/src/config/skills.ts +11 -0
  89. package/src/config/system-prompt.ts +11 -1
  90. package/src/config/templates/SOUL.md +2 -0
  91. package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
  92. package/src/config/vellum-skills/trusted-contacts/SKILL.md +10 -9
  93. package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
  94. package/src/daemon/call-pointer-generators.ts +59 -0
  95. package/src/daemon/computer-use-session.ts +2 -5
  96. package/src/daemon/handlers/apps.ts +76 -20
  97. package/src/daemon/handlers/config-channels.ts +5 -55
  98. package/src/daemon/handlers/config-inbox.ts +9 -3
  99. package/src/daemon/handlers/config-ingress.ts +28 -3
  100. package/src/daemon/handlers/config-telegram.ts +12 -0
  101. package/src/daemon/handlers/config.ts +2 -6
  102. package/src/daemon/handlers/pairing.ts +2 -0
  103. package/src/daemon/handlers/sessions.ts +48 -3
  104. package/src/daemon/handlers/shared.ts +17 -2
  105. package/src/daemon/ipc-contract/integrations.ts +1 -99
  106. package/src/daemon/ipc-contract/messages.ts +47 -1
  107. package/src/daemon/ipc-contract/notifications.ts +11 -0
  108. package/src/daemon/ipc-contract-inventory.json +2 -4
  109. package/src/daemon/lifecycle.ts +17 -0
  110. package/src/daemon/server.ts +14 -1
  111. package/src/daemon/session-agent-loop-handlers.ts +20 -0
  112. package/src/daemon/session-agent-loop.ts +22 -11
  113. package/src/daemon/session-lifecycle.ts +1 -1
  114. package/src/daemon/session-process.ts +11 -1
  115. package/src/daemon/session-runtime-assembly.ts +3 -0
  116. package/src/daemon/session-surfaces.ts +3 -2
  117. package/src/daemon/session.ts +88 -1
  118. package/src/daemon/tool-side-effects.ts +22 -0
  119. package/src/home-base/prebuilt/brain-graph.html +1483 -0
  120. package/src/home-base/prebuilt/index.html +40 -0
  121. package/src/inbound/platform-callback-registration.ts +157 -0
  122. package/src/memory/canonical-guardian-store.ts +1 -1
  123. package/src/memory/db-init.ts +4 -0
  124. package/src/memory/migrations/038-actor-token-records.ts +39 -0
  125. package/src/memory/migrations/index.ts +1 -0
  126. package/src/memory/schema.ts +16 -0
  127. package/src/messaging/provider-types.ts +24 -0
  128. package/src/messaging/provider.ts +7 -0
  129. package/src/messaging/providers/gmail/adapter.ts +127 -0
  130. package/src/messaging/providers/sms/adapter.ts +40 -37
  131. package/src/notifications/adapters/macos.ts +45 -2
  132. package/src/notifications/broadcaster.ts +16 -0
  133. package/src/notifications/copy-composer.ts +39 -1
  134. package/src/notifications/decision-engine.ts +22 -9
  135. package/src/notifications/destination-resolver.ts +16 -2
  136. package/src/notifications/emit-signal.ts +16 -8
  137. package/src/notifications/guardian-question-mode.ts +419 -0
  138. package/src/notifications/signal.ts +14 -3
  139. package/src/permissions/checker.ts +13 -1
  140. package/src/permissions/prompter.ts +14 -0
  141. package/src/providers/anthropic/client.ts +20 -0
  142. package/src/providers/provider-send-message.ts +15 -3
  143. package/src/runtime/access-request-helper.ts +71 -1
  144. package/src/runtime/actor-token-service.ts +234 -0
  145. package/src/runtime/actor-token-store.ts +236 -0
  146. package/src/runtime/channel-approvals.ts +5 -3
  147. package/src/runtime/channel-readiness-service.ts +23 -64
  148. package/src/runtime/channel-readiness-types.ts +3 -4
  149. package/src/runtime/channel-retry-sweep.ts +4 -1
  150. package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
  151. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  152. package/src/runtime/guardian-context-resolver.ts +82 -0
  153. package/src/runtime/guardian-outbound-actions.ts +0 -3
  154. package/src/runtime/guardian-reply-router.ts +67 -30
  155. package/src/runtime/guardian-vellum-migration.ts +57 -0
  156. package/src/runtime/http-server.ts +65 -12
  157. package/src/runtime/http-types.ts +13 -0
  158. package/src/runtime/invite-redemption-service.ts +8 -0
  159. package/src/runtime/local-actor-identity.ts +76 -0
  160. package/src/runtime/middleware/actor-token.ts +271 -0
  161. package/src/runtime/routes/approval-routes.ts +82 -7
  162. package/src/runtime/routes/brain-graph-routes.ts +222 -0
  163. package/src/runtime/routes/channel-readiness-routes.ts +71 -0
  164. package/src/runtime/routes/conversation-routes.ts +140 -52
  165. package/src/runtime/routes/events-routes.ts +20 -5
  166. package/src/runtime/routes/guardian-action-routes.ts +45 -3
  167. package/src/runtime/routes/guardian-approval-interception.ts +29 -0
  168. package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
  169. package/src/runtime/routes/inbound-message-handler.ts +143 -2
  170. package/src/runtime/routes/integration-routes.ts +7 -15
  171. package/src/runtime/routes/pairing-routes.ts +163 -0
  172. package/src/runtime/routes/twilio-routes.ts +934 -0
  173. package/src/runtime/tool-grant-request-helper.ts +3 -1
  174. package/src/security/oauth2.ts +27 -2
  175. package/src/security/token-manager.ts +46 -10
  176. package/src/tools/browser/browser-execution.ts +4 -3
  177. package/src/tools/browser/browser-handoff.ts +10 -18
  178. package/src/tools/browser/browser-manager.ts +80 -25
  179. package/src/tools/browser/browser-screencast.ts +35 -119
  180. package/src/tools/permission-checker.ts +15 -4
  181. package/src/tools/tool-approval-handler.ts +242 -18
  182. package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
  183. package/src/daemon/handlers/config-twilio.ts +0 -1082
@@ -196,8 +196,8 @@ describe('Invariant 2: no generic plaintext secret read API', () => {
196
196
  'daemon/handlers.ts', // Vercel API token + integration OAuth
197
197
  'daemon/handlers/config-integrations.ts', // Vercel API token + Twitter integration OAuth
198
198
  'daemon/handlers/config-telegram.ts', // Telegram bot token management
199
- 'daemon/handlers/config-twilio.ts', // Twilio credential management
200
199
  'daemon/handlers/config-ingress.ts', // Ingress config (reads Twilio credentials for webhook sync)
200
+ 'runtime/routes/twilio-routes.ts', // Twilio credential management (HTTP control-plane)
201
201
  'security/token-manager.ts', // OAuth token refresh flow
202
202
  'email/providers/index.ts', // email provider API key lookup
203
203
  'tools/network/script-proxy/session-manager.ts', // proxy credential injection at runtime
@@ -299,6 +299,10 @@ mock.module('../memory/conversation-store.js', () => ({
299
299
  getDisplayMetaForConversations: () => new Map(),
300
300
  }));
301
301
 
302
+ mock.module('../runtime/confirmation-request-guardian-bridge.js', () => ({
303
+ bridgeConfirmationRequestToGuardian: () => ({ skipped: true, reason: 'not_trusted_contact' }),
304
+ }));
305
+
302
306
  mock.module('../daemon/session.js', () => ({
303
307
  Session: MockSession,
304
308
  DEFAULT_MEMORY_POLICY: MOCK_DEFAULT_MEMORY_POLICY,
@@ -58,12 +58,17 @@ import {
58
58
  import { initializeDb, resetDb } from '../memory/db.js';
59
59
  import { getDb } from '../memory/db.js';
60
60
  import { conversations } from '../memory/schema.js';
61
+ import type { ServerWithRequestIP } from '../runtime/middleware/actor-token.js';
61
62
  import {
62
63
  handleGuardianActionDecision,
63
64
  handleGuardianActionsPending,
64
65
  listGuardianDecisionPrompts,
65
66
  } from '../runtime/routes/guardian-action-routes.js';
66
67
 
68
+ const mockLoopbackServer: ServerWithRequestIP = {
69
+ requestIP: () => ({ address: '127.0.0.1', family: 'IPv4', port: 0 }),
70
+ };
71
+
67
72
  initializeDb();
68
73
 
69
74
  function ensureConversation(id: string): void {
@@ -145,7 +150,7 @@ describe('HTTP handleGuardianActionDecision', () => {
145
150
  method: 'POST',
146
151
  body: JSON.stringify({ action: 'approve_once' }),
147
152
  });
148
- const res = await handleGuardianActionDecision(req);
153
+ const res = await handleGuardianActionDecision(req, mockLoopbackServer);
149
154
  expect(res.status).toBe(400);
150
155
  const body = await res.json();
151
156
  expect(body.error.message).toContain('requestId');
@@ -156,7 +161,7 @@ describe('HTTP handleGuardianActionDecision', () => {
156
161
  method: 'POST',
157
162
  body: JSON.stringify({ requestId: 'req-1' }),
158
163
  });
159
- const res = await handleGuardianActionDecision(req);
164
+ const res = await handleGuardianActionDecision(req, mockLoopbackServer);
160
165
  expect(res.status).toBe(400);
161
166
  const body = await res.json();
162
167
  expect(body.error.message).toContain('action');
@@ -167,7 +172,7 @@ describe('HTTP handleGuardianActionDecision', () => {
167
172
  method: 'POST',
168
173
  body: JSON.stringify({ requestId: 'req-1', action: 'nuke_from_orbit' }),
169
174
  });
170
- const res = await handleGuardianActionDecision(req);
175
+ const res = await handleGuardianActionDecision(req, mockLoopbackServer);
171
176
  expect(res.status).toBe(400);
172
177
  const body = await res.json();
173
178
  expect(body.error.message).toContain('Invalid action');
@@ -180,7 +185,7 @@ describe('HTTP handleGuardianActionDecision', () => {
180
185
  method: 'POST',
181
186
  body: JSON.stringify({ requestId: 'nonexistent', action: 'approve_once' }),
182
187
  });
183
- const res = await handleGuardianActionDecision(req);
188
+ const res = await handleGuardianActionDecision(req, mockLoopbackServer);
184
189
  expect(res.status).toBe(404);
185
190
  });
186
191
 
@@ -192,7 +197,7 @@ describe('HTTP handleGuardianActionDecision', () => {
192
197
  method: 'POST',
193
198
  body: JSON.stringify({ requestId: 'req-gd-1', action: 'approve_once' }),
194
199
  });
195
- const res = await handleGuardianActionDecision(req);
200
+ const res = await handleGuardianActionDecision(req, mockLoopbackServer);
196
201
  expect(res.status).toBe(200);
197
202
  const body = await res.json();
198
203
  expect(body.applied).toBe(true);
@@ -207,7 +212,7 @@ describe('HTTP handleGuardianActionDecision', () => {
207
212
  method: 'POST',
208
213
  body: JSON.stringify({ requestId: 'req-scope-1', action: 'approve_once', conversationId: 'conv-wrong' }),
209
214
  });
210
- const res = await handleGuardianActionDecision(req);
215
+ const res = await handleGuardianActionDecision(req, mockLoopbackServer);
211
216
  expect(res.status).toBe(404);
212
217
  const body = await res.json();
213
218
  expect(body.error.message).toContain('No pending guardian action');
@@ -222,7 +227,7 @@ describe('HTTP handleGuardianActionDecision', () => {
222
227
  method: 'POST',
223
228
  body: JSON.stringify({ requestId: 'req-scope-2', action: 'reject', conversationId: 'conv-match' }),
224
229
  });
225
- const res = await handleGuardianActionDecision(req);
230
+ const res = await handleGuardianActionDecision(req, mockLoopbackServer);
226
231
  expect(res.status).toBe(200);
227
232
  const body = await res.json();
228
233
  expect(body.applied).toBe(true);
@@ -236,7 +241,7 @@ describe('HTTP handleGuardianActionDecision', () => {
236
241
  method: 'POST',
237
242
  body: JSON.stringify({ requestId: 'req-scope-3', action: 'approve_once' }),
238
243
  });
239
- const res = await handleGuardianActionDecision(req);
244
+ const res = await handleGuardianActionDecision(req, mockLoopbackServer);
240
245
  expect(res.status).toBe(200);
241
246
  });
242
247
 
@@ -254,7 +259,7 @@ describe('HTTP handleGuardianActionDecision', () => {
254
259
  method: 'POST',
255
260
  body: JSON.stringify({ requestId: 'req-access-1', action: 'approve_once' }),
256
261
  });
257
- const res = await handleGuardianActionDecision(req);
262
+ const res = await handleGuardianActionDecision(req, mockLoopbackServer);
258
263
  expect(res.status).toBe(200);
259
264
  const body = await res.json();
260
265
  expect(body.applied).toBe(true);
@@ -276,7 +281,7 @@ describe('HTTP handleGuardianActionDecision', () => {
276
281
  method: 'POST',
277
282
  body: JSON.stringify({ requestId: 'req-voice-access-1', action: 'approve_once' }),
278
283
  });
279
- const res = await handleGuardianActionDecision(req);
284
+ const res = await handleGuardianActionDecision(req, mockLoopbackServer);
280
285
  expect(res.status).toBe(200);
281
286
  const body = await res.json();
282
287
  expect(body.applied).toBe(true);
@@ -291,7 +296,7 @@ describe('HTTP handleGuardianActionDecision', () => {
291
296
  method: 'POST',
292
297
  body: JSON.stringify({ requestId: 'req-stale-1', action: 'approve_once' }),
293
298
  });
294
- const res = await handleGuardianActionDecision(req);
299
+ const res = await handleGuardianActionDecision(req, mockLoopbackServer);
295
300
  const body = await res.json();
296
301
  expect(body.applied).toBe(false);
297
302
  expect(body.reason).toBe('already_resolved');
@@ -307,7 +312,7 @@ describe('HTTP handleGuardianActionDecision', () => {
307
312
  method: 'POST',
308
313
  body: JSON.stringify({ requestId: 'req-actor-1', action: 'approve_once' }),
309
314
  });
310
- await handleGuardianActionDecision(req);
315
+ await handleGuardianActionDecision(req, mockLoopbackServer);
311
316
  const call = mockApplyCanonicalGuardianDecision.mock.calls[0]![0] as Record<string, unknown>;
312
317
  const actorContext = call.actorContext as Record<string, unknown>;
313
318
  expect(actorContext.externalUserId).toBeUndefined();
@@ -325,7 +330,7 @@ describe('HTTP handleGuardianActionsPending', () => {
325
330
 
326
331
  test('returns 400 when conversationId is missing', () => {
327
332
  const req = new Request('http://localhost/v1/guardian-actions/pending');
328
- const res = handleGuardianActionsPending(req);
333
+ const res = handleGuardianActionsPending(req, mockLoopbackServer);
329
334
  expect(res.status).toBe(400);
330
335
  });
331
336
 
@@ -337,7 +342,7 @@ describe('HTTP handleGuardianActionsPending', () => {
337
342
  });
338
343
 
339
344
  const req = new Request('http://localhost/v1/guardian-actions/pending?conversationId=conv-list');
340
- const res = handleGuardianActionsPending(req);
345
+ const res = handleGuardianActionsPending(req, mockLoopbackServer);
341
346
  expect(res.status).toBe(200);
342
347
 
343
348
  // Verify the prompts directly via the shared helper
@@ -367,6 +367,11 @@ describe('guardian-dispatch', () => {
367
367
  expect(request).toBeDefined();
368
368
  expect(request!.tool_name).toBe('send_email');
369
369
  expect(request!.input_digest).toBe('abc123def456');
370
+
371
+ const signalParams = emitCalls[0] as Record<string, unknown>;
372
+ const payload = signalParams.contextPayload as Record<string, unknown>;
373
+ expect(payload.requestKind).toBe('pending_question');
374
+ expect(payload.toolName).toBe('send_email');
370
375
  });
371
376
 
372
377
  test('omitting toolName and inputDigest stores null for informational ASK_GUARDIAN dispatches', async () => {
@@ -422,6 +427,9 @@ describe('guardian-dispatch', () => {
422
427
  // The request was just created so there is 1 pending request for this session
423
428
  expect(payload.activeGuardianRequestCount).toBe(1);
424
429
  expect(payload.callSessionId).toBe(session.id);
430
+ expect(payload.requestKind).toBe('pending_question');
431
+ expect(payload.toolName).toBeUndefined();
432
+ expect(payload.pendingQuestionId).toBeUndefined();
425
433
  });
426
434
 
427
435
  test('repeated guardian questions in the same call each create per-request delivery rows even when sharing a conversation', async () => {
@@ -268,7 +268,7 @@ describe('startOutbound', () => {
268
268
 
269
269
  describe('resendOutbound', () => {
270
270
  test('returns no_active_session when no session exists', () => {
271
- const result = resendOutbound({ channel: 'sms', assistantId: 'no-such-assistant' });
271
+ const result = resendOutbound({ channel: 'sms' });
272
272
  expect(result.success).toBe(false);
273
273
  expect(result.error).toBe('no_active_session');
274
274
  });
@@ -339,7 +339,7 @@ describe('resendOutbound', () => {
339
339
 
340
340
  describe('cancelOutbound', () => {
341
341
  test('returns no_active_session when no session exists', () => {
342
- const result = cancelOutbound({ channel: 'sms', assistantId: 'no-such-assistant-cancel' });
342
+ const result = cancelOutbound({ channel: 'sms' });
343
343
  expect(result.success).toBe(false);
344
344
  expect(result.error).toBe('no_active_session');
345
345
  });
@@ -397,7 +397,7 @@ describe('HTTP route: handleResendOutbound', () => {
397
397
  });
398
398
 
399
399
  test('returns 400 for no_active_session', async () => {
400
- const req = jsonRequest({ channel: 'sms', assistantId: 'resend-no-session' });
400
+ const req = jsonRequest({ channel: 'sms' });
401
401
  const resp = await handleResendOutbound(req);
402
402
  expect(resp.status).toBe(400);
403
403
  const body = await resp.json() as { error?: string };
@@ -439,7 +439,7 @@ describe('HTTP route: handleCancelOutbound', () => {
439
439
  });
440
440
 
441
441
  test('returns 400 for no_active_session', async () => {
442
- const req = jsonRequest({ channel: 'sms', assistantId: 'cancel-no-session' });
442
+ const req = jsonRequest({ channel: 'sms' });
443
443
  const resp = await handleCancelOutbound(req);
444
444
  expect(resp.status).toBe(400);
445
445
  const body = await resp.json() as { error?: string };
@@ -0,0 +1,200 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import {
4
+ buildGuardianCodeOnlyClarification,
5
+ buildGuardianDisambiguationExample,
6
+ buildGuardianDisambiguationLabel,
7
+ buildGuardianInvalidActionReply,
8
+ buildGuardianReplyDirective,
9
+ buildGuardianRequestCodeInstruction,
10
+ hasGuardianRequestCodeInstruction,
11
+ parseGuardianQuestionPayload,
12
+ resolveGuardianInstructionModeForRequest,
13
+ resolveGuardianInstructionModeFromFields,
14
+ resolveGuardianQuestionInstructionMode,
15
+ stripConflictingGuardianRequestInstructions,
16
+ } from '../notifications/guardian-question-mode.js';
17
+
18
+ describe('guardian-question-mode', () => {
19
+ test('parses pending_question payload as discriminated union', () => {
20
+ const parsed = parseGuardianQuestionPayload({
21
+ requestKind: 'pending_question',
22
+ requestId: 'req-1',
23
+ requestCode: 'A1B2C3',
24
+ questionText: 'What time works?',
25
+ callSessionId: 'call-1',
26
+ activeGuardianRequestCount: 2,
27
+ });
28
+
29
+ expect(parsed).not.toBeNull();
30
+ expect(parsed?.requestKind).toBe('pending_question');
31
+ if (!parsed || parsed.requestKind !== 'pending_question') return;
32
+ expect(parsed.callSessionId).toBe('call-1');
33
+ expect(parsed.activeGuardianRequestCount).toBe(2);
34
+ });
35
+
36
+ test('parses tool_grant_request payload and requires toolName', () => {
37
+ const parsed = parseGuardianQuestionPayload({
38
+ requestKind: 'tool_grant_request',
39
+ requestId: 'req-2',
40
+ requestCode: 'D4E5F6',
41
+ questionText: 'Allow host bash?',
42
+ toolName: 'host_bash',
43
+ });
44
+
45
+ expect(parsed).not.toBeNull();
46
+ expect(parsed?.requestKind).toBe('tool_grant_request');
47
+ if (!parsed || parsed.requestKind !== 'tool_grant_request') return;
48
+ expect(parsed.toolName).toBe('host_bash');
49
+ });
50
+
51
+ test('parses pending_question payload with optional toolName metadata', () => {
52
+ const parsed = parseGuardianQuestionPayload({
53
+ requestKind: 'pending_question',
54
+ requestId: 'req-voice-tool-1',
55
+ requestCode: 'AA11BB',
56
+ questionText: 'Allow send_email?',
57
+ callSessionId: 'call-voice-1',
58
+ activeGuardianRequestCount: 1,
59
+ toolName: 'send_email',
60
+ });
61
+
62
+ expect(parsed).not.toBeNull();
63
+ expect(parsed?.requestKind).toBe('pending_question');
64
+ if (!parsed || parsed.requestKind !== 'pending_question') return;
65
+ expect(parsed.toolName).toBe('send_email');
66
+ });
67
+
68
+ test('rejects invalid pending_question payload missing required fields', () => {
69
+ const parsed = parseGuardianQuestionPayload({
70
+ requestKind: 'pending_question',
71
+ requestId: 'req-3',
72
+ requestCode: 'AAA111',
73
+ questionText: 'Missing call session and count',
74
+ });
75
+ expect(parsed).toBeNull();
76
+ });
77
+
78
+ test('resolve mode uses discriminant for valid typed payloads', () => {
79
+ const resolved = resolveGuardianQuestionInstructionMode({
80
+ requestKind: 'pending_question',
81
+ requestId: 'req-1',
82
+ requestCode: 'A1B2C3',
83
+ questionText: 'What time works?',
84
+ callSessionId: 'call-1',
85
+ activeGuardianRequestCount: 2,
86
+ });
87
+
88
+ expect(resolved.mode).toBe('answer');
89
+ expect(resolved.requestKind).toBe('pending_question');
90
+ expect(resolved.legacyFallbackUsed).toBe(false);
91
+ });
92
+
93
+ test('resolve mode uses legacy fallback when requestKind is missing', () => {
94
+ const resolved = resolveGuardianQuestionInstructionMode({
95
+ requestCode: 'A1B2C3',
96
+ questionText: 'Allow host bash?',
97
+ toolName: 'host_bash',
98
+ });
99
+
100
+ expect(resolved.mode).toBe('approval');
101
+ expect(resolved.requestKind).toBeNull();
102
+ expect(resolved.legacyFallbackUsed).toBe(true);
103
+ });
104
+
105
+ test('resolve mode treats pending_question with toolName as approval-mode', () => {
106
+ const resolved = resolveGuardianQuestionInstructionMode({
107
+ requestKind: 'pending_question',
108
+ requestId: 'req-voice-tool-2',
109
+ requestCode: 'CC22DD',
110
+ questionText: 'Allow send_email?',
111
+ callSessionId: 'call-voice-2',
112
+ activeGuardianRequestCount: 1,
113
+ toolName: 'send_email',
114
+ });
115
+
116
+ expect(resolved.mode).toBe('approval');
117
+ expect(resolved.requestKind).toBe('pending_question');
118
+ expect(resolved.legacyFallbackUsed).toBe(false);
119
+ });
120
+
121
+ test('resolveGuardianInstructionModeFromFields returns null for unknown request kind', () => {
122
+ const resolved = resolveGuardianInstructionModeFromFields('unknown_kind', 'send_email');
123
+ expect(resolved).toBeNull();
124
+ });
125
+
126
+ test('answer-mode instruction detection rejects approval phrasing', () => {
127
+ const code = 'A1B2C3';
128
+ const wrongInstruction = buildGuardianRequestCodeInstruction(code, 'approval');
129
+ const correctInstruction = buildGuardianRequestCodeInstruction(code, 'answer');
130
+
131
+ expect(hasGuardianRequestCodeInstruction(wrongInstruction, code, 'answer')).toBe(false);
132
+ expect(hasGuardianRequestCodeInstruction(correctInstruction, code, 'answer')).toBe(true);
133
+ });
134
+
135
+ test('buildGuardianReplyDirective uses mode-specific wording', () => {
136
+ expect(buildGuardianReplyDirective('A1B2C3', 'approval')).toBe('Reply "A1B2C3 approve" or "A1B2C3 reject".');
137
+ expect(buildGuardianReplyDirective('A1B2C3', 'answer')).toBe('Reply "A1B2C3 <your answer>".');
138
+ });
139
+
140
+ test('resolveGuardianInstructionModeForRequest handles tool-backed pending_question as approval', () => {
141
+ expect(
142
+ resolveGuardianInstructionModeForRequest({
143
+ kind: 'pending_question',
144
+ toolName: 'send_email',
145
+ }),
146
+ ).toBe('approval');
147
+ expect(
148
+ resolveGuardianInstructionModeForRequest({
149
+ kind: 'pending_question',
150
+ toolName: null,
151
+ }),
152
+ ).toBe('answer');
153
+ });
154
+
155
+ test('centralized guardian response copy builders produce mode-specific copy', () => {
156
+ expect(buildGuardianInvalidActionReply('approval', 'A1B2C3')).toContain('approve');
157
+ expect(buildGuardianInvalidActionReply('answer', 'A1B2C3')).toContain('<your answer>');
158
+
159
+ expect(
160
+ buildGuardianCodeOnlyClarification('approval', {
161
+ requestCode: 'A1B2C3',
162
+ questionText: 'Allow send_email to bob@example.com?',
163
+ toolName: 'send_email',
164
+ }),
165
+ ).toContain('I found request A1B2C3 for send_email.');
166
+ expect(
167
+ buildGuardianCodeOnlyClarification('answer', {
168
+ requestCode: 'A1B2C3',
169
+ questionText: 'What time works best?',
170
+ }),
171
+ ).toContain('I found question A1B2C3.');
172
+
173
+ expect(
174
+ buildGuardianDisambiguationLabel('approval', {
175
+ questionText: 'Allow send_email to bob@example.com?',
176
+ toolName: 'send_email',
177
+ }),
178
+ ).toBe('send_email');
179
+ expect(
180
+ buildGuardianDisambiguationLabel('answer', {
181
+ questionText: 'What time works best?',
182
+ }),
183
+ ).toBe('What time works best?');
184
+
185
+ expect(buildGuardianDisambiguationExample('approval', 'A1B2C3')).toBe(
186
+ 'For approvals: reply "A1B2C3 approve" or "A1B2C3 reject".',
187
+ );
188
+ expect(buildGuardianDisambiguationExample('answer', 'A1B2C3')).toBe(
189
+ 'For questions: reply "A1B2C3 <your answer>".',
190
+ );
191
+ });
192
+
193
+ test('stripConflictingGuardianRequestInstructions removes opposite-mode instructions', () => {
194
+ const approvalText = 'Reference code: A1B2C3. Reply "A1B2C3 approve" or "A1B2C3 reject".';
195
+ const answerText = 'Reference code: A1B2C3. Reply "A1B2C3 <your answer>".';
196
+
197
+ expect(stripConflictingGuardianRequestInstructions(approvalText, 'A1B2C3', 'answer')).toBe('');
198
+ expect(stripConflictingGuardianRequestInstructions(answerText, 'A1B2C3', 'approval')).toBe('');
199
+ });
200
+ });
@@ -475,6 +475,69 @@ describe('routing invariant: code-only messages return clarification', () => {
475
475
  expect(unchanged!.status).toBe('pending');
476
476
  });
477
477
 
478
+ test('code-only pending_question asks for free-text answer (not approve/reject)', async () => {
479
+ const req = createCanonicalGuardianRequest({
480
+ kind: 'pending_question',
481
+ sourceType: 'voice',
482
+ sourceChannel: 'voice',
483
+ conversationId: 'conv-1',
484
+ guardianExternalUserId: 'guardian-1',
485
+ callSessionId: 'call-1',
486
+ pendingQuestionId: 'pq-1',
487
+ requestCode: 'A2B3C4',
488
+ questionText: 'What time works best?',
489
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
490
+ });
491
+
492
+ const result = await routeGuardianReply(replyCtx({
493
+ messageText: 'A2B3C4',
494
+ conversationId: 'conv-1',
495
+ }));
496
+
497
+ expect(result.consumed).toBe(true);
498
+ expect(result.type).toBe('code_only_clarification');
499
+ expect(result.decisionApplied).toBe(false);
500
+ expect(result.replyText).toContain('A2B3C4');
501
+ expect(result.replyText).toContain('<your answer>');
502
+ expect(result.replyText).not.toContain('approve');
503
+ expect(result.replyText).not.toContain('reject');
504
+
505
+ const unchanged = getCanonicalGuardianRequest(req.id);
506
+ expect(unchanged!.status).toBe('pending');
507
+ });
508
+
509
+ test('code-only tool-backed pending_question asks for approve/reject decision', async () => {
510
+ const req = createCanonicalGuardianRequest({
511
+ kind: 'pending_question',
512
+ sourceType: 'voice',
513
+ sourceChannel: 'voice',
514
+ conversationId: 'conv-1',
515
+ guardianExternalUserId: 'guardian-1',
516
+ callSessionId: 'call-2',
517
+ pendingQuestionId: 'pq-2',
518
+ requestCode: 'B2C3D4',
519
+ questionText: 'Allow send_email to bob@example.com?',
520
+ toolName: 'send_email',
521
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
522
+ });
523
+
524
+ const result = await routeGuardianReply(replyCtx({
525
+ messageText: 'B2C3D4',
526
+ conversationId: 'conv-1',
527
+ }));
528
+
529
+ expect(result.consumed).toBe(true);
530
+ expect(result.type).toBe('code_only_clarification');
531
+ expect(result.decisionApplied).toBe(false);
532
+ expect(result.replyText).toContain('B2C3D4');
533
+ expect(result.replyText).toContain('approve');
534
+ expect(result.replyText).toContain('reject');
535
+ expect(result.replyText).not.toContain('<your answer>');
536
+
537
+ const unchanged = getCanonicalGuardianRequest(req.id);
538
+ expect(unchanged!.status).toBe('pending');
539
+ });
540
+
478
541
  test('code with decision text does apply the decision', async () => {
479
542
  const req = createCanonicalGuardianRequest({
480
543
  kind: 'tool_approval',
@@ -701,6 +764,51 @@ describe('routing invariant: disambiguation stays fail-closed', () => {
701
764
  expect(result.replyText).toContain('BBB222');
702
765
  });
703
766
 
767
+ test('disambiguation treats tool-backed pending_question as approval request', async () => {
768
+ const answerRequest = createCanonicalGuardianRequest({
769
+ kind: 'pending_question',
770
+ sourceType: 'voice',
771
+ sourceChannel: 'voice',
772
+ conversationId: 'conv-1',
773
+ guardianExternalUserId: 'guardian-1',
774
+ callSessionId: 'call-answer',
775
+ pendingQuestionId: 'pq-answer',
776
+ requestCode: 'ABC123',
777
+ questionText: 'What time works best?',
778
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
779
+ });
780
+
781
+ const approvalRequest = createCanonicalGuardianRequest({
782
+ kind: 'pending_question',
783
+ sourceType: 'voice',
784
+ sourceChannel: 'voice',
785
+ conversationId: 'conv-1',
786
+ guardianExternalUserId: 'guardian-1',
787
+ callSessionId: 'call-approval',
788
+ pendingQuestionId: 'pq-approval',
789
+ requestCode: 'DEF456',
790
+ questionText: 'Allow send_email to bob@example.com?',
791
+ toolName: 'send_email',
792
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
793
+ });
794
+
795
+ const result = await routeGuardianReply(replyCtx({
796
+ messageText: 'approve',
797
+ conversationId: 'conv-guardian-thread',
798
+ pendingRequestIds: [answerRequest.id, approvalRequest.id],
799
+ approvalConversationGenerator: undefined,
800
+ }));
801
+
802
+ expect(result.consumed).toBe(true);
803
+ expect(result.type).toBe('disambiguation_needed');
804
+ expect(result.decisionApplied).toBe(false);
805
+ expect(result.replyText).toContain('ABC123');
806
+ expect(result.replyText).toContain('DEF456');
807
+ expect(result.replyText).toContain('send_email');
808
+ expect(result.replyText).toContain('For questions: reply "ABC123 <your answer>".');
809
+ expect(result.replyText).toContain('For approvals: reply "DEF456 approve" or "DEF456 reject".');
810
+ });
811
+
704
812
  test('single pending request does not need disambiguation', async () => {
705
813
  const req = createCanonicalGuardianRequest({
706
814
  kind: 'tool_approval',
@@ -1130,4 +1238,74 @@ describe('routing invariant: invite handoff bypass for access requests', () => {
1130
1238
  const resolved = getCanonicalGuardianRequest(req.id);
1131
1239
  expect(resolved!.status).toBe('approved');
1132
1240
  });
1241
+
1242
+ test('trusted desktop access-request approval returns a verification code reply', async () => {
1243
+ const req = createCanonicalGuardianRequest({
1244
+ kind: 'access_request',
1245
+ sourceType: 'channel',
1246
+ sourceChannel: 'telegram',
1247
+ conversationId: 'conv-access-desktop',
1248
+ guardianExternalUserId: 'guardian-1',
1249
+ requestCode: 'C0D3A5',
1250
+ toolName: 'ingress_access_request',
1251
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
1252
+ });
1253
+
1254
+ const result = await routeGuardianReply({
1255
+ messageText: 'C0D3A5 approve',
1256
+ channel: 'vellum',
1257
+ actor: trustedActor({ channel: 'vellum' }),
1258
+ conversationId: 'conv-guardian-thread',
1259
+ pendingRequestIds: [req.id],
1260
+ approvalConversationGenerator: undefined,
1261
+ });
1262
+
1263
+ expect(result.consumed).toBe(true);
1264
+ expect(result.decisionApplied).toBe(true);
1265
+ expect(result.replyText).toContain('verification code');
1266
+ expect(result.replyText).toMatch(/\b\d{6}\b/);
1267
+
1268
+ const resolved = getCanonicalGuardianRequest(req.id);
1269
+ expect(resolved!.status).toBe('approved');
1270
+ });
1271
+
1272
+ test('NL decision path preserves resolver verification code reply text', async () => {
1273
+ const req = createCanonicalGuardianRequest({
1274
+ kind: 'access_request',
1275
+ sourceType: 'channel',
1276
+ sourceChannel: 'telegram',
1277
+ conversationId: 'conv-access-desktop-nl',
1278
+ guardianExternalUserId: 'guardian-1',
1279
+ requesterExternalUserId: 'requester-1',
1280
+ requesterChatId: 'chat-1',
1281
+ requestCode: 'A1B2C3',
1282
+ toolName: 'ingress_access_request',
1283
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
1284
+ });
1285
+
1286
+ const approvalConversationGenerator = async () => ({
1287
+ disposition: 'approve_once' as const,
1288
+ replyText: 'Access approved.',
1289
+ targetRequestId: req.id,
1290
+ });
1291
+
1292
+ const result = await routeGuardianReply({
1293
+ messageText: 'please approve this request',
1294
+ channel: 'vellum',
1295
+ actor: trustedActor({ channel: 'vellum' }),
1296
+ conversationId: 'conv-guardian-thread',
1297
+ pendingRequestIds: [req.id],
1298
+ approvalConversationGenerator: approvalConversationGenerator as any,
1299
+ });
1300
+
1301
+ expect(result.consumed).toBe(true);
1302
+ expect(result.decisionApplied).toBe(true);
1303
+ expect(result.type).toBe('canonical_decision_applied');
1304
+ expect(result.replyText).toContain('verification code');
1305
+ expect(result.replyText).toMatch(/\b\d{6}\b/);
1306
+ expect(result.replyText).not.toBe('Access approved.');
1307
+
1308
+ const resolved = getCanonicalGuardianRequest(req.id);
1309
+ expect(resolved!.status).toBe('approved');
1310
+ });
1133
1311
  });