@vellumai/assistant 0.3.19 → 0.3.21

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 (199) hide show
  1. package/ARCHITECTURE.md +151 -15
  2. package/Dockerfile +1 -0
  3. package/README.md +40 -4
  4. package/bun.lock +139 -2
  5. package/docs/architecture/integrations.md +7 -11
  6. package/package.json +2 -1
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +54 -0
  8. package/src/__tests__/approval-primitive.test.ts +540 -0
  9. package/src/__tests__/assistant-feature-flag-guard.test.ts +206 -0
  10. package/src/__tests__/assistant-feature-flag-guardrails.test.ts +198 -0
  11. package/src/__tests__/assistant-feature-flags-integration.test.ts +272 -0
  12. package/src/__tests__/call-controller.test.ts +439 -108
  13. package/src/__tests__/channel-invite-transport.test.ts +264 -0
  14. package/src/__tests__/cli.test.ts +42 -1
  15. package/src/__tests__/config-schema.test.ts +11 -127
  16. package/src/__tests__/config-watcher.test.ts +0 -8
  17. package/src/__tests__/daemon-lifecycle.test.ts +1 -0
  18. package/src/__tests__/daemon-server-session-init.test.ts +8 -2
  19. package/src/__tests__/diff.test.ts +22 -0
  20. package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
  21. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +300 -32
  22. package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
  23. package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
  24. package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
  25. package/src/__tests__/guardian-dispatch.test.ts +124 -0
  26. package/src/__tests__/guardian-grant-minting.test.ts +6 -17
  27. package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
  28. package/src/__tests__/invite-redemption-service.test.ts +306 -0
  29. package/src/__tests__/ipc-snapshot.test.ts +57 -0
  30. package/src/__tests__/notification-decision-fallback.test.ts +88 -0
  31. package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
  32. package/src/__tests__/sandbox-host-parity.test.ts +6 -13
  33. package/src/__tests__/scoped-approval-grants.test.ts +6 -6
  34. package/src/__tests__/scoped-grant-security-matrix.test.ts +5 -4
  35. package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
  36. package/src/__tests__/session-load-history-repair.test.ts +169 -2
  37. package/src/__tests__/session-runtime-assembly.test.ts +33 -5
  38. package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
  39. package/src/__tests__/skill-feature-flags.test.ts +188 -0
  40. package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
  41. package/src/__tests__/skill-mirror-parity.test.ts +1 -0
  42. package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
  43. package/src/__tests__/system-prompt.test.ts +1 -1
  44. package/src/__tests__/terminal-sandbox.test.ts +142 -9
  45. package/src/__tests__/terminal-tools.test.ts +2 -93
  46. package/src/__tests__/thread-seed-composer.test.ts +18 -0
  47. package/src/__tests__/tool-approval-handler.test.ts +350 -0
  48. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
  49. package/src/__tests__/voice-scoped-grant-consumer.test.ts +46 -84
  50. package/src/agent/loop.ts +36 -1
  51. package/src/approvals/approval-primitive.ts +381 -0
  52. package/src/approvals/guardian-decision-primitive.ts +191 -0
  53. package/src/calls/call-controller.ts +252 -209
  54. package/src/calls/call-domain.ts +44 -6
  55. package/src/calls/guardian-dispatch.ts +48 -0
  56. package/src/calls/types.ts +1 -1
  57. package/src/calls/voice-session-bridge.ts +46 -30
  58. package/src/cli/core-commands.ts +0 -4
  59. package/src/cli/mcp.ts +58 -0
  60. package/src/cli.ts +76 -34
  61. package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
  62. package/src/config/assistant-feature-flags.ts +162 -0
  63. package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
  64. package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
  65. package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
  66. package/src/config/bundled-skills/notifications/SKILL.md +1 -1
  67. package/src/config/bundled-skills/reminder/SKILL.md +49 -2
  68. package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
  69. package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
  70. package/src/config/core-schema.ts +1 -1
  71. package/src/config/env-registry.ts +10 -0
  72. package/src/config/feature-flag-registry.json +61 -0
  73. package/src/config/loader.ts +22 -1
  74. package/src/config/mcp-schema.ts +46 -0
  75. package/src/config/sandbox-schema.ts +0 -39
  76. package/src/config/schema.ts +18 -2
  77. package/src/config/skill-state.ts +34 -0
  78. package/src/config/skills-schema.ts +0 -1
  79. package/src/config/skills.ts +9 -0
  80. package/src/config/system-prompt.ts +110 -46
  81. package/src/config/templates/SOUL.md +1 -1
  82. package/src/config/types.ts +19 -1
  83. package/src/config/vellum-skills/catalog.json +1 -1
  84. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
  85. package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
  86. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -5
  87. package/src/config/vellum-skills/trusted-contacts/SKILL.md +105 -3
  88. package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
  89. package/src/daemon/config-watcher.ts +0 -1
  90. package/src/daemon/daemon-control.ts +1 -1
  91. package/src/daemon/guardian-invite-intent.ts +124 -0
  92. package/src/daemon/handlers/avatar.ts +68 -0
  93. package/src/daemon/handlers/browser.ts +2 -2
  94. package/src/daemon/handlers/guardian-actions.ts +120 -0
  95. package/src/daemon/handlers/index.ts +4 -0
  96. package/src/daemon/handlers/sessions.ts +19 -0
  97. package/src/daemon/handlers/shared.ts +3 -1
  98. package/src/daemon/install-cli-launchers.ts +58 -13
  99. package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
  100. package/src/daemon/ipc-contract/sessions.ts +8 -2
  101. package/src/daemon/ipc-contract/settings.ts +25 -2
  102. package/src/daemon/ipc-contract-inventory.json +10 -0
  103. package/src/daemon/ipc-contract.ts +4 -0
  104. package/src/daemon/lifecycle.ts +14 -2
  105. package/src/daemon/main.ts +1 -0
  106. package/src/daemon/providers-setup.ts +26 -1
  107. package/src/daemon/server.ts +1 -0
  108. package/src/daemon/session-lifecycle.ts +52 -7
  109. package/src/daemon/session-memory.ts +45 -0
  110. package/src/daemon/session-process.ts +258 -432
  111. package/src/daemon/session-runtime-assembly.ts +12 -0
  112. package/src/daemon/session-skill-tools.ts +14 -1
  113. package/src/daemon/session-tool-setup.ts +5 -0
  114. package/src/daemon/session.ts +11 -0
  115. package/src/daemon/shutdown-handlers.ts +11 -0
  116. package/src/daemon/tool-side-effects.ts +35 -9
  117. package/src/index.ts +2 -2
  118. package/src/mcp/client.ts +152 -0
  119. package/src/mcp/manager.ts +139 -0
  120. package/src/memory/conversation-display-order-migration.ts +44 -0
  121. package/src/memory/conversation-queries.ts +2 -0
  122. package/src/memory/conversation-store.ts +91 -0
  123. package/src/memory/db-init.ts +5 -1
  124. package/src/memory/embedding-local.ts +13 -8
  125. package/src/memory/guardian-action-store.ts +125 -2
  126. package/src/memory/ingress-invite-store.ts +95 -1
  127. package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
  128. package/src/memory/migrations/index.ts +2 -1
  129. package/src/memory/schema.ts +5 -1
  130. package/src/memory/scoped-approval-grants.ts +14 -5
  131. package/src/messaging/providers/slack/client.ts +12 -0
  132. package/src/messaging/providers/slack/types.ts +5 -0
  133. package/src/notifications/decision-engine.ts +49 -12
  134. package/src/notifications/emit-signal.ts +7 -0
  135. package/src/notifications/signal.ts +7 -0
  136. package/src/notifications/thread-seed-composer.ts +2 -1
  137. package/src/runtime/channel-approval-types.ts +16 -6
  138. package/src/runtime/channel-approvals.ts +19 -15
  139. package/src/runtime/channel-invite-transport.ts +85 -0
  140. package/src/runtime/channel-invite-transports/telegram.ts +105 -0
  141. package/src/runtime/guardian-action-grant-minter.ts +92 -35
  142. package/src/runtime/guardian-action-message-composer.ts +30 -0
  143. package/src/runtime/guardian-decision-types.ts +91 -0
  144. package/src/runtime/http-server.ts +23 -1
  145. package/src/runtime/ingress-service.ts +22 -0
  146. package/src/runtime/invite-redemption-service.ts +181 -0
  147. package/src/runtime/invite-redemption-templates.ts +39 -0
  148. package/src/runtime/routes/call-routes.ts +2 -1
  149. package/src/runtime/routes/guardian-action-routes.ts +206 -0
  150. package/src/runtime/routes/guardian-approval-interception.ts +66 -190
  151. package/src/runtime/routes/identity-routes.ts +73 -0
  152. package/src/runtime/routes/inbound-message-handler.ts +486 -394
  153. package/src/runtime/routes/pairing-routes.ts +4 -0
  154. package/src/security/encrypted-store.ts +31 -17
  155. package/src/security/keychain.ts +176 -2
  156. package/src/security/secure-keys.ts +97 -0
  157. package/src/security/tool-approval-digest.ts +1 -1
  158. package/src/tools/browser/browser-execution.ts +2 -2
  159. package/src/tools/browser/browser-manager.ts +46 -32
  160. package/src/tools/browser/browser-screencast.ts +2 -2
  161. package/src/tools/calls/call-start.ts +1 -1
  162. package/src/tools/executor.ts +22 -17
  163. package/src/tools/mcp/mcp-tool-factory.ts +100 -0
  164. package/src/tools/network/script-proxy/session-manager.ts +1 -5
  165. package/src/tools/registry.ts +64 -1
  166. package/src/tools/skills/load.ts +22 -8
  167. package/src/tools/system/avatar-generator.ts +119 -0
  168. package/src/tools/system/navigate-settings.ts +65 -0
  169. package/src/tools/system/open-system-settings.ts +75 -0
  170. package/src/tools/system/voice-config.ts +121 -32
  171. package/src/tools/terminal/backends/native.ts +40 -19
  172. package/src/tools/terminal/backends/types.ts +3 -3
  173. package/src/tools/terminal/parser.ts +1 -1
  174. package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
  175. package/src/tools/terminal/sandbox.ts +1 -12
  176. package/src/tools/terminal/shell.ts +3 -31
  177. package/src/tools/tool-approval-handler.ts +141 -3
  178. package/src/tools/tool-manifest.ts +6 -0
  179. package/src/tools/types.ts +10 -2
  180. package/src/util/diff.ts +36 -13
  181. package/Dockerfile.sandbox +0 -5
  182. package/src/__tests__/doordash-client.test.ts +0 -187
  183. package/src/__tests__/doordash-session.test.ts +0 -154
  184. package/src/__tests__/signup-e2e.test.ts +0 -354
  185. package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
  186. package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
  187. package/src/cli/doordash.ts +0 -1057
  188. package/src/config/bundled-skills/doordash/SKILL.md +0 -163
  189. package/src/config/templates/LOOKS.md +0 -25
  190. package/src/doordash/cart-queries.ts +0 -787
  191. package/src/doordash/client.ts +0 -1016
  192. package/src/doordash/order-queries.ts +0 -85
  193. package/src/doordash/queries.ts +0 -13
  194. package/src/doordash/query-extractor.ts +0 -94
  195. package/src/doordash/search-queries.ts +0 -203
  196. package/src/doordash/session.ts +0 -84
  197. package/src/doordash/store-queries.ts +0 -246
  198. package/src/doordash/types.ts +0 -367
  199. package/src/tools/terminal/backends/docker.ts +0 -379
