@vellumai/assistant 0.3.16 → 0.3.19

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 (114) hide show
  1. package/ARCHITECTURE.md +74 -13
  2. package/README.md +6 -0
  3. package/docs/architecture/http-token-refresh.md +23 -1
  4. package/docs/architecture/security.md +80 -0
  5. package/package.json +1 -1
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +4 -0
  7. package/src/__tests__/access-request-decision.test.ts +4 -7
  8. package/src/__tests__/call-controller.test.ts +170 -0
  9. package/src/__tests__/channel-guardian.test.ts +3 -1
  10. package/src/__tests__/checker.test.ts +139 -48
  11. package/src/__tests__/config-watcher.test.ts +11 -13
  12. package/src/__tests__/conversation-pairing.test.ts +103 -3
  13. package/src/__tests__/guardian-action-conversation-turn.test.ts +1 -1
  14. package/src/__tests__/guardian-action-followup-executor.test.ts +1 -1
  15. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +511 -0
  16. package/src/__tests__/guardian-action-late-reply.test.ts +131 -0
  17. package/src/__tests__/guardian-action-store.test.ts +182 -0
  18. package/src/__tests__/guardian-dispatch.test.ts +180 -0
  19. package/src/__tests__/guardian-grant-minting.test.ts +543 -0
  20. package/src/__tests__/ipc-snapshot.test.ts +22 -0
  21. package/src/__tests__/non-member-access-request.test.ts +1 -2
  22. package/src/__tests__/notification-broadcaster.test.ts +115 -4
  23. package/src/__tests__/notification-decision-strategy.test.ts +2 -1
  24. package/src/__tests__/notification-deep-link.test.ts +44 -1
  25. package/src/__tests__/notification-guardian-path.test.ts +157 -0
  26. package/src/__tests__/notification-thread-candidate-validation.test.ts +215 -0
  27. package/src/__tests__/remote-skill-policy.test.ts +215 -0
  28. package/src/__tests__/scoped-approval-grants.test.ts +521 -0
  29. package/src/__tests__/scoped-grant-security-matrix.test.ts +443 -0
  30. package/src/__tests__/slack-channel-config.test.ts +3 -3
  31. package/src/__tests__/trust-store.test.ts +23 -21
  32. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +5 -7
  33. package/src/__tests__/trusted-contact-multichannel.test.ts +2 -6
  34. package/src/__tests__/trusted-contact-verification.test.ts +9 -9
  35. package/src/__tests__/update-bulletin-state.test.ts +1 -1
  36. package/src/__tests__/update-bulletin.test.ts +66 -3
  37. package/src/__tests__/update-template-contract.test.ts +6 -11
  38. package/src/__tests__/voice-scoped-grant-consumer.test.ts +571 -0
  39. package/src/__tests__/voice-session-bridge.test.ts +109 -9
  40. package/src/calls/call-controller.ts +150 -8
  41. package/src/calls/call-domain.ts +12 -0
  42. package/src/calls/guardian-action-sweep.ts +1 -1
  43. package/src/calls/guardian-dispatch.ts +16 -0
  44. package/src/calls/relay-server.ts +13 -0
  45. package/src/calls/voice-session-bridge.ts +46 -5
  46. package/src/cli/core-commands.ts +41 -1
  47. package/src/config/bundled-skills/notifications/SKILL.md +18 -0
  48. package/src/config/schema.ts +6 -0
  49. package/src/config/skills-schema.ts +27 -0
  50. package/src/config/templates/UPDATES.md +5 -6
  51. package/src/config/update-bulletin-format.ts +2 -0
  52. package/src/config/update-bulletin-state.ts +1 -1
  53. package/src/config/update-bulletin-template-path.ts +6 -0
  54. package/src/config/update-bulletin.ts +21 -6
  55. package/src/daemon/config-watcher.ts +3 -2
  56. package/src/daemon/daemon-control.ts +64 -10
  57. package/src/daemon/handlers/config-channels.ts +18 -0
  58. package/src/daemon/handlers/config-slack-channel.ts +1 -1
  59. package/src/daemon/handlers/identity.ts +45 -25
  60. package/src/daemon/handlers/sessions.ts +1 -1
  61. package/src/daemon/handlers/skills.ts +45 -2
  62. package/src/daemon/ipc-contract/sessions.ts +1 -1
  63. package/src/daemon/ipc-contract/skills.ts +1 -0
  64. package/src/daemon/ipc-contract/workspace.ts +12 -1
  65. package/src/daemon/ipc-contract-inventory.json +1 -0
  66. package/src/daemon/lifecycle.ts +8 -0
  67. package/src/daemon/server.ts +25 -3
  68. package/src/daemon/session-process.ts +450 -184
  69. package/src/daemon/tls-certs.ts +17 -12
  70. package/src/daemon/tool-side-effects.ts +1 -1
  71. package/src/memory/channel-delivery-store.ts +18 -20
  72. package/src/memory/channel-guardian-store.ts +39 -42
  73. package/src/memory/conversation-crud.ts +2 -2
  74. package/src/memory/conversation-queries.ts +2 -2
  75. package/src/memory/conversation-store.ts +24 -25
  76. package/src/memory/db-init.ts +17 -1
  77. package/src/memory/embedding-local.ts +16 -7
  78. package/src/memory/fts-reconciler.ts +41 -26
  79. package/src/memory/guardian-action-store.ts +65 -7
  80. package/src/memory/guardian-verification.ts +1 -0
  81. package/src/memory/jobs-worker.ts +2 -2
  82. package/src/memory/migrations/032-guardian-delivery-conversation-index.ts +15 -0
  83. package/src/memory/migrations/032-notification-delivery-thread-decision.ts +20 -0
  84. package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
  85. package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
  86. package/src/memory/migrations/index.ts +6 -2
  87. package/src/memory/schema-migration.ts +1 -0
  88. package/src/memory/schema.ts +36 -1
  89. package/src/memory/scoped-approval-grants.ts +509 -0
  90. package/src/memory/search/semantic.ts +3 -3
  91. package/src/notifications/README.md +158 -17
  92. package/src/notifications/broadcaster.ts +68 -50
  93. package/src/notifications/conversation-pairing.ts +96 -18
  94. package/src/notifications/decision-engine.ts +6 -3
  95. package/src/notifications/deliveries-store.ts +12 -0
  96. package/src/notifications/emit-signal.ts +1 -0
  97. package/src/notifications/thread-candidates.ts +60 -25
  98. package/src/notifications/types.ts +2 -1
  99. package/src/permissions/checker.ts +28 -16
  100. package/src/permissions/defaults.ts +14 -4
  101. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  102. package/src/runtime/guardian-action-grant-minter.ts +97 -0
  103. package/src/runtime/http-server.ts +11 -11
  104. package/src/runtime/routes/access-request-decision.ts +1 -1
  105. package/src/runtime/routes/debug-routes.ts +4 -4
  106. package/src/runtime/routes/guardian-approval-interception.ts +120 -4
  107. package/src/runtime/routes/inbound-message-handler.ts +100 -33
  108. package/src/runtime/routes/integration-routes.ts +2 -2
  109. package/src/security/tool-approval-digest.ts +67 -0
  110. package/src/skills/remote-skill-policy.ts +131 -0
  111. package/src/tools/permission-checker.ts +1 -2
  112. package/src/tools/secret-detection-handler.ts +1 -1
  113. package/src/tools/system/voice-config.ts +1 -1
  114. package/src/version.ts +29 -2
