@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
@@ -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,
@@ -30,8 +34,9 @@ import { processGuardianFollowUpTurn } from '../runtime/guardian-action-conversa
30
34
  import { executeFollowupAction } from '../runtime/guardian-action-followup-executor.js';
31
35
  import { tryMintGuardianActionGrant } from '../runtime/guardian-action-grant-minter.js';
32
36
  import { composeGuardianActionMessageGenerative } from '../runtime/guardian-action-message-composer.js';
33
- import type { GuardianActionCopyGenerator, GuardianFollowUpConversationGenerator } from '../runtime/http-types.js';
37
+ import type { ApprovalConversationGenerator, GuardianActionCopyGenerator, GuardianFollowUpConversationGenerator } from '../runtime/http-types.js';
34
38
  import { getLogger } from '../util/logger.js';
39
+ import { resolveGuardianInviteIntent } from './guardian-invite-intent.js';
35
40
  import { resolveGuardianVerificationIntent } from './guardian-verification-intent.js';
36
41
  import type { UsageStats } from './ipc-contract.js';
37
42
  import type { ServerMessage, UserMessageAttachment } from './ipc-protocol.js';
@@ -51,6 +56,7 @@ const log = getLogger('session-process');
51
56
  // generator through Session / DaemonServer constructors.
52
57
  let _guardianFollowUpGenerator: GuardianFollowUpConversationGenerator | undefined;
53
58
  let _guardianActionCopyGenerator: GuardianActionCopyGenerator | undefined;
59
+ let _approvalConversationGenerator: ApprovalConversationGenerator | undefined;
54
60
 
55
61
  /** Inject the guardian follow-up conversation generator (called from lifecycle.ts). */
56
62
  export function setGuardianFollowUpConversationGenerator(gen: GuardianFollowUpConversationGenerator): void {
@@ -62,6 +68,11 @@ export function setGuardianActionCopyGenerator(gen: GuardianActionCopyGenerator)
62
68
  _guardianActionCopyGenerator = gen;
63
69
  }
64
70
 
71
+ /** Inject the approval conversation generator (called from lifecycle.ts). */
72
+ export function setApprovalConversationGenerator(gen: ApprovalConversationGenerator): void {
73
+ _approvalConversationGenerator = gen;
74
+ }
75
+
65
76
  /** Build a model_info event with fresh config data. */