@@ -1,9 +1,14 @@
1
1
  /**
2
2
  * Channel inbound message handler: validates, records, and routes inbound
3
3
  * messages from all channels. Handles ingress ACL, edits, guardian
4
- * verification, guardian action answers, and approval interception.
4
+ * verification, guardian action answers, approval interception, and
5
+ * invite token redemption.
5
6
  */
7
+ // Side-effect import: registers the Telegram invite transport adapter so
8
+ // getTransport('telegram') resolves at runtime.
6
9
  import { answerCall } from '../../calls/call-domain.js';
10
+ import { isTerminalState } from '../../calls/call-state-machine.js';
11
+ import { getCallSession } from '../../calls/call-store.js';
7
12
  import type { ChannelId, InterfaceId } from '../../channels/types.js';
8
13
  import { CHANNEL_IDS, INTERFACE_IDS, isChannelId, parseInterfaceId } from '../../channels/types.js';
9
14
  import { getGatewayInternalBaseUrl } from '../../config/env.js';
@@ -19,10 +24,12 @@ import * as conversationStore from '../../memory/conversation-store.js';
19
24
  import * as externalConversationStore from '../../memory/external-conversation-store.js';
20
25
  import {
21
26
  finalizeFollowup,
27
+ getDeliveriesByRequestId,
22
28
  getExpiredDeliveriesByDestination,
23
29
  getFollowupDeliveriesByDestination,
24
30
  getGuardianActionRequest,
25
31
  getPendingDeliveriesByDestination,
32
+ getPendingRequestByCallSessionId,
26
33
  progressFollowupState,
27
34
  resolveGuardianActionRequest,
28
35
  startFollowupFromExpiredRequest,
@@ -49,9 +56,11 @@ import {
49
56
  updateSessionStatus,
50
57
  validateAndConsumeChallenge,
51
58
  } from '../channel-guardian-service.js';
59
+ import { getTransport } from '../channel-invite-transport.js';
52
60
  import { deliverChannelReply } from '../gateway-client.js';
53
61
  import { processGuardianFollowUpTurn } from '../guardian-action-conversation-turn.js';
54
62
  import { executeFollowupAction } from '../guardian-action-followup-executor.js';
63
+ import { tryMintGuardianActionGrant } from '../guardian-action-grant-minter.js';
55
64
  import { composeGuardianActionMessageGenerative } from '../guardian-action-message-composer.js';
56
65
  import { resolveGuardianContext } from '../guardian-context-resolver.js';
57
66
  import {
@@ -67,6 +76,8 @@ import type {
67
76
  GuardianFollowUpConversationGenerator,
68
77
  MessageProcessor,
69
78
  } from '../http-types.js';
79
+ import { redeemInvite } from '../invite-redemption-service.js';
80
+ import { getInviteRedemptionReply } from '../invite-redemption-templates.js';
70
81
  import { deliverReplyViaCallback } from './channel-delivery-routes.js';
71
82
  import {
72
83
  canonicalChannelAssistantId,
@@ -78,7 +89,8 @@ import {
78
89
  } from './channel-route-shared.js';
79
90
  import { handleApprovalInterception } from './guardian-approval-interception.js';
80
91
  import { deliverGeneratedApprovalPrompt } from './guardian-approval-prompt.js';
81
- import { tryMintGuardianActionGrant } from '../guardian-action-grant-minter.js';
92
+
93
+ import '../channel-invite-transports/telegram.js';
82
94
 
83
95
  const log = getLogger('runtime-http');
84
96
 
@@ -210,6 +222,19 @@ export async function handleChannelInbound(
210
222
  typeof (rawCommandIntentForAcl as Record<string, unknown>).payload === 'string' &&
211
223
  ((rawCommandIntentForAcl as Record<string, unknown>).payload as string).startsWith('gv_');
212
224
 
225
+ // Parse invite token from /start iv_<token> commands using the transport
226
+ // adapter. The token is extracted once here so both the ACL bypass and
227
+ // the intercept handler can reference it without re-parsing.
228
+ const commandIntentForAcl = rawCommandIntentForAcl && typeof rawCommandIntentForAcl === 'object' && !Array.isArray(rawCommandIntentForAcl)
229
+ ? rawCommandIntentForAcl as Record<string, unknown>
230
+ : undefined;
231
+ const inviteTransport = getTransport(sourceChannel);
232
+ const inviteToken = inviteTransport?.extractInboundToken({
233
+ commandIntent: commandIntentForAcl,
234
+ content: trimmedContent,
235
+ sourceMetadata: body.sourceMetadata,
236
+ });
237
+
213
238
  if (body.senderExternalUserId) {
214
239
  resolvedMember = findMember({
215
240
  assistantId: canonicalAssistantId,
@@ -253,6 +278,27 @@ export async function handleChannelInbound(
253
278
  }
254
279
  }
255
280
 
281
+ // ── Invite token intercept (non-member) ──
282
+ // /start iv_<token> deep links grant access without guardian approval.
283
+ // Intercept here — before the deny gate — so valid invites short-circuit
284
+ // the ACL rejection and never reach the agent pipeline.
285
+ if (inviteToken && denyNonMember) {
286
+ const inviteResult = await handleInviteTokenIntercept({
287
+ rawToken: inviteToken,
288
+ sourceChannel,
289
+ externalChatId,
290
+ externalMessageId,
291
+ senderExternalUserId: body.senderExternalUserId,
292
+ senderName: body.senderName,
293
+ senderUsername: body.senderUsername,
294
+ replyCallbackUrl: body.replyCallbackUrl,
295
+ bearerToken,
296
+ assistantId,
297
+ canonicalAssistantId,
298
+ });
299
+ if (inviteResult) return inviteResult;
300
+ }
301
+
256
302
  if (denyNonMember) {
257
303
  log.info({ sourceChannel, externalUserId: body.senderExternalUserId }, 'Ingress ACL: no member record, denying');
258
304
 
@@ -275,7 +321,7 @@ export async function handleChannelInbound(
275
321
 
276
322
  if (body.replyCallbackUrl) {
277
323
  const replyText = guardianNotified
278
- ? "I've let my guardian know you'd like access. They'll send you a verification code if they approve your request."
324
+ ? "Hmm looks like you don't have access to talk to me. I'll let them know you tried talking to me and get back to you."
279
325
  : "Sorry, you haven't been approved to message this assistant.";
280
326
  try {
281
327
  await deliverChannelReply(body.replyCallbackUrl, {
@@ -318,6 +364,26 @@ export async function handleChannelInbound(
318
364
  }
319
365
  }
320
366
 
367
+ // ── Invite token intercept (inactive member) ──
368
+ // Same as the non-member branch: invite tokens can reactivate
369
+ // revoked/pending members without requiring guardian approval.
370
+ if (inviteToken && denyInactiveMember) {
371
+ const inviteResult = await handleInviteTokenIntercept({
372
+ rawToken: inviteToken,
373
+ sourceChannel,
374
+ externalChatId,
375
+ externalMessageId,
376
+ senderExternalUserId: body.senderExternalUserId,
377
+ senderName: body.senderName,
378
+ senderUsername: body.senderUsername,
379
+ replyCallbackUrl: body.replyCallbackUrl,
380
+ bearerToken,
381
+ assistantId,
382
+ canonicalAssistantId,
383
+ });
384
+ if (inviteResult) return inviteResult;
385
+ }
386
+
321
387
  if (denyInactiveMember) {
322
388
  log.info({ sourceChannel, memberId: resolvedMember.id, status: resolvedMember.status }, 'Ingress ACL: member not active, denying');
323
389
 
@@ -342,7 +408,7 @@ export async function handleChannelInbound(
342
408
 
343
409
  if (body.replyCallbackUrl) {
344
410
  const replyText = guardianNotified
345
- ? "I've let my guardian know you'd like access. They'll send you a verification code if they approve your request."
411
+ ? "Hmm looks like you don't have access to talk to me. I'll let them know you tried talking to me and get back to you."
346
412
  : "Sorry, you haven't been approved to message this assistant.";
347
413
  try {
348
414
  await deliverChannelReply(body.replyCallbackUrl, {
@@ -807,12 +873,13 @@ export async function handleChannelInbound(
807
873
  });
808
874
  }
809
875
 
810
- // ── Guardian action answer interception ──
811
- // Check if this inbound message is a reply to a cross-channel guardian
812
- // action request (from a voice call). Must run before approval interception
813
- // so guardian answers are not mistakenly routed into the approval flow.
814
- // Callback payloads (inline button presses) are excluded they should
815
- // not be misclassified as guardian answers.
876
+ // ── Unified guardian action answer interception ──
877
+ // Deterministic priority matching: pending follow-up expired.
878
+ // When the guardian includes an explicit request code, match it across all
879
+ // states in priority order. When only one actionable request exists,
880
+ // auto-match without requiring a code prefix. Callback payloads (inline
881
+ // button presses) are excluded — they should not be misclassified as
882
+ // guardian answers.
816
883
  if (
817
884
  !result.duplicate &&
818
885
  !hasCallbackData &&
@@ -820,439 +887,346 @@ export async function handleChannelInbound(
820
887
  body.senderExternalUserId &&
821
888
  replyCallbackUrl
822
889
  ) {
823
- const pendingDeliveries = getPendingDeliveriesByDestination(canonicalAssistantId, sourceChannel, externalChatId);
824
- if (pendingDeliveries.length > 0) {
825
- // Identity check: only the designated guardian can answer
826
- const validDeliveries = pendingDeliveries.filter(
827
- (d) => d.destinationExternalUserId === body.senderExternalUserId,
828
- );
829
-
830
- if (validDeliveries.length > 0) {
831
- let matchedDelivery = validDeliveries.length === 1 ? validDeliveries[0] : null;
832
- let answerText = trimmedContent;
833
-
834
- // Multiple pending deliveries: require request code prefix for disambiguation
835
- if (validDeliveries.length > 1) {
836
- for (const d of validDeliveries) {
837
- const req = getGuardianActionRequest(d.requestId);
838
- if (req && trimmedContent.toUpperCase().startsWith(req.requestCode)) {
839
- matchedDelivery = d;
840
- answerText = trimmedContent.slice(req.requestCode.length).trim();
841
- break;
842
- }
890
+ // Gather deliveries across all states for this destination, filtered by sender identity
891
+ const allPending = getPendingDeliveriesByDestination(canonicalAssistantId, sourceChannel, externalChatId)
892
+ .filter((d) => d.destinationExternalUserId === body.senderExternalUserId);
893
+ const allFollowup = getFollowupDeliveriesByDestination(canonicalAssistantId, sourceChannel, externalChatId)
894
+ .filter((d) => d.destinationExternalUserId === body.senderExternalUserId);
895
+ const allExpired = getExpiredDeliveriesByDestination(canonicalAssistantId, sourceChannel, externalChatId)
896
+ .filter((d) => d.destinationExternalUserId === body.senderExternalUserId);
897
+ const totalActionable = allPending.length + allFollowup.length + allExpired.length;
898
+
899
+ if (totalActionable > 0) {
900
+ // ── Try to parse an explicit request code from the message ──
901
+ // Check all deliveries across states for a code prefix match, in priority order
902
+ type CodeMatch = { delivery: typeof allPending[0]; request: NonNullable<ReturnType<typeof getGuardianActionRequest>>; state: 'pending' | 'followup' | 'expired'; answerText: string };
903
+ let codeMatch: CodeMatch | null = null;
904
+ const upperContent = trimmedContent.toUpperCase();
905
+ const orderedSets: Array<{ deliveries: typeof allPending; state: 'pending' | 'followup' | 'expired' }> = [
906
+ { deliveries: allPending, state: 'pending' },
907
+ { deliveries: allFollowup, state: 'followup' },
908
+ { deliveries: allExpired, state: 'expired' },
909
+ ];
910
+ for (const { deliveries, state } of orderedSets) {
911
+ for (const d of deliveries) {
912
+ const req = getGuardianActionRequest(d.requestId);
913
+ if (req && upperContent.startsWith(req.requestCode)) {
914
+ codeMatch = { delivery: d, request: req, state, answerText: trimmedContent.slice(req.requestCode.length).trim() };
915
+ break;
843
916
  }
917
+ }
918
+ if (codeMatch) break;
919
+ }
844
920
 
845
- if (!matchedDelivery) {
846
- // Send disambiguation message listing the request codes
847
- const codes = validDeliveries
848
- .map((d) => {
849
- const req = getGuardianActionRequest(d.requestId);
850
- return req ? req.requestCode : null;
851
- })
852
- .filter(Boolean);
921
+ // ── Explicit code targets a non-pending state: handle terminal/remap ──
922
+ if (codeMatch && codeMatch.state !== 'pending') {
923
+ const targetReq = codeMatch.request;
924
+
925
+ // Superseded request with no active call → terminal notice
926
+ if (targetReq.status === 'expired' && targetReq.expiredReason === 'superseded') {
927
+ const callSession = getCallSession(targetReq.callSessionId);
928
+ const callStillActive = callSession && !isTerminalState(callSession.status);
929
+ if (!callStillActive) {
930
+ const staleText = await composeGuardianActionMessageGenerative(
931
+ { scenario: 'guardian_stale_superseded' },
932
+ {},
933
+ guardianActionCopyGenerator,
934
+ );
853
935
  try {
854
- await deliverChannelReply(replyCallbackUrl, {
855
- chatId: externalChatId,
856
- text: `You have multiple pending guardian questions. Please prefix your reply with the reference code (${codes.join(', ')}) to indicate which question you are answering.`,
857
- assistantId,
858
- }, bearerToken);
936
+ await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: staleText, assistantId }, bearerToken);
859
937
  } catch (err) {
860
- log.error({ err, externalChatId }, 'Failed to deliver guardian action disambiguation message');
938
+ log.error({ err, externalChatId }, 'Failed to deliver superseded terminal notice');
861
939
  }
862
- return Response.json({
863
- accepted: true,
864
- duplicate: false,
865
- eventId: result.eventId,
866
- guardianAnswer: 'disambiguation_sent',
867
- });
940
+ return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'stale_superseded' });
868
941
  }
869
942
  }
870
943
 
871
- if (matchedDelivery) {
872
- const request = getGuardianActionRequest(matchedDelivery.requestId);
873
- if (request) {
874
- // Attempt to deliver the answer to the call first. Only resolve
875
- // the guardian action request if answerCall succeeds, so that a
876
- // failed delivery (e.g. pending question timed out) leaves the
877
- // request pending for retry from another channel.
878
- const answerResult = await answerCall({ callSessionId: request.callSessionId, answer: answerText });
879
-
880
- if (!('ok' in answerResult) || !answerResult.ok) {
881
- const errorMsg = 'error' in answerResult ? answerResult.error : 'Unknown error';
882
- log.warn({ callSessionId: request.callSessionId, error: errorMsg }, 'answerCall failed for guardian answer');
883
- try {
884
- const failureText = await composeGuardianActionMessageGenerative(
885
- { scenario: 'guardian_answer_delivery_failed' },
886
- {},
887
- guardianActionCopyGenerator,
888
- );
889
- await deliverChannelReply(replyCallbackUrl, {
890
- chatId: externalChatId,
891
- text: failureText,
892
- assistantId,
893
- }, bearerToken);
894
- } catch (deliverErr) {
895
- log.error({ err: deliverErr, externalChatId }, 'Failed to deliver guardian answer failure notice');
896
- }
897
- return Response.json({
898
- accepted: true,
899
- duplicate: false,
900
- eventId: result.eventId,
901
- guardianAnswer: 'answer_failed',
902
- });
903
- }
904
-
905
- const resolved = resolveGuardianActionRequest(
906
- request.id,
907
- answerText,
908
- sourceChannel,
909
- body.senderExternalUserId,
910
- );
911
-
912
- if (resolved) {
913
- // Mint a scoped grant so the voice call can consume it
914
- // for subsequent tool confirmations.
915
- tryMintGuardianActionGrant({
916
- resolvedRequest: resolved,
917
- answerText,
918
- decisionChannel: sourceChannel,
919
- guardianExternalUserId: body.senderExternalUserId,
920
- });
944
+ // If the code pointed to expired/follow-up but there's a pending request,
945
+ // route intentionally to the expired/follow-up handler with explanation
946
+ // (the per-state blocks below will pick it up via codeMatch).
947
+ }
921
948
 
922
- return Response.json({
923
- accepted: true,
924
- duplicate: false,
925
- eventId: result.eventId,
926
- guardianAnswer: 'resolved',
927
- });
928
- } else {
929
- // resolveGuardianActionRequest returned null request was no
930
- // longer pending. answerCall already succeeded above, so the
931
- // answer WAS delivered to the call. Don't initiate a follow-up
932
- // negotiation; instead tell the guardian the answer was relayed.
933
- const freshRequest = getGuardianActionRequest(request.id);
934
-
935
- // answerCall succeeded, so the answer was delivered regardless
936
- // of the resolve race. Inform the guardian accordingly.
937
- const relayedText = await composeGuardianActionMessageGenerative(
938
- {
939
- scenario: 'guardian_stale_answered' as const,
940
- },
941
- {},
942
- guardianActionCopyGenerator,
943
- );
944
- try {
945
- await deliverChannelReply(replyCallbackUrl, {
946
- chatId: externalChatId,
947
- text: relayedText,
948
- assistantId,
949
- }, bearerToken);
950
- } catch (err) {
951
- log.error({ err, externalChatId }, 'Failed to deliver guardian action stale notice');
952
- }
953
- log.info(
954
- { requestId: request.id, freshStatus: freshRequest?.status },
955
- 'answerCall succeeded but resolveGuardianActionRequest returned null — informed guardian answer was relayed',
956
- );
957
- return Response.json({
958
- accepted: true,
959
- duplicate: false,
960
- eventId: result.eventId,
961
- guardianAnswer: 'stale',
962
- });
963
- }
949
+ // ── Auto-match: single actionable request across all states ──
950
+ // When there's only one request and no explicit code, auto-match directly
951
+ if (!codeMatch && totalActionable === 1) {
952
+ const singleDelivery = allPending[0] ?? allFollowup[0] ?? allExpired[0];
953
+ const singleReq = getGuardianActionRequest(singleDelivery.requestId);
954
+ if (singleReq) {
955
+ const state: 'pending' | 'followup' | 'expired' = allPending.length === 1 ? 'pending' : allFollowup.length === 1 ? 'followup' : 'expired';
956
+ // Strip the code prefix if the guardian uses it out of habit
957
+ let text = trimmedContent;
958
+ if (upperContent.startsWith(singleReq.requestCode)) {
959
+ text = trimmedContent.slice(singleReq.requestCode.length).trim();
964
960
  }
961
+ codeMatch = { delivery: singleDelivery, request: singleReq, state, answerText: text };
965
962
  }
966
963
  }
967
- }
968
- }
969
964
 
970
- // ── Expired guardian action late answer interception ──
971
- // When no pending delivery was found above, check for expired requests
972
- // eligible for follow-up (status='expired', followup_state='none').
973
- // Exclude callback payloads inline button presses should not be
974
- // misclassified as late guardian answers.
975
- if (
976
- !result.duplicate &&
977
- !hasCallbackData &&
978
- trimmedContent.length > 0 &&
979
- body.senderExternalUserId &&
980
- replyCallbackUrl
981
- ) {
982
- const expiredDeliveries = getExpiredDeliveriesByDestination(canonicalAssistantId, sourceChannel, externalChatId);
983
- if (expiredDeliveries.length > 0) {
984
- const validExpired = expiredDeliveries.filter(
985
- (d) => d.destinationExternalUserId === body.senderExternalUserId,
986
- );
987
-
988
- if (validExpired.length > 0) {
989
- let matchedExpired = validExpired.length === 1 ? validExpired[0] : null;
990
- let expiredAnswerText = trimmedContent;
991
-
992
- // Multiple expired deliveries: require request code prefix for disambiguation
993
- if (validExpired.length > 1) {
994
- for (const d of validExpired) {
995
- const req = getGuardianActionRequest(d.requestId);
996
- if (req && trimmedContent.toUpperCase().startsWith(req.requestCode)) {
997
- matchedExpired = d;
998
- expiredAnswerText = trimmedContent.slice(req.requestCode.length).trim();
999
- break;
1000
- }
1001
- }
1002
-
1003
- if (!matchedExpired) {
1004
- // Send disambiguation message listing the request codes
1005
- const codes = validExpired
1006
- .map((d) => {
1007
- const req = getGuardianActionRequest(d.requestId);
1008
- return req ? req.requestCode : null;
1009
- })
1010
- .filter((code): code is string => typeof code === 'string' && code.length > 0);
1011
- const disambiguationText = await composeGuardianActionMessageGenerative(
1012
- {
1013
- scenario: 'guardian_expired_disambiguation',
1014
- requestCodes: codes,
1015
- channel: sourceChannel,
1016
- },
1017
- { requiredKeywords: codes },
965
+ // ── Unknown code: message looks like a code prefix but doesn't match anything ──
966
+ // Detect when the message starts with a 6-char alphanumeric token that
967
+ // resembles a request code but doesn't match any known delivery.
968
+ if (!codeMatch && totalActionable > 0) {
969
+ const possibleCodeMatch = trimmedContent.match(/^([A-F0-9]{6})\s/i);
970
+ if (possibleCodeMatch) {
971
+ const candidateCode = possibleCodeMatch[1].toUpperCase();
972
+ // Check if this code exists in ANY delivery across states
973
+ const allDeliveries = [...allPending, ...allFollowup, ...allExpired];
974
+ const knownCodes = allDeliveries
975
+ .map((d) => { const req = getGuardianActionRequest(d.requestId); return req?.requestCode; })
976
+ .filter((code): code is string => typeof code === 'string');
977
+ const isKnown = knownCodes.includes(candidateCode);
978
+ if (!isKnown) {
979
+ const unknownText = await composeGuardianActionMessageGenerative(
980
+ { scenario: 'guardian_unknown_code', unknownCode: candidateCode },
981
+ {},
1018
982
  guardianActionCopyGenerator,
1019
983
  );
1020
984
  try {
1021
- await deliverChannelReply(replyCallbackUrl, {
1022
- chatId: externalChatId,
1023
- text: disambiguationText,
1024
- assistantId,
1025
- }, bearerToken);
985
+ await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: unknownText, assistantId }, bearerToken);
1026
986
  } catch (err) {
1027
- log.error({ err, externalChatId }, 'Failed to deliver guardian action expired disambiguation message');
987
+ log.error({ err, externalChatId }, 'Failed to deliver unknown code notice');
1028
988
  }
1029
- return Response.json({
1030
- accepted: true,
1031
- duplicate: false,
1032
- eventId: result.eventId,
1033
- guardianAnswer: 'disambiguation_sent',
1034
- });
989
+ return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'unknown_code' });
1035
990
  }
1036
991
  }
992
+ }
1037
993
 
1038
- if (matchedExpired) {
1039
- const expiredRequest = getGuardianActionRequest(matchedExpired.requestId);
1040
-
1041
- if (expiredRequest && expiredRequest.status === 'expired' && expiredRequest.followupState === 'none') {
1042
- const followupResult = startFollowupFromExpiredRequest(expiredRequest.id, expiredAnswerText);
1043
- if (followupResult) {
1044
- const followupText = await composeGuardianActionMessageGenerative(
1045
- {
1046
- scenario: 'guardian_late_answer_followup',
1047
- questionText: expiredRequest.questionText,
1048
- lateAnswerText: expiredAnswerText,
1049
- },
1050
- {},
1051
- guardianActionCopyGenerator,
1052
- );
1053
- try {
1054
- await deliverChannelReply(replyCallbackUrl, {
1055
- chatId: externalChatId,
1056
- text: followupText,
1057
- assistantId,
1058
- }, bearerToken);
1059
- } catch (err) {
1060
- log.error({ err, externalChatId }, 'Failed to deliver guardian action late answer follow-up');
1061
- }
1062
- return Response.json({
1063
- accepted: true,
1064
- duplicate: false,
1065
- eventId: result.eventId,
1066
- guardianAnswer: 'followup_initiated',
1067
- });
1068
- } else {
1069
- // startFollowupFromExpiredRequest returned null (race condition:
1070
- // another reply already transitioned the request). Send a stale
1071
- // notice instead of falling through to the normal agent pipeline.
1072
- const staleText = await composeGuardianActionMessageGenerative(
1073
- { scenario: 'guardian_stale_expired' as const },
994
+ // ── No match and multiple actionable requests → disambiguation ──
995
+ if (!codeMatch && totalActionable > 1) {
996
+ const allDeliveries = [...allPending, ...allFollowup, ...allExpired];
997
+ const codes = allDeliveries
998
+ .map((d) => { const req = getGuardianActionRequest(d.requestId); return req ? req.requestCode : null; })
999
+ .filter((code): code is string => typeof code === 'string' && code.length > 0);
1000
+
1001
+ // Choose the appropriate disambiguation scenario based on which states are present
1002
+ const disambiguationScenario = allPending.length > 0
1003
+ ? 'guardian_pending_disambiguation' as const
1004
+ : allFollowup.length > 0
1005
+ ? 'guardian_followup_disambiguation' as const
1006
+ : 'guardian_expired_disambiguation' as const;
1007
+
1008
+ const disambiguationText = await composeGuardianActionMessageGenerative(
1009
+ { scenario: disambiguationScenario, requestCodes: codes, channel: sourceChannel },
1010
+ { requiredKeywords: codes },
1011
+ guardianActionCopyGenerator,
1012
+ );
1013
+ try {
1014
+ await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: disambiguationText, assistantId }, bearerToken);
1015
+ } catch (err) {
1016
+ log.error({ err, externalChatId }, 'Failed to deliver guardian action disambiguation message');
1017
+ }
1018
+ return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'disambiguation_sent' });
1019
+ }
1020
+
1021
+ // ── Dispatch matched delivery by state ──
1022
+ if (codeMatch) {
1023
+ const { request, state, answerText } = codeMatch;
1024
+
1025
+ // ── PENDING state handler ──
1026
+ if (state === 'pending' && request.status === 'pending') {
1027
+ const answerResult = await answerCall({ callSessionId: request.callSessionId, answer: answerText, pendingQuestionId: request.pendingQuestionId });
1028
+
1029
+ if (!('ok' in answerResult) || !answerResult.ok) {
1030
+ const errorMsg = 'error' in answerResult ? answerResult.error : 'Unknown error';
1031
+ log.warn({ callSessionId: request.callSessionId, error: errorMsg }, 'answerCall failed for guardian answer');
1032
+ try {
1033
+ const failureText = await composeGuardianActionMessageGenerative(
1034
+ { scenario: 'guardian_answer_delivery_failed' },
1074
1035
  {},
1075
1036
  guardianActionCopyGenerator,
1076
1037
  );
1077
- try {
1078
- await deliverChannelReply(replyCallbackUrl, {
1079
- chatId: externalChatId,
1080
- text: staleText,
1081
- assistantId,
1082
- }, bearerToken);
1083
- } catch (err) {
1084
- log.error({ err, externalChatId }, 'Failed to deliver guardian action stale notice for expired follow-up race');
1085
- }
1086
- return Response.json({
1087
- accepted: true,
1088
- duplicate: false,
1089
- eventId: result.eventId,
1090
- guardianAnswer: 'stale',
1091
- });
1038
+ await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: failureText, assistantId }, bearerToken);
1039
+ } catch (deliverErr) {
1040
+ log.error({ err: deliverErr, externalChatId }, 'Failed to deliver guardian answer failure notice');
1092
1041
  }
1042
+ return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'answer_failed' });
1093
1043
  }
1094
- }
1095
- }
1096
- }
1097
- }
1098
1044
 
1099
- // ── Guardian follow-up conversation interception ──
1100
- // When a request is in `awaiting_guardian_choice` state, the guardian has
1101
- // already been asked "call back or send a message?". Their next message
1102
- // is the reply to that prompt — route it through the conversation engine
1103
- // to classify their intent.
1104
- if (
1105
- !result.duplicate &&
1106
- !hasCallbackData &&
1107
- trimmedContent.length > 0 &&
1108
- body.senderExternalUserId &&
1109
- replyCallbackUrl
1110
- ) {
1111
- const followupDeliveries = getFollowupDeliveriesByDestination(canonicalAssistantId, sourceChannel, externalChatId);
1112
- if (followupDeliveries.length > 0) {
1113
- const validFollowup = followupDeliveries.filter(
1114
- (d) => d.destinationExternalUserId === body.senderExternalUserId,
1115
- );
1045
+ const resolved = resolveGuardianActionRequest(request.id, answerText, sourceChannel, body.senderExternalUserId);
1116
1046
 
1117
- if (validFollowup.length > 0) {
1118
- let matchedFollowup = validFollowup.length === 1 ? validFollowup[0] : null;
1119
- let followupReplyText = trimmedContent;
1120
-
1121
- // Multiple follow-up deliveries: require request code prefix for disambiguation
1122
- if (validFollowup.length > 1) {
1123
- for (const d of validFollowup) {
1124
- const req = getGuardianActionRequest(d.requestId);
1125
- if (req && trimmedContent.toUpperCase().startsWith(req.requestCode)) {
1126
- matchedFollowup = d;
1127
- followupReplyText = trimmedContent.slice(req.requestCode.length).trim();
1128
- break;
1129
- }
1130
- }
1047
+ if (resolved) {
1048
+ await tryMintGuardianActionGrant({
1049
+ request,
1050
+ answerText,
1051
+ decisionChannel: sourceChannel,
1052
+ guardianExternalUserId: body.senderExternalUserId,
1053
+ approvalConversationGenerator,
1054
+ });
1131
1055
 
1132
- if (!matchedFollowup) {
1133
- // Send disambiguation message listing the request codes
1134
- const codes = validFollowup
1135
- .map((d) => {
1136
- const req = getGuardianActionRequest(d.requestId);
1137
- return req ? req.requestCode : null;
1138
- })
1139
- .filter((code): code is string => typeof code === 'string' && code.length > 0);
1140
- const disambiguationText = await composeGuardianActionMessageGenerative(
1141
- {
1142
- scenario: 'guardian_followup_disambiguation',
1143
- requestCodes: codes,
1144
- channel: sourceChannel,
1145
- },
1146
- { requiredKeywords: codes },
1056
+ return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'resolved' });
1057
+ } else {
1058
+ const freshRequest = getGuardianActionRequest(request.id);
1059
+ const relayedText = await composeGuardianActionMessageGenerative(
1060
+ { scenario: 'guardian_stale_answered' as const },
1061
+ {},
1147
1062
  guardianActionCopyGenerator,
1148
1063
  );
1149
1064
  try {
1150
- await deliverChannelReply(replyCallbackUrl, {
1151
- chatId: externalChatId,
1152
- text: disambiguationText,
1153
- assistantId,
1154
- }, bearerToken);
1065
+ await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: relayedText, assistantId }, bearerToken);
1155
1066
  } catch (err) {
1156
- log.error({ err, externalChatId }, 'Failed to deliver guardian follow-up disambiguation message');
1067
+ log.error({ err, externalChatId }, 'Failed to deliver guardian action stale notice');
1157
1068
  }
1158
- return Response.json({
1159
- accepted: true,
1160
- duplicate: false,
1161
- eventId: result.eventId,
1162
- guardianFollowUp: 'disambiguation_sent',
1163
- });
1069
+ log.info(
1070
+ { requestId: request.id, freshStatus: freshRequest?.status },
1071
+ 'answerCall succeeded but resolveGuardianActionRequest returned null — informed guardian answer was relayed',
1072
+ );
1073
+ return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'stale' });
1164
1074
  }
1165
1075
  }
1166
1076
 
1167
- if (matchedFollowup) {
1168
- const followupRequest = getGuardianActionRequest(matchedFollowup.requestId);
1169
-
1170
- if (followupRequest && followupRequest.followupState === 'awaiting_guardian_choice') {
1171
- const turnResult = await processGuardianFollowUpTurn(
1172
- {
1173
- questionText: followupRequest.questionText,
1174
- lateAnswerText: followupRequest.lateAnswerText ?? '',
1175
- guardianReply: followupReplyText,
1176
- },
1177
- guardianFollowUpConversationGenerator,
1178
- );
1077
+ // ── FOLLOW-UP state handler ──
1078
+ if (state === 'followup' && request.followupState === 'awaiting_guardian_choice') {
1079
+ const turnResult = await processGuardianFollowUpTurn(
1080
+ {
1081
+ questionText: request.questionText,
1082
+ lateAnswerText: request.lateAnswerText ?? '',
1083
+ guardianReply: answerText,
1084
+ },
1085
+ guardianFollowUpConversationGenerator,
1086
+ );
1087
+
1088
+ let stateApplied = true;
1089
+ if (turnResult.disposition === 'call_back' || turnResult.disposition === 'message_back') {
1090
+ stateApplied = progressFollowupState(request.id, 'dispatching', turnResult.disposition) !== undefined;
1091
+ } else if (turnResult.disposition === 'decline') {
1092
+ stateApplied = finalizeFollowup(request.id, 'declined') !== undefined;
1093
+ }
1179
1094
 
1180
- // Apply the disposition to the follow-up state machine.
1181
- // Both progressFollowupState and finalizeFollowup are compare-and-set:
1182
- // they return null when the transition was not applied (e.g. a concurrent
1183
- // reply already advanced the state). In that case we notify the guardian
1184
- // that the request was already resolved and skip action execution.
1185
- let stateApplied = true;
1186
- if (turnResult.disposition === 'call_back' || turnResult.disposition === 'message_back') {
1187
- stateApplied = progressFollowupState(followupRequest.id, 'dispatching', turnResult.disposition) !== undefined;
1188
- } else if (turnResult.disposition === 'decline') {
1189
- stateApplied = finalizeFollowup(followupRequest.id, 'declined') !== undefined;
1095
+ if (!stateApplied) {
1096
+ log.warn({ requestId: request.id, disposition: turnResult.disposition }, 'Follow-up state transition failed (already resolved)');
1097
+ const staleText = await composeGuardianActionMessageGenerative(
1098
+ { scenario: 'guardian_stale_followup' as const },
1099
+ {},
1100
+ guardianActionCopyGenerator,
1101
+ );
1102
+ try {
1103
+ await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: staleText, assistantId }, bearerToken);
1104
+ } catch (err) {
1105
+ log.error({ err, externalChatId }, 'Failed to deliver stale follow-up notice');
1190
1106
  }
1191
- // keep_pending: no state change guardian can reply again
1107
+ return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianFollowUp: 'stale_ignored' });
1108
+ }
1192
1109
 
1193
- if (!stateApplied) {
1194
- log.warn({ requestId: followupRequest.id, disposition: turnResult.disposition }, 'Follow-up state transition failed (already resolved)');
1195
- const staleText = await composeGuardianActionMessageGenerative(
1196
- { scenario: 'guardian_stale_followup' as const },
1197
- {},
1198
- guardianActionCopyGenerator,
1199
- );
1110
+ try {
1111
+ await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: turnResult.replyText, assistantId }, bearerToken);
1112
+ } catch (err) {
1113
+ log.error({ err, externalChatId }, 'Failed to deliver guardian follow-up conversation reply');
1114
+ }
1115
+
1116
+ if (turnResult.disposition === 'call_back' || turnResult.disposition === 'message_back') {
1117
+ void (async () => {
1200
1118
  try {
1201
- await deliverChannelReply(replyCallbackUrl, {
1202
- chatId: externalChatId,
1203
- text: staleText,
1204
- assistantId,
1205
- }, bearerToken);
1206
- } catch (err) {
1207
- log.error({ err, externalChatId }, 'Failed to deliver stale follow-up notice');
1119
+ const execResult = await executeFollowupAction(
1120
+ request.id,
1121
+ turnResult.disposition as 'call_back' | 'message_back',
1122
+ guardianActionCopyGenerator,
1123
+ );
1124
+ await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: execResult.guardianReplyText, assistantId }, bearerToken);
1125
+ } catch (execErr) {
1126
+ log.error({ err: execErr, requestId: request.id }, 'Follow-up action execution or completion reply failed');
1208
1127
  }
1209
- return Response.json({
1210
- accepted: true,
1211
- duplicate: false,
1212
- eventId: result.eventId,
1213
- guardianFollowUp: 'stale_ignored',
1214
- });
1215
- }
1128
+ })();
1129
+ }
1216
1130
 
1217
- // Deliver the generated reply to the guardian
1218
- try {
1219
- await deliverChannelReply(replyCallbackUrl, {
1220
- chatId: externalChatId,
1221
- text: turnResult.replyText,
1222
- assistantId,
1223
- }, bearerToken);
1224
- } catch (err) {
1225
- log.error({ err, externalChatId }, 'Failed to deliver guardian follow-up conversation reply');
1226
- }
1131
+ return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianFollowUp: turnResult.disposition });
1132
+ }
1227
1133
 
1228
- // Execute the action and send a completion/failure reply (fire-and-forget).
1229
- // The initial reply above acknowledges the guardian's choice; the executor
1230
- // carries out the actual call_back or message_back and posts a second message.
1231
- if (turnResult.disposition === 'call_back' || turnResult.disposition === 'message_back') {
1232
- void (async () => {
1233
- try {
1234
- const execResult = await executeFollowupAction(
1235
- followupRequest.id,
1236
- turnResult.disposition as 'call_back' | 'message_back',
1134
+ // ── EXPIRED state handler ──
1135
+ if (state === 'expired' && request.status === 'expired' && request.followupState === 'none') {
1136
+ // Superseded remap: if the request was superseded (not timed out
1137
+ // or disconnected), check whether the call is still active with a
1138
+ // current pending request. If so, remap the late approval to the
1139
+ // current request instead of entering the callback/message follow-up.
1140
+ if (request.expiredReason === 'superseded') {
1141
+ const callSession = getCallSession(request.callSessionId);
1142
+ const callStillActive = callSession && !isTerminalState(callSession.status);
1143
+ const currentPending = callStillActive
1144
+ ? getPendingRequestByCallSessionId(request.callSessionId)
1145
+ : null;
1146
+
1147
+ if (callStillActive && currentPending) {
1148
+ const currentDeliveries = getDeliveriesByRequestId(currentPending.id);
1149
+ // When senderExternalUserId is present, verify the sender has a
1150
+ // matching delivery on the current pending request. When it's absent
1151
+ // (trusted session), allow the remap without delivery check.
1152
+ const senderHasDelivery = body.senderExternalUserId
1153
+ ? currentDeliveries.some((d) => d.destinationExternalUserId === body.senderExternalUserId)
1154
+ : true;
1155
+ if (!senderHasDelivery) {
1156
+ log.info(
1157
+ { supersededRequestId: request.id, currentRequestId: currentPending.id, senderExternalUserId: body.senderExternalUserId },
1158
+ 'Superseded remap skipped: sender has no delivery on current pending request',
1159
+ );
1160
+ } else {
1161
+ const remapResult = await answerCall({
1162
+ callSessionId: currentPending.callSessionId,
1163
+ answer: answerText,
1164
+ pendingQuestionId: currentPending.pendingQuestionId,
1165
+ });
1166
+
1167
+ if ('ok' in remapResult && remapResult.ok) {
1168
+ const resolved = resolveGuardianActionRequest(currentPending.id, answerText, sourceChannel, body.senderExternalUserId);
1169
+
1170
+ if (resolved) {
1171
+ await tryMintGuardianActionGrant({
1172
+ request: currentPending,
1173
+ answerText,
1174
+ decisionChannel: sourceChannel,
1175
+ guardianExternalUserId: body.senderExternalUserId,
1176
+ approvalConversationGenerator,
1177
+ });
1178
+ }
1179
+
1180
+ const remapText = await composeGuardianActionMessageGenerative(
1181
+ { scenario: 'guardian_superseded_remap', questionText: currentPending.questionText },
1182
+ {},
1237
1183
  guardianActionCopyGenerator,
1238
1184
  );
1239
- await deliverChannelReply(replyCallbackUrl, {
1240
- chatId: externalChatId,
1241
- text: execResult.guardianReplyText,
1242
- assistantId,
1243
- }, bearerToken);
1244
- } catch (execErr) {
1245
- log.error({ err: execErr, requestId: followupRequest.id }, 'Follow-up action execution or completion reply failed');
1185
+ try {
1186
+ await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: remapText, assistantId }, bearerToken);
1187
+ } catch (err) {
1188
+ log.error({ err, externalChatId }, 'Failed to deliver superseded remap confirmation');
1189
+ }
1190
+ log.info(
1191
+ { supersededRequestId: request.id, remappedToRequestId: currentPending.id },
1192
+ 'Late approval for superseded request remapped to current pending request',
1193
+ );
1194
+ return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'superseded_remapped' });
1246
1195
  }
1247
- })();
1196
+ log.warn(
1197
+ { callSessionId: currentPending.callSessionId, error: 'error' in remapResult ? remapResult.error : 'unknown' },
1198
+ 'Superseded remap answerCall failed, falling through to follow-up',
1199
+ );
1200
+ }
1248
1201
  }
1202
+ // Call not active or no pending request — fall through to follow-up
1203
+ }
1249
1204
 
1250
- return Response.json({
1251
- accepted: true,
1252
- duplicate: false,
1253
- eventId: result.eventId,
1254
- guardianFollowUp: turnResult.disposition,
1255
- });
1205
+ const followupResult = startFollowupFromExpiredRequest(request.id, answerText);
1206
+ if (followupResult) {
1207
+ const followupText = await composeGuardianActionMessageGenerative(
1208
+ { scenario: 'guardian_late_answer_followup', questionText: request.questionText, lateAnswerText: answerText },
1209
+ {},
1210
+ guardianActionCopyGenerator,
1211
+ );
1212
+ try {
1213
+ await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: followupText, assistantId }, bearerToken);
1214
+ } catch (err) {
1215
+ log.error({ err, externalChatId }, 'Failed to deliver guardian action late answer follow-up');
1216
+ }
1217
+ return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'followup_initiated' });
1218
+ } else {
1219
+ const staleText = await composeGuardianActionMessageGenerative(
1220
+ { scenario: 'guardian_stale_expired' as const },
1221
+ {},
1222
+ guardianActionCopyGenerator,
1223
+ );
1224
+ try {
1225
+ await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: staleText, assistantId }, bearerToken);
1226
+ } catch (err) {
1227
+ log.error({ err, externalChatId }, 'Failed to deliver guardian action stale notice for expired follow-up race');
1228
+ }
1229
+ return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'stale' });
1256
1230
  }