@@ -15,21 +15,22 @@ import * as conversationStore from '../memory/conversation-store.js';
15
15
  import { provenanceFromGuardianContext } from '../memory/conversation-store.js';
16
16
  import {
17
17
  finalizeFollowup,
18
- getExpiredDeliveryByConversation,
19
- getFollowupDeliveryByConversation,
18
+ getExpiredDeliveriesByConversation,
19
+ getFollowupDeliveriesByConversation,
20
20
  getGuardianActionRequest,
21
- getPendingDeliveryByConversation,
21
+ getPendingDeliveriesByConversation,
22
22
  progressFollowupState,
23
23
  resolveGuardianActionRequest,
24
24
  startFollowupFromExpiredRequest,
25
25
  } from '../memory/guardian-action-store.js';
26
+ import { extractPreferences } from '../notifications/preference-extractor.js';
27
+ import { createPreference } from '../notifications/preferences-store.js';
28
+ import type { Message } from '../providers/types.js';
26
29
  import { processGuardianFollowUpTurn } from '../runtime/guardian-action-conversation-turn.js';
27
30
  import { executeFollowupAction } from '../runtime/guardian-action-followup-executor.js';
31
+ import { tryMintGuardianActionGrant } from '../runtime/guardian-action-grant-minter.js';
28
32
  import { composeGuardianActionMessageGenerative } from '../runtime/guardian-action-message-composer.js';
29
33
  import type { GuardianActionCopyGenerator, GuardianFollowUpConversationGenerator } from '../runtime/http-types.js';