66
77
  function buildModelInfoEvent(): ServerMessage {
67
78
  const config = getConfig();
@@ -105,6 +116,7 @@ export interface ProcessSessionContext {
105
116
  /** Assistant identity — used for scoping notification preferences. */
106
117
  readonly assistantId?: string;
107
118
  guardianContext?: GuardianRuntimeContext;
119
+ ensureActorScopedHistory(): Promise<void>;
108
120
  persistUserMessage(content: string, attachments: UserMessageAttachment[], requestId?: string, metadata?: Record<string, unknown>, displayContent?: string): Promise<string>;
109
121
  runAgentLoop(
110
122
  content: string,
@@ -294,6 +306,15 @@ export async function drainQueue(session: ProcessSessionContext, reason: QueueDr
294
306
  log.info({ conversationId: session.conversationId, channelHint: guardianIntent.channelHint }, 'Guardian verification intent intercepted in queue — forcing skill flow');
295
307
  agentLoopContent = guardianIntent.rewrittenContent;
296
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
+ }
297
318
  }
298
319
  }
299
320
 
@@ -380,481 +401,277 @@ export async function processMessage(
380
401
  options?: { isInteractive?: boolean },
381
402
  displayContent?: string,
382
403
  ): Promise<string> {
404
+ await session.ensureActorScopedHistory();
383
405
  session.currentActiveSurfaceId = activeSurfaceId;
384
406
  session.currentPage = currentPage;
385
407
 
386
- // ── Guardian action answer interception (mac channel) ──
387
- // If this conversation has pending guardian action deliveries, treat the
388
- // user message as the guardian's answer instead of running the agent loop.
389
- // When multiple deliveries exist in the same reused thread, require a
390
- // request-code prefix for disambiguation (matching the channel path pattern).
391
- const pendingDeliveries = getPendingDeliveriesByConversation(session.conversationId);
392
- if (pendingDeliveries.length > 0) {
393
- // Before auto-selecting a lone pending delivery, check if expired or
394
- // follow-up deliveries also exist in this conversation. When the total
395
- // count across all states exceeds 1, require request-code disambiguation
396
- // even if only one pending delivery exists — otherwise a reply prefixed
397
- // with an expired/follow-up request code would be silently routed to
398
- // the pending delivery instead of being correctly rejected or routed.
399
- const crossStateExpired = getExpiredDeliveriesByConversation(session.conversationId);
400
- const crossStateFollowup = getFollowupDeliveriesByConversation(session.conversationId);
401
- const totalCrossStateCount = pendingDeliveries.length + crossStateExpired.length + crossStateFollowup.length;
402
- let matchedDelivery = (pendingDeliveries.length === 1 && totalCrossStateCount === 1) ? pendingDeliveries[0] : null;
403
- let answerText = content;
404
-
405
- // Strip the request code prefix from the answer text when the single
406
- // pending delivery is auto-matched (content may include a code prefix
407
- // if the guardian uses it out of habit after a previous disambiguation round).
408
- if (matchedDelivery) {
409
- const req = getGuardianActionRequest(matchedDelivery.requestId);
410
- if (req && content.toUpperCase().startsWith(req.requestCode)) {
411
- answerText = content.slice(req.requestCode.length).trim();
412
- }
413
- }
414
-
415
- // Multiple deliveries across any state: require request code prefix for disambiguation
416
- if (!matchedDelivery && pendingDeliveries.length >= 1) {
417
- for (const d of pendingDeliveries) {
418
- const req = getGuardianActionRequest(d.requestId);
419
- if (req && content.toUpperCase().startsWith(req.requestCode)) {
420
- matchedDelivery = d;
421
- answerText = content.slice(req.requestCode.length).trim();
422
- break;
423
- }
424
- }
425
-
426
- // If no pending delivery matched, check whether the code targets an
427
- // expired or follow-up delivery. If so, skip the pending section entirely
428
- // and let the message fall through to the expired/follow-up handlers below.
429
- if (!matchedDelivery) {
430
- let matchesOtherState = false;
431
- 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) {
432
434
  const req = getGuardianActionRequest(d.requestId);
433
- if (req && content.toUpperCase().startsWith(req.requestCode)) {
434
- matchesOtherState = true;
435
+ if (req && upperContent.startsWith(req.requestCode)) {
436
+ codeMatch = { delivery: d, request: req, state, answerText: content.slice(req.requestCode.length).trim() };
435
437
  break;
436
438
  }
437
439
  }
438
-
439
- if (!matchesOtherState) {
440
- const guardianIfCtx = session.getTurnInterfaceContext();
441
- const guardianChannelMeta = { userMessageChannel: 'vellum' as const, assistantMessageChannel: 'vellum' as const, userMessageInterface: guardianIfCtx?.userMessageInterface ?? 'vellum', assistantMessageInterface: guardianIfCtx?.assistantMessageInterface ?? 'vellum', provenanceActorRole: 'guardian' as const };
442
- const userMsg = createUserMessage(content, attachments);
443
- const persisted = await conversationStore.addMessage(
444
- session.conversationId,
445
- 'user',
446
- JSON.stringify(userMsg.content),
447
- guardianChannelMeta,
448
- );
449
- session.messages.push(userMsg);
450
-
451
- // Include codes from all states so the guardian sees all options
452
- const allDeliveries = [...pendingDeliveries, ...crossStateExpired, ...crossStateFollowup];
453
- const codes = allDeliveries
454
- .map((d) => { const req = getGuardianActionRequest(d.requestId); return req ? req.requestCode : null; })
455
- .filter((code): code is string => typeof code === 'string' && code.length > 0);
456
- 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.`;
457
- const disambiguationMsg = createAssistantMessage(disambiguationText);
458
- await conversationStore.addMessage(
459
- session.conversationId,
460
- 'assistant',
461
- JSON.stringify(disambiguationMsg.content),
462
- guardianChannelMeta,
463
- );
464
- session.messages.push(disambiguationMsg);
465
- onEvent({ type: 'assistant_text_delta', text: disambiguationText });
466
- onEvent({ type: 'message_complete', sessionId: session.conversationId });
467
- return persisted.id;
468
- }
469
- // Code matched an expired/follow-up delivery — fall through to those handlers
440
+ if (codeMatch) break;
470
441
  }
471
- }
472
442
 
473
- if (matchedDelivery) {
474
- const guardianRequest = getGuardianActionRequest(matchedDelivery.requestId);
475
- if (guardianRequest && guardianRequest.status === 'pending') {
476
- const guardianIfCtx = session.getTurnInterfaceContext();
477
- const guardianChannelMeta = { userMessageChannel: 'vellum' as const, assistantMessageChannel: 'vellum' as const, userMessageInterface: guardianIfCtx?.userMessageInterface ?? 'vellum', assistantMessageInterface: guardianIfCtx?.assistantMessageInterface ?? 'vellum', provenanceActorRole: 'guardian' as const };
478
- const userMsg = createUserMessage(content, attachments);
479
- const persisted = await conversationStore.addMessage(
480
- session.conversationId,
481
- 'user',
482
- JSON.stringify(userMsg.content),
483
- guardianChannelMeta,
484
- );
485
- session.messages.push(userMsg);
486
-
487
- // Attempt to deliver the answer to the call first. Only resolve
488
- // the guardian action request if answerCall succeeds, so that a
489
- // failed delivery leaves the request pending for retry from
490
- // another channel.
491
- const answerResult = await answerCall({ callSessionId: guardianRequest.callSessionId, answer: answerText });
492
-
493
- if ('ok' in answerResult && answerResult.ok) {
494
- const resolved = resolveGuardianActionRequest(guardianRequest.id, answerText, 'vellum');
495
-
496
- // Mint a scoped grant so the voice call can consume it
497
- // for subsequent tool confirmations.
498
- if (resolved) {
499
- tryMintGuardianActionGrant({
500
- resolvedRequest: resolved,
501
- answerText,
502
- decisionChannel: 'vellum',
503
- });
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;
504
460
  }
505
-
506
- const replyText = resolved
507
- ? 'Your answer has been relayed to the call.'
508
- : await composeGuardianActionMessageGenerative({ scenario: 'guardian_stale_answered' }, {}, _guardianActionCopyGenerator);
509
- const replyMsg = createAssistantMessage(replyText);
510
- await conversationStore.addMessage(
511
- session.conversationId,
512
- 'assistant',
513
- JSON.stringify(replyMsg.content),
514
- guardianChannelMeta,
515
- );
516
- session.messages.push(replyMsg);
517
- onEvent({ type: 'assistant_text_delta', text: replyText });
518
- } else {
519
- const errorDetail = 'error' in answerResult ? answerResult.error : 'Unknown error';
520
- log.warn({ callSessionId: guardianRequest.callSessionId, error: errorDetail }, 'answerCall failed for mac guardian answer');
521
- const failureText = await composeGuardianActionMessageGenerative(
522
- { scenario: 'guardian_answer_delivery_failed' },
523
- {},
524
- _guardianActionCopyGenerator,
525
- );
526
- const failMsg = createAssistantMessage(failureText);
527
- await conversationStore.addMessage(
528
- session.conversationId,
529
- 'assistant',
530
- JSON.stringify(failMsg.content),
531
- guardianChannelMeta,
532
- );
533
- session.messages.push(failMsg);
534
- onEvent({ type: 'assistant_text_delta', text: failureText });
535
461
  }
536
- onEvent({ type: 'message_complete', sessionId: session.conversationId });
537
- return persisted.id;
538
462
  }
539
- }
540
- }
541
463
 
542
- // ── Expired guardian action late answer interception (mac channel) ──
543
- // If no pending delivery was found, check for expired requests eligible
544
- // for follow-up (status='expired', followup_state='none'). When multiple
545
- // expired deliveries exist, require request-code prefix for disambiguation.
546
- const expiredDeliveries = getExpiredDeliveriesByConversation(session.conversationId);
547
- if (expiredDeliveries.length > 0) {
548
- // Cross-state disambiguation: check total deliveries across all states
549
- // (pending + expired + follow-up). When the total exceeds 1, require
550
- // request-code disambiguation even for a lone expired delivery — otherwise
551
- // a reply targeting a different state's delivery gets silently misrouted.
552
- const expCrossStatePending = getPendingDeliveriesByConversation(session.conversationId);
553
- const expCrossStateFollowup = getFollowupDeliveriesByConversation(session.conversationId);
554
- const expTotalCrossStateCount = expiredDeliveries.length + expCrossStatePending.length + expCrossStateFollowup.length;
555
- let matchedExpired = (expiredDeliveries.length === 1 && expTotalCrossStateCount === 1) ? expiredDeliveries[0] : null;
556
- let expiredAnswerText = content;
557
-
558
- // Strip the request code prefix from the answer text when the single
559
- // expired delivery is auto-matched (content may include a code prefix
560
- // if the pending section fell through via matchesOtherState).
561
- if (matchedExpired) {
562
- const req = getGuardianActionRequest(matchedExpired.requestId);
563
- if (req && content.toUpperCase().startsWith(req.requestCode)) {
564
- expiredAnswerText = content.slice(req.requestCode.length).trim();
565
- }
566
- }
567
-
568
- // Multiple expired deliveries (or cross-state disambiguation needed):
569
- // require request code prefix for disambiguation
570
- if (!matchedExpired && expiredDeliveries.length >= 1) {
571
- for (const d of expiredDeliveries) {
572
- const req = getGuardianActionRequest(d.requestId);
573
- if (req && content.toUpperCase().startsWith(req.requestCode)) {
574
- matchedExpired = d;
575
- expiredAnswerText = content.slice(req.requestCode.length).trim();
576
- 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 };
577
475
  }
578
476
  }
579
477
 
580
- if (!matchedExpired) {
581
- // Before disambiguating, check if the code matches a follow-up or
582
- // pending delivery. If so, fall through to let those handlers process it.
583
- let matchesFollowupState = false;
584
- for (const d of [...expCrossStateFollowup, ...expCrossStatePending]) {
585
- const req = getGuardianActionRequest(d.requestId);
586
- if (req && content.toUpperCase().startsWith(req.requestCode)) {
587
- matchesFollowupState = true;
588
- 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;
589
498
  }
590
499
  }
591
-
592
- if (!matchesFollowupState) {
593
- const guardianIfCtx = session.getTurnInterfaceContext();
594
- const guardianChannelMeta = { userMessageChannel: 'vellum' as const, assistantMessageChannel: 'vellum' as const, userMessageInterface: guardianIfCtx?.userMessageInterface ?? 'vellum', assistantMessageInterface: guardianIfCtx?.assistantMessageInterface ?? 'vellum', provenanceActorRole: 'guardian' as const };
595
- const userMsg = createUserMessage(content, attachments);
596
- const persisted = await conversationStore.addMessage(
597
- session.conversationId,
598
- 'user',
599
- JSON.stringify(userMsg.content),
600
- guardianChannelMeta,
601
- );
602
- session.messages.push(userMsg);
603
-
604
- // Include codes from all states so the guardian sees all options
605
- const allExpiredDeliveries = [...expiredDeliveries, ...expCrossStatePending, ...expCrossStateFollowup];
606
- const codes = allExpiredDeliveries
607
- .map((d) => { const req = getGuardianActionRequest(d.requestId); return req ? req.requestCode : null; })
608
- .filter((code): code is string => typeof code === 'string' && code.length > 0);
609
- const disambiguationText = await composeGuardianActionMessageGenerative(
610
- { scenario: 'guardian_expired_disambiguation', requestCodes: codes },
611
- { requiredKeywords: codes },
612
- _guardianActionCopyGenerator,
613
- );
614
- const disambiguationMsg = createAssistantMessage(disambiguationText);
615
- await conversationStore.addMessage(
616
- session.conversationId,
617
- 'assistant',
618
- JSON.stringify(disambiguationMsg.content),
619
- guardianChannelMeta,
620
- );
621
- session.messages.push(disambiguationMsg);
622
- onEvent({ type: 'assistant_text_delta', text: disambiguationText });
623
- onEvent({ type: 'message_complete', sessionId: session.conversationId });
624
- return persisted.id;
625
- }
626
- // Code matched a follow-up or pending delivery — fall through to those handlers
627
500
  }
628
- }
629
501
 
630
- if (matchedExpired) {
631
- const expiredRequest = getGuardianActionRequest(matchedExpired.requestId);
632
- if (expiredRequest && expiredRequest.status === 'expired' && expiredRequest.followupState === 'none') {
633
- const guardianIfCtx = session.getTurnInterfaceContext();
634
- 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) {
635
504
  const userMsg = createUserMessage(content, attachments);
636
- const persisted = await conversationStore.addMessage(
637
- session.conversationId,
638
- 'user',
639
- JSON.stringify(userMsg.content),
640
- guardianChannelMeta,
641
- );
505
+ const persisted = await conversationStore.addMessage(session.conversationId, 'user', JSON.stringify(userMsg.content), guardianChannelMeta);
642
506
  session.messages.push(userMsg);
643
-
644
- const followupResult = startFollowupFromExpiredRequest(expiredRequest.id, expiredAnswerText);
645
- if (followupResult) {
646
- const followupText = await composeGuardianActionMessageGenerative(
647
- {
648
- scenario: 'guardian_late_answer_followup',
649
- questionText: expiredRequest.questionText,
650
- lateAnswerText: expiredAnswerText,
651
- },
652
- {},
653
- _guardianActionCopyGenerator,
654
- );
655
- const replyMsg = createAssistantMessage(followupText);
656
- await conversationStore.addMessage(
657
- session.conversationId,
658
- 'assistant',
659
- JSON.stringify(replyMsg.content),
660
- guardianChannelMeta,
661
- );
662
- session.messages.push(replyMsg);
663
- onEvent({ type: 'assistant_text_delta', text: followupText });
664
- } else {
665
- // Follow-up already started or conflict — send stale message
666
- const staleText = await composeGuardianActionMessageGenerative(
667
- { scenario: 'guardian_stale_expired' },
668
- {},
669
- _guardianActionCopyGenerator,
670
- );
671
- const staleMsg = createAssistantMessage(staleText);
672
- await conversationStore.addMessage(
673
- session.conversationId,
674
- 'assistant',
675
- JSON.stringify(staleMsg.content),
676
- guardianChannelMeta,
677
- );
678
- session.messages.push(staleMsg);
679
- onEvent({ type: 'assistant_text_delta', text: staleText });
680
- }
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 });
681
525
  onEvent({ type: 'message_complete', sessionId: session.conversationId });
682
526
  return persisted.id;
683
527
  }
684
- }
685
- }
686
528
 
687
- // ── Guardian follow-up conversation interception (mac channel) ──
688
- // When a request is in `awaiting_guardian_choice` state, the guardian has
689
- // already been asked "call back or send a message?". Their next message
690
- // is the reply — route it through the conversation engine. When multiple
691
- // follow-up deliveries exist, require request-code prefix for disambiguation.
692
- const followupDeliveries = getFollowupDeliveriesByConversation(session.conversationId);
693
- if (followupDeliveries.length > 0) {
694
- // Cross-state disambiguation: check total deliveries across all states
695
- // (pending + expired + follow-up). When the total exceeds 1, require
696
- // request-code disambiguation even for a lone follow-up delivery.
697
- const fuCrossStatePending = getPendingDeliveriesByConversation(session.conversationId);
698
- const fuCrossStateExpired = getExpiredDeliveriesByConversation(session.conversationId);
699
- const fuTotalCrossStateCount = followupDeliveries.length + fuCrossStatePending.length + fuCrossStateExpired.length;
700
- let matchedFollowup = (followupDeliveries.length === 1 && fuTotalCrossStateCount === 1) ? followupDeliveries[0] : null;
701
- let followupReplyText = content;
702
-
703
- // Strip the request code prefix from the reply text when the single
704
- // follow-up delivery is auto-matched (content may include a code prefix
705
- // if the pending section fell through via matchesOtherState).
706
- if (matchedFollowup) {
707
- const req = getGuardianActionRequest(matchedFollowup.requestId);
708
- if (req && content.toUpperCase().startsWith(req.requestCode)) {
709
- followupReplyText = content.slice(req.requestCode.length).trim();
710
- }
711
- }
529
+ // Dispatch matched delivery by state
530
+ if (codeMatch) {
531
+ const { request, state, answerText } = codeMatch;
712
532
 
713
- // Multiple follow-up deliveries (or cross-state disambiguation needed):
714
- // require request code prefix for disambiguation
715
- if (!matchedFollowup && followupDeliveries.length >= 1) {
716
- for (const d of followupDeliveries) {
717
- const req = getGuardianActionRequest(d.requestId);
718
- if (req && content.toUpperCase().startsWith(req.requestCode)) {
719
- matchedFollowup = d;
720
- followupReplyText = content.slice(req.requestCode.length).trim();
721
- break;
722
- }
723
- }
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);
724
538
 
725
- if (!matchedFollowup) {
726
- // Before disambiguating, check if the code matches an expired or
727
- // pending delivery. If so, fall through to let the normal agent loop
728
- // handle it (expired/pending blocks already ran and didn't match).
729
- let matchesOtherFollowupState = false;
730
- for (const d of [...fuCrossStateExpired, ...fuCrossStatePending]) {
731
- const req = getGuardianActionRequest(d.requestId);
732
- if (req && content.toUpperCase().startsWith(req.requestCode)) {
733
- matchesOtherFollowupState = true;
734
- 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 });
735
561
  }
562
+ onEvent({ type: 'message_complete', sessionId: session.conversationId });
563
+ return persisted.id;
736
564
  }
737
565
 
738
- if (!matchesOtherFollowupState) {
739
- const guardianIfCtx = session.getTurnInterfaceContext();
740
- 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') {
741
568
  const userMsg = createUserMessage(content, attachments);
742
- const persisted = await conversationStore.addMessage(
743
- session.conversationId,
744
- 'user',
745
- JSON.stringify(userMsg.content),
746
- guardianChannelMeta,
747
- );
569
+ const persisted = await conversationStore.addMessage(session.conversationId, 'user', JSON.stringify(userMsg.content), guardianChannelMeta);
748
570
  session.messages.push(userMsg);
749
571
 
750
- // Include codes from all states so the guardian sees all options
751
- const allFollowupDeliveries = [...followupDeliveries, ...fuCrossStatePending, ...fuCrossStateExpired];
752
- const codes = allFollowupDeliveries
753
- .map((d) => { const req = getGuardianActionRequest(d.requestId); return req ? req.requestCode : null; })
754
- .filter((code): code is string => typeof code === 'string' && code.length > 0);
755
- const disambiguationText = await composeGuardianActionMessageGenerative(
756
- { scenario: 'guardian_followup_disambiguation', requestCodes: codes },
757
- { requiredKeywords: codes },
758
- _guardianActionCopyGenerator,
759
- );
760
- const disambiguationMsg = createAssistantMessage(disambiguationText);
761
- await conversationStore.addMessage(
762
- session.conversationId,
763
- 'assistant',
764
- JSON.stringify(disambiguationMsg.content),
765
- guardianChannelMeta,
572
+ const turnResult = await processGuardianFollowUpTurn(
573
+ { questionText: request.questionText, lateAnswerText: request.lateAnswerText ?? '', guardianReply: answerText },
574
+ _guardianFollowUpGenerator,
766
575
  );
767
- session.messages.push(disambiguationMsg);
768
- onEvent({ type: 'assistant_text_delta', text: disambiguationText });
769
- onEvent({ type: 'message_complete', sessionId: session.conversationId });
770
- return persisted.id;
771
- }
772
- // Code matched an expired or pending delivery — fall through to agent loop
773
- }
774
- }
775
576
 
776
- if (matchedFollowup) {
777
- const followupRequest = getGuardianActionRequest(matchedFollowup.requestId);
778
- if (followupRequest && followupRequest.followupState === 'awaiting_guardian_choice') {
779
- const guardianIfCtx = session.getTurnInterfaceContext();
780
- const guardianChannelMeta = { userMessageChannel: 'vellum' as const, assistantMessageChannel: 'vellum' as const, userMessageInterface: guardianIfCtx?.userMessageInterface ?? 'vellum', assistantMessageInterface: guardianIfCtx?.assistantMessageInterface ?? 'vellum', provenanceActorRole: 'guardian' as const };
781
- const userMsg = createUserMessage(content, attachments);
782
- const persisted = await conversationStore.addMessage(
783
- session.conversationId,
784
- 'user',
785
- JSON.stringify(userMsg.content),
786
- guardianChannelMeta,
787
- );
788
- 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
+ }
789
583
 
790
- const turnResult = await processGuardianFollowUpTurn(
791
- {
792
- questionText: followupRequest.questionText,
793
- lateAnswerText: followupRequest.lateAnswerText ?? '',
794
- guardianReply: followupReplyText,
795
- },
796
- _guardianFollowUpGenerator,
797
- );
584
+ if (!stateApplied) {
585
+ log.warn({ requestId: request.id, disposition: turnResult.disposition }, 'Follow-up state transition failed (already resolved)');
586
+ }
798
587
 
799
- // Apply the disposition to the follow-up state machine.
800
- // Both progressFollowupState and finalizeFollowup are compare-and-set:
801
- // they return null when the transition was not applied (e.g. a concurrent
802
- // reply already advanced the state). In that case we notify the guardian
803
- // that the request was already resolved and skip action execution.
804
- let stateApplied = true;
805
- if (turnResult.disposition === 'call_back' || turnResult.disposition === 'message_back') {
806
- stateApplied = progressFollowupState(followupRequest.id, 'dispatching', turnResult.disposition) !== undefined;
807
- } else if (turnResult.disposition === 'decline') {
808
- stateApplied = finalizeFollowup(followupRequest.id, 'declined') !== undefined;
809
- }
810
- // 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 });
811
596
 
812
- if (!stateApplied) {
813
- 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;
814
612
  }
815
613
 
816
- const replyText = stateApplied
817
- ? turnResult.replyText
818
- : await composeGuardianActionMessageGenerative({ scenario: 'guardian_stale_followup' }, {}, _guardianActionCopyGenerator);
819
- const replyMsg = createAssistantMessage(replyText);
820
- await conversationStore.addMessage(
821
- session.conversationId,
822
- 'assistant',
823
- JSON.stringify(replyMsg.content),
824
- guardianChannelMeta,
825
- );
826
- session.messages.push(replyMsg);
827
- onEvent({ type: 'assistant_text_delta', text: replyText });
828
- 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);
829
619
 
830
- // Execute the action and send a completion/failure message (fire-and-forget).
831
- // The initial reply above acknowledges the guardian's choice; the executor
832
- // carries out the actual call_back or message_back and posts a second message.
833
- if (stateApplied && (turnResult.disposition === 'call_back' || turnResult.disposition === 'message_back')) {
834
- void (async () => {
835
- try {
836
- const execResult = await executeFollowupAction(
837
- followupRequest.id,
838
- turnResult.disposition as 'call_back' | 'message_back',
839
- _guardianActionCopyGenerator,
840
- );
841
- const completionMsg = createAssistantMessage(execResult.guardianReplyText);
842
- await conversationStore.addMessage(
843
- session.conversationId,
844
- 'assistant',
845
- JSON.stringify(completionMsg.content),
846
- guardianChannelMeta,
847
- );
848
- session.messages.push(completionMsg);
849
- onEvent({ type: 'assistant_text_delta', text: execResult.guardianReplyText });
850
- onEvent({ type: 'message_complete', sessionId: session.conversationId });
851
- } catch (execErr) {
852
- 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
+ }
853
655
  }
854
- })();
855
- }
656
+ }
856
657
 
857
- 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
+ }
858
675
  }
859
676
  }
860
677
  }
@@ -942,6 +759,15 @@ export async function processMessage(
942
759
  log.info({ conversationId: session.conversationId, channelHint: guardianIntent.channelHint }, 'Guardian verification intent intercepted — forcing skill flow');
943
760
  agentLoopContent = guardianIntent.rewrittenContent;
944
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
+ }
945
771
  }
946
772
  }
947
773