1257
1231
  }
1258
1232
  }
@@ -1455,6 +1429,124 @@ export async function handleChannelInbound(
1455
1429
  });
1456
1430
  }
1457
1431
 
1432
+ // ---------------------------------------------------------------------------
1433
+ // Invite token intercept
1434
+ // ---------------------------------------------------------------------------
1435
+
1436
+ /**
1437
+ * Handle an inbound invite token for a non-member or inactive member.
1438
+ *
1439
+ * Redeems the invite, delivers a deterministic reply, and returns a Response
1440
+ * to short-circuit the handler. Returns `null` when the intercept should not
1441
+ * fire (e.g. already_member outcome — let normal flow handle it).
1442
+ */
1443
+ async function handleInviteTokenIntercept(params: {
1444
+ rawToken: string;
1445
+ sourceChannel: ChannelId;
1446
+ externalChatId: string;
1447
+ externalMessageId: string;
1448
+ senderExternalUserId?: string;
1449
+ senderName?: string;
1450
+ senderUsername?: string;
1451
+ replyCallbackUrl?: string;
1452
+ bearerToken?: string;
1453
+ assistantId?: string;
1454
+ canonicalAssistantId: string;
1455
+ }): Promise<Response | null> {
1456
+ const {
1457
+ rawToken,
1458
+ sourceChannel,
1459
+ externalChatId,
1460
+ externalMessageId,
1461
+ senderExternalUserId,
1462
+ senderName,
1463
+ senderUsername,
1464
+ replyCallbackUrl,
1465
+ bearerToken,
1466
+ assistantId,
1467
+ canonicalAssistantId,
1468
+ } = params;
1469
+
1470
+ // Record the inbound event for dedup tracking BEFORE performing redemption.
1471
+ // Without this, duplicate webhook deliveries (common with Telegram) would
1472
+ // not be tracked: the first delivery redeems the invite and returns early,
1473
+ // then the retry finds an active member, passes ACL, and the raw
1474
+ // /start iv_<token> message leaks into the agent pipeline.
1475
+ const dedupResult = channelDeliveryStore.recordInbound(
1476
+ sourceChannel,
1477
+ externalChatId,
1478
+ externalMessageId,
1479
+ { assistantId: canonicalAssistantId },
1480
+ );
1481
+
1482
+ if (dedupResult.duplicate) {
1483
+ return Response.json({
1484
+ accepted: true,
1485
+ duplicate: true,
1486
+ eventId: dedupResult.eventId,
1487
+ });
1488
+ }
1489
+
1490
+ const outcome = redeemInvite({
1491
+ rawToken,
1492
+ sourceChannel,
1493
+ externalUserId: senderExternalUserId,
1494
+ externalChatId,
1495
+ displayName: senderName,
1496
+ username: senderUsername,
1497
+ assistantId: canonicalAssistantId,
1498
+ });
1499
+
1500
+ log.info(
1501
+ { sourceChannel, externalChatId, ok: outcome.ok, type: outcome.ok ? outcome.type : undefined, reason: !outcome.ok ? outcome.reason : undefined },
1502
+ 'Invite token intercept: redemption result',
1503
+ );
1504
+
1505
+ // already_member means the user has an active record — let the normal
1506
+ // flow handle them (they passed ACL or the member is active).
1507
+ if (outcome.ok && outcome.type === 'already_member') {
1508
+ // Deliver a quick acknowledgement and short-circuit so the user
1509
+ // does not trigger the deny gate or a duplicate agent loop.
1510
+ const replyText = getInviteRedemptionReply(outcome);
1511
+ if (replyCallbackUrl) {
1512
+ try {
1513
+ await deliverChannelReply(replyCallbackUrl, {
1514
+ chatId: externalChatId,
1515
+ text: replyText,
1516
+ assistantId,
1517
+ }, bearerToken);
1518
+ } catch (err) {
1519
+ log.error({ err, externalChatId }, 'Failed to deliver invite already-member reply');
1520
+ }
1521
+ }
1522
+ channelDeliveryStore.markProcessed(dedupResult.eventId);
1523
+ return Response.json({ accepted: true, eventId: dedupResult.eventId, inviteRedemption: 'already_member' });
1524
+ }
1525
+
1526
+ const replyText = getInviteRedemptionReply(outcome);
1527
+
1528
+ if (replyCallbackUrl) {
1529
+ try {
1530
+ await deliverChannelReply(replyCallbackUrl, {
1531
+ chatId: externalChatId,
1532
+ text: replyText,
1533
+ assistantId,
1534
+ }, bearerToken);
1535
+ } catch (err) {
1536
+ log.error({ err, externalChatId }, 'Failed to deliver invite redemption reply');
1537
+ }
1538
+ }
1539
+
1540
+ if (outcome.ok && outcome.type === 'redeemed') {
1541
+ channelDeliveryStore.markProcessed(dedupResult.eventId);
1542
+ return Response.json({ accepted: true, eventId: dedupResult.eventId, inviteRedemption: 'redeemed', memberId: outcome.memberId });
1543
+ }
1544
+
1545
+ // Failed redemption — inform the user and deny
1546
+ channelDeliveryStore.markProcessed(dedupResult.eventId);
1547
+ return Response.json({ accepted: true, eventId: dedupResult.eventId, denied: true, inviteRedemption: outcome.reason });
1548
+ }
1549
+
1458
1550
  // ---------------------------------------------------------------------------
1459
1551
  // Non-member access request notification
1460
1552
  // ---------------------------------------------------------------------------