@vellumai/assistant 0.3.18 → 0.3.20

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 (202) hide show
  1. package/ARCHITECTURE.md +155 -15
  2. package/Dockerfile +1 -0
  3. package/README.md +40 -4
  4. package/docs/architecture/integrations.md +7 -11
  5. package/docs/architecture/security.md +80 -0
  6. package/package.json +1 -1
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -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 +605 -104
  13. package/src/__tests__/channel-invite-transport.test.ts +264 -0
  14. package/src/__tests__/checker.test.ts +60 -0
  15. package/src/__tests__/cli.test.ts +42 -1
  16. package/src/__tests__/config-schema.test.ts +11 -127
  17. package/src/__tests__/config-watcher.test.ts +0 -8
  18. package/src/__tests__/daemon-lifecycle.test.ts +1 -0
  19. package/src/__tests__/daemon-server-session-init.test.ts +8 -2
  20. package/src/__tests__/diff.test.ts +22 -0
  21. package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
  22. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +779 -0
  23. package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
  24. package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
  25. package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
  26. package/src/__tests__/guardian-dispatch.test.ts +185 -1
  27. package/src/__tests__/guardian-grant-minting.test.ts +532 -0
  28. package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
  29. package/src/__tests__/invite-redemption-service.test.ts +306 -0
  30. package/src/__tests__/ipc-snapshot.test.ts +58 -0
  31. package/src/__tests__/notification-decision-fallback.test.ts +88 -0
  32. package/src/__tests__/remote-skill-policy.test.ts +215 -0
  33. package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
  34. package/src/__tests__/sandbox-host-parity.test.ts +6 -13
  35. package/src/__tests__/scoped-approval-grants.test.ts +521 -0
  36. package/src/__tests__/scoped-grant-security-matrix.test.ts +444 -0
  37. package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
  38. package/src/__tests__/session-load-history-repair.test.ts +169 -2
  39. package/src/__tests__/session-runtime-assembly.test.ts +33 -5
  40. package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
  41. package/src/__tests__/skill-feature-flags.test.ts +188 -0
  42. package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
  43. package/src/__tests__/skill-mirror-parity.test.ts +1 -0
  44. package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
  45. package/src/__tests__/system-prompt.test.ts +1 -1
  46. package/src/__tests__/terminal-sandbox.test.ts +142 -9
  47. package/src/__tests__/terminal-tools.test.ts +2 -93
  48. package/src/__tests__/thread-seed-composer.test.ts +18 -0
  49. package/src/__tests__/tool-approval-handler.test.ts +350 -0
  50. package/src/__tests__/trust-store.test.ts +2 -0
  51. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
  52. package/src/__tests__/voice-scoped-grant-consumer.test.ts +533 -0
  53. package/src/agent/loop.ts +36 -1
  54. package/src/approvals/approval-primitive.ts +381 -0
  55. package/src/approvals/guardian-decision-primitive.ts +191 -0
  56. package/src/calls/call-controller.ts +276 -212
  57. package/src/calls/call-domain.ts +56 -6
  58. package/src/calls/guardian-dispatch.ts +56 -0
  59. package/src/calls/relay-server.ts +13 -0
  60. package/src/calls/types.ts +1 -1
  61. package/src/calls/voice-session-bridge.ts +59 -4
  62. package/src/cli/core-commands.ts +0 -4
  63. package/src/cli.ts +76 -34
  64. package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
  65. package/src/config/assistant-feature-flags.ts +162 -0
  66. package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
  67. package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
  68. package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
  69. package/src/config/bundled-skills/notifications/SKILL.md +18 -0
  70. package/src/config/bundled-skills/reminder/SKILL.md +49 -2
  71. package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
  72. package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
  73. package/src/config/core-schema.ts +1 -1
  74. package/src/config/env-registry.ts +10 -0
  75. package/src/config/feature-flag-registry.json +61 -0
  76. package/src/config/loader.ts +22 -1
  77. package/src/config/sandbox-schema.ts +0 -39
  78. package/src/config/schema.ts +12 -2
  79. package/src/config/skill-state.ts +34 -0
  80. package/src/config/skills-schema.ts +26 -0
  81. package/src/config/skills.ts +9 -0
  82. package/src/config/system-prompt.ts +110 -46
  83. package/src/config/templates/SOUL.md +1 -1
  84. package/src/config/types.ts +19 -1
  85. package/src/config/vellum-skills/catalog.json +1 -1
  86. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
  87. package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
  88. package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -1
  89. package/src/config/vellum-skills/trusted-contacts/SKILL.md +104 -3
  90. package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
  91. package/src/daemon/config-watcher.ts +0 -1
  92. package/src/daemon/daemon-control.ts +1 -1
  93. package/src/daemon/guardian-invite-intent.ts +124 -0
  94. package/src/daemon/handlers/avatar.ts +68 -0
  95. package/src/daemon/handlers/browser.ts +2 -2
  96. package/src/daemon/handlers/config-channels.ts +18 -0
  97. package/src/daemon/handlers/guardian-actions.ts +120 -0
  98. package/src/daemon/handlers/index.ts +4 -0
  99. package/src/daemon/handlers/sessions.ts +19 -0
  100. package/src/daemon/handlers/shared.ts +3 -1
  101. package/src/daemon/handlers/skills.ts +45 -2
  102. package/src/daemon/install-cli-launchers.ts +58 -13
  103. package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
  104. package/src/daemon/ipc-contract/sessions.ts +8 -2
  105. package/src/daemon/ipc-contract/settings.ts +25 -2
  106. package/src/daemon/ipc-contract/skills.ts +1 -0
  107. package/src/daemon/ipc-contract-inventory.json +10 -0
  108. package/src/daemon/ipc-contract.ts +4 -0
  109. package/src/daemon/lifecycle.ts +6 -2
  110. package/src/daemon/main.ts +1 -0
  111. package/src/daemon/server.ts +1 -0
  112. package/src/daemon/session-lifecycle.ts +52 -7
  113. package/src/daemon/session-memory.ts +45 -0
  114. package/src/daemon/session-process.ts +260 -422
  115. package/src/daemon/session-runtime-assembly.ts +12 -0
  116. package/src/daemon/session-skill-tools.ts +14 -1
  117. package/src/daemon/session-tool-setup.ts +5 -0
  118. package/src/daemon/session.ts +11 -0
  119. package/src/daemon/tool-side-effects.ts +35 -9
  120. package/src/index.ts +0 -2
  121. package/src/memory/conversation-display-order-migration.ts +44 -0
  122. package/src/memory/conversation-queries.ts +2 -0
  123. package/src/memory/conversation-store.ts +91 -0
  124. package/src/memory/db-init.ts +13 -1
  125. package/src/memory/embedding-local.ts +22 -8
  126. package/src/memory/guardian-action-store.ts +133 -2
  127. package/src/memory/guardian-verification.ts +1 -1
  128. package/src/memory/ingress-invite-store.ts +95 -1
  129. package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
  130. package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
  131. package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
  132. package/src/memory/migrations/index.ts +3 -0
  133. package/src/memory/schema.ts +35 -1
  134. package/src/memory/scoped-approval-grants.ts +518 -0
  135. package/src/messaging/providers/slack/client.ts +12 -0
  136. package/src/messaging/providers/slack/types.ts +5 -0
  137. package/src/notifications/decision-engine.ts +49 -12
  138. package/src/notifications/emit-signal.ts +7 -0
  139. package/src/notifications/signal.ts +7 -0
  140. package/src/notifications/thread-seed-composer.ts +2 -1
  141. package/src/permissions/checker.ts +27 -0
  142. package/src/runtime/channel-approval-types.ts +16 -6
  143. package/src/runtime/channel-approvals.ts +19 -15
  144. package/src/runtime/channel-invite-transport.ts +85 -0
  145. package/src/runtime/channel-invite-transports/telegram.ts +105 -0
  146. package/src/runtime/guardian-action-grant-minter.ts +154 -0
  147. package/src/runtime/guardian-action-message-composer.ts +30 -0
  148. package/src/runtime/guardian-decision-types.ts +91 -0
  149. package/src/runtime/http-server.ts +23 -1
  150. package/src/runtime/ingress-service.ts +22 -0
  151. package/src/runtime/invite-redemption-service.ts +181 -0
  152. package/src/runtime/invite-redemption-templates.ts +39 -0
  153. package/src/runtime/routes/call-routes.ts +2 -1
  154. package/src/runtime/routes/guardian-action-routes.ts +206 -0
  155. package/src/runtime/routes/guardian-approval-interception.ts +66 -74
  156. package/src/runtime/routes/inbound-message-handler.ts +568 -409
  157. package/src/runtime/routes/pairing-routes.ts +4 -0
  158. package/src/security/encrypted-store.ts +31 -17
  159. package/src/security/keychain.ts +176 -2
  160. package/src/security/secure-keys.ts +97 -0
  161. package/src/security/tool-approval-digest.ts +67 -0
  162. package/src/skills/remote-skill-policy.ts +131 -0
  163. package/src/tools/browser/browser-execution.ts +2 -2
  164. package/src/tools/browser/browser-manager.ts +46 -32
  165. package/src/tools/browser/browser-screencast.ts +2 -2
  166. package/src/tools/calls/call-start.ts +1 -1
  167. package/src/tools/executor.ts +22 -17
  168. package/src/tools/network/script-proxy/session-manager.ts +1 -5
  169. package/src/tools/skills/load.ts +22 -8
  170. package/src/tools/system/avatar-generator.ts +119 -0
  171. package/src/tools/system/navigate-settings.ts +65 -0
  172. package/src/tools/system/open-system-settings.ts +75 -0
  173. package/src/tools/system/voice-config.ts +121 -32
  174. package/src/tools/terminal/backends/native.ts +40 -19
  175. package/src/tools/terminal/backends/types.ts +3 -3
  176. package/src/tools/terminal/parser.ts +1 -1
  177. package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
  178. package/src/tools/terminal/sandbox.ts +1 -12
  179. package/src/tools/terminal/shell.ts +3 -31
  180. package/src/tools/tool-approval-handler.ts +141 -3
  181. package/src/tools/tool-manifest.ts +6 -0
  182. package/src/tools/types.ts +6 -0
  183. package/src/util/diff.ts +36 -13
  184. package/Dockerfile.sandbox +0 -5
  185. package/src/__tests__/doordash-client.test.ts +0 -187
  186. package/src/__tests__/doordash-session.test.ts +0 -154
  187. package/src/__tests__/signup-e2e.test.ts +0 -354
  188. package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
  189. package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
  190. package/src/cli/doordash.ts +0 -1057
  191. package/src/config/bundled-skills/doordash/SKILL.md +0 -163
  192. package/src/config/templates/LOOKS.md +0 -25
  193. package/src/doordash/cart-queries.ts +0 -787
  194. package/src/doordash/client.ts +0 -1016
  195. package/src/doordash/order-queries.ts +0 -85
  196. package/src/doordash/queries.ts +0 -13
  197. package/src/doordash/query-extractor.ts +0 -94
  198. package/src/doordash/search-queries.ts +0 -203
  199. package/src/doordash/session.ts +0 -84
  200. package/src/doordash/store-queries.ts +0 -246
  201. package/src/doordash/types.ts +0 -367
  202. package/src/tools/terminal/backends/docker.ts +0 -379