30
- import { extractPreferences } from '../notifications/preference-extractor.js';
31
- import { createPreference } from '../notifications/preferences-store.js';
32
- import type { Message } from '../providers/types.js';
33
34
  import { getLogger } from '../util/logger.js';
34
35
  import { resolveGuardianVerificationIntent } from './guardian-verification-intent.js';
35
36
  import type { UsageStats } from './ipc-contract.js';
@@ -383,213 +384,478 @@ export async function processMessage(
383
384
  session.currentPage = currentPage;
384
385
 
385
386
  // ── Guardian action answer interception (mac channel) ──
386
- // If this conversation has a pending guardian action delivery, treat the
387
+ // If this conversation has pending guardian action deliveries, treat the
387
388
  // user message as the guardian's answer instead of running the agent loop.
388
- const guardianDelivery = getPendingDeliveryByConversation(session.conversationId);
389
- if (guardianDelivery) {
390
- const guardianRequest = getGuardianActionRequest(guardianDelivery.requestId);
391
- if (guardianRequest && guardianRequest.status === 'pending') {
392
- const guardianIfCtx = session.getTurnInterfaceContext();
393
- const guardianChannelMeta = { userMessageChannel: 'vellum' as const, assistantMessageChannel: 'vellum' as const, userMessageInterface: guardianIfCtx?.userMessageInterface ?? 'vellum', assistantMessageInterface: guardianIfCtx?.assistantMessageInterface ?? 'vellum', provenanceActorRole: 'guardian' as const };
394
- const userMsg = createUserMessage(content, attachments);
395
- const persisted = await conversationStore.addMessage(
396
- session.conversationId,
397
- 'user',
398
- JSON.stringify(userMsg.content),
399
- guardianChannelMeta,
400
- );
401
- session.messages.push(userMsg);
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
+ }
402
414
 
403
- // Attempt to deliver the answer to the call first. Only resolve
404
- // the guardian action request if answerCall succeeds, so that a
405
- // failed delivery leaves the request pending for retry from
406
- // another channel.
407
- const answerResult = await answerCall({ callSessionId: guardianRequest.callSessionId, answer: content });
408
-
409
- if ('ok' in answerResult && answerResult.ok) {
410
- const resolved = resolveGuardianActionRequest(guardianRequest.id, content, 'vellum');
411
- const replyText = resolved
412
- ? 'Your answer has been relayed to the call.'
413
- : await composeGuardianActionMessageGenerative({ scenario: 'guardian_stale_answered' }, {}, _guardianActionCopyGenerator);
414
- const replyMsg = createAssistantMessage(replyText);
415
- await conversationStore.addMessage(
416
- session.conversationId,
417
- 'assistant',
418
- JSON.stringify(replyMsg.content),
419
- guardianChannelMeta,
420
- );
421
- session.messages.push(replyMsg);
422
- onEvent({ type: 'assistant_text_delta', text: replyText });
423
- } else {
424
- const errorDetail = 'error' in answerResult ? answerResult.error : 'Unknown error';
425
- log.warn({ callSessionId: guardianRequest.callSessionId, error: errorDetail }, 'answerCall failed for mac guardian answer');
426
- const failureText = await composeGuardianActionMessageGenerative(
427
- { scenario: 'guardian_answer_delivery_failed' },
428
- {},
429
- _guardianActionCopyGenerator,
430
- );
431
- const failMsg = createAssistantMessage(failureText);
432
- await conversationStore.addMessage(
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]) {
432
+ const req = getGuardianActionRequest(d.requestId);
433
+ if (req && content.toUpperCase().startsWith(req.requestCode)) {
434
+ matchesOtherState = true;
435
+ break;
436
+ }
437
+ }
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
470
+ }
471
+ }
472
+
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(
433
480
  session.conversationId,
434
- 'assistant',
435
- JSON.stringify(failMsg.content),
481
+ 'user',
482
+ JSON.stringify(userMsg.content),
436
483
  guardianChannelMeta,
437
484
  );
438
- session.messages.push(failMsg);
439
- onEvent({ type: 'assistant_text_delta', text: failureText });
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
+ });
504
+ }
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
+ }
536
+ onEvent({ type: 'message_complete', sessionId: session.conversationId });
537
+ return persisted.id;
440
538
  }
441
- onEvent({ type: 'message_complete', sessionId: session.conversationId });
442
- return persisted.id;
443
539
  }
444
540
  }
445
541
 
446
542
  // ── Expired guardian action late answer interception (mac channel) ──
447
543
  // If no pending delivery was found, check for expired requests eligible
448
- // for follow-up (status='expired', followup_state='none').
449
- const expiredDelivery = getExpiredDeliveryByConversation(session.conversationId);
450
- if (expiredDelivery) {
451
- const expiredRequest = getGuardianActionRequest(expiredDelivery.requestId);
452
- if (expiredRequest && expiredRequest.status === 'expired' && expiredRequest.followupState === 'none') {
453
- const guardianIfCtx = session.getTurnInterfaceContext();
454
- const guardianChannelMeta = { userMessageChannel: 'vellum' as const, assistantMessageChannel: 'vellum' as const, userMessageInterface: guardianIfCtx?.userMessageInterface ?? 'vellum', assistantMessageInterface: guardianIfCtx?.assistantMessageInterface ?? 'vellum', provenanceActorRole: 'guardian' as const };
455
- const userMsg = createUserMessage(content, attachments);
456
- const persisted = await conversationStore.addMessage(
457
- session.conversationId,
458
- 'user',
459
- JSON.stringify(userMsg.content),
460
- guardianChannelMeta,
461
- );
462
- session.messages.push(userMsg);
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
+ }
463
567
 
464
- const followupResult = startFollowupFromExpiredRequest(expiredRequest.id, content);
465
- if (followupResult) {
466
- const followupText = await composeGuardianActionMessageGenerative(
467
- {
468
- scenario: 'guardian_late_answer_followup',
469
- questionText: expiredRequest.questionText,
470
- lateAnswerText: content,
471
- },
472
- {},
473
- _guardianActionCopyGenerator,
474
- );
475
- const replyMsg = createAssistantMessage(followupText);
476
- await conversationStore.addMessage(
477
- session.conversationId,
478
- 'assistant',
479
- JSON.stringify(replyMsg.content),
480
- guardianChannelMeta,
481
- );
482
- session.messages.push(replyMsg);
483
- onEvent({ type: 'assistant_text_delta', text: followupText });
484
- } else {
485
- // Follow-up already started or conflict — send stale message
486
- const staleText = await composeGuardianActionMessageGenerative(
487
- { scenario: 'guardian_stale_expired' },
488
- {},
489
- _guardianActionCopyGenerator,
490
- );
491
- const staleMsg = createAssistantMessage(staleText);
492
- await conversationStore.addMessage(
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;
577
+ }
578
+ }
579
+
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;
589
+ }
590
+ }
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
+ }
628
+ }
629
+
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 };
635
+ const userMsg = createUserMessage(content, attachments);
636
+ const persisted = await conversationStore.addMessage(
493
637
  session.conversationId,
494
- 'assistant',
495
- JSON.stringify(staleMsg.content),
638
+ 'user',
639
+ JSON.stringify(userMsg.content),
496
640
  guardianChannelMeta,
497
641
  );
498
- session.messages.push(staleMsg);
499
- onEvent({ type: 'assistant_text_delta', text: staleText });
642
+ 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
+ }
681
+ onEvent({ type: 'message_complete', sessionId: session.conversationId });
682
+ return persisted.id;
500
683
  }
501
- onEvent({ type: 'message_complete', sessionId: session.conversationId });
502
- return persisted.id;
503
684
  }
504
685
  }
505
686
 
506
687
  // ── Guardian follow-up conversation interception (mac channel) ──
507
688
  // When a request is in `awaiting_guardian_choice` state, the guardian has
508
689
  // already been asked "call back or send a message?". Their next message
509
- // is the reply — route it through the conversation engine.
510
- const followupDelivery = getFollowupDeliveryByConversation(session.conversationId);
511
- if (followupDelivery) {
512
- const followupRequest = getGuardianActionRequest(followupDelivery.requestId);
513
- if (followupRequest && followupRequest.followupState === 'awaiting_guardian_choice') {
514
- const guardianIfCtx = session.getTurnInterfaceContext();
515
- const guardianChannelMeta = { userMessageChannel: 'vellum' as const, assistantMessageChannel: 'vellum' as const, userMessageInterface: guardianIfCtx?.userMessageInterface ?? 'vellum', assistantMessageInterface: guardianIfCtx?.assistantMessageInterface ?? 'vellum', provenanceActorRole: 'guardian' as const };
516
- const userMsg = createUserMessage(content, attachments);
517
- const persisted = await conversationStore.addMessage(
518
- session.conversationId,
519
- 'user',
520
- JSON.stringify(userMsg.content),
521
- guardianChannelMeta,
522
- );
523
- session.messages.push(userMsg);
524
-
525
- const turnResult = await processGuardianFollowUpTurn(
526
- {
527
- questionText: followupRequest.questionText,
528
- lateAnswerText: followupRequest.lateAnswerText ?? '',
529
- guardianReply: content,
530
- },
531
- _guardianFollowUpGenerator,
532
- );
533
-
534
- // Apply the disposition to the follow-up state machine.
535
- // Both progressFollowupState and finalizeFollowup are compare-and-set:
536
- // they return null when the transition was not applied (e.g. a concurrent
537
- // reply already advanced the state). In that case we notify the guardian
538
- // that the request was already resolved and skip action execution.
539
- let stateApplied = true;
540
- if (turnResult.disposition === 'call_back' || turnResult.disposition === 'message_back') {
541
- stateApplied = progressFollowupState(followupRequest.id, 'dispatching', turnResult.disposition) !== null;
542
- } else if (turnResult.disposition === 'decline') {
543
- stateApplied = finalizeFollowup(followupRequest.id, 'declined') !== null;
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();
544
710
  }
545
- // keep_pending: no state change — guardian can reply again
711
+ }
546
712
 
547
- if (!stateApplied) {
548
- log.warn({ requestId: followupRequest.id, disposition: turnResult.disposition }, 'Follow-up state transition failed (already resolved)');
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
+ }
549
723
  }
550
724
 
551
- const replyText = stateApplied
552
- ? turnResult.replyText
553
- : await composeGuardianActionMessageGenerative({ scenario: 'guardian_stale_followup' }, {}, _guardianActionCopyGenerator);
554
- const replyMsg = createAssistantMessage(replyText);
555
- await conversationStore.addMessage(
556
- session.conversationId,
557
- 'assistant',
558
- JSON.stringify(replyMsg.content),
559
- guardianChannelMeta,
560
- );
561
- session.messages.push(replyMsg);
562
- onEvent({ type: 'assistant_text_delta', text: replyText });
563
- onEvent({ type: 'message_complete', sessionId: session.conversationId });
564
-
565
- // Execute the action and send a completion/failure message (fire-and-forget).
566
- // The initial reply above acknowledges the guardian's choice; the executor
567
- // carries out the actual call_back or message_back and posts a second message.
568
- if (stateApplied && (turnResult.disposition === 'call_back' || turnResult.disposition === 'message_back')) {
569
- void (async () => {
570
- try {
571
- const execResult = await executeFollowupAction(
572
- followupRequest.id,
573
- turnResult.disposition as 'call_back' | 'message_back',
574
- _guardianActionCopyGenerator,
575
- );
576
- const completionMsg = createAssistantMessage(execResult.guardianReplyText);
577
- await conversationStore.addMessage(
578
- session.conversationId,
579
- 'assistant',
580
- JSON.stringify(completionMsg.content),
581
- guardianChannelMeta,
582
- );
583
- session.messages.push(completionMsg);
584
- onEvent({ type: 'assistant_text_delta', text: execResult.guardianReplyText });
585
- onEvent({ type: 'message_complete', sessionId: session.conversationId });
586
- } catch (execErr) {
587
- log.error({ err: execErr, requestId: followupRequest.id }, 'Follow-up action execution or completion message failed');
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;
588
735
  }
589
- })();
736
+ }
737
+
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 };
741
+ const userMsg = createUserMessage(content, attachments);
742
+ const persisted = await conversationStore.addMessage(
743
+ session.conversationId,
744
+ 'user',
745
+ JSON.stringify(userMsg.content),
746
+ guardianChannelMeta,
747
+ );
748
+ session.messages.push(userMsg);
749
+
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,
766
+ );
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
590
773
  }
774
+ }
775
+
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);
591
789
 
592
- return persisted.id;
790
+ const turnResult = await processGuardianFollowUpTurn(
791
+ {
792
+ questionText: followupRequest.questionText,
793
+ lateAnswerText: followupRequest.lateAnswerText ?? '',
794
+ guardianReply: followupReplyText,
795
+ },
796
+ _guardianFollowUpGenerator,
797
+ );
798
+
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
811
+
812
+ if (!stateApplied) {
813
+ log.warn({ requestId: followupRequest.id, disposition: turnResult.disposition }, 'Follow-up state transition failed (already resolved)');
814
+ }
815
+
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 });
829
+
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');
853
+ }
854
+ })();
855
+ }
856
+
857
+ return persisted.id;
858
+ }
593
859
  }
594
860
  }
595
861