@vellumai/assistant 0.3.16 → 0.3.18

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 (90) hide show
  1. package/ARCHITECTURE.md +70 -13
  2. package/README.md +6 -0
  3. package/docs/architecture/http-token-refresh.md +23 -1
  4. package/package.json +1 -1
  5. package/src/__tests__/access-request-decision.test.ts +4 -7
  6. package/src/__tests__/channel-guardian.test.ts +3 -1
  7. package/src/__tests__/checker.test.ts +79 -48
  8. package/src/__tests__/config-watcher.test.ts +11 -13
  9. package/src/__tests__/conversation-pairing.test.ts +103 -3
  10. package/src/__tests__/guardian-action-conversation-turn.test.ts +1 -1
  11. package/src/__tests__/guardian-action-followup-executor.test.ts +1 -1
  12. package/src/__tests__/guardian-action-late-reply.test.ts +131 -0
  13. package/src/__tests__/guardian-action-store.test.ts +182 -0
  14. package/src/__tests__/guardian-dispatch.test.ts +120 -0
  15. package/src/__tests__/ipc-snapshot.test.ts +21 -0
  16. package/src/__tests__/non-member-access-request.test.ts +1 -2
  17. package/src/__tests__/notification-broadcaster.test.ts +115 -4
  18. package/src/__tests__/notification-decision-strategy.test.ts +2 -1
  19. package/src/__tests__/notification-deep-link.test.ts +44 -1
  20. package/src/__tests__/notification-guardian-path.test.ts +157 -0
  21. package/src/__tests__/notification-thread-candidate-validation.test.ts +215 -0
  22. package/src/__tests__/slack-channel-config.test.ts +3 -3
  23. package/src/__tests__/trust-store.test.ts +21 -21
  24. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +5 -7
  25. package/src/__tests__/trusted-contact-multichannel.test.ts +2 -6
  26. package/src/__tests__/trusted-contact-verification.test.ts +9 -9
  27. package/src/__tests__/update-bulletin-state.test.ts +1 -1
  28. package/src/__tests__/update-bulletin.test.ts +66 -3
  29. package/src/__tests__/update-template-contract.test.ts +6 -11
  30. package/src/__tests__/voice-session-bridge.test.ts +109 -9
  31. package/src/calls/call-controller.ts +129 -8
  32. package/src/calls/guardian-action-sweep.ts +1 -1
  33. package/src/calls/guardian-dispatch.ts +8 -0
  34. package/src/calls/voice-session-bridge.ts +4 -2
  35. package/src/cli/core-commands.ts +41 -1
  36. package/src/config/templates/UPDATES.md +5 -6
  37. package/src/config/update-bulletin-format.ts +2 -0
  38. package/src/config/update-bulletin-state.ts +1 -1
  39. package/src/config/update-bulletin-template-path.ts +6 -0
  40. package/src/config/update-bulletin.ts +21 -6
  41. package/src/daemon/config-watcher.ts +3 -2
  42. package/src/daemon/daemon-control.ts +64 -10
  43. package/src/daemon/handlers/config-slack-channel.ts +1 -1
  44. package/src/daemon/handlers/identity.ts +45 -25
  45. package/src/daemon/handlers/sessions.ts +1 -1
  46. package/src/daemon/ipc-contract/sessions.ts +1 -1
  47. package/src/daemon/ipc-contract/workspace.ts +12 -1
  48. package/src/daemon/ipc-contract-inventory.json +1 -0
  49. package/src/daemon/lifecycle.ts +8 -0
  50. package/src/daemon/server.ts +25 -3
  51. package/src/daemon/session-process.ts +438 -184
  52. package/src/daemon/tls-certs.ts +17 -12
  53. package/src/daemon/tool-side-effects.ts +1 -1
  54. package/src/memory/channel-delivery-store.ts +18 -20
  55. package/src/memory/channel-guardian-store.ts +39 -42
  56. package/src/memory/conversation-crud.ts +2 -2
  57. package/src/memory/conversation-queries.ts +2 -2
  58. package/src/memory/conversation-store.ts +24 -25
  59. package/src/memory/db-init.ts +9 -1
  60. package/src/memory/fts-reconciler.ts +41 -26
  61. package/src/memory/guardian-action-store.ts +57 -7
  62. package/src/memory/guardian-verification.ts +1 -0
  63. package/src/memory/jobs-worker.ts +2 -2
  64. package/src/memory/migrations/032-guardian-delivery-conversation-index.ts +15 -0
  65. package/src/memory/migrations/032-notification-delivery-thread-decision.ts +20 -0
  66. package/src/memory/migrations/index.ts +4 -2
  67. package/src/memory/schema-migration.ts +1 -0
  68. package/src/memory/schema.ts +6 -1
  69. package/src/memory/search/semantic.ts +3 -3
  70. package/src/notifications/README.md +158 -17
  71. package/src/notifications/broadcaster.ts +68 -50
  72. package/src/notifications/conversation-pairing.ts +96 -18
  73. package/src/notifications/decision-engine.ts +6 -3
  74. package/src/notifications/deliveries-store.ts +12 -0
  75. package/src/notifications/emit-signal.ts +1 -0
  76. package/src/notifications/thread-candidates.ts +60 -25
  77. package/src/notifications/types.ts +2 -1
  78. package/src/permissions/checker.ts +1 -16
  79. package/src/permissions/defaults.ts +14 -4
  80. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  81. package/src/runtime/http-server.ts +11 -11
  82. package/src/runtime/routes/access-request-decision.ts +1 -1
  83. package/src/runtime/routes/debug-routes.ts +4 -4
  84. package/src/runtime/routes/guardian-approval-interception.ts +4 -4
  85. package/src/runtime/routes/inbound-message-handler.ts +6 -6
  86. package/src/runtime/routes/integration-routes.ts +2 -2
  87. package/src/tools/permission-checker.ts +1 -2
  88. package/src/tools/secret-detection-handler.ts +1 -1
  89. package/src/tools/system/voice-config.ts +1 -1
  90. package/src/version.ts +29 -2
@@ -15,21 +15,21 @@ 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';
28
31
  import { composeGuardianActionMessageGenerative } from '../runtime/guardian-action-message-composer.js';
29
32
  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
33
  import { getLogger } from '../util/logger.js';
34
34
  import { resolveGuardianVerificationIntent } from './guardian-verification-intent.js';
35
35
  import type { UsageStats } from './ipc-contract.js';
@@ -383,213 +383,467 @@ export async function processMessage(
383
383
  session.currentPage = currentPage;
384
384
 
385
385
  // ── Guardian action answer interception (mac channel) ──
386
- // If this conversation has a pending guardian action delivery, treat the
386
+ // If this conversation has pending guardian action deliveries, treat the
387
387
  // 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);
388
+ // When multiple deliveries exist in the same reused thread, require a
389
+ // request-code prefix for disambiguation (matching the channel path pattern).
390
+ const pendingDeliveries = getPendingDeliveriesByConversation(session.conversationId);
391
+ if (pendingDeliveries.length > 0) {
392
+ // Before auto-selecting a lone pending delivery, check if expired or
393
+ // follow-up deliveries also exist in this conversation. When the total
394
+ // count across all states exceeds 1, require request-code disambiguation
395
+ // even if only one pending delivery exists — otherwise a reply prefixed
396
+ // with an expired/follow-up request code would be silently routed to
397
+ // the pending delivery instead of being correctly rejected or routed.
398
+ const crossStateExpired = getExpiredDeliveriesByConversation(session.conversationId);
399
+ const crossStateFollowup = getFollowupDeliveriesByConversation(session.conversationId);
400
+ const totalCrossStateCount = pendingDeliveries.length + crossStateExpired.length + crossStateFollowup.length;
401
+ let matchedDelivery = (pendingDeliveries.length === 1 && totalCrossStateCount === 1) ? pendingDeliveries[0] : null;
402
+ let answerText = content;
403
+
404
+ // Strip the request code prefix from the answer text when the single
405
+ // pending delivery is auto-matched (content may include a code prefix
406
+ // if the guardian uses it out of habit after a previous disambiguation round).
407
+ if (matchedDelivery) {
408
+ const req = getGuardianActionRequest(matchedDelivery.requestId);
409
+ if (req && content.toUpperCase().startsWith(req.requestCode)) {
410
+ answerText = content.slice(req.requestCode.length).trim();
411
+ }
412
+ }
402
413
 
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(
414
+ // Multiple deliveries across any state: require request code prefix for disambiguation
415
+ if (!matchedDelivery && pendingDeliveries.length >= 1) {
416
+ for (const d of pendingDeliveries) {
417
+ const req = getGuardianActionRequest(d.requestId);
418
+ if (req && content.toUpperCase().startsWith(req.requestCode)) {
419
+ matchedDelivery = d;
420
+ answerText = content.slice(req.requestCode.length).trim();
421
+ break;
422
+ }
423
+ }
424
+
425
+ // If no pending delivery matched, check whether the code targets an
426
+ // expired or follow-up delivery. If so, skip the pending section entirely
427
+ // and let the message fall through to the expired/follow-up handlers below.
428
+ if (!matchedDelivery) {
429
+ let matchesOtherState = false;
430
+ for (const d of [...crossStateExpired, ...crossStateFollowup]) {
431
+ const req = getGuardianActionRequest(d.requestId);
432
+ if (req && content.toUpperCase().startsWith(req.requestCode)) {
433
+ matchesOtherState = true;
434
+ break;
435
+ }
436
+ }
437
+
438
+ if (!matchesOtherState) {
439
+ const guardianIfCtx = session.getTurnInterfaceContext();
440
+ const guardianChannelMeta = { userMessageChannel: 'vellum' as const, assistantMessageChannel: 'vellum' as const, userMessageInterface: guardianIfCtx?.userMessageInterface ?? 'vellum', assistantMessageInterface: guardianIfCtx?.assistantMessageInterface ?? 'vellum', provenanceActorRole: 'guardian' as const };
441
+ const userMsg = createUserMessage(content, attachments);
442
+ const persisted = await conversationStore.addMessage(
443
+ session.conversationId,
444
+ 'user',
445
+ JSON.stringify(userMsg.content),
446
+ guardianChannelMeta,
447
+ );
448
+ session.messages.push(userMsg);
449
+
450
+ // Include codes from all states so the guardian sees all options
451
+ const allDeliveries = [...pendingDeliveries, ...crossStateExpired, ...crossStateFollowup];
452
+ const codes = allDeliveries
453
+ .map((d) => { const req = getGuardianActionRequest(d.requestId); return req ? req.requestCode : null; })
454
+ .filter((code): code is string => typeof code === 'string' && code.length > 0);
455
+ const disambiguationText = `You have multiple pending guardian questions. Please prefix your reply with the reference code (${codes.join(', ')}) to indicate which question you are answering.`;
456
+ const disambiguationMsg = createAssistantMessage(disambiguationText);
457
+ await conversationStore.addMessage(
458
+ session.conversationId,
459
+ 'assistant',
460
+ JSON.stringify(disambiguationMsg.content),
461
+ guardianChannelMeta,
462
+ );
463
+ session.messages.push(disambiguationMsg);
464
+ onEvent({ type: 'assistant_text_delta', text: disambiguationText });
465
+ onEvent({ type: 'message_complete', sessionId: session.conversationId });
466
+ return persisted.id;
467
+ }
468
+ // Code matched an expired/follow-up delivery — fall through to those handlers
469
+ }
470
+ }
471
+
472
+ if (matchedDelivery) {
473
+ const guardianRequest = getGuardianActionRequest(matchedDelivery.requestId);
474
+ if (guardianRequest && guardianRequest.status === 'pending') {
475
+ const guardianIfCtx = session.getTurnInterfaceContext();
476
+ const guardianChannelMeta = { userMessageChannel: 'vellum' as const, assistantMessageChannel: 'vellum' as const, userMessageInterface: guardianIfCtx?.userMessageInterface ?? 'vellum', assistantMessageInterface: guardianIfCtx?.assistantMessageInterface ?? 'vellum', provenanceActorRole: 'guardian' as const };
477
+ const userMsg = createUserMessage(content, attachments);
478
+ const persisted = await conversationStore.addMessage(
433
479
  session.conversationId,
434
- 'assistant',
435
- JSON.stringify(failMsg.content),
480
+ 'user',
481
+ JSON.stringify(userMsg.content),
436
482
  guardianChannelMeta,
437
483
  );
438
- session.messages.push(failMsg);
439
- onEvent({ type: 'assistant_text_delta', text: failureText });
484
+ session.messages.push(userMsg);
485
+
486
+ // Attempt to deliver the answer to the call first. Only resolve
487
+ // the guardian action request if answerCall succeeds, so that a
488
+ // failed delivery leaves the request pending for retry from
489
+ // another channel.
490
+ const answerResult = await answerCall({ callSessionId: guardianRequest.callSessionId, answer: answerText });
491
+
492
+ if ('ok' in answerResult && answerResult.ok) {
493
+ const resolved = resolveGuardianActionRequest(guardianRequest.id, answerText, 'vellum');
494
+ const replyText = resolved
495
+ ? 'Your answer has been relayed to the call.'
496
+ : await composeGuardianActionMessageGenerative({ scenario: 'guardian_stale_answered' }, {}, _guardianActionCopyGenerator);
497
+ const replyMsg = createAssistantMessage(replyText);
498
+ await conversationStore.addMessage(
499
+ session.conversationId,
500
+ 'assistant',
501
+ JSON.stringify(replyMsg.content),
502
+ guardianChannelMeta,
503
+ );
504
+ session.messages.push(replyMsg);
505
+ onEvent({ type: 'assistant_text_delta', text: replyText });
506
+ } else {
507
+ const errorDetail = 'error' in answerResult ? answerResult.error : 'Unknown error';
508
+ log.warn({ callSessionId: guardianRequest.callSessionId, error: errorDetail }, 'answerCall failed for mac guardian answer');
509
+ const failureText = await composeGuardianActionMessageGenerative(
510
+ { scenario: 'guardian_answer_delivery_failed' },
511
+ {},
512
+ _guardianActionCopyGenerator,
513
+ );
514
+ const failMsg = createAssistantMessage(failureText);
515
+ await conversationStore.addMessage(
516
+ session.conversationId,
517
+ 'assistant',
518
+ JSON.stringify(failMsg.content),
519
+ guardianChannelMeta,
520
+ );
521
+ session.messages.push(failMsg);
522
+ onEvent({ type: 'assistant_text_delta', text: failureText });
523
+ }
524
+ onEvent({ type: 'message_complete', sessionId: session.conversationId });
525
+ return persisted.id;
440
526
  }
441
- onEvent({ type: 'message_complete', sessionId: session.conversationId });
442
- return persisted.id;
443
527
  }
444
528
  }
445
529
 
446
530
  // ── Expired guardian action late answer interception (mac channel) ──
447
531
  // 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);
532
+ // for follow-up (status='expired', followup_state='none'). When multiple
533
+ // expired deliveries exist, require request-code prefix for disambiguation.
534
+ const expiredDeliveries = getExpiredDeliveriesByConversation(session.conversationId);
535
+ if (expiredDeliveries.length > 0) {
536
+ // Cross-state disambiguation: check total deliveries across all states
537
+ // (pending + expired + follow-up). When the total exceeds 1, require
538
+ // request-code disambiguation even for a lone expired delivery otherwise
539
+ // a reply targeting a different state's delivery gets silently misrouted.
540
+ const expCrossStatePending = getPendingDeliveriesByConversation(session.conversationId);
541
+ const expCrossStateFollowup = getFollowupDeliveriesByConversation(session.conversationId);
542
+ const expTotalCrossStateCount = expiredDeliveries.length + expCrossStatePending.length + expCrossStateFollowup.length;
543
+ let matchedExpired = (expiredDeliveries.length === 1 && expTotalCrossStateCount === 1) ? expiredDeliveries[0] : null;
544
+ let expiredAnswerText = content;
545
+
546
+ // Strip the request code prefix from the answer text when the single
547
+ // expired delivery is auto-matched (content may include a code prefix
548
+ // if the pending section fell through via matchesOtherState).
549
+ if (matchedExpired) {
550
+ const req = getGuardianActionRequest(matchedExpired.requestId);
551
+ if (req && content.toUpperCase().startsWith(req.requestCode)) {
552
+ expiredAnswerText = content.slice(req.requestCode.length).trim();
553
+ }
554
+ }
463
555
 
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(
556
+ // Multiple expired deliveries (or cross-state disambiguation needed):
557
+ // require request code prefix for disambiguation
558
+ if (!matchedExpired && expiredDeliveries.length >= 1) {
559
+ for (const d of expiredDeliveries) {
560
+ const req = getGuardianActionRequest(d.requestId);
561
+ if (req && content.toUpperCase().startsWith(req.requestCode)) {
562
+ matchedExpired = d;
563
+ expiredAnswerText = content.slice(req.requestCode.length).trim();
564
+ break;
565
+ }
566
+ }
567
+
568
+ if (!matchedExpired) {
569
+ // Before disambiguating, check if the code matches a follow-up or
570
+ // pending delivery. If so, fall through to let those handlers process it.
571
+ let matchesFollowupState = false;
572
+ for (const d of [...expCrossStateFollowup, ...expCrossStatePending]) {
573
+ const req = getGuardianActionRequest(d.requestId);
574
+ if (req && content.toUpperCase().startsWith(req.requestCode)) {
575
+ matchesFollowupState = true;
576
+ break;
577
+ }
578
+ }
579
+
580
+ if (!matchesFollowupState) {
581
+ const guardianIfCtx = session.getTurnInterfaceContext();
582
+ const guardianChannelMeta = { userMessageChannel: 'vellum' as const, assistantMessageChannel: 'vellum' as const, userMessageInterface: guardianIfCtx?.userMessageInterface ?? 'vellum', assistantMessageInterface: guardianIfCtx?.assistantMessageInterface ?? 'vellum', provenanceActorRole: 'guardian' as const };
583
+ const userMsg = createUserMessage(content, attachments);
584
+ const persisted = await conversationStore.addMessage(
585
+ session.conversationId,
586
+ 'user',
587
+ JSON.stringify(userMsg.content),
588
+ guardianChannelMeta,
589
+ );
590
+ session.messages.push(userMsg);
591
+
592
+ // Include codes from all states so the guardian sees all options
593
+ const allExpiredDeliveries = [...expiredDeliveries, ...expCrossStatePending, ...expCrossStateFollowup];
594
+ const codes = allExpiredDeliveries
595
+ .map((d) => { const req = getGuardianActionRequest(d.requestId); return req ? req.requestCode : null; })
596
+ .filter((code): code is string => typeof code === 'string' && code.length > 0);
597
+ const disambiguationText = await composeGuardianActionMessageGenerative(
598
+ { scenario: 'guardian_expired_disambiguation', requestCodes: codes },
599
+ { requiredKeywords: codes },
600
+ _guardianActionCopyGenerator,
601
+ );
602
+ const disambiguationMsg = createAssistantMessage(disambiguationText);
603
+ await conversationStore.addMessage(
604
+ session.conversationId,
605
+ 'assistant',
606
+ JSON.stringify(disambiguationMsg.content),
607
+ guardianChannelMeta,
608
+ );
609
+ session.messages.push(disambiguationMsg);
610
+ onEvent({ type: 'assistant_text_delta', text: disambiguationText });
611
+ onEvent({ type: 'message_complete', sessionId: session.conversationId });
612
+ return persisted.id;
613
+ }
614
+ // Code matched a follow-up or pending delivery — fall through to those handlers
615
+ }
616
+ }
617
+
618
+ if (matchedExpired) {
619
+ const expiredRequest = getGuardianActionRequest(matchedExpired.requestId);
620
+ if (expiredRequest && expiredRequest.status === 'expired' && expiredRequest.followupState === 'none') {
621
+ const guardianIfCtx = session.getTurnInterfaceContext();
622
+ const guardianChannelMeta = { userMessageChannel: 'vellum' as const, assistantMessageChannel: 'vellum' as const, userMessageInterface: guardianIfCtx?.userMessageInterface ?? 'vellum', assistantMessageInterface: guardianIfCtx?.assistantMessageInterface ?? 'vellum', provenanceActorRole: 'guardian' as const };
623
+ const userMsg = createUserMessage(content, attachments);
624
+ const persisted = await conversationStore.addMessage(
493
625
  session.conversationId,
494
- 'assistant',
495
- JSON.stringify(staleMsg.content),
626
+ 'user',
627
+ JSON.stringify(userMsg.content),
496
628
  guardianChannelMeta,
497
629
  );
498
- session.messages.push(staleMsg);
499
- onEvent({ type: 'assistant_text_delta', text: staleText });
630
+ session.messages.push(userMsg);
631
+
632
+ const followupResult = startFollowupFromExpiredRequest(expiredRequest.id, expiredAnswerText);
633
+ if (followupResult) {
634
+ const followupText = await composeGuardianActionMessageGenerative(
635
+ {
636
+ scenario: 'guardian_late_answer_followup',
637
+ questionText: expiredRequest.questionText,
638
+ lateAnswerText: expiredAnswerText,
639
+ },
640
+ {},
641
+ _guardianActionCopyGenerator,
642
+ );
643
+ const replyMsg = createAssistantMessage(followupText);
644
+ await conversationStore.addMessage(
645
+ session.conversationId,
646
+ 'assistant',
647
+ JSON.stringify(replyMsg.content),
648
+ guardianChannelMeta,
649
+ );
650
+ session.messages.push(replyMsg);
651
+ onEvent({ type: 'assistant_text_delta', text: followupText });
652
+ } else {
653
+ // Follow-up already started or conflict — send stale message
654
+ const staleText = await composeGuardianActionMessageGenerative(
655
+ { scenario: 'guardian_stale_expired' },
656
+ {},
657
+ _guardianActionCopyGenerator,
658
+ );
659
+ const staleMsg = createAssistantMessage(staleText);
660
+ await conversationStore.addMessage(
661
+ session.conversationId,
662
+ 'assistant',
663
+ JSON.stringify(staleMsg.content),
664
+ guardianChannelMeta,
665
+ );
666
+ session.messages.push(staleMsg);
667
+ onEvent({ type: 'assistant_text_delta', text: staleText });
668
+ }
669
+ onEvent({ type: 'message_complete', sessionId: session.conversationId });
670
+ return persisted.id;
500
671
  }
501
- onEvent({ type: 'message_complete', sessionId: session.conversationId });
502
- return persisted.id;
503
672
  }
504
673
  }
505
674
 
506
675
  // ── Guardian follow-up conversation interception (mac channel) ──
507
676
  // When a request is in `awaiting_guardian_choice` state, the guardian has
508
677
  // 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;
678
+ // is the reply — route it through the conversation engine. When multiple
679
+ // follow-up deliveries exist, require request-code prefix for disambiguation.
680
+ const followupDeliveries = getFollowupDeliveriesByConversation(session.conversationId);
681
+ if (followupDeliveries.length > 0) {
682
+ // Cross-state disambiguation: check total deliveries across all states
683
+ // (pending + expired + follow-up). When the total exceeds 1, require
684
+ // request-code disambiguation even for a lone follow-up delivery.
685
+ const fuCrossStatePending = getPendingDeliveriesByConversation(session.conversationId);
686
+ const fuCrossStateExpired = getExpiredDeliveriesByConversation(session.conversationId);
687
+ const fuTotalCrossStateCount = followupDeliveries.length + fuCrossStatePending.length + fuCrossStateExpired.length;
688
+ let matchedFollowup = (followupDeliveries.length === 1 && fuTotalCrossStateCount === 1) ? followupDeliveries[0] : null;
689
+ let followupReplyText = content;
690
+
691
+ // Strip the request code prefix from the reply text when the single
692
+ // follow-up delivery is auto-matched (content may include a code prefix
693
+ // if the pending section fell through via matchesOtherState).
694
+ if (matchedFollowup) {
695
+ const req = getGuardianActionRequest(matchedFollowup.requestId);
696
+ if (req && content.toUpperCase().startsWith(req.requestCode)) {
697
+ followupReplyText = content.slice(req.requestCode.length).trim();
544
698
  }
545
- // keep_pending: no state change — guardian can reply again
699
+ }
546
700
 
547
- if (!stateApplied) {
548
- log.warn({ requestId: followupRequest.id, disposition: turnResult.disposition }, 'Follow-up state transition failed (already resolved)');
701
+ // Multiple follow-up deliveries (or cross-state disambiguation needed):
702
+ // require request code prefix for disambiguation
703
+ if (!matchedFollowup && followupDeliveries.length >= 1) {
704
+ for (const d of followupDeliveries) {
705
+ const req = getGuardianActionRequest(d.requestId);
706
+ if (req && content.toUpperCase().startsWith(req.requestCode)) {
707
+ matchedFollowup = d;
708
+ followupReplyText = content.slice(req.requestCode.length).trim();
709
+ break;
710
+ }
549
711
  }
550
712
 
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');
713
+ if (!matchedFollowup) {
714
+ // Before disambiguating, check if the code matches an expired or
715
+ // pending delivery. If so, fall through to let the normal agent loop
716
+ // handle it (expired/pending blocks already ran and didn't match).
717
+ let matchesOtherFollowupState = false;
718
+ for (const d of [...fuCrossStateExpired, ...fuCrossStatePending]) {
719
+ const req = getGuardianActionRequest(d.requestId);
720
+ if (req && content.toUpperCase().startsWith(req.requestCode)) {
721
+ matchesOtherFollowupState = true;
722
+ break;
588
723
  }
589
- })();
724
+ }
725
+
726
+ if (!matchesOtherFollowupState) {
727
+ const guardianIfCtx = session.getTurnInterfaceContext();
728
+ const guardianChannelMeta = { userMessageChannel: 'vellum' as const, assistantMessageChannel: 'vellum' as const, userMessageInterface: guardianIfCtx?.userMessageInterface ?? 'vellum', assistantMessageInterface: guardianIfCtx?.assistantMessageInterface ?? 'vellum', provenanceActorRole: 'guardian' as const };
729
+ const userMsg = createUserMessage(content, attachments);
730
+ const persisted = await conversationStore.addMessage(
731
+ session.conversationId,
732
+ 'user',
733
+ JSON.stringify(userMsg.content),
734
+ guardianChannelMeta,
735
+ );
736
+ session.messages.push(userMsg);
737
+
738
+ // Include codes from all states so the guardian sees all options
739
+ const allFollowupDeliveries = [...followupDeliveries, ...fuCrossStatePending, ...fuCrossStateExpired];
740
+ const codes = allFollowupDeliveries
741
+ .map((d) => { const req = getGuardianActionRequest(d.requestId); return req ? req.requestCode : null; })
742
+ .filter((code): code is string => typeof code === 'string' && code.length > 0);
743
+ const disambiguationText = await composeGuardianActionMessageGenerative(
744
+ { scenario: 'guardian_followup_disambiguation', requestCodes: codes },
745
+ { requiredKeywords: codes },
746
+ _guardianActionCopyGenerator,
747
+ );
748
+ const disambiguationMsg = createAssistantMessage(disambiguationText);
749
+ await conversationStore.addMessage(
750
+ session.conversationId,
751
+ 'assistant',
752
+ JSON.stringify(disambiguationMsg.content),
753
+ guardianChannelMeta,
754
+ );
755
+ session.messages.push(disambiguationMsg);
756
+ onEvent({ type: 'assistant_text_delta', text: disambiguationText });
757
+ onEvent({ type: 'message_complete', sessionId: session.conversationId });
758
+ return persisted.id;
759
+ }
760
+ // Code matched an expired or pending delivery — fall through to agent loop
590
761
  }
762
+ }
763
+
764
+ if (matchedFollowup) {
765
+ const followupRequest = getGuardianActionRequest(matchedFollowup.requestId);
766
+ if (followupRequest && followupRequest.followupState === 'awaiting_guardian_choice') {
767
+ const guardianIfCtx = session.getTurnInterfaceContext();
768
+ const guardianChannelMeta = { userMessageChannel: 'vellum' as const, assistantMessageChannel: 'vellum' as const, userMessageInterface: guardianIfCtx?.userMessageInterface ?? 'vellum', assistantMessageInterface: guardianIfCtx?.assistantMessageInterface ?? 'vellum', provenanceActorRole: 'guardian' as const };
769
+ const userMsg = createUserMessage(content, attachments);
770
+ const persisted = await conversationStore.addMessage(
771
+ session.conversationId,
772
+ 'user',
773
+ JSON.stringify(userMsg.content),
774
+ guardianChannelMeta,
775
+ );
776
+ session.messages.push(userMsg);
777
+
778
+ const turnResult = await processGuardianFollowUpTurn(
779
+ {
780
+ questionText: followupRequest.questionText,
781
+ lateAnswerText: followupRequest.lateAnswerText ?? '',
782
+ guardianReply: followupReplyText,
783
+ },
784
+ _guardianFollowUpGenerator,
785
+ );
786
+
787
+ // Apply the disposition to the follow-up state machine.
788
+ // Both progressFollowupState and finalizeFollowup are compare-and-set:
789
+ // they return null when the transition was not applied (e.g. a concurrent
790
+ // reply already advanced the state). In that case we notify the guardian
791
+ // that the request was already resolved and skip action execution.
792
+ let stateApplied = true;
793
+ if (turnResult.disposition === 'call_back' || turnResult.disposition === 'message_back') {
794
+ stateApplied = progressFollowupState(followupRequest.id, 'dispatching', turnResult.disposition) !== undefined;
795
+ } else if (turnResult.disposition === 'decline') {
796
+ stateApplied = finalizeFollowup(followupRequest.id, 'declined') !== undefined;
797
+ }
798
+ // keep_pending: no state change — guardian can reply again
799
+
800
+ if (!stateApplied) {
801
+ log.warn({ requestId: followupRequest.id, disposition: turnResult.disposition }, 'Follow-up state transition failed (already resolved)');
802
+ }
591
803
 
592
- return persisted.id;
804
+ const replyText = stateApplied
805
+ ? turnResult.replyText
806
+ : await composeGuardianActionMessageGenerative({ scenario: 'guardian_stale_followup' }, {}, _guardianActionCopyGenerator);
807
+ const replyMsg = createAssistantMessage(replyText);
808
+ await conversationStore.addMessage(
809
+ session.conversationId,
810
+ 'assistant',
811
+ JSON.stringify(replyMsg.content),
812
+ guardianChannelMeta,
813
+ );
814
+ session.messages.push(replyMsg);
815
+ onEvent({ type: 'assistant_text_delta', text: replyText });
816
+ onEvent({ type: 'message_complete', sessionId: session.conversationId });
817
+
818
+ // Execute the action and send a completion/failure message (fire-and-forget).
819
+ // The initial reply above acknowledges the guardian's choice; the executor
820
+ // carries out the actual call_back or message_back and posts a second message.
821
+ if (stateApplied && (turnResult.disposition === 'call_back' || turnResult.disposition === 'message_back')) {
822
+ void (async () => {
823
+ try {
824
+ const execResult = await executeFollowupAction(
825
+ followupRequest.id,
826
+ turnResult.disposition as 'call_back' | 'message_back',
827
+ _guardianActionCopyGenerator,
828
+ );
829
+ const completionMsg = createAssistantMessage(execResult.guardianReplyText);
830
+ await conversationStore.addMessage(
831
+ session.conversationId,
832
+ 'assistant',
833
+ JSON.stringify(completionMsg.content),
834
+ guardianChannelMeta,
835
+ );
836
+ session.messages.push(completionMsg);
837
+ onEvent({ type: 'assistant_text_delta', text: execResult.guardianReplyText });
838
+ onEvent({ type: 'message_complete', sessionId: session.conversationId });
839
+ } catch (execErr) {
840
+ log.error({ err: execErr, requestId: followupRequest.id }, 'Follow-up action execution or completion message failed');
841
+ }
842
+ })();
843
+ }
844
+
845
+ return persisted.id;
846
+ }
593
847
  }
594
848
  }
595
849