@@ -8,6 +8,8 @@
8
8
 
9
9
  import { createAssistantMessage,createUserMessage } from '../agent/message-types.js';
10
10
  import { answerCall } from '../calls/call-domain.js';
11
+ import { isTerminalState } from '../calls/call-state-machine.js';
12
+ import { getCallSession } from '../calls/call-store.js';
11
13
  import type { TurnChannelContext, TurnInterfaceContext } from '../channels/types.js';
12
14
  import { parseChannelId, parseInterfaceId } from '../channels/types.js';
13
15
  import { getConfig } from '../config/loader.js';
@@ -15,10 +17,12 @@ import * as conversationStore from '../memory/conversation-store.js';
15
17
  import { provenanceFromGuardianContext } from '../memory/conversation-store.js';
16
18
  import {
17
19
  finalizeFollowup,
20
+ getDeliveriesByRequestId,
18
21
  getExpiredDeliveriesByConversation,
19
22
  getFollowupDeliveriesByConversation,
20
23
  getGuardianActionRequest,
21
24
  getPendingDeliveriesByConversation,
25
+ getPendingRequestByCallSessionId,
22
26
  progressFollowupState,
23
27
  resolveGuardianActionRequest,
24
28
  startFollowupFromExpiredRequest,
@@ -28,9 +32,11 @@ import { createPreference } from '../notifications/preferences-store.js';
28
32
  import type { Message } from '../providers/types.js';
29
33
  import { processGuardianFollowUpTurn } from '../runtime/guardian-action-conversation-turn.js';
30
34
  import { executeFollowupAction } from '../runtime/guardian-action-followup-executor.js';
35
+ import { tryMintGuardianActionGrant } from '../runtime/guardian-action-grant-minter.js';
31
36
  import { composeGuardianActionMessageGenerative } from '../runtime/guardian-action-message-composer.js';
32
- import type { GuardianActionCopyGenerator, GuardianFollowUpConversationGenerator } from '../runtime/http-types.js';
37
+ import type { ApprovalConversationGenerator, GuardianActionCopyGenerator, GuardianFollowUpConversationGenerator } from '../runtime/http-types.js';
33
38
  import { getLogger } from '../util/logger.js';
39
+ import { resolveGuardianInviteIntent } from './guardian-invite-intent.js';
34
40
  import { resolveGuardianVerificationIntent } from './guardian-verification-intent.js';
35
41
  import type { UsageStats } from './ipc-contract.js';
36
42
  import type { ServerMessage, UserMessageAttachment } from './ipc-protocol.js';
@@ -50,6 +56,7 @@ const log = getLogger('session-process');
50
56
  // generator through Session / DaemonServer constructors.
51
57
  let _guardianFollowUpGenerator: GuardianFollowUpConversationGenerator | undefined;
52
58
  let _guardianActionCopyGenerator: GuardianActionCopyGenerator | undefined;
59
+ let _approvalConversationGenerator: ApprovalConversationGenerator | undefined;
53
60
 
54
61
  /** Inject the guardian follow-up conversation generator (called from lifecycle.ts). */
55
62
  export function setGuardianFollowUpConversationGenerator(gen: GuardianFollowUpConversationGenerator): void {
@@ -61,6 +68,11 @@ export function setGuardianActionCopyGenerator(gen: GuardianActionCopyGenerator)
61
68
  _guardianActionCopyGenerator = gen;
62
69
  }
63
70
 
71
+ /** Inject the approval conversation generator (called from lifecycle.ts). */
72
+ export function setApprovalConversationGenerator(gen: ApprovalConversationGenerator): void {
73
+ _approvalConversationGenerator = gen;
74
+ }
75
+
64
76
  /** Build a model_info event with fresh config data. */
65
77
  function buildModelInfoEvent(): ServerMessage {
66
78
  const config = getConfig();
@@ -104,6 +116,7 @@ export interface ProcessSessionContext {
104
116
  /** Assistant identity — used for scoping notification preferences. */
105
117
  readonly assistantId?: string;
106
118
  guardianContext?: GuardianRuntimeContext;
119
+ ensureActorScopedHistory(): Promise<void>;
107
120
  persistUserMessage(content: string, attachments: UserMessageAttachment[], requestId?: string, metadata?: Record<string, unknown>, displayContent?: string): Promise<string>;
108
121
  runAgentLoop(
109
122
  content: string,
@@ -293,6 +306,15 @@ export async function drainQueue(session: ProcessSessionContext, reason: QueueDr
293
306
  log.info({ conversationId: session.conversationId, channelHint: guardianIntent.channelHint }, 'Guardian verification intent intercepted in queue — forcing skill flow');
294
307
  agentLoopContent = guardianIntent.rewrittenContent;
295
308
  session.preactivatedSkillIds = ['guardian-verify-setup'];
309
+ } else {
310
+ // Guardian invite intent interception — force invite management
311
+ // requests into the trusted-contacts skill flow.
312
+ const inviteIntent = resolveGuardianInviteIntent(resolvedContent);
313
+ if (inviteIntent.kind === 'invite_management') {
314
+ log.info({ conversationId: session.conversationId, action: inviteIntent.action }, 'Guardian invite intent intercepted in queue — forcing skill flow');
315
+ agentLoopContent = inviteIntent.rewrittenContent;
316
+ session.preactivatedSkillIds = ['trusted-contacts'];
317
+ }
296
318
  }
297
319
  }
298
320
 
@@ -379,470 +401,277 @@ export async function processMessage(
379
401
  options?: { isInteractive?: boolean },
380
402
  displayContent?: string,
381
403
  ): Promise<string> {
404
+ await session.ensureActorScopedHistory();
382
405
  session.currentActiveSurfaceId = activeSurfaceId;
383
406
  session.currentPage = currentPage;
384
407
 
385
- // ── Guardian action answer interception (mac channel) ──
386
- // If this conversation has pending guardian action deliveries, treat the
387
- // user message as the guardian's answer instead of running the agent loop.
388
- // When multiple deliveries exist in the same reused thread, require a
389
- // request-code prefix for disambiguation (matching the channel path pattern).
390
- const pendingDeliveries = getPendingDeliveriesByConversation(session.conversationId);
391
- if (pendingDeliveries.length > 0) {
392
- // Before auto-selecting a lone pending delivery, check if expired or
393
- // follow-up deliveries also exist in this conversation. When the total
394
- // count across all states exceeds 1, require request-code disambiguation
395
- // even if only one pending delivery exists — otherwise a reply prefixed
396
- // with an expired/follow-up request code would be silently routed to
397
- // the pending delivery instead of being correctly rejected or routed.
398
- const crossStateExpired = getExpiredDeliveriesByConversation(session.conversationId);
399
- const crossStateFollowup = getFollowupDeliveriesByConversation(session.conversationId);
400
- const totalCrossStateCount = pendingDeliveries.length + crossStateExpired.length + crossStateFollowup.length;
401
- let matchedDelivery = (pendingDeliveries.length === 1 && totalCrossStateCount === 1) ? pendingDeliveries[0] : null;
402
- let answerText = content;
403
-
404
- // Strip the request code prefix from the answer text when the single
405
- // pending delivery is auto-matched (content may include a code prefix
406
- // if the guardian uses it out of habit after a previous disambiguation round).
407
- if (matchedDelivery) {
408
- const req = getGuardianActionRequest(matchedDelivery.requestId);
409
- if (req && content.toUpperCase().startsWith(req.requestCode)) {
410
- answerText = content.slice(req.requestCode.length).trim();
411
- }
412
- }
413
-
414
- // Multiple deliveries across any state: require request code prefix for disambiguation
415
- if (!matchedDelivery && pendingDeliveries.length >= 1) {
416
- for (const d of pendingDeliveries) {
417
- const req = getGuardianActionRequest(d.requestId);
418
- if (req && content.toUpperCase().startsWith(req.requestCode)) {
419
- matchedDelivery = d;
420
- answerText = content.slice(req.requestCode.length).trim();
421
- break;
422
- }
423
- }
424
-
425
- // If no pending delivery matched, check whether the code targets an
426
- // expired or follow-up delivery. If so, skip the pending section entirely
427
- // and let the message fall through to the expired/follow-up handlers below.
428
- if (!matchedDelivery) {
429
- let matchesOtherState = false;
430
- for (const d of [...crossStateExpired, ...crossStateFollowup]) {
408
+ // ── Unified guardian action answer interception (mac channel) ──
409
+ // Deterministic priority matching: pending follow-up expired.
410
+ // When the guardian includes an explicit request code, match it across all
411
+ // states in priority order. When only one actionable request exists,
412
+ // auto-match without requiring a code prefix.
413
+ {
414
+ const allPending = getPendingDeliveriesByConversation(session.conversationId);
415
+ const allFollowup = getFollowupDeliveriesByConversation(session.conversationId);
416
+ const allExpired = getExpiredDeliveriesByConversation(session.conversationId);
417
+ const totalActionable = allPending.length + allFollowup.length + allExpired.length;
418
+
419
+ if (totalActionable > 0) {
420
+ const guardianIfCtx = session.getTurnInterfaceContext();
421
+ const guardianChannelMeta = { userMessageChannel: 'vellum' as const, assistantMessageChannel: 'vellum' as const, userMessageInterface: guardianIfCtx?.userMessageInterface ?? 'vellum', assistantMessageInterface: guardianIfCtx?.assistantMessageInterface ?? 'vellum', provenanceActorRole: 'guardian' as const };
422
+
423
+ // Try to parse an explicit request code from the message, in priority order
424
+ type CodeMatch = { delivery: typeof allPending[0]; request: NonNullable<ReturnType<typeof getGuardianActionRequest>>; state: 'pending' | 'followup' | 'expired'; answerText: string };
425
+ let codeMatch: CodeMatch | null = null;
426
+ const upperContent = content.toUpperCase();
427
+ const orderedSets: Array<{ deliveries: typeof allPending; state: 'pending' | 'followup' | 'expired' }> = [
428
+ { deliveries: allPending, state: 'pending' },
429
+ { deliveries: allFollowup, state: 'followup' },
430
+ { deliveries: allExpired, state: 'expired' },
431
+ ];
432
+ for (const { deliveries, state } of orderedSets) {
433
+ for (const d of deliveries) {
431
434
  const req = getGuardianActionRequest(d.requestId);
432
- if (req && content.toUpperCase().startsWith(req.requestCode)) {
433
- matchesOtherState = true;
435
+ if (req && upperContent.startsWith(req.requestCode)) {
436
+ codeMatch = { delivery: d, request: req, state, answerText: content.slice(req.requestCode.length).trim() };
434
437
  break;
435
438
  }
436
439
  }
437
-
438
- if (!matchesOtherState) {
439
- const guardianIfCtx = session.getTurnInterfaceContext();
440
- const guardianChannelMeta = { userMessageChannel: 'vellum' as const, assistantMessageChannel: 'vellum' as const, userMessageInterface: guardianIfCtx?.userMessageInterface ?? 'vellum', assistantMessageInterface: guardianIfCtx?.assistantMessageInterface ?? 'vellum', provenanceActorRole: 'guardian' as const };
441
- const userMsg = createUserMessage(content, attachments);
442
- const persisted = await conversationStore.addMessage(
443
- session.conversationId,
444
- 'user',
445
- JSON.stringify(userMsg.content),
446
- guardianChannelMeta,
447
- );
448
- session.messages.push(userMsg);
449
-
450
- // Include codes from all states so the guardian sees all options
451
- const allDeliveries = [...pendingDeliveries, ...crossStateExpired, ...crossStateFollowup];
452
- const codes = allDeliveries
453
- .map((d) => { const req = getGuardianActionRequest(d.requestId); return req ? req.requestCode : null; })
454
- .filter((code): code is string => typeof code === 'string' && code.length > 0);
455
- const disambiguationText = `You have multiple pending guardian questions. Please prefix your reply with the reference code (${codes.join(', ')}) to indicate which question you are answering.`;
456
- const disambiguationMsg = createAssistantMessage(disambiguationText);
457
- await conversationStore.addMessage(
458
- session.conversationId,
459
- 'assistant',
460
- JSON.stringify(disambiguationMsg.content),
461
- guardianChannelMeta,
462
- );
463
- session.messages.push(disambiguationMsg);
464
- onEvent({ type: 'assistant_text_delta', text: disambiguationText });
465
- onEvent({ type: 'message_complete', sessionId: session.conversationId });
466
- return persisted.id;
467
- }
468
- // Code matched an expired/follow-up delivery — fall through to those handlers
440
+ if (codeMatch) break;
469
441
  }
470
- }
471
442
 
472
- if (matchedDelivery) {
473
- const guardianRequest = getGuardianActionRequest(matchedDelivery.requestId);
474
- if (guardianRequest && guardianRequest.status === 'pending') {
475
- const guardianIfCtx = session.getTurnInterfaceContext();
476
- const guardianChannelMeta = { userMessageChannel: 'vellum' as const, assistantMessageChannel: 'vellum' as const, userMessageInterface: guardianIfCtx?.userMessageInterface ?? 'vellum', assistantMessageInterface: guardianIfCtx?.assistantMessageInterface ?? 'vellum', provenanceActorRole: 'guardian' as const };
477
- const userMsg = createUserMessage(content, attachments);
478
- const persisted = await conversationStore.addMessage(
479
- session.conversationId,
480
- 'user',
481
- JSON.stringify(userMsg.content),
482
- guardianChannelMeta,
483
- );
484
- session.messages.push(userMsg);
485
-
486
- // Attempt to deliver the answer to the call first. Only resolve
487
- // the guardian action request if answerCall succeeds, so that a
488
- // failed delivery leaves the request pending for retry from
489
- // another channel.
490
- const answerResult = await answerCall({ callSessionId: guardianRequest.callSessionId, answer: answerText });
491
-
492
- if ('ok' in answerResult && answerResult.ok) {
493
- const resolved = resolveGuardianActionRequest(guardianRequest.id, answerText, 'vellum');
494
- const replyText = resolved
495
- ? 'Your answer has been relayed to the call.'
496
- : await composeGuardianActionMessageGenerative({ scenario: 'guardian_stale_answered' }, {}, _guardianActionCopyGenerator);
497
- const replyMsg = createAssistantMessage(replyText);
498
- await conversationStore.addMessage(
499
- session.conversationId,
500
- 'assistant',
501
- JSON.stringify(replyMsg.content),
502
- guardianChannelMeta,
503
- );
504
- session.messages.push(replyMsg);
505
- onEvent({ type: 'assistant_text_delta', text: replyText });
506
- } else {
507
- const errorDetail = 'error' in answerResult ? answerResult.error : 'Unknown error';
508
- log.warn({ callSessionId: guardianRequest.callSessionId, error: errorDetail }, 'answerCall failed for mac guardian answer');
509
- const failureText = await composeGuardianActionMessageGenerative(
510
- { scenario: 'guardian_answer_delivery_failed' },
511
- {},
512
- _guardianActionCopyGenerator,
513
- );
514
- const failMsg = createAssistantMessage(failureText);
515
- await conversationStore.addMessage(
516
- session.conversationId,
517
- 'assistant',
518
- JSON.stringify(failMsg.content),
519
- guardianChannelMeta,
520
- );
521
- session.messages.push(failMsg);
522
- onEvent({ type: 'assistant_text_delta', text: failureText });
443
+ // Explicit code targets a non-pending state: handle terminal superseded
444
+ if (codeMatch && codeMatch.state !== 'pending') {
445
+ const targetReq = codeMatch.request;
446
+ if (targetReq.status === 'expired' && targetReq.expiredReason === 'superseded') {
447
+ const callSession = getCallSession(targetReq.callSessionId);
448
+ const callStillActive = callSession && !isTerminalState(callSession.status);
449
+ if (!callStillActive) {
450
+ const userMsg = createUserMessage(content, attachments);
451
+ const persisted = await conversationStore.addMessage(session.conversationId, 'user', JSON.stringify(userMsg.content), guardianChannelMeta);
452
+ session.messages.push(userMsg);
453
+ const staleText = await composeGuardianActionMessageGenerative({ scenario: 'guardian_stale_superseded' }, {}, _guardianActionCopyGenerator);
454
+ const staleMsg = createAssistantMessage(staleText);
455
+ await conversationStore.addMessage(session.conversationId, 'assistant', JSON.stringify(staleMsg.content), guardianChannelMeta);
456
+ session.messages.push(staleMsg);
457
+ onEvent({ type: 'assistant_text_delta', text: staleText });
458
+ onEvent({ type: 'message_complete', sessionId: session.conversationId });
459
+ return persisted.id;
460
+ }
523
461
  }
524
- onEvent({ type: 'message_complete', sessionId: session.conversationId });
525
- return persisted.id;
526
462
  }
527
- }
528
- }
529
463
 
530
- // ── Expired guardian action late answer interception (mac channel) ──
531
- // If no pending delivery was found, check for expired requests eligible
532
- // for follow-up (status='expired', followup_state='none'). When multiple
533
- // expired deliveries exist, require request-code prefix for disambiguation.
534
- const expiredDeliveries = getExpiredDeliveriesByConversation(session.conversationId);
535
- if (expiredDeliveries.length > 0) {
536
- // Cross-state disambiguation: check total deliveries across all states
537
- // (pending + expired + follow-up). When the total exceeds 1, require
538
- // request-code disambiguation even for a lone expired delivery — otherwise
539
- // a reply targeting a different state's delivery gets silently misrouted.
540
- const expCrossStatePending = getPendingDeliveriesByConversation(session.conversationId);
541
- const expCrossStateFollowup = getFollowupDeliveriesByConversation(session.conversationId);
542
- const expTotalCrossStateCount = expiredDeliveries.length + expCrossStatePending.length + expCrossStateFollowup.length;
543
- let matchedExpired = (expiredDeliveries.length === 1 && expTotalCrossStateCount === 1) ? expiredDeliveries[0] : null;
544
- let expiredAnswerText = content;
545
-
546
- // Strip the request code prefix from the answer text when the single
547
- // expired delivery is auto-matched (content may include a code prefix
548
- // if the pending section fell through via matchesOtherState).
549
- if (matchedExpired) {
550
- const req = getGuardianActionRequest(matchedExpired.requestId);
551
- if (req && content.toUpperCase().startsWith(req.requestCode)) {
552
- expiredAnswerText = content.slice(req.requestCode.length).trim();
553
- }
554
- }
555
-
556
- // Multiple expired deliveries (or cross-state disambiguation needed):
557
- // require request code prefix for disambiguation
558
- if (!matchedExpired && expiredDeliveries.length >= 1) {
559
- for (const d of expiredDeliveries) {
560
- const req = getGuardianActionRequest(d.requestId);
561
- if (req && content.toUpperCase().startsWith(req.requestCode)) {
562
- matchedExpired = d;
563
- expiredAnswerText = content.slice(req.requestCode.length).trim();
564
- break;
464
+ // Auto-match: single actionable request across all states
465
+ if (!codeMatch && totalActionable === 1) {
466
+ const singleDelivery = allPending[0] ?? allFollowup[0] ?? allExpired[0];
467
+ const singleReq = getGuardianActionRequest(singleDelivery.requestId);
468
+ if (singleReq) {
469
+ const state: 'pending' | 'followup' | 'expired' = allPending.length === 1 ? 'pending' : allFollowup.length === 1 ? 'followup' : 'expired';
470
+ let text = content;
471
+ if (upperContent.startsWith(singleReq.requestCode)) {
472
+ text = content.slice(singleReq.requestCode.length).trim();
473
+ }
474
+ codeMatch = { delivery: singleDelivery, request: singleReq, state, answerText: text };
565
475
  }
566
476
  }
567
477
 
568
- if (!matchedExpired) {
569
- // Before disambiguating, check if the code matches a follow-up or
570
- // pending delivery. If so, fall through to let those handlers process it.
571
- let matchesFollowupState = false;
572
- for (const d of [...expCrossStateFollowup, ...expCrossStatePending]) {
573
- const req = getGuardianActionRequest(d.requestId);
574
- if (req && content.toUpperCase().startsWith(req.requestCode)) {
575
- matchesFollowupState = true;
576
- break;
478
+ // Unknown code: message starts with a 6-char alphanumeric token that doesn't match
479
+ if (!codeMatch && totalActionable > 0) {
480
+ const possibleCodeMatch = content.match(/^([A-F0-9]{6})\s/i);
481
+ if (possibleCodeMatch) {
482
+ const candidateCode = possibleCodeMatch[1].toUpperCase();
483
+ const allDeliveries = [...allPending, ...allFollowup, ...allExpired];
484
+ const knownCodes = allDeliveries
485
+ .map((d) => { const req = getGuardianActionRequest(d.requestId); return req?.requestCode; })
486
+ .filter((code): code is string => typeof code === 'string');
487
+ if (!knownCodes.includes(candidateCode)) {
488
+ const userMsg = createUserMessage(content, attachments);
489
+ const persisted = await conversationStore.addMessage(session.conversationId, 'user', JSON.stringify(userMsg.content), guardianChannelMeta);
490
+ session.messages.push(userMsg);
491
+ const unknownText = await composeGuardianActionMessageGenerative({ scenario: 'guardian_unknown_code', unknownCode: candidateCode }, {}, _guardianActionCopyGenerator);
492
+ const unknownMsg = createAssistantMessage(unknownText);
493
+ await conversationStore.addMessage(session.conversationId, 'assistant', JSON.stringify(unknownMsg.content), guardianChannelMeta);
494
+ session.messages.push(unknownMsg);
495
+ onEvent({ type: 'assistant_text_delta', text: unknownText });
496
+ onEvent({ type: 'message_complete', sessionId: session.conversationId });
497
+ return persisted.id;
577
498
  }
578
499
  }
579
-
580
- if (!matchesFollowupState) {
581
- const guardianIfCtx = session.getTurnInterfaceContext();
582
- const guardianChannelMeta = { userMessageChannel: 'vellum' as const, assistantMessageChannel: 'vellum' as const, userMessageInterface: guardianIfCtx?.userMessageInterface ?? 'vellum', assistantMessageInterface: guardianIfCtx?.assistantMessageInterface ?? 'vellum', provenanceActorRole: 'guardian' as const };
583
- const userMsg = createUserMessage(content, attachments);
584
- const persisted = await conversationStore.addMessage(
585
- session.conversationId,
586
- 'user',
587
- JSON.stringify(userMsg.content),
588
- guardianChannelMeta,
589
- );
590
- session.messages.push(userMsg);
591
-
592
- // Include codes from all states so the guardian sees all options
593
- const allExpiredDeliveries = [...expiredDeliveries, ...expCrossStatePending, ...expCrossStateFollowup];
594
- const codes = allExpiredDeliveries
595
- .map((d) => { const req = getGuardianActionRequest(d.requestId); return req ? req.requestCode : null; })
596
- .filter((code): code is string => typeof code === 'string' && code.length > 0);
597
- const disambiguationText = await composeGuardianActionMessageGenerative(
598
- { scenario: 'guardian_expired_disambiguation', requestCodes: codes },
599
- { requiredKeywords: codes },
600
- _guardianActionCopyGenerator,
601
- );
602
- const disambiguationMsg = createAssistantMessage(disambiguationText);
603
- await conversationStore.addMessage(
604
- session.conversationId,
605
- 'assistant',
606
- JSON.stringify(disambiguationMsg.content),
607
- guardianChannelMeta,
608
- );
609
- session.messages.push(disambiguationMsg);
610
- onEvent({ type: 'assistant_text_delta', text: disambiguationText });
611
- onEvent({ type: 'message_complete', sessionId: session.conversationId });
612
- return persisted.id;
613
- }
614
- // Code matched a follow-up or pending delivery — fall through to those handlers
615
500
  }
616
- }
617
501
 
618
- if (matchedExpired) {
619
- const expiredRequest = getGuardianActionRequest(matchedExpired.requestId);
620
- if (expiredRequest && expiredRequest.status === 'expired' && expiredRequest.followupState === 'none') {
621
- const guardianIfCtx = session.getTurnInterfaceContext();
622
- const guardianChannelMeta = { userMessageChannel: 'vellum' as const, assistantMessageChannel: 'vellum' as const, userMessageInterface: guardianIfCtx?.userMessageInterface ?? 'vellum', assistantMessageInterface: guardianIfCtx?.assistantMessageInterface ?? 'vellum', provenanceActorRole: 'guardian' as const };
502
+ // No match and multiple actionable requests → disambiguation
503
+ if (!codeMatch && totalActionable > 1) {
623
504
  const userMsg = createUserMessage(content, attachments);
624
- const persisted = await conversationStore.addMessage(
625
- session.conversationId,
626
- 'user',
627
- JSON.stringify(userMsg.content),
628
- guardianChannelMeta,
629
- );
505
+ const persisted = await conversationStore.addMessage(session.conversationId, 'user', JSON.stringify(userMsg.content), guardianChannelMeta);
630
506
  session.messages.push(userMsg);
631
-
632
- const followupResult = startFollowupFromExpiredRequest(expiredRequest.id, expiredAnswerText);
633
- if (followupResult) {
634
- const followupText = await composeGuardianActionMessageGenerative(
635
- {
636
- scenario: 'guardian_late_answer_followup',
637
- questionText: expiredRequest.questionText,
638
- lateAnswerText: expiredAnswerText,
639
- },
640
- {},
641
- _guardianActionCopyGenerator,
642
- );
643
- const replyMsg = createAssistantMessage(followupText);
644
- await conversationStore.addMessage(
645
- session.conversationId,
646
- 'assistant',
647
- JSON.stringify(replyMsg.content),
648
- guardianChannelMeta,
649
- );
650
- session.messages.push(replyMsg);
651
- onEvent({ type: 'assistant_text_delta', text: followupText });
652
- } else {
653
- // Follow-up already started or conflict — send stale message
654
- const staleText = await composeGuardianActionMessageGenerative(
655
- { scenario: 'guardian_stale_expired' },
656
- {},
657
- _guardianActionCopyGenerator,
658
- );
659
- const staleMsg = createAssistantMessage(staleText);
660
- await conversationStore.addMessage(
661
- session.conversationId,
662
- 'assistant',
663
- JSON.stringify(staleMsg.content),
664
- guardianChannelMeta,
665
- );
666
- session.messages.push(staleMsg);
667
- onEvent({ type: 'assistant_text_delta', text: staleText });
668
- }
507
+ const allDeliveries = [...allPending, ...allFollowup, ...allExpired];
508
+ const codes = allDeliveries
509
+ .map((d) => { const req = getGuardianActionRequest(d.requestId); return req ? req.requestCode : null; })
510
+ .filter((code): code is string => typeof code === 'string' && code.length > 0);
511
+ const disambiguationScenario = allPending.length > 0
512
+ ? 'guardian_pending_disambiguation' as const
513
+ : allFollowup.length > 0
514
+ ? 'guardian_followup_disambiguation' as const
515
+ : 'guardian_expired_disambiguation' as const;
516
+ const disambiguationText = await composeGuardianActionMessageGenerative(
517
+ { scenario: disambiguationScenario, requestCodes: codes },
518
+ { requiredKeywords: codes },
519
+ _guardianActionCopyGenerator,
520
+ );
521
+ const disambiguationMsg = createAssistantMessage(disambiguationText);
522
+ await conversationStore.addMessage(session.conversationId, 'assistant', JSON.stringify(disambiguationMsg.content), guardianChannelMeta);
523
+ session.messages.push(disambiguationMsg);
524
+ onEvent({ type: 'assistant_text_delta', text: disambiguationText });
669
525
  onEvent({ type: 'message_complete', sessionId: session.conversationId });
670
526
  return persisted.id;
671
527
  }
672
- }
673
- }
674
528
 
675
- // ── Guardian follow-up conversation interception (mac channel) ──
676
- // When a request is in `awaiting_guardian_choice` state, the guardian has
677
- // already been asked "call back or send a message?". Their next message
678
- // is the reply — route it through the conversation engine. When multiple
679
- // follow-up deliveries exist, require request-code prefix for disambiguation.
680
- const followupDeliveries = getFollowupDeliveriesByConversation(session.conversationId);
681
- if (followupDeliveries.length > 0) {
682
- // Cross-state disambiguation: check total deliveries across all states
683
- // (pending + expired + follow-up). When the total exceeds 1, require
684
- // request-code disambiguation even for a lone follow-up delivery.
685
- const fuCrossStatePending = getPendingDeliveriesByConversation(session.conversationId);
686
- const fuCrossStateExpired = getExpiredDeliveriesByConversation(session.conversationId);
687
- const fuTotalCrossStateCount = followupDeliveries.length + fuCrossStatePending.length + fuCrossStateExpired.length;
688
- let matchedFollowup = (followupDeliveries.length === 1 && fuTotalCrossStateCount === 1) ? followupDeliveries[0] : null;
689
- let followupReplyText = content;
690
-
691
- // Strip the request code prefix from the reply text when the single
692
- // follow-up delivery is auto-matched (content may include a code prefix
693
- // if the pending section fell through via matchesOtherState).
694
- if (matchedFollowup) {
695
- const req = getGuardianActionRequest(matchedFollowup.requestId);
696
- if (req && content.toUpperCase().startsWith(req.requestCode)) {
697
- followupReplyText = content.slice(req.requestCode.length).trim();
698
- }
699
- }
529
+ // Dispatch matched delivery by state
530
+ if (codeMatch) {
531
+ const { request, state, answerText } = codeMatch;
700
532
 
701
- // Multiple follow-up deliveries (or cross-state disambiguation needed):
702
- // require request code prefix for disambiguation
703
- if (!matchedFollowup && followupDeliveries.length >= 1) {
704
- for (const d of followupDeliveries) {
705
- const req = getGuardianActionRequest(d.requestId);
706
- if (req && content.toUpperCase().startsWith(req.requestCode)) {
707
- matchedFollowup = d;
708
- followupReplyText = content.slice(req.requestCode.length).trim();
709
- break;
710
- }
711
- }
533
+ // PENDING state handler
534
+ if (state === 'pending' && request.status === 'pending') {
535
+ const userMsg = createUserMessage(content, attachments);
536
+ const persisted = await conversationStore.addMessage(session.conversationId, 'user', JSON.stringify(userMsg.content), guardianChannelMeta);
537
+ session.messages.push(userMsg);
712
538
 
713
- if (!matchedFollowup) {
714
- // Before disambiguating, check if the code matches an expired or
715
- // pending delivery. If so, fall through to let the normal agent loop
716
- // handle it (expired/pending blocks already ran and didn't match).
717
- let matchesOtherFollowupState = false;
718
- for (const d of [...fuCrossStateExpired, ...fuCrossStatePending]) {
719
- const req = getGuardianActionRequest(d.requestId);
720
- if (req && content.toUpperCase().startsWith(req.requestCode)) {
721
- matchesOtherFollowupState = true;
722
- break;
539
+ const answerResult = await answerCall({ callSessionId: request.callSessionId, answer: answerText, pendingQuestionId: request.pendingQuestionId });
540
+
541
+ if ('ok' in answerResult && answerResult.ok) {
542
+ const resolved = resolveGuardianActionRequest(request.id, answerText, 'vellum');
543
+ if (resolved) {
544
+ await tryMintGuardianActionGrant({ request, answerText, decisionChannel: 'vellum', approvalConversationGenerator: _approvalConversationGenerator });
545
+ }
546
+ const replyText = resolved
547
+ ? 'Your answer has been relayed to the call.'
548
+ : await composeGuardianActionMessageGenerative({ scenario: 'guardian_stale_answered' }, {}, _guardianActionCopyGenerator);
549
+ const replyMsg = createAssistantMessage(replyText);
550
+ await conversationStore.addMessage(session.conversationId, 'assistant', JSON.stringify(replyMsg.content), guardianChannelMeta);
551
+ session.messages.push(replyMsg);
552
+ onEvent({ type: 'assistant_text_delta', text: replyText });
553
+ } else {
554
+ const errorDetail = 'error' in answerResult ? answerResult.error : 'Unknown error';
555
+ log.warn({ callSessionId: request.callSessionId, error: errorDetail }, 'answerCall failed for mac guardian answer');
556
+ const failureText = await composeGuardianActionMessageGenerative({ scenario: 'guardian_answer_delivery_failed' }, {}, _guardianActionCopyGenerator);
557
+ const failMsg = createAssistantMessage(failureText);
558
+ await conversationStore.addMessage(session.conversationId, 'assistant', JSON.stringify(failMsg.content), guardianChannelMeta);
559
+ session.messages.push(failMsg);
560
+ onEvent({ type: 'assistant_text_delta', text: failureText });
723
561
  }
562
+ onEvent({ type: 'message_complete', sessionId: session.conversationId });
563
+ return persisted.id;
724
564
  }
725
565
 
726
- if (!matchesOtherFollowupState) {
727
- const guardianIfCtx = session.getTurnInterfaceContext();
728
- const guardianChannelMeta = { userMessageChannel: 'vellum' as const, assistantMessageChannel: 'vellum' as const, userMessageInterface: guardianIfCtx?.userMessageInterface ?? 'vellum', assistantMessageInterface: guardianIfCtx?.assistantMessageInterface ?? 'vellum', provenanceActorRole: 'guardian' as const };
566
+ // FOLLOW-UP state handler
567
+ if (state === 'followup' && request.followupState === 'awaiting_guardian_choice') {
729
568
  const userMsg = createUserMessage(content, attachments);
730
- const persisted = await conversationStore.addMessage(
731
- session.conversationId,
732
- 'user',
733
- JSON.stringify(userMsg.content),
734
- guardianChannelMeta,
735
- );
569
+ const persisted = await conversationStore.addMessage(session.conversationId, 'user', JSON.stringify(userMsg.content), guardianChannelMeta);
736
570
  session.messages.push(userMsg);
737
571
 
738
- // Include codes from all states so the guardian sees all options
739
- const allFollowupDeliveries = [...followupDeliveries, ...fuCrossStatePending, ...fuCrossStateExpired];
740
- const codes = allFollowupDeliveries
741
- .map((d) => { const req = getGuardianActionRequest(d.requestId); return req ? req.requestCode : null; })
742
- .filter((code): code is string => typeof code === 'string' && code.length > 0);
743
- const disambiguationText = await composeGuardianActionMessageGenerative(
744
- { scenario: 'guardian_followup_disambiguation', requestCodes: codes },
745
- { requiredKeywords: codes },
746
- _guardianActionCopyGenerator,
747
- );
748
- const disambiguationMsg = createAssistantMessage(disambiguationText);
749
- await conversationStore.addMessage(
750
- session.conversationId,
751
- 'assistant',
752
- JSON.stringify(disambiguationMsg.content),
753
- guardianChannelMeta,
572
+ const turnResult = await processGuardianFollowUpTurn(
573
+ { questionText: request.questionText, lateAnswerText: request.lateAnswerText ?? '', guardianReply: answerText },
574
+ _guardianFollowUpGenerator,
754
575
  );
755
- session.messages.push(disambiguationMsg);
756
- onEvent({ type: 'assistant_text_delta', text: disambiguationText });
757
- onEvent({ type: 'message_complete', sessionId: session.conversationId });
758
- return persisted.id;
759
- }
760
- // Code matched an expired or pending delivery — fall through to agent loop
761
- }
762
- }
763
576
 
764
- if (matchedFollowup) {
765
- const followupRequest = getGuardianActionRequest(matchedFollowup.requestId);
766
- if (followupRequest && followupRequest.followupState === 'awaiting_guardian_choice') {
767
- const guardianIfCtx = session.getTurnInterfaceContext();
768
- const guardianChannelMeta = { userMessageChannel: 'vellum' as const, assistantMessageChannel: 'vellum' as const, userMessageInterface: guardianIfCtx?.userMessageInterface ?? 'vellum', assistantMessageInterface: guardianIfCtx?.assistantMessageInterface ?? 'vellum', provenanceActorRole: 'guardian' as const };
769
- const userMsg = createUserMessage(content, attachments);
770
- const persisted = await conversationStore.addMessage(
771
- session.conversationId,
772
- 'user',
773
- JSON.stringify(userMsg.content),
774
- guardianChannelMeta,
775
- );
776
- session.messages.push(userMsg);
577
+ let stateApplied = true;
578
+ if (turnResult.disposition === 'call_back' || turnResult.disposition === 'message_back') {
579
+ stateApplied = progressFollowupState(request.id, 'dispatching', turnResult.disposition) !== undefined;
580
+ } else if (turnResult.disposition === 'decline') {
581
+ stateApplied = finalizeFollowup(request.id, 'declined') !== undefined;
582
+ }
777
583
 
778
- const turnResult = await processGuardianFollowUpTurn(
779
- {
780
- questionText: followupRequest.questionText,
781
- lateAnswerText: followupRequest.lateAnswerText ?? '',
782
- guardianReply: followupReplyText,
783
- },
784
- _guardianFollowUpGenerator,
785
- );
584
+ if (!stateApplied) {
585
+ log.warn({ requestId: request.id, disposition: turnResult.disposition }, 'Follow-up state transition failed (already resolved)');
586
+ }
786
587
 
787
- // Apply the disposition to the follow-up state machine.
788
- // Both progressFollowupState and finalizeFollowup are compare-and-set:
789
- // they return null when the transition was not applied (e.g. a concurrent
790
- // reply already advanced the state). In that case we notify the guardian
791
- // that the request was already resolved and skip action execution.
792
- let stateApplied = true;
793
- if (turnResult.disposition === 'call_back' || turnResult.disposition === 'message_back') {
794
- stateApplied = progressFollowupState(followupRequest.id, 'dispatching', turnResult.disposition) !== undefined;
795
- } else if (turnResult.disposition === 'decline') {
796
- stateApplied = finalizeFollowup(followupRequest.id, 'declined') !== undefined;
797
- }
798
- // keep_pending: no state change — guardian can reply again
588
+ const replyText = stateApplied
589
+ ? turnResult.replyText
590
+ : await composeGuardianActionMessageGenerative({ scenario: 'guardian_stale_followup' }, {}, _guardianActionCopyGenerator);
591
+ const replyMsg = createAssistantMessage(replyText);
592
+ await conversationStore.addMessage(session.conversationId, 'assistant', JSON.stringify(replyMsg.content), guardianChannelMeta);
593
+ session.messages.push(replyMsg);
594
+ onEvent({ type: 'assistant_text_delta', text: replyText });
595
+ onEvent({ type: 'message_complete', sessionId: session.conversationId });
799
596
 
800
- if (!stateApplied) {
801
- log.warn({ requestId: followupRequest.id, disposition: turnResult.disposition }, 'Follow-up state transition failed (already resolved)');
597
+ if (stateApplied && (turnResult.disposition === 'call_back' || turnResult.disposition === 'message_back')) {
598
+ void (async () => {
599
+ try {
600
+ const execResult = await executeFollowupAction(request.id, turnResult.disposition as 'call_back' | 'message_back', _guardianActionCopyGenerator);
601
+ const completionMsg = createAssistantMessage(execResult.guardianReplyText);
602
+ await conversationStore.addMessage(session.conversationId, 'assistant', JSON.stringify(completionMsg.content), guardianChannelMeta);
603
+ session.messages.push(completionMsg);
604
+ onEvent({ type: 'assistant_text_delta', text: execResult.guardianReplyText });
605
+ onEvent({ type: 'message_complete', sessionId: session.conversationId });
606
+ } catch (execErr) {
607
+ log.error({ err: execErr, requestId: request.id }, 'Follow-up action execution or completion message failed');
608
+ }
609
+ })();
610
+ }
611
+ return persisted.id;
802
612
  }
803
613
 
804
- const replyText = stateApplied
805
- ? turnResult.replyText
806
- : await composeGuardianActionMessageGenerative({ scenario: 'guardian_stale_followup' }, {}, _guardianActionCopyGenerator);
807
- const replyMsg = createAssistantMessage(replyText);
808
- await conversationStore.addMessage(
809
- session.conversationId,
810
- 'assistant',
811
- JSON.stringify(replyMsg.content),
812
- guardianChannelMeta,
813
- );
814
- session.messages.push(replyMsg);
815
- onEvent({ type: 'assistant_text_delta', text: replyText });
816
- onEvent({ type: 'message_complete', sessionId: session.conversationId });
614
+ // EXPIRED state handler
615
+ if (state === 'expired' && request.status === 'expired' && request.followupState === 'none') {
616
+ const userMsg = createUserMessage(content, attachments);
617
+ const persisted = await conversationStore.addMessage(session.conversationId, 'user', JSON.stringify(userMsg.content), guardianChannelMeta);
618
+ session.messages.push(userMsg);
817
619
 
818
- // Execute the action and send a completion/failure message (fire-and-forget).
819
- // The initial reply above acknowledges the guardian's choice; the executor
820
- // carries out the actual call_back or message_back and posts a second message.
821
- if (stateApplied && (turnResult.disposition === 'call_back' || turnResult.disposition === 'message_back')) {
822
- void (async () => {
823
- try {
824
- const execResult = await executeFollowupAction(
825
- followupRequest.id,
826
- turnResult.disposition as 'call_back' | 'message_back',
827
- _guardianActionCopyGenerator,
828
- );
829
- const completionMsg = createAssistantMessage(execResult.guardianReplyText);
830
- await conversationStore.addMessage(
831
- session.conversationId,
832
- 'assistant',
833
- JSON.stringify(completionMsg.content),
834
- guardianChannelMeta,
835
- );
836
- session.messages.push(completionMsg);
837
- onEvent({ type: 'assistant_text_delta', text: execResult.guardianReplyText });
838
- onEvent({ type: 'message_complete', sessionId: session.conversationId });
839
- } catch (execErr) {
840
- log.error({ err: execErr, requestId: followupRequest.id }, 'Follow-up action execution or completion message failed');
620
+ // Superseded remap
621
+ if (request.expiredReason === 'superseded') {
622
+ const callSession = getCallSession(request.callSessionId);
623
+ const callStillActive = callSession && !isTerminalState(callSession.status);
624
+ const currentPending = callStillActive ? getPendingRequestByCallSessionId(request.callSessionId) : null;
625
+
626
+ if (callStillActive && currentPending) {
627
+ const currentDeliveries = getDeliveriesByRequestId(currentPending.id);
628
+ const guardianExtUserId = session.guardianContext?.guardianExternalUserId;
629
+ // When guardianExternalUserId is present, verify the sender has a
630
+ // matching delivery on the current pending request. When it's absent
631
+ // (trusted Vellum/HTTP session), allow the remap without delivery check.
632
+ const senderHasDelivery = guardianExtUserId
633
+ ? currentDeliveries.some((d) => d.destinationExternalUserId === guardianExtUserId)
634
+ : true;
635
+ if (!senderHasDelivery) {
636
+ log.info({ supersededRequestId: request.id, currentRequestId: currentPending.id, guardianExternalUserId: guardianExtUserId }, 'Superseded remap skipped: sender has no delivery on current pending request');
637
+ } else {
638
+ const remapResult = await answerCall({ callSessionId: currentPending.callSessionId, answer: answerText, pendingQuestionId: currentPending.pendingQuestionId });
639
+ if ('ok' in remapResult && remapResult.ok) {
640
+ const resolved = resolveGuardianActionRequest(currentPending.id, answerText, 'vellum');
641
+ if (resolved) {
642
+ await tryMintGuardianActionGrant({ request: currentPending, answerText, decisionChannel: 'vellum', approvalConversationGenerator: _approvalConversationGenerator });
643
+ }
644
+ const remapText = await composeGuardianActionMessageGenerative({ scenario: 'guardian_superseded_remap', questionText: currentPending.questionText }, {}, _guardianActionCopyGenerator);
645
+ const remapMsg = createAssistantMessage(remapText);
646
+ await conversationStore.addMessage(session.conversationId, 'assistant', JSON.stringify(remapMsg.content), guardianChannelMeta);
647
+ session.messages.push(remapMsg);
648
+ onEvent({ type: 'assistant_text_delta', text: remapText });
649
+ onEvent({ type: 'message_complete', sessionId: session.conversationId });
650
+ log.info({ supersededRequestId: request.id, remappedToRequestId: currentPending.id }, 'Late approval for superseded request remapped to current pending request');
651
+ return persisted.id;
652
+ }
653
+ log.warn({ callSessionId: currentPending.callSessionId, error: 'error' in remapResult ? remapResult.error : 'unknown' }, 'Superseded remap answerCall failed, falling through to follow-up');
654
+ }
841
655
  }
842
- })();
843
- }
656
+ }
844
657
 
845
- return persisted.id;
658
+ const followupResult = startFollowupFromExpiredRequest(request.id, answerText);
659
+ if (followupResult) {
660
+ const followupText = await composeGuardianActionMessageGenerative({ scenario: 'guardian_late_answer_followup', questionText: request.questionText, lateAnswerText: answerText }, {}, _guardianActionCopyGenerator);
661
+ const replyMsg = createAssistantMessage(followupText);
662
+ await conversationStore.addMessage(session.conversationId, 'assistant', JSON.stringify(replyMsg.content), guardianChannelMeta);
663
+ session.messages.push(replyMsg);
664
+ onEvent({ type: 'assistant_text_delta', text: followupText });
665
+ } else {
666
+ const staleText = await composeGuardianActionMessageGenerative({ scenario: 'guardian_stale_expired' }, {}, _guardianActionCopyGenerator);
667
+ const staleMsg = createAssistantMessage(staleText);
668
+ await conversationStore.addMessage(session.conversationId, 'assistant', JSON.stringify(staleMsg.content), guardianChannelMeta);
669
+ session.messages.push(staleMsg);
670
+ onEvent({ type: 'assistant_text_delta', text: staleText });
671
+ }
672
+ onEvent({ type: 'message_complete', sessionId: session.conversationId });
673
+ return persisted.id;
674
+ }
846
675
  }
847
676
  }
848
677
  }
@@ -930,6 +759,15 @@ export async function processMessage(
930
759
  log.info({ conversationId: session.conversationId, channelHint: guardianIntent.channelHint }, 'Guardian verification intent intercepted — forcing skill flow');
931
760
  agentLoopContent = guardianIntent.rewrittenContent;
932
761
  session.preactivatedSkillIds = ['guardian-verify-setup'];
762
+ } else {
763
+ // Guardian invite intent interception — force invite management
764
+ // requests into the trusted-contacts skill flow.
765
+ const inviteIntent = resolveGuardianInviteIntent(resolvedContent);
766
+ if (inviteIntent.kind === 'invite_management') {
767
+ log.info({ conversationId: session.conversationId, action: inviteIntent.action }, 'Guardian invite intent intercepted — forcing skill flow');
768
+ agentLoopContent = inviteIntent.rewrittenContent;
769
+ session.preactivatedSkillIds = ['trusted-contacts'];
770
+ }
933
771
  }
934
772
  }
935
773