@vellumai/assistant 0.3.28 → 0.4.0

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 +33 -3
  2. package/bun.lock +4 -1
  3. package/docs/trusted-contact-access.md +9 -2
  4. package/package.json +6 -3
  5. package/scripts/ipc/generate-swift.ts +3 -3
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +80 -0
  7. package/src/__tests__/agent-loop-thinking.test.ts +1 -1
  8. package/src/__tests__/approval-routes-http.test.ts +13 -5
  9. package/src/__tests__/asset-materialize-tool.test.ts +2 -0
  10. package/src/__tests__/asset-search-tool.test.ts +2 -0
  11. package/src/__tests__/assistant-events-sse-hardening.test.ts +4 -2
  12. package/src/__tests__/attachments-store.test.ts +2 -0
  13. package/src/__tests__/browser-skill-endstate.test.ts +3 -3
  14. package/src/__tests__/call-controller.test.ts +30 -29
  15. package/src/__tests__/call-routes-http.test.ts +34 -32
  16. package/src/__tests__/call-start-guardian-guard.test.ts +2 -0
  17. package/src/__tests__/channel-invite-transport.test.ts +6 -6
  18. package/src/__tests__/channel-reply-delivery.test.ts +19 -0
  19. package/src/__tests__/channel-retry-sweep.test.ts +130 -0
  20. package/src/__tests__/clarification-resolver.test.ts +2 -0
  21. package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
  22. package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
  23. package/src/__tests__/commit-message-enrichment-service.test.ts +9 -1
  24. package/src/__tests__/computer-use-session-lifecycle.test.ts +2 -0
  25. package/src/__tests__/computer-use-session-working-dir.test.ts +1 -0
  26. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +2 -0
  27. package/src/__tests__/config-schema.test.ts +5 -5
  28. package/src/__tests__/config-watcher.test.ts +3 -1
  29. package/src/__tests__/connection-policy.test.ts +14 -5
  30. package/src/__tests__/contacts-tools.test.ts +3 -1
  31. package/src/__tests__/contradiction-checker.test.ts +2 -0
  32. package/src/__tests__/conversation-pairing.test.ts +10 -0
  33. package/src/__tests__/conversation-routes.test.ts +1 -1
  34. package/src/__tests__/credential-security-invariants.test.ts +16 -6
  35. package/src/__tests__/credential-vault-unit.test.ts +2 -2
  36. package/src/__tests__/credential-vault.test.ts +5 -4
  37. package/src/__tests__/daemon-lifecycle.test.ts +9 -0
  38. package/src/__tests__/daemon-server-session-init.test.ts +27 -0
  39. package/src/__tests__/elevenlabs-config.test.ts +2 -0
  40. package/src/__tests__/encrypted-store.test.ts +10 -5
  41. package/src/__tests__/followup-tools.test.ts +3 -1
  42. package/src/__tests__/gateway-only-enforcement.test.ts +21 -21
  43. package/src/__tests__/gmail-integration.test.ts +0 -1
  44. package/src/__tests__/guardian-control-plane-policy.test.ts +19 -19
  45. package/src/__tests__/guardian-dispatch.test.ts +2 -0
  46. package/src/__tests__/guardian-grant-minting.test.ts +68 -1
  47. package/src/__tests__/guardian-outbound-http.test.ts +12 -9
  48. package/src/__tests__/guardian-routing-invariants.test.ts +138 -0
  49. package/src/__tests__/handle-user-message-secret-resume.test.ts +1 -0
  50. package/src/__tests__/handlers-slack-config.test.ts +3 -1
  51. package/src/__tests__/handlers-telegram-config.test.ts +3 -1
  52. package/src/__tests__/handlers-twilio-config.test.ts +3 -1
  53. package/src/__tests__/handlers-twitter-config.test.ts +3 -1
  54. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +318 -0
  55. package/src/__tests__/heartbeat-service.test.ts +20 -0
  56. package/src/__tests__/inbound-invite-redemption.test.ts +33 -0
  57. package/src/__tests__/ingress-reconcile.test.ts +3 -1
  58. package/src/__tests__/ingress-routes-http.test.ts +231 -4
  59. package/src/__tests__/intent-routing.test.ts +2 -0
  60. package/src/__tests__/ipc-snapshot.test.ts +13 -0
  61. package/src/__tests__/media-generate-image.test.ts +21 -0
  62. package/src/__tests__/media-reuse-story.e2e.test.ts +2 -0
  63. package/src/__tests__/memory-regressions.test.ts +20 -20
  64. package/src/__tests__/non-member-access-request.test.ts +183 -9
  65. package/src/__tests__/notification-decision-fallback.test.ts +2 -0
  66. package/src/__tests__/notification-decision-strategy.test.ts +61 -0
  67. package/src/__tests__/notification-guardian-path.test.ts +2 -0
  68. package/src/__tests__/oauth-connect-handler.test.ts +3 -1
  69. package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
  70. package/src/__tests__/onboarding-starter-tasks.test.ts +4 -4
  71. package/src/__tests__/pairing-routes.test.ts +171 -0
  72. package/src/__tests__/playbook-execution.test.ts +3 -1
  73. package/src/__tests__/playbook-tools.test.ts +3 -1
  74. package/src/__tests__/provider-error-scenarios.test.ts +59 -8
  75. package/src/__tests__/proxy-approval-callback.test.ts +2 -0
  76. package/src/__tests__/recording-handler.test.ts +11 -0
  77. package/src/__tests__/recording-intent-handler.test.ts +15 -0
  78. package/src/__tests__/recording-state-machine.test.ts +13 -2
  79. package/src/__tests__/registry.test.ts +7 -3
  80. package/src/__tests__/relay-server.test.ts +148 -28
  81. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  82. package/src/__tests__/runtime-events-sse-parity.test.ts +21 -0
  83. package/src/__tests__/runtime-events-sse.test.ts +4 -2
  84. package/src/__tests__/sandbox-diagnostics.test.ts +2 -0
  85. package/src/__tests__/schedule-tools.test.ts +3 -1
  86. package/src/__tests__/send-endpoint-busy.test.ts +4 -0
  87. package/src/__tests__/session-abort-tool-results.test.ts +23 -0
  88. package/src/__tests__/session-agent-loop.test.ts +16 -0
  89. package/src/__tests__/session-conflict-gate.test.ts +21 -0
  90. package/src/__tests__/session-load-history-repair.test.ts +27 -17
  91. package/src/__tests__/session-pre-run-repair.test.ts +23 -0
  92. package/src/__tests__/session-profile-injection.test.ts +21 -0
  93. package/src/__tests__/session-provider-retry-repair.test.ts +20 -0
  94. package/src/__tests__/session-queue.test.ts +23 -0
  95. package/src/__tests__/session-runtime-assembly.test.ts +50 -12
  96. package/src/__tests__/session-skill-tools.test.ts +27 -5
  97. package/src/__tests__/session-slash-known.test.ts +23 -0
  98. package/src/__tests__/session-slash-queue.test.ts +23 -0
  99. package/src/__tests__/session-slash-unknown.test.ts +23 -0
  100. package/src/__tests__/session-workspace-cache-state.test.ts +7 -0
  101. package/src/__tests__/session-workspace-injection.test.ts +21 -0
  102. package/src/__tests__/session-workspace-tool-tracking.test.ts +21 -0
  103. package/src/__tests__/shell-credential-ref.test.ts +2 -0
  104. package/src/__tests__/skill-feature-flags-integration.test.ts +6 -6
  105. package/src/__tests__/skill-load-feature-flag.test.ts +5 -4
  106. package/src/__tests__/skill-projection-feature-flag.test.ts +22 -0
  107. package/src/__tests__/skills.test.ts +8 -4
  108. package/src/__tests__/slack-channel-config.test.ts +3 -1
  109. package/src/__tests__/subagent-tools.test.ts +19 -0
  110. package/src/__tests__/swarm-recursion.test.ts +2 -0
  111. package/src/__tests__/swarm-session-integration.test.ts +2 -0
  112. package/src/__tests__/swarm-tool.test.ts +2 -0
  113. package/src/__tests__/system-prompt.test.ts +3 -1
  114. package/src/__tests__/task-compiler.test.ts +3 -1
  115. package/src/__tests__/task-management-tools.test.ts +3 -1
  116. package/src/__tests__/task-tools.test.ts +3 -1
  117. package/src/__tests__/terminal-sandbox.test.ts +13 -12
  118. package/src/__tests__/terminal-tools.test.ts +2 -0
  119. package/src/__tests__/tool-approval-handler.test.ts +15 -15
  120. package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -0
  121. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -0
  122. package/src/__tests__/tool-grant-request-escalation.test.ts +7 -7
  123. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +48 -0
  124. package/src/__tests__/trusted-contact-multichannel.test.ts +22 -19
  125. package/src/__tests__/trusted-contact-verification.test.ts +91 -0
  126. package/src/__tests__/twilio-routes-elevenlabs.test.ts +2 -0
  127. package/src/__tests__/twitter-auth-handler.test.ts +3 -1
  128. package/src/__tests__/twitter-cli-routing.test.ts +3 -1
  129. package/src/__tests__/view-image-tool.test.ts +3 -1
  130. package/src/__tests__/voice-invite-redemption.test.ts +329 -0
  131. package/src/__tests__/voice-scoped-grant-consumer.test.ts +7 -5
  132. package/src/__tests__/voice-session-bridge.test.ts +10 -10
  133. package/src/__tests__/work-item-output.test.ts +3 -1
  134. package/src/__tests__/workspace-lifecycle.test.ts +13 -2
  135. package/src/calls/call-controller.ts +26 -23
  136. package/src/calls/guardian-action-sweep.ts +10 -2
  137. package/src/calls/relay-server.ts +216 -27
  138. package/src/calls/types.ts +1 -1
  139. package/src/calls/voice-session-bridge.ts +3 -3
  140. package/src/cli.ts +12 -0
  141. package/src/config/agent-schema.ts +14 -3
  142. package/src/config/calls-schema.ts +6 -6
  143. package/src/config/core-schema.ts +3 -3
  144. package/src/config/feature-flag-registry.json +8 -0
  145. package/src/config/mcp-schema.ts +1 -1
  146. package/src/config/memory-schema.ts +27 -19
  147. package/src/config/schema.ts +21 -21
  148. package/src/config/skills-schema.ts +7 -7
  149. package/src/config/vellum-skills/trusted-contacts/SKILL.md +139 -16
  150. package/src/daemon/handlers/config-inbox.ts +4 -4
  151. package/src/daemon/handlers/sessions.ts +148 -4
  152. package/src/daemon/ipc-contract/messages.ts +16 -0
  153. package/src/daemon/ipc-contract-inventory.json +1 -0
  154. package/src/daemon/lifecycle.ts +19 -0
  155. package/src/daemon/pairing-store.ts +86 -3
  156. package/src/daemon/session-agent-loop.ts +5 -5
  157. package/src/daemon/session-lifecycle.ts +25 -17
  158. package/src/daemon/session-memory.ts +2 -2
  159. package/src/daemon/session-process.ts +1 -20
  160. package/src/daemon/session-runtime-assembly.ts +28 -22
  161. package/src/daemon/session-tool-setup.ts +2 -2
  162. package/src/daemon/session.ts +3 -3
  163. package/src/memory/canonical-guardian-store.ts +63 -1
  164. package/src/memory/channel-guardian-store.ts +1 -0
  165. package/src/memory/conversation-crud.ts +7 -7
  166. package/src/memory/db-init.ts +4 -0
  167. package/src/memory/embedding-local.ts +257 -39
  168. package/src/memory/embedding-runtime-manager.ts +471 -0
  169. package/src/memory/guardian-bindings.ts +25 -1
  170. package/src/memory/indexer.ts +3 -3
  171. package/src/memory/ingress-invite-store.ts +45 -0
  172. package/src/memory/job-handlers/backfill.ts +16 -9
  173. package/src/memory/migrations/037-voice-invite-columns.ts +16 -0
  174. package/src/memory/migrations/index.ts +1 -0
  175. package/src/memory/qdrant-client.ts +31 -22
  176. package/src/memory/schema.ts +4 -0
  177. package/src/notifications/copy-composer.ts +15 -0
  178. package/src/runtime/access-request-helper.ts +43 -7
  179. package/src/runtime/actor-trust-resolver.ts +46 -50
  180. package/src/runtime/channel-invite-transports/voice.ts +58 -0
  181. package/src/runtime/channel-retry-sweep.ts +18 -6
  182. package/src/runtime/guardian-context-resolver.ts +38 -96
  183. package/src/runtime/guardian-reply-router.ts +31 -1
  184. package/src/runtime/ingress-service.ts +80 -3
  185. package/src/runtime/invite-redemption-service.ts +141 -2
  186. package/src/runtime/routes/channel-route-shared.ts +1 -1
  187. package/src/runtime/routes/channel-routes.ts +1 -1
  188. package/src/runtime/routes/conversation-routes.ts +2 -2
  189. package/src/runtime/routes/guardian-approval-interception.ts +17 -6
  190. package/src/runtime/routes/inbound-message-handler.ts +41 -10
  191. package/src/runtime/routes/ingress-routes.ts +52 -4
  192. package/src/runtime/routes/pairing-routes.ts +3 -0
  193. package/src/tools/guardian-control-plane-policy.ts +2 -2
  194. package/src/tools/tool-approval-handler.ts +11 -11
  195. package/src/tools/types.ts +2 -2
  196. package/src/util/logger.ts +20 -8
  197. package/src/util/platform.ts +10 -0
  198. package/src/util/voice-code.ts +29 -0
  199. package/src/daemon/guardian-invite-intent.ts +0 -124
@@ -732,6 +732,33 @@ describe('routing invariant: disambiguation stays fail-closed', () => {
732
732
  const resolved = getCanonicalGuardianRequest(req.id);
733
733
  expect(resolved!.status).toBe('approved');
734
734
  });
735
+
736
+ test('single pending request accepts "go for it" as deterministic approval', async () => {
737
+ const req = createCanonicalGuardianRequest({
738
+ kind: 'tool_approval',
739
+ sourceType: 'channel',
740
+ conversationId: 'conv-1',
741
+ guardianExternalUserId: 'guardian-1',
742
+ toolName: 'shell',
743
+ requestCode: 'GO1234',
744
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
745
+ });
746
+ registerPendingToolApprovalInteraction(req.id, 'conv-1', 'shell');
747
+
748
+ const result = await routeGuardianReply(replyCtx({
749
+ messageText: 'go for it',
750
+ conversationId: 'conv-1',
751
+ pendingRequestIds: [req.id],
752
+ approvalConversationGenerator: undefined,
753
+ }));
754
+
755
+ expect(result.consumed).toBe(true);
756
+ expect(result.decisionApplied).toBe(true);
757
+ expect(result.type).toBe('canonical_decision_applied');
758
+
759
+ const resolved = getCanonicalGuardianRequest(req.id);
760
+ expect(resolved!.status).toBe('approved');
761
+ });
735
762
  });
736
763
 
737
764
  // ===========================================================================
@@ -952,3 +979,114 @@ describe('routing invariant: destination hints enable NL approval without guardi
952
979
  expect(unchanged!.status).toBe('pending');
953
980
  });
954
981
  });
982
+
983
+ // ===========================================================================
984
+ // SECTION 10: Invite handoff bypass for access requests
985
+ // ===========================================================================
986
+
987
+ describe('routing invariant: invite handoff bypass for access requests', () => {
988
+ beforeEach(() => resetTables());
989
+
990
+ test('pending access_request + message "open invite flow" returns not_consumed with skipApprovalInterception', async () => {
991
+ const req = createCanonicalGuardianRequest({
992
+ kind: 'access_request',
993
+ sourceType: 'channel',
994
+ sourceChannel: 'telegram',
995
+ conversationId: 'conv-access-1',
996
+ guardianExternalUserId: 'guardian-1',
997
+ requestCode: 'INV001',
998
+ toolName: 'ingress_access_request',
999
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
1000
+ });
1001
+
1002
+ const result = await routeGuardianReply(replyCtx({
1003
+ messageText: 'open invite flow',
1004
+ conversationId: 'conv-guardian-thread',
1005
+ pendingRequestIds: [req.id],
1006
+ approvalConversationGenerator: undefined,
1007
+ }));
1008
+
1009
+ expect(result.consumed).toBe(false);
1010
+ expect(result.type).toBe('not_consumed');
1011
+ expect(result.decisionApplied).toBe(false);
1012
+ expect(result.skipApprovalInterception).toBe(true);
1013
+
1014
+ // Request remains pending — not resolved by the handoff
1015
+ const unchanged = getCanonicalGuardianRequest(req.id);
1016
+ expect(unchanged!.status).toBe('pending');
1017
+ });
1018
+
1019
+ test('invite handoff is case-insensitive and punctuation-trimmed', async () => {
1020
+ const req = createCanonicalGuardianRequest({
1021
+ kind: 'access_request',
1022
+ sourceType: 'channel',
1023
+ sourceChannel: 'telegram',
1024
+ guardianExternalUserId: 'guardian-1',
1025
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
1026
+ });
1027
+
1028
+ for (const phrase of ['Open Invite Flow', 'OPEN INVITE FLOW', 'open invite flow.', 'Open invite flow!']) {
1029
+ const result = await routeGuardianReply(replyCtx({
1030
+ messageText: phrase,
1031
+ conversationId: 'conv-test',
1032
+ pendingRequestIds: [req.id],
1033
+ approvalConversationGenerator: undefined,
1034
+ }));
1035
+
1036
+ expect(result.consumed).toBe(false);
1037
+ expect(result.type).toBe('not_consumed');
1038
+ }
1039
+ });
1040
+
1041
+ test('invite handoff does NOT bypass for non-access-request kinds', async () => {
1042
+ const req = createCanonicalGuardianRequest({
1043
+ kind: 'tool_approval',
1044
+ sourceType: 'channel',
1045
+ conversationId: 'conv-1',
1046
+ guardianExternalUserId: 'guardian-1',
1047
+ requestCode: 'TAP001',
1048
+ toolName: 'shell',
1049
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
1050
+ });
1051
+
1052
+ await routeGuardianReply(replyCtx({
1053
+ messageText: 'open invite flow',
1054
+ conversationId: 'conv-guardian-thread',
1055
+ pendingRequestIds: [req.id],
1056
+ approvalConversationGenerator: undefined,
1057
+ }));
1058
+
1059
+ // Should NOT return not_consumed via the invite handoff path.
1060
+ // Without NL generator and no explicit approve/reject, it falls through
1061
+ // to not_consumed anyway, but the key invariant is the request remains pending.
1062
+ const unchanged = getCanonicalGuardianRequest(req.id);
1063
+ expect(unchanged!.status).toBe('pending');
1064
+ });
1065
+
1066
+ test('explicit approve/reject messages still consume with pending access_request', async () => {
1067
+ const req = createCanonicalGuardianRequest({
1068
+ kind: 'access_request',
1069
+ sourceType: 'channel',
1070
+ sourceChannel: 'telegram',
1071
+ conversationId: 'conv-access-2',
1072
+ guardianExternalUserId: 'guardian-1',
1073
+ requestCode: 'A00B01',
1074
+ toolName: 'ingress_access_request',
1075
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
1076
+ });
1077
+
1078
+ // Code-based approve should still work (request code must be valid hex: [A-F0-9]{6})
1079
+ const result = await routeGuardianReply(replyCtx({
1080
+ messageText: 'A00B01 approve',
1081
+ conversationId: 'conv-guardian-thread',
1082
+ pendingRequestIds: [req.id],
1083
+ approvalConversationGenerator: undefined,
1084
+ }));
1085
+
1086
+ expect(result.consumed).toBe(true);
1087
+ expect(result.decisionApplied).toBe(true);
1088
+
1089
+ const resolved = getCanonicalGuardianRequest(req.id);
1090
+ expect(resolved!.status).toBe('approved');
1091
+ });
1092
+ });
@@ -37,6 +37,7 @@ describe('handleUserMessage secret redirect continuation', () => {
37
37
  setTurnChannelContext: () => {},
38
38
  setTurnInterfaceContext: () => {},
39
39
  setAssistantId: () => {},
40
+ setChannelCapabilities: () => {},
40
41
  setGuardianContext: () => {},
41
42
  setCommandIntent: () => {},
42
43
  processMessage: (content: string, _attachments: unknown[], _onEvent: unknown, requestId: string) => {
@@ -12,7 +12,9 @@ let rawConfigStore: Record<string, unknown> = {};
12
12
  const saveRawConfigCalls: Record<string, unknown>[] = [];
13
13
 
14
14
  mock.module('../config/loader.js', () => ({
15
- getConfig: () => ({}),
15
+ getConfig: () => ({
16
+ ui: {},
17
+ }),
16
18
  loadConfig: () => ({}),
17
19
  loadRawConfig: () => ({ ...rawConfigStore }),
18
20
  saveRawConfig: (cfg: Record<string, unknown>) => {
@@ -11,7 +11,9 @@ const testDir = mkdtempSync(join(tmpdir(), 'handlers-telegram-cfg-test-'));
11
11
  let rawConfigStore: Record<string, unknown> = {};
12
12
 
13
13
  mock.module('../config/loader.js', () => ({
14
- getConfig: () => ({}),
14
+ getConfig: () => ({
15
+ ui: {},
16
+ }),
15
17
  loadConfig: () => ({}),
16
18
  loadRawConfig: () => ({ ...rawConfigStore }),
17
19
  saveRawConfig: (cfg: Record<string, unknown>) => {
@@ -11,7 +11,9 @@ const testDir = mkdtempSync(join(tmpdir(), 'handlers-twilio-cfg-test-'));
11
11
  let rawConfigStore: Record<string, unknown> = {};
12
12
 
13
13
  mock.module('../config/loader.js', () => ({
14
- getConfig: () => ({ ...rawConfigStore }),
14
+ getConfig: () => ({
15
+ ui: {},
16
+ ...rawConfigStore }),
15
17
  loadConfig: () => ({ ...rawConfigStore }),
16
18
  loadRawConfig: () => ({ ...rawConfigStore }),
17
19
  saveRawConfig: (cfg: Record<string, unknown>) => {
@@ -12,7 +12,9 @@ let rawConfigStore: Record<string, unknown> = {};
12
12
  const saveRawConfigCalls: Record<string, unknown>[] = [];
13
13
 
14
14
  mock.module('../config/loader.js', () => ({
15
- getConfig: () => ({}),
15
+ getConfig: () => ({
16
+ ui: {},
17
+ }),
16
18
  loadConfig: () => ({}),
17
19
  loadRawConfig: () => ({ ...rawConfigStore }),
18
20
  saveRawConfig: (cfg: Record<string, unknown>) => {
@@ -0,0 +1,318 @@
1
+ import * as net from 'node:net';
2
+
3
+ import { beforeEach, describe, expect, mock, test } from 'bun:test';
4
+
5
+ import type { HandlerContext } from '../daemon/handlers.js';
6
+ import type { UserMessage } from '../daemon/ipc-contract.js';
7
+ import type { ServerMessage } from '../daemon/ipc-protocol.js';
8
+ import { DebouncerMap } from '../util/debounce.js';
9
+
10
+ const routeGuardianReplyMock = mock(async () => ({
11
+ consumed: false,
12
+ decisionApplied: false,
13
+ type: 'not_consumed' as const,
14
+ })) as any;
15
+ const listPendingByDestinationMock = mock(() => [] as Array<{ id: string; kind?: string }>);
16
+ const listCanonicalMock = mock(() => [] as Array<{ id: string }>);
17
+ const getByConversationMock = mock(
18
+ () => [] as Array<{
19
+ requestId: string;
20
+ kind: 'confirmation' | 'secret';
21
+ session?: unknown;
22
+ }>,
23
+ );
24
+ const resolveMock = mock(() => undefined as unknown);
25
+ const addMessageMock = mock(async () => ({ id: 'persisted-message-id' }));
26
+ const getConfigMock = mock(() => ({
27
+ daemon: { standaloneRecording: false },
28
+ secretDetection: { customPatterns: [], entropyThreshold: 3.5 },
29
+ }));
30
+
31
+ mock.module('../runtime/guardian-reply-router.js', () => ({
32
+ routeGuardianReply: routeGuardianReplyMock,
33
+ }));
34
+
35
+ mock.module('../memory/canonical-guardian-store.js', () => ({
36
+ listPendingCanonicalGuardianRequestsByDestinationConversation: listPendingByDestinationMock,
37
+ listCanonicalGuardianRequests: listCanonicalMock,
38
+ }));
39
+
40
+ mock.module('../runtime/pending-interactions.js', () => ({
41
+ getByConversation: getByConversationMock,
42
+ resolve: resolveMock,
43
+ }));
44
+
45
+ mock.module('../memory/conversation-store.js', () => ({
46
+ addMessage: addMessageMock,
47
+ }));
48
+
49
+ mock.module('../config/loader.js', () => ({
50
+ getConfig: getConfigMock,
51
+ }));
52
+
53
+ mock.module('../daemon/approval-generators.js', () => ({
54
+ createApprovalConversationGenerator: () => async () => ({
55
+ disposition: 'keep_pending',
56
+ replyText: 'pending',
57
+ }),
58
+ }));
59
+
60
+ mock.module('../util/logger.js', () => ({
61
+ getLogger: () => ({
62
+ info: () => {},
63
+ warn: () => {},
64
+ error: () => {},
65
+ debug: () => {},
66
+ child: () => ({
67
+ info: () => {},
68
+ warn: () => {},
69
+ error: () => {},
70
+ debug: () => {},
71
+ }),
72
+ }),
73
+ }));
74
+
75
+ import { handleUserMessage } from '../daemon/handlers/sessions.js';
76
+
77
+ interface TestSession {
78
+ messages: Array<{ role: string; content: unknown[] }>;
79
+ hasEscalationHandler: () => boolean;
80
+ setChannelCapabilities: (caps: unknown) => void;
81
+ isProcessing: () => boolean;
82
+ hasPendingConfirmation: (requestId: string) => boolean;
83
+ hasAnyPendingConfirmation: () => boolean;
84
+ getQueueDepth: () => number;
85
+ denyAllPendingConfirmations: () => void;
86
+ enqueueMessage: (...args: unknown[]) => { queued: boolean; rejected?: boolean; requestId: string };
87
+ traceEmitter: { emit: (...args: unknown[]) => void };
88
+ setTurnChannelContext: (ctx: unknown) => void;
89
+ setTurnInterfaceContext: (ctx: unknown) => void;
90
+ setAssistantId: (assistantId: string) => void;
91
+ setGuardianContext: (ctx: unknown) => void;
92
+ setCommandIntent: (intent: unknown) => void;
93
+ processMessage: (...args: unknown[]) => Promise<string>;
94
+ }
95
+
96
+ function createContext(session: TestSession): { ctx: HandlerContext; sent: ServerMessage[] } {
97
+ const sent: ServerMessage[] = [];
98
+ const ctx: HandlerContext = {
99
+ sessions: new Map(),
100
+ socketToSession: new Map(),
101
+ cuSessions: new Map(),
102
+ socketToCuSession: new Map(),
103
+ cuObservationParseSequence: new Map(),
104
+ socketSandboxOverride: new Map(),
105
+ sharedRequestTimestamps: [],
106
+ debounceTimers: new DebouncerMap({ defaultDelayMs: 100 }),
107
+ suppressConfigReload: false,
108
+ setSuppressConfigReload: () => {},
109
+ updateConfigFingerprint: () => {},
110
+ send: (_socket, msg) => { sent.push(msg); },
111
+ broadcast: () => {},
112
+ clearAllSessions: () => 0,
113
+ getOrCreateSession: async () => session as any,
114
+ touchSession: () => {},
115
+ };
116
+ return { ctx, sent };
117
+ }
118
+
119
+ function makeMessage(content: string): UserMessage {
120
+ return {
121
+ type: 'user_message',
122
+ sessionId: 'conv-1',
123
+ content,
124
+ channel: 'vellum',
125
+ interface: 'macos',
126
+ };
127
+ }
128
+
129
+ function makeSession(overrides: Partial<TestSession> = {}): TestSession {
130
+ return {
131
+ messages: [],
132
+ hasEscalationHandler: () => true,
133
+ setChannelCapabilities: () => {},
134
+ isProcessing: () => false,
135
+ hasPendingConfirmation: () => true,
136
+ hasAnyPendingConfirmation: () => true,
137
+ getQueueDepth: () => 0,
138
+ denyAllPendingConfirmations: mock(() => {}),
139
+ enqueueMessage: mock(() => ({ queued: true, requestId: 'queued-id' })),
140
+ traceEmitter: { emit: () => {} },
141
+ setTurnChannelContext: () => {},
142
+ setTurnInterfaceContext: () => {},
143
+ setAssistantId: () => {},
144
+ setGuardianContext: () => {},
145
+ setCommandIntent: () => {},
146
+ processMessage: async () => 'msg-id',
147
+ ...overrides,
148
+ };
149
+ }
150
+
151
+ describe('handleUserMessage pending-confirmation reply interception', () => {
152
+ beforeEach(() => {
153
+ routeGuardianReplyMock.mockClear();
154
+ listPendingByDestinationMock.mockClear();
155
+ listCanonicalMock.mockClear();
156
+ getByConversationMock.mockClear();
157
+ resolveMock.mockClear();
158
+ addMessageMock.mockClear();
159
+ getConfigMock.mockClear();
160
+ });
161
+
162
+ test('consumes decision replies before auto-deny', async () => {
163
+ listPendingByDestinationMock.mockReturnValue([{ id: 'req-1', kind: 'tool_approval' }]);
164
+ listCanonicalMock.mockReturnValue([{ id: 'req-1' }]);
165
+ routeGuardianReplyMock.mockResolvedValue({
166
+ consumed: true,
167
+ decisionApplied: true,
168
+ type: 'canonical_decision_applied',
169
+ requestId: 'req-1',
170
+ });
171
+
172
+ const session = makeSession();
173
+ const { ctx, sent } = createContext(session);
174
+
175
+ await handleUserMessage(makeMessage('go for it'), {} as net.Socket, ctx);
176
+
177
+ expect(routeGuardianReplyMock).toHaveBeenCalledTimes(1);
178
+ const routeCall = (routeGuardianReplyMock as any).mock.calls[0][0] as Record<string, unknown>;
179
+ expect(routeCall.messageText).toBe('go for it');
180
+ expect(typeof routeCall.approvalConversationGenerator).toBe('function');
181
+ expect((session.denyAllPendingConfirmations as any).mock.calls.length).toBe(0);
182
+ expect((session.enqueueMessage as any).mock.calls.length).toBe(0);
183
+ expect(session.messages).toHaveLength(2);
184
+ expect(session.messages[0]?.role).toBe('user');
185
+ expect(session.messages[1]?.role).toBe('assistant');
186
+ expect(addMessageMock).toHaveBeenCalledTimes(2);
187
+ expect(addMessageMock).toHaveBeenCalledWith(
188
+ 'conv-1',
189
+ 'user',
190
+ expect.any(String),
191
+ expect.objectContaining({
192
+ userMessageChannel: 'vellum',
193
+ assistantMessageChannel: 'vellum',
194
+ userMessageInterface: 'macos',
195
+ assistantMessageInterface: 'macos',
196
+ provenanceActorRole: 'guardian',
197
+ }),
198
+ );
199
+ expect(addMessageMock).toHaveBeenCalledWith(
200
+ 'conv-1',
201
+ 'assistant',
202
+ expect.stringContaining('Decision applied.'),
203
+ expect.objectContaining({
204
+ userMessageChannel: 'vellum',
205
+ assistantMessageChannel: 'vellum',
206
+ userMessageInterface: 'macos',
207
+ assistantMessageInterface: 'macos',
208
+ provenanceActorRole: 'guardian',
209
+ }),
210
+ );
211
+ expect(sent.map((msg) => msg.type)).toEqual([
212
+ 'message_queued',
213
+ 'message_dequeued',
214
+ 'assistant_text_delta',
215
+ 'message_request_complete',
216
+ ]);
217
+ const assistantDelta = sent.find(
218
+ (msg): msg is Extract<ServerMessage, { type: 'assistant_text_delta' }> => msg.type === 'assistant_text_delta',
219
+ );
220
+ expect(assistantDelta?.text).toBe('Decision applied.');
221
+ const requestComplete = sent.find(
222
+ (msg): msg is Extract<ServerMessage, { type: 'message_request_complete' }> => msg.type === 'message_request_complete',
223
+ );
224
+ expect(requestComplete?.runStillActive).toBe(false);
225
+ });
226
+
227
+ test('does not mutate in-memory history while processing', async () => {
228
+ listPendingByDestinationMock.mockReturnValue([{ id: 'req-1', kind: 'tool_approval' }]);
229
+ listCanonicalMock.mockReturnValue([{ id: 'req-1' }]);
230
+ routeGuardianReplyMock.mockResolvedValue({
231
+ consumed: true,
232
+ decisionApplied: true,
233
+ type: 'canonical_decision_applied',
234
+ requestId: 'req-1',
235
+ });
236
+
237
+ const session = makeSession({ isProcessing: () => true });
238
+ const { ctx, sent } = createContext(session);
239
+
240
+ await handleUserMessage(makeMessage('approve'), {} as net.Socket, ctx);
241
+
242
+ expect(addMessageMock).toHaveBeenCalledTimes(2);
243
+ expect(session.messages).toHaveLength(0);
244
+ // assistant_text_delta must NOT be sent when the session is processing —
245
+ // it would contaminate the agent's in-flight streaming message on the client.
246
+ expect(sent.some((msg) => msg.type === 'assistant_text_delta')).toBe(false);
247
+ expect(sent.map((msg) => msg.type)).toEqual([
248
+ 'message_queued',
249
+ 'message_dequeued',
250
+ 'message_request_complete',
251
+ ]);
252
+ const requestComplete = sent.find(
253
+ (msg): msg is Extract<ServerMessage, { type: 'message_request_complete' }> => msg.type === 'message_request_complete',
254
+ );
255
+ expect(requestComplete?.runStillActive).toBe(true);
256
+ });
257
+
258
+ test('nl keep_pending falls back to existing auto-deny + queue behavior', async () => {
259
+ listPendingByDestinationMock.mockReturnValue([{ id: 'req-1', kind: 'tool_approval' }]);
260
+ listCanonicalMock.mockReturnValue([{ id: 'req-1' }]);
261
+ routeGuardianReplyMock.mockResolvedValue({
262
+ consumed: true,
263
+ decisionApplied: false,
264
+ type: 'nl_keep_pending',
265
+ requestId: 'req-1',
266
+ replyText: 'Need clarification',
267
+ });
268
+
269
+ const session = makeSession();
270
+ const { ctx, sent } = createContext(session);
271
+
272
+ await handleUserMessage(makeMessage('what does that do?'), {} as net.Socket, ctx);
273
+
274
+ expect(routeGuardianReplyMock).toHaveBeenCalledTimes(1);
275
+ expect((session.denyAllPendingConfirmations as any).mock.calls.length).toBe(1);
276
+ expect((session.enqueueMessage as any).mock.calls.length).toBe(1);
277
+ expect(session.messages).toHaveLength(0);
278
+ expect(addMessageMock).toHaveBeenCalledTimes(0);
279
+ expect(sent.some((msg) => msg.type === 'message_queued')).toBe(true);
280
+ expect(sent.some((msg) => msg.type === 'message_dequeued')).toBe(false);
281
+ });
282
+
283
+ test('routes only live pending confirmation request ids', async () => {
284
+ const session = makeSession({
285
+ hasPendingConfirmation: (requestId: string) => requestId === 'req-live',
286
+ });
287
+
288
+ getByConversationMock.mockReturnValue([
289
+ { requestId: 'req-stale', kind: 'confirmation', session: {} },
290
+ { requestId: 'req-live', kind: 'confirmation', session: session as unknown },
291
+ ]);
292
+ listPendingByDestinationMock.mockReturnValue([
293
+ { id: 'req-stale', kind: 'tool_approval' },
294
+ { id: 'req-live', kind: 'tool_approval' },
295
+ ]);
296
+ listCanonicalMock.mockReturnValue([
297
+ { id: 'req-stale' },
298
+ { id: 'req-live' },
299
+ ]);
300
+ routeGuardianReplyMock.mockResolvedValue({
301
+ consumed: false,
302
+ decisionApplied: false,
303
+ type: 'not_consumed',
304
+ });
305
+
306
+ const { ctx } = createContext(session);
307
+ await handleUserMessage(makeMessage('allow'), {} as net.Socket, ctx);
308
+
309
+ expect(routeGuardianReplyMock).toHaveBeenCalledTimes(1);
310
+ const routeCall = (routeGuardianReplyMock as any).mock.calls[0][0] as Record<string, unknown>;
311
+ expect(routeCall.pendingRequestIds).toEqual(['req-live']);
312
+ // Auto-deny clears matching confirmation entries from pending-interactions
313
+ // so stale IDs are not reused as routing candidates. Only the live
314
+ // session-scoped interaction should be resolved.
315
+ expect(resolveMock).toHaveBeenCalledTimes(1);
316
+ expect(resolveMock).toHaveBeenCalledWith('req-live');
317
+ });
318
+ });
@@ -31,6 +31,26 @@ const createdConversations: Array<{ title: string; threadType: string }> = [];
31
31
  let conversationIdCounter = 0;
32
32
 
33
33
  mock.module('../memory/conversation-store.js', () => ({
34
+ getConversationThreadType: () => 'default',
35
+ setConversationOriginChannelIfUnset: () => {},
36
+ updateConversationContextWindow: () => {},
37
+ deleteMessageById: () => {},
38
+ updateConversationTitle: () => {},
39
+ updateConversationUsage: () => {},
40
+ addMessage: () => ({ id: 'mock-msg-id' }),
41
+ getMessages: () => [],
42
+ getConversation: () => ({
43
+ id: 'conv-1',
44
+ contextSummary: null,
45
+ contextCompactedMessageCount: 0,
46
+ totalInputTokens: 0,
47
+ totalOutputTokens: 0,
48
+ totalEstimatedCost: 0,
49
+ title: null,
50
+ }),
51
+ provenanceFromGuardianContext: () => ({ source: 'user', guardianContext: undefined }),
52
+ getConversationOriginInterface: () => null,
53
+ getConversationOriginChannel: () => null,
34
54
  createConversation: (opts: { title: string; threadType: string }) => {
35
55
  createdConversations.push(opts);
36
56
  return { id: `conv-${++conversationIdCounter}`, ...opts };
@@ -364,4 +364,37 @@ describe('inbound invite redemption intercept', () => {
364
364
  expect(json.accepted).toBe(true);
365
365
  expect(json.denied).toBeUndefined();
366
366
  });
367
+
368
+ test('reactivation via invite preserves existing guardian-managed member display name', async () => {
369
+ const { rawToken } = createInvite({ sourceChannel: 'telegram', maxUses: 5 });
370
+
371
+ upsertMember({
372
+ assistantId: 'self',
373
+ sourceChannel: 'telegram',
374
+ externalUserId: 'user-invite-123',
375
+ externalChatId: 'chat-invite-test',
376
+ status: 'revoked',
377
+ policy: 'allow',
378
+ displayName: 'Jeff',
379
+ });
380
+
381
+ const req = buildInviteRequest(rawToken, {
382
+ senderName: 'Noa Flaherty',
383
+ });
384
+ const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
385
+ const json = await resp.json() as Record<string, unknown>;
386
+
387
+ expect(json.accepted).toBe(true);
388
+ expect(json.inviteRedemption).toBe('redeemed');
389
+
390
+ const member = findMember({
391
+ assistantId: 'self',
392
+ sourceChannel: 'telegram',
393
+ externalUserId: 'user-invite-123',
394
+ externalChatId: 'chat-invite-test',
395
+ });
396
+ expect(member).not.toBeNull();
397
+ expect(member!.status).toBe('active');
398
+ expect(member!.displayName).toBe('Jeff');
399
+ });
367
400
  });
@@ -11,7 +11,9 @@ const testDir = mkdtempSync(join(tmpdir(), 'ingress-reconcile-test-'));
11
11
  let rawConfigStore: Record<string, unknown> = {};
12
12
 
13
13
  mock.module('../config/loader.js', () => ({
14
- getConfig: () => ({}),
14
+ getConfig: () => ({
15
+ ui: {},
16
+ }),
15
17
  loadConfig: () => ({}),
16
18
  loadRawConfig: () => ({ ...rawConfigStore }),
17
19
  saveRawConfig: (cfg: Record<string, unknown>) => {