@vellumai/assistant 0.3.27 → 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 (247) hide show
  1. package/ARCHITECTURE.md +81 -4
  2. package/Dockerfile +2 -2
  3. package/bun.lock +4 -1
  4. package/docs/trusted-contact-access.md +9 -2
  5. package/package.json +6 -3
  6. package/scripts/ipc/generate-swift.ts +9 -5
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +80 -0
  8. package/src/__tests__/agent-loop-thinking.test.ts +1 -1
  9. package/src/__tests__/agent-loop.test.ts +119 -0
  10. package/src/__tests__/approval-routes-http.test.ts +13 -5
  11. package/src/__tests__/asset-materialize-tool.test.ts +2 -0
  12. package/src/__tests__/asset-search-tool.test.ts +2 -0
  13. package/src/__tests__/assistant-events-sse-hardening.test.ts +4 -2
  14. package/src/__tests__/attachments-store.test.ts +2 -0
  15. package/src/__tests__/browser-skill-endstate.test.ts +3 -3
  16. package/src/__tests__/bundled-asset.test.ts +107 -0
  17. package/src/__tests__/call-controller.test.ts +30 -29
  18. package/src/__tests__/call-routes-http.test.ts +34 -32
  19. package/src/__tests__/call-start-guardian-guard.test.ts +2 -0
  20. package/src/__tests__/canonical-guardian-store.test.ts +636 -0
  21. package/src/__tests__/channel-approval-routes.test.ts +174 -1
  22. package/src/__tests__/channel-invite-transport.test.ts +6 -6
  23. package/src/__tests__/channel-reply-delivery.test.ts +19 -0
  24. package/src/__tests__/channel-retry-sweep.test.ts +130 -0
  25. package/src/__tests__/clarification-resolver.test.ts +2 -0
  26. package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
  27. package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
  28. package/src/__tests__/commit-message-enrichment-service.test.ts +9 -1
  29. package/src/__tests__/computer-use-session-lifecycle.test.ts +2 -0
  30. package/src/__tests__/computer-use-session-working-dir.test.ts +1 -0
  31. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +2 -0
  32. package/src/__tests__/config-schema.test.ts +5 -5
  33. package/src/__tests__/config-watcher.test.ts +3 -1
  34. package/src/__tests__/connection-policy.test.ts +14 -5
  35. package/src/__tests__/contacts-tools.test.ts +3 -1
  36. package/src/__tests__/contradiction-checker.test.ts +2 -0
  37. package/src/__tests__/conversation-pairing.test.ts +10 -0
  38. package/src/__tests__/conversation-routes.test.ts +1 -1
  39. package/src/__tests__/credential-security-invariants.test.ts +16 -6
  40. package/src/__tests__/credential-vault-unit.test.ts +2 -2
  41. package/src/__tests__/credential-vault.test.ts +5 -4
  42. package/src/__tests__/daemon-lifecycle.test.ts +9 -0
  43. package/src/__tests__/daemon-server-session-init.test.ts +27 -0
  44. package/src/__tests__/elevenlabs-config.test.ts +2 -0
  45. package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
  46. package/src/__tests__/encrypted-store.test.ts +10 -5
  47. package/src/__tests__/followup-tools.test.ts +3 -1
  48. package/src/__tests__/gateway-only-enforcement.test.ts +21 -21
  49. package/src/__tests__/gmail-integration.test.ts +0 -1
  50. package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
  51. package/src/__tests__/guardian-control-plane-policy.test.ts +19 -19
  52. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
  53. package/src/__tests__/guardian-dispatch.test.ts +21 -19
  54. package/src/__tests__/guardian-grant-minting.test.ts +68 -1
  55. package/src/__tests__/guardian-outbound-http.test.ts +12 -9
  56. package/src/__tests__/guardian-routing-invariants.test.ts +1092 -0
  57. package/src/__tests__/handle-user-message-secret-resume.test.ts +1 -0
  58. package/src/__tests__/handlers-slack-config.test.ts +3 -1
  59. package/src/__tests__/handlers-telegram-config.test.ts +3 -1
  60. package/src/__tests__/handlers-twilio-config.test.ts +3 -1
  61. package/src/__tests__/handlers-twitter-config.test.ts +3 -1
  62. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +318 -0
  63. package/src/__tests__/heartbeat-service.test.ts +20 -0
  64. package/src/__tests__/inbound-invite-redemption.test.ts +33 -0
  65. package/src/__tests__/ingress-reconcile.test.ts +3 -1
  66. package/src/__tests__/ingress-routes-http.test.ts +231 -4
  67. package/src/__tests__/intent-routing.test.ts +2 -0
  68. package/src/__tests__/ipc-snapshot.test.ts +13 -0
  69. package/src/__tests__/mcp-cli.test.ts +77 -0
  70. package/src/__tests__/media-generate-image.test.ts +21 -0
  71. package/src/__tests__/media-reuse-story.e2e.test.ts +2 -0
  72. package/src/__tests__/memory-regressions.test.ts +20 -20
  73. package/src/__tests__/non-member-access-request.test.ts +212 -36
  74. package/src/__tests__/notification-decision-fallback.test.ts +63 -3
  75. package/src/__tests__/notification-decision-strategy.test.ts +78 -0
  76. package/src/__tests__/notification-guardian-path.test.ts +15 -15
  77. package/src/__tests__/oauth-connect-handler.test.ts +3 -1
  78. package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
  79. package/src/__tests__/onboarding-starter-tasks.test.ts +4 -4
  80. package/src/__tests__/onboarding-template-contract.test.ts +116 -21
  81. package/src/__tests__/pairing-routes.test.ts +171 -0
  82. package/src/__tests__/playbook-execution.test.ts +3 -1
  83. package/src/__tests__/playbook-tools.test.ts +3 -1
  84. package/src/__tests__/provider-error-scenarios.test.ts +59 -8
  85. package/src/__tests__/proxy-approval-callback.test.ts +2 -0
  86. package/src/__tests__/recording-handler.test.ts +11 -0
  87. package/src/__tests__/recording-intent-handler.test.ts +15 -0
  88. package/src/__tests__/recording-state-machine.test.ts +13 -2
  89. package/src/__tests__/registry.test.ts +7 -3
  90. package/src/__tests__/relay-server.test.ts +148 -28
  91. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  92. package/src/__tests__/runtime-events-sse-parity.test.ts +21 -0
  93. package/src/__tests__/runtime-events-sse.test.ts +4 -2
  94. package/src/__tests__/sandbox-diagnostics.test.ts +2 -0
  95. package/src/__tests__/schedule-tools.test.ts +3 -1
  96. package/src/__tests__/secret-scanner-executor.test.ts +59 -0
  97. package/src/__tests__/secret-scanner.test.ts +8 -0
  98. package/src/__tests__/send-endpoint-busy.test.ts +4 -0
  99. package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
  100. package/src/__tests__/session-abort-tool-results.test.ts +23 -0
  101. package/src/__tests__/session-agent-loop.test.ts +16 -0
  102. package/src/__tests__/session-conflict-gate.test.ts +21 -0
  103. package/src/__tests__/session-load-history-repair.test.ts +27 -17
  104. package/src/__tests__/session-pre-run-repair.test.ts +23 -0
  105. package/src/__tests__/session-profile-injection.test.ts +21 -0
  106. package/src/__tests__/session-provider-retry-repair.test.ts +20 -0
  107. package/src/__tests__/session-queue.test.ts +23 -0
  108. package/src/__tests__/session-runtime-assembly.test.ts +126 -59
  109. package/src/__tests__/session-skill-tools.test.ts +27 -5
  110. package/src/__tests__/session-slash-known.test.ts +23 -0
  111. package/src/__tests__/session-slash-queue.test.ts +23 -0
  112. package/src/__tests__/session-slash-unknown.test.ts +23 -0
  113. package/src/__tests__/session-workspace-cache-state.test.ts +7 -0
  114. package/src/__tests__/session-workspace-injection.test.ts +21 -0
  115. package/src/__tests__/session-workspace-tool-tracking.test.ts +21 -0
  116. package/src/__tests__/shell-credential-ref.test.ts +2 -0
  117. package/src/__tests__/skill-feature-flags-integration.test.ts +6 -6
  118. package/src/__tests__/skill-load-feature-flag.test.ts +5 -4
  119. package/src/__tests__/skill-projection-feature-flag.test.ts +22 -0
  120. package/src/__tests__/skills.test.ts +8 -4
  121. package/src/__tests__/slack-channel-config.test.ts +3 -1
  122. package/src/__tests__/subagent-tools.test.ts +19 -0
  123. package/src/__tests__/swarm-recursion.test.ts +2 -0
  124. package/src/__tests__/swarm-session-integration.test.ts +2 -0
  125. package/src/__tests__/swarm-tool.test.ts +2 -0
  126. package/src/__tests__/system-prompt.test.ts +3 -1
  127. package/src/__tests__/task-compiler.test.ts +3 -1
  128. package/src/__tests__/task-management-tools.test.ts +3 -1
  129. package/src/__tests__/task-tools.test.ts +3 -1
  130. package/src/__tests__/terminal-sandbox.test.ts +13 -12
  131. package/src/__tests__/terminal-tools.test.ts +2 -0
  132. package/src/__tests__/tool-approval-handler.test.ts +15 -15
  133. package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -0
  134. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -0
  135. package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
  136. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +48 -0
  137. package/src/__tests__/trusted-contact-multichannel.test.ts +22 -19
  138. package/src/__tests__/trusted-contact-verification.test.ts +91 -0
  139. package/src/__tests__/twilio-routes-elevenlabs.test.ts +2 -0
  140. package/src/__tests__/twitter-auth-handler.test.ts +3 -1
  141. package/src/__tests__/twitter-cli-routing.test.ts +3 -1
  142. package/src/__tests__/view-image-tool.test.ts +3 -1
  143. package/src/__tests__/voice-invite-redemption.test.ts +329 -0
  144. package/src/__tests__/voice-scoped-grant-consumer.test.ts +7 -5
  145. package/src/__tests__/voice-session-bridge.test.ts +10 -10
  146. package/src/__tests__/work-item-output.test.ts +3 -1
  147. package/src/__tests__/workspace-lifecycle.test.ts +13 -2
  148. package/src/agent/loop.ts +46 -3
  149. package/src/approvals/guardian-decision-primitive.ts +285 -0
  150. package/src/approvals/guardian-request-resolvers.ts +539 -0
  151. package/src/calls/call-controller.ts +26 -23
  152. package/src/calls/guardian-action-sweep.ts +10 -2
  153. package/src/calls/guardian-dispatch.ts +46 -40
  154. package/src/calls/relay-server.ts +358 -24
  155. package/src/calls/types.ts +1 -1
  156. package/src/calls/voice-session-bridge.ts +3 -3
  157. package/src/cli.ts +12 -0
  158. package/src/config/agent-schema.ts +14 -3
  159. package/src/config/calls-schema.ts +6 -6
  160. package/src/config/core-schema.ts +3 -3
  161. package/src/config/feature-flag-registry.json +8 -0
  162. package/src/config/mcp-schema.ts +1 -1
  163. package/src/config/memory-schema.ts +27 -19
  164. package/src/config/schema.ts +21 -21
  165. package/src/config/skills-schema.ts +7 -7
  166. package/src/config/system-prompt.ts +2 -1
  167. package/src/config/templates/BOOTSTRAP.md +47 -31
  168. package/src/config/templates/USER.md +5 -0
  169. package/src/config/update-bulletin-template-path.ts +4 -1
  170. package/src/config/vellum-skills/trusted-contacts/SKILL.md +149 -21
  171. package/src/daemon/handlers/config-inbox.ts +4 -4
  172. package/src/daemon/handlers/guardian-actions.ts +45 -66
  173. package/src/daemon/handlers/sessions.ts +148 -4
  174. package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
  175. package/src/daemon/ipc-contract/messages.ts +16 -0
  176. package/src/daemon/ipc-contract-inventory.json +1 -0
  177. package/src/daemon/lifecycle.ts +22 -16
  178. package/src/daemon/pairing-store.ts +86 -3
  179. package/src/daemon/server.ts +18 -0
  180. package/src/daemon/session-agent-loop-handlers.ts +5 -4
  181. package/src/daemon/session-agent-loop.ts +33 -6
  182. package/src/daemon/session-lifecycle.ts +25 -17
  183. package/src/daemon/session-memory.ts +2 -2
  184. package/src/daemon/session-process.ts +68 -326
  185. package/src/daemon/session-runtime-assembly.ts +119 -25
  186. package/src/daemon/session-tool-setup.ts +3 -2
  187. package/src/daemon/session.ts +4 -3
  188. package/src/home-base/prebuilt/seed.ts +2 -1
  189. package/src/hooks/templates.ts +2 -1
  190. package/src/memory/canonical-guardian-store.ts +586 -0
  191. package/src/memory/channel-guardian-store.ts +2 -0
  192. package/src/memory/conversation-crud.ts +7 -7
  193. package/src/memory/db-init.ts +20 -0
  194. package/src/memory/embedding-local.ts +257 -39
  195. package/src/memory/embedding-runtime-manager.ts +471 -0
  196. package/src/memory/guardian-action-store.ts +7 -60
  197. package/src/memory/guardian-approvals.ts +9 -4
  198. package/src/memory/guardian-bindings.ts +25 -1
  199. package/src/memory/indexer.ts +3 -3
  200. package/src/memory/ingress-invite-store.ts +45 -0
  201. package/src/memory/job-handlers/backfill.ts +16 -9
  202. package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
  203. package/src/memory/migrations/037-voice-invite-columns.ts +16 -0
  204. package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
  205. package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
  206. package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
  207. package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
  208. package/src/memory/migrations/index.ts +5 -0
  209. package/src/memory/migrations/registry.ts +5 -0
  210. package/src/memory/qdrant-client.ts +31 -22
  211. package/src/memory/schema-migration.ts +1 -0
  212. package/src/memory/schema.ts +56 -0
  213. package/src/notifications/copy-composer.ts +31 -4
  214. package/src/notifications/decision-engine.ts +57 -0
  215. package/src/permissions/defaults.ts +2 -0
  216. package/src/runtime/access-request-helper.ts +173 -0
  217. package/src/runtime/actor-trust-resolver.ts +221 -0
  218. package/src/runtime/channel-guardian-service.ts +12 -4
  219. package/src/runtime/channel-invite-transports/voice.ts +58 -0
  220. package/src/runtime/channel-retry-sweep.ts +18 -6
  221. package/src/runtime/guardian-context-resolver.ts +38 -71
  222. package/src/runtime/guardian-decision-types.ts +6 -0
  223. package/src/runtime/guardian-reply-router.ts +717 -0
  224. package/src/runtime/http-server.ts +8 -0
  225. package/src/runtime/ingress-service.ts +80 -3
  226. package/src/runtime/invite-redemption-service.ts +141 -2
  227. package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
  228. package/src/runtime/routes/channel-route-shared.ts +1 -1
  229. package/src/runtime/routes/channel-routes.ts +1 -1
  230. package/src/runtime/routes/conversation-routes.ts +20 -2
  231. package/src/runtime/routes/guardian-action-routes.ts +100 -109
  232. package/src/runtime/routes/guardian-approval-interception.ts +17 -6
  233. package/src/runtime/routes/inbound-message-handler.ts +205 -529
  234. package/src/runtime/routes/ingress-routes.ts +52 -4
  235. package/src/runtime/routes/pairing-routes.ts +3 -0
  236. package/src/runtime/tool-grant-request-helper.ts +195 -0
  237. package/src/tools/executor.ts +13 -1
  238. package/src/tools/guardian-control-plane-policy.ts +2 -2
  239. package/src/tools/sensitive-output-placeholders.ts +203 -0
  240. package/src/tools/tool-approval-handler.ts +53 -10
  241. package/src/tools/types.ts +13 -2
  242. package/src/util/bundled-asset.ts +31 -0
  243. package/src/util/canonicalize-identity.ts +52 -0
  244. package/src/util/logger.ts +20 -8
  245. package/src/util/platform.ts +10 -0
  246. package/src/util/voice-code.ts +29 -0
  247. package/src/daemon/guardian-invite-intent.ts +0 -124
@@ -67,6 +67,11 @@ mock.module('../memory/ingress-member-store.js', () => ({
67
67
  updateLastSeen: () => {},
68
68
  }));
69
69
  import type { Session } from '../daemon/session.js';
70
+ import {
71
+ createCanonicalGuardianDelivery,
72
+ createCanonicalGuardianRequest,
73
+ getCanonicalGuardianRequest,
74
+ } from '../memory/canonical-guardian-store.js';
70
75
  import * as channelDeliveryStore from '../memory/channel-delivery-store.js';
71
76
  import {
72
77
  createApprovalRequest,
@@ -110,6 +115,9 @@ function ensureConversation(conversationId: string): void {
110
115
 
111
116
  function resetTables(): void {
112
117
  const db = getDb();
118
+ db.run('DELETE FROM scoped_approval_grants');
119
+ db.run('DELETE FROM canonical_guardian_deliveries');
120
+ db.run('DELETE FROM canonical_guardian_requests');
113
121
  db.run('DELETE FROM channel_guardian_approval_requests');
114
122
  db.run('DELETE FROM channel_guardian_verification_challenges');
115
123
  db.run('DELETE FROM channel_guardian_bindings');
@@ -458,7 +466,7 @@ describe('empty content with callbackData bypasses validation', () => {
458
466
  const res = await handleChannelInbound(req, noopProcessMessage);
459
467
  expect(res.status).toBe(400);
460
468
  const body = await res.json() as Record<string, unknown>;
461
- expect(body.error).toBe('content or attachmentIds is required');
469
+ expect((body.error as Record<string, unknown>).message).toBe('content or attachmentIds is required');
462
470
  });
463
471
 
464
472
  test('allows empty content when callbackData is present', async () => {
@@ -2645,4 +2653,169 @@ describe('background channel processing approval prompts', () => {
2645
2653
  expect(processCalls.length).toBeGreaterThan(0);
2646
2654
  expect(processCalls[0].options?.isInteractive).toBe(false);
2647
2655
  });
2656
+
2657
+ test('unverified channel turns never broadcast approval prompts', async () => {
2658
+ // No guardian binding is created, so the sender resolves to unverified_channel.
2659
+ const deliverPromptSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
2660
+ const processCalls: Array<{ options?: Record<string, unknown> }> = [];
2661
+
2662
+ const processMessage = mock(async (
2663
+ conversationId: string,
2664
+ _content: string,
2665
+ _attachmentIds?: string[],
2666
+ options?: Record<string, unknown>,
2667
+ ) => {
2668
+ processCalls.push({ options });
2669
+
2670
+ // Simulate a pending confirmation becoming visible while background
2671
+ // processing is running. Unverified actors must still not receive it.
2672
+ registerPendingInteraction('req-bg-unverified-1', conversationId, 'host_bash', {
2673
+ input: { command: 'ls -la' },
2674
+ riskLevel: 'medium',
2675
+ });
2676
+
2677
+ await new Promise((resolve) => setTimeout(resolve, 350));
2678
+ return { messageId: 'msg-bg-unverified-1' };
2679
+ });
2680
+
2681
+ const req = makeInboundRequest({
2682
+ content: 'run ls',
2683
+ sourceChannel: 'telegram',
2684
+ replyCallbackUrl: 'https://gateway.test/deliver/telegram',
2685
+ externalMessageId: 'msg-bg-unverified-1',
2686
+ });
2687
+
2688
+ const res = await handleChannelInbound(req, processMessage as unknown as typeof noopProcessMessage, 'token');
2689
+ const body = await res.json() as Record<string, unknown>;
2690
+ expect(body.accepted).toBe(true);
2691
+
2692
+ await new Promise((resolve) => setTimeout(resolve, 700));
2693
+
2694
+ expect(processCalls.length).toBeGreaterThan(0);
2695
+ expect(processCalls[0].options?.isInteractive).toBe(false);
2696
+ expect(deliverPromptSpy).not.toHaveBeenCalled();
2697
+
2698
+ deliverPromptSpy.mockRestore();
2699
+ });
2700
+ });
2701
+
2702
+ // ═══════════════════════════════════════════════════════════════════════════
2703
+ // NL approval routing via destination-scoped canonical requests
2704
+ // ═══════════════════════════════════════════════════════════════════════════
2705
+
2706
+ describe('NL approval routing via destination-scoped canonical requests', () => {
2707
+ beforeEach(() => {
2708
+ resetTables();
2709
+ noopProcessMessage.mockClear();
2710
+ });
2711
+
2712
+ test('guardian plain-text "yes" resolves a pending_question with no guardianExternalUserId via delivery-scoped hint', async () => {
2713
+ // Simulate a voice-originated pending_question without guardianExternalUserId
2714
+ const guardianChatId = 'guardian-chat-nl-1';
2715
+ const guardianUserId = 'guardian-user-nl-1';
2716
+
2717
+ // Ensure the conversation exists so the resolver finds it
2718
+ ensureConversation('conv-voice-nl-1');
2719
+
2720
+ // Create guardian binding for Telegram
2721
+ createBinding({
2722
+ assistantId: 'self',
2723
+ channel: 'telegram',
2724
+ guardianExternalUserId: guardianUserId,
2725
+ guardianDeliveryChatId: guardianChatId,
2726
+ });
2727
+
2728
+ // Create canonical tool_approval request WITHOUT guardianExternalUserId
2729
+ // but WITH a conversationId (required by the tool_approval resolver)
2730
+ const canonicalReq = createCanonicalGuardianRequest({
2731
+ kind: 'tool_approval',
2732
+ sourceType: 'voice',
2733
+ sourceChannel: 'twilio',
2734
+ conversationId: 'conv-voice-nl-1',
2735
+ toolName: 'shell',
2736
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
2737
+ // guardianExternalUserId intentionally omitted
2738
+ });
2739
+
2740
+ // Register pending interaction so resolver can find it
2741
+ registerPendingInteraction(canonicalReq.id, 'conv-voice-nl-1', 'shell');
2742
+
2743
+ // Create canonical delivery row targeting guardian chat
2744
+ createCanonicalGuardianDelivery({
2745
+ requestId: canonicalReq.id,
2746
+ destinationChannel: 'telegram',
2747
+ destinationChatId: guardianChatId,
2748
+ });
2749
+
2750
+ // Send inbound guardian text reply "yes" from that chat
2751
+ const req = makeInboundRequest({
2752
+ sourceChannel: 'telegram',
2753
+ externalChatId: guardianChatId,
2754
+ senderExternalUserId: guardianUserId,
2755
+ content: 'yes',
2756
+ externalMessageId: `msg-nl-approve-${Date.now()}`,
2757
+ });
2758
+ const res = await handleChannelInbound(req, noopProcessMessage as any, TEST_BEARER_TOKEN);
2759
+ const body = await res.json() as Record<string, unknown>;
2760
+
2761
+ expect(body.accepted).toBe(true);
2762
+ expect(body.canonicalRouter).toBe('canonical_decision_applied');
2763
+
2764
+ // Verify the request was resolved
2765
+ const resolved = getCanonicalGuardianRequest(canonicalReq.id);
2766
+ expect(resolved).not.toBeNull();
2767
+ expect(resolved!.status).toBe('approved');
2768
+ });
2769
+
2770
+ test('inbound from different chat ID does not auto-match delivery-scoped canonical request', async () => {
2771
+ const guardianChatId = 'guardian-chat-nl-2';
2772
+ const guardianUserId = 'guardian-user-nl-2';
2773
+ const differentChatId = 'different-chat-999';
2774
+
2775
+ // Create guardian binding for the guardian user on the different chat
2776
+ createBinding({
2777
+ assistantId: 'self',
2778
+ channel: 'telegram',
2779
+ guardianExternalUserId: guardianUserId,
2780
+ guardianDeliveryChatId: differentChatId,
2781
+ });
2782
+
2783
+ // Create canonical pending_question WITHOUT guardianExternalUserId
2784
+ const canonicalReq = createCanonicalGuardianRequest({
2785
+ kind: 'tool_approval',
2786
+ sourceType: 'voice',
2787
+ sourceChannel: 'twilio',
2788
+ toolName: 'shell',
2789
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
2790
+ });
2791
+
2792
+ // Delivery targets the original guardian chat, NOT the different chat
2793
+ createCanonicalGuardianDelivery({
2794
+ requestId: canonicalReq.id,
2795
+ destinationChannel: 'telegram',
2796
+ destinationChatId: guardianChatId,
2797
+ });
2798
+
2799
+ // Send from differentChatId — delivery-scoped lookup should not match
2800
+ const req = makeInboundRequest({
2801
+ sourceChannel: 'telegram',
2802
+ externalChatId: differentChatId,
2803
+ senderExternalUserId: guardianUserId,
2804
+ content: 'approve',
2805
+ externalMessageId: `msg-nl-mismatch-${Date.now()}`,
2806
+ });
2807
+ const res = await handleChannelInbound(req, noopProcessMessage as any, TEST_BEARER_TOKEN);
2808
+ const body = await res.json() as Record<string, unknown>;
2809
+
2810
+ expect(body.accepted).toBe(true);
2811
+ // Should NOT have been consumed by canonical router since there are no
2812
+ // delivery-scoped pending requests for this chat, and identity-based
2813
+ // fallback finds no match either (no guardianExternalUserId on request)
2814
+ expect(body.canonicalRouter).toBeUndefined();
2815
+
2816
+ // Request should remain pending
2817
+ const unchanged = getCanonicalGuardianRequest(canonicalReq.id);
2818
+ expect(unchanged).not.toBeNull();
2819
+ expect(unchanged!.status).toBe('pending');
2820
+ });
2648
2821
  });
@@ -71,7 +71,7 @@ describe('channel-invite-transport', () => {
71
71
 
72
72
  describe('telegram buildShareableInvite', () => {
73
73
  test('produces a valid Telegram deep link', () => {
74
- const result = telegramInviteTransport.buildShareableInvite({
74
+ const result = telegramInviteTransport.buildShareableInvite!({
75
75
  rawToken: 'abc123_test-token',
76
76
  sourceChannel: 'telegram',
77
77
  });
@@ -81,15 +81,15 @@ describe('channel-invite-transport', () => {
81
81
  });
82
82
 
83
83
  test('deep link is deterministic for the same token', () => {
84
- const a = telegramInviteTransport.buildShareableInvite({ rawToken: 'tok1', sourceChannel: 'telegram' });
85
- const b = telegramInviteTransport.buildShareableInvite({ rawToken: 'tok1', sourceChannel: 'telegram' });
84
+ const a = telegramInviteTransport.buildShareableInvite!({ rawToken: 'tok1', sourceChannel: 'telegram' });
85
+ const b = telegramInviteTransport.buildShareableInvite!({ rawToken: 'tok1', sourceChannel: 'telegram' });
86
86
  expect(a.url).toBe(b.url);
87
87
  expect(a.displayText).toBe(b.displayText);
88
88
  });
89
89
 
90
90
  test('uses the configured bot username', () => {
91
91
  mockBotUsername = 'my_custom_bot';
92
- const result = telegramInviteTransport.buildShareableInvite({
92
+ const result = telegramInviteTransport.buildShareableInvite!({
93
93
  rawToken: 'token',
94
94
  sourceChannel: 'telegram',
95
95
  });
@@ -103,7 +103,7 @@ describe('channel-invite-transport', () => {
103
103
  delete process.env.TELEGRAM_BOT_USERNAME;
104
104
  try {
105
105
  expect(() =>
106
- telegramInviteTransport.buildShareableInvite({
106
+ telegramInviteTransport.buildShareableInvite!({
107
107
  rawToken: 'token',
108
108
  sourceChannel: 'telegram',
109
109
  }),
@@ -118,7 +118,7 @@ describe('channel-invite-transport', () => {
118
118
  const prev = process.env.TELEGRAM_BOT_USERNAME;
119
119
  process.env.TELEGRAM_BOT_USERNAME = 'env_bot';
120
120
  try {
121
- const result = telegramInviteTransport.buildShareableInvite({
121
+ const result = telegramInviteTransport.buildShareableInvite!({
122
122
  rawToken: 'token',
123
123
  sourceChannel: 'telegram',
124
124
  });
@@ -50,6 +50,25 @@ mock.module('../runtime/gateway-client.js', () => ({
50
50
  }));
51
51
 
52
52
  mock.module('../memory/conversation-store.js', () => ({
53
+ getConversationThreadType: () => 'default',
54
+ setConversationOriginChannelIfUnset: () => {},
55
+ updateConversationContextWindow: () => {},
56
+ deleteMessageById: () => {},
57
+ updateConversationTitle: () => {},
58
+ updateConversationUsage: () => {},
59
+ addMessage: () => ({ id: 'mock-msg-id' }),
60
+ getConversation: () => ({
61
+ id: 'conv-1',
62
+ contextSummary: null,
63
+ contextCompactedMessageCount: 0,
64
+ totalInputTokens: 0,
65
+ totalOutputTokens: 0,
66
+ totalEstimatedCost: 0,
67
+ title: null,
68
+ }),
69
+ provenanceFromGuardianContext: () => ({ source: 'user', guardianContext: undefined }),
70
+ getConversationOriginInterface: () => null,
71
+ getConversationOriginChannel: () => null,
53
72
  getMessages: () => conversationMessages,
54
73
  }));
55
74
 
@@ -0,0 +1,130 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
6
+ import { eq } from 'drizzle-orm';
7
+
8
+ const testDir = mkdtempSync(join(tmpdir(), 'channel-retry-sweep-test-'));
9
+
10
+ mock.module('../util/platform.js', () => ({
11
+ getDataDir: () => testDir,
12
+ isMacOS: () => process.platform === 'darwin',
13
+ isLinux: () => process.platform === 'linux',
14
+ isWindows: () => process.platform === 'win32',
15
+ getSocketPath: () => join(testDir, 'test.sock'),
16
+ getPidPath: () => join(testDir, 'test.pid'),
17
+ getDbPath: () => join(testDir, 'test.db'),
18
+ getLogPath: () => join(testDir, 'test.log'),
19
+ ensureDataDir: () => {},
20
+ }));
21
+
22
+ mock.module('../util/logger.js', () => ({
23
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
24
+ get: () => () => {},
25
+ }),
26
+ }));
27
+
28
+ import * as channelDeliveryStore from '../memory/channel-delivery-store.js';
29
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
30
+ import { channelInboundEvents, messages } from '../memory/schema.js';
31
+ import { sweepFailedEvents } from '../runtime/channel-retry-sweep.js';
32
+
33
+ initializeDb();
34
+
35
+ afterAll(() => {
36
+ resetDb();
37
+ try {
38
+ rmSync(testDir, { recursive: true });
39
+ } catch {
40
+ // Best effort cleanup
41
+ }
42
+ });
43
+
44
+ function resetTables(): void {
45
+ const db = getDb();
46
+ db.run('DELETE FROM channel_inbound_events');
47
+ db.run('DELETE FROM conversation_keys');
48
+ db.run('DELETE FROM messages');
49
+ db.run('DELETE FROM conversations');
50
+ }
51
+
52
+ function seedFailedLegacyEvent(actorRole: 'guardian' | 'non-guardian' | 'unverified_channel'): string {
53
+ const inbound = channelDeliveryStore.recordInbound('telegram', 'chat-legacy', `msg-${actorRole}`);
54
+ channelDeliveryStore.storePayload(inbound.eventId, {
55
+ content: 'retry me',
56
+ sourceChannel: 'telegram',
57
+ interface: 'telegram',
58
+ guardianCtx: {
59
+ actorRole,
60
+ sourceChannel: 'telegram',
61
+ requesterExternalUserId: 'legacy-user',
62
+ requesterChatId: 'chat-legacy',
63
+ },
64
+ });
65
+
66
+ const db = getDb();
67
+ db.update(channelInboundEvents)
68
+ .set({
69
+ processingStatus: 'failed',
70
+ processingAttempts: 1,
71
+ retryAfter: Date.now() - 1,
72
+ })
73
+ .where(eq(channelInboundEvents.id, inbound.eventId))
74
+ .run();
75
+
76
+ return inbound.eventId;
77
+ }
78
+
79
+ describe('channel-retry-sweep', () => {
80
+ beforeEach(() => {
81
+ resetTables();
82
+ });
83
+
84
+ test('replays legacy guardianCtx.actorRole with preserved trust semantics', async () => {
85
+ const cases: Array<{
86
+ actorRole: 'guardian' | 'non-guardian' | 'unverified_channel';
87
+ expectedTrustClass: 'guardian' | 'trusted_contact' | 'unknown';
88
+ expectedInteractive: boolean;
89
+ }> = [
90
+ { actorRole: 'guardian', expectedTrustClass: 'guardian', expectedInteractive: true },
91
+ { actorRole: 'non-guardian', expectedTrustClass: 'trusted_contact', expectedInteractive: false },
92
+ { actorRole: 'unverified_channel', expectedTrustClass: 'unknown', expectedInteractive: false },
93
+ ];
94
+
95
+ for (const c of cases) {
96
+ const eventId = seedFailedLegacyEvent(c.actorRole);
97
+ let capturedOptions: {
98
+ guardianContext?: { trustClass?: string };
99
+ isInteractive?: boolean;
100
+ } | undefined;
101
+
102
+ await sweepFailedEvents(
103
+ async (conversationId, _content, _attachmentIds, options) => {
104
+ capturedOptions = options as {
105
+ guardianContext?: { trustClass?: string };
106
+ isInteractive?: boolean;
107
+ };
108
+ const messageId = `message-${c.actorRole}`;
109
+ const db = getDb();
110
+ db.insert(messages).values({
111
+ id: messageId,
112
+ conversationId,
113
+ role: 'user',
114
+ content: JSON.stringify([{ type: 'text', text: 'retry me' }]),
115
+ createdAt: Date.now(),
116
+ }).run();
117
+ return { messageId };
118
+ },
119
+ undefined,
120
+ );
121
+
122
+ expect(capturedOptions?.guardianContext?.trustClass).toBe(c.expectedTrustClass);
123
+ expect(capturedOptions?.isInteractive).toBe(c.expectedInteractive);
124
+
125
+ const db = getDb();
126
+ const row = db.select().from(channelInboundEvents).where(eq(channelInboundEvents.id, eventId)).get();
127
+ expect(row?.processingStatus).toBe('processed');
128
+ }
129
+ });
130
+ });
@@ -57,6 +57,8 @@ mock.module('../providers/provider-send-message.js', () => ({
57
57
 
58
58
  mock.module('../config/loader.js', () => ({
59
59
  getConfig: () => ({
60
+ ui: {},
61
+
60
62
  apiKeys: {
61
63
  anthropic: 'test-key',
62
64
  },
@@ -23,6 +23,8 @@ mock.module('../util/logger.js', () => ({
23
23
 
24
24
  mock.module('../config/loader.js', () => ({
25
25
  getConfig: () => ({
26
+ ui: {},
27
+
26
28
  apiKeys: { anthropic: 'test-key' },
27
29
  }),
28
30
  }));
@@ -31,6 +31,8 @@ mock.module('../util/logger.js', () => ({
31
31
  // Mock config
32
32
  mock.module('../config/loader.js', () => ({
33
33
  getConfig: () => ({
34
+ ui: {},
35
+
34
36
  apiKeys: { anthropic: 'test-key' },
35
37
  }),
36
38
  }));
@@ -3,11 +3,12 @@ import { existsSync,mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
 
6
- import { afterEach,beforeEach, describe, expect, test } from 'bun:test';
6
+ import { afterAll, afterEach, beforeEach, describe, expect, test } from 'bun:test';
7
7
 
8
8
  import {
9
9
  _resetEnrichmentService,
10
10
  CommitEnrichmentService,
11
+ getEnrichmentService,
11
12
  } from '../workspace/commit-message-enrichment-service.js';
12
13
  import type { CommitContext } from '../workspace/commit-message-provider.js';
13
14
  import { _resetGitServiceRegistry,WorkspaceGitService } from '../workspace/git-service.js';
@@ -27,11 +28,18 @@ describe('CommitEnrichmentService', () => {
27
28
  });
28
29
 
29
30
  afterEach(async () => {
31
+ try { await getEnrichmentService().shutdown(); } catch { /* ignore */ }
32
+ _resetEnrichmentService();
30
33
  if (existsSync(testDir)) {
31
34
  rmSync(testDir, { recursive: true, force: true });
32
35
  }
33
36
  });
34
37
 
38
+ afterAll(async () => {
39
+ try { await getEnrichmentService().shutdown(); } catch { /* ignore */ }
40
+ _resetEnrichmentService();
41
+ });
42
+
35
43
  function makeContext(overrides?: Partial<CommitContext>): CommitContext {
36
44
  return {
37
45
  workspaceDir: testDir,
@@ -5,6 +5,8 @@ import { describe, expect, mock,test } from 'bun:test';
5
5
  // (which have no trust rules in test) don't trigger approval prompts.
6
6
  mock.module('../config/loader.js', () => ({
7
7
  getConfig: () => ({
8
+ ui: {},
9
+
8
10
  provider: 'mock-provider',
9
11
  permissions: { mode: 'legacy' },
10
12
  apiKeys: {},
@@ -47,6 +47,7 @@ mock.module('../util/platform.js', () => ({
47
47
  isLinux: () => true,
48
48
  isWindows: () => false,
49
49
  readHttpToken: () => null,
50
+ normalizeAssistantId: (id: string) => id,
50
51
  }));
51
52
 
52
53
  mock.module('../tools/executor.js', () => ({
@@ -3,6 +3,8 @@ import { beforeAll, describe, expect, mock,test } from 'bun:test';
3
3
  // Mock config before importing modules that depend on it.
4
4
  mock.module('../config/loader.js', () => ({
5
5
  getConfig: () => ({
6
+ ui: {},
7
+
6
8
  provider: 'mock-provider',
7
9
  permissions: { mode: 'legacy' },
8
10
  apiKeys: {},
@@ -79,7 +79,7 @@ describe('AssistantConfigSchema', () => {
79
79
  expect(result.thinking).toEqual({ enabled: false, budgetTokens: 10000, streamThinking: false });
80
80
  expect(result.contextWindow).toEqual({
81
81
  enabled: true,
82
- maxInputTokens: 180000,
82
+ maxInputTokens: 200000,
83
83
  targetInputTokens: 110000,
84
84
  compactThreshold: 0.8,
85
85
  preserveRecentUserTurns: 8,
@@ -1098,7 +1098,7 @@ describe('loadConfig with schema validation', () => {
1098
1098
  expect(config.thinking).toEqual({ enabled: false, budgetTokens: 10000, streamThinking: false });
1099
1099
  expect(config.contextWindow).toEqual({
1100
1100
  enabled: true,
1101
- maxInputTokens: 180000,
1101
+ maxInputTokens: 200000,
1102
1102
  targetInputTokens: 110000,
1103
1103
  compactThreshold: 0.8,
1104
1104
  preserveRecentUserTurns: 8,
@@ -1188,7 +1188,7 @@ describe('loadConfig with schema validation', () => {
1188
1188
  test('falls back for invalid contextWindow relationship', () => {
1189
1189
  writeConfig({ contextWindow: { maxInputTokens: 1000, targetInputTokens: 1000 } });
1190
1190
  const config = loadConfig();
1191
- expect(config.contextWindow.maxInputTokens).toBe(180000);
1191
+ expect(config.contextWindow.maxInputTokens).toBe(200000);
1192
1192
  expect(config.contextWindow.targetInputTokens).toBe(110000);
1193
1193
  });
1194
1194
 
@@ -1348,7 +1348,7 @@ describe('Call entrypoint gating', () => {
1348
1348
  const response = await handleStartCall(req);
1349
1349
  expect(response.status).toBe(403);
1350
1350
 
1351
- const body = await response.json() as { error: string };
1352
- expect(body.error).toContain('disabled');
1351
+ const body = await response.json() as { error: { message: string } };
1352
+ expect(body.error.message).toContain('disabled');
1353
1353
  });
1354
1354
  });
@@ -96,7 +96,9 @@ mock.module('node:fs', () => {
96
96
 
97
97
  // Mock config/loader and other dependencies that ConfigWatcher imports
98
98
  mock.module('../config/loader.js', () => ({
99
- getConfig: () => ({}),
99
+ getConfig: () => ({
100
+ ui: {},
101
+ }),
100
102
  invalidateConfigCache: () => {},
101
103
  }));
102
104
 
@@ -41,21 +41,30 @@ describe('hasNoAuthOverride', () => {
41
41
  expect(hasNoAuthOverride({ VELLUM_DAEMON_NOAUTH: 'false' })).toBe(false);
42
42
  });
43
43
 
44
- test('returns true when VELLUM_DAEMON_NOAUTH is 1', () => {
45
- expect(hasNoAuthOverride({ VELLUM_DAEMON_NOAUTH: '1' })).toBe(true);
44
+ test('returns true when VELLUM_DAEMON_NOAUTH is 1 with safety gate', () => {
45
+ expect(hasNoAuthOverride({ VELLUM_DAEMON_NOAUTH: '1', VELLUM_UNSAFE_AUTH_BYPASS: '1' })).toBe(true);
46
46
  });
47
47
 
48
- test('returns true when VELLUM_DAEMON_NOAUTH is true', () => {
49
- expect(hasNoAuthOverride({ VELLUM_DAEMON_NOAUTH: 'true' })).toBe(true);
48
+ test('returns false when VELLUM_DAEMON_NOAUTH is 1 without safety gate', () => {
49
+ expect(hasNoAuthOverride({ VELLUM_DAEMON_NOAUTH: '1' })).toBe(false);
50
+ });
51
+
52
+ test('returns true when VELLUM_DAEMON_NOAUTH is true with safety gate', () => {
53
+ expect(hasNoAuthOverride({ VELLUM_DAEMON_NOAUTH: 'true', VELLUM_UNSAFE_AUTH_BYPASS: '1' })).toBe(true);
54
+ });
55
+
56
+ test('returns false when VELLUM_DAEMON_NOAUTH is true without safety gate', () => {
57
+ expect(hasNoAuthOverride({ VELLUM_DAEMON_NOAUTH: 'true' })).toBe(false);
50
58
  });
51
59
 
52
60
  test('is independent of VELLUM_DAEMON_SOCKET', () => {
53
61
  // Socket override alone does NOT enable no-auth
54
62
  expect(hasNoAuthOverride({ VELLUM_DAEMON_SOCKET: '/tmp/custom.sock' })).toBe(false);
55
- // No-auth requires its own explicit flag
63
+ // No-auth requires its own explicit flag plus safety gate
56
64
  expect(hasNoAuthOverride({
57
65
  VELLUM_DAEMON_SOCKET: '/tmp/custom.sock',
58
66
  VELLUM_DAEMON_NOAUTH: '1',
67
+ VELLUM_UNSAFE_AUTH_BYPASS: '1',
59
68
  })).toBe(true);
60
69
  });
61
70
  });
@@ -27,7 +27,9 @@ mock.module('../util/logger.js', () => ({
27
27
  }));
28
28
 
29
29
  mock.module('../config/loader.js', () => ({
30
- getConfig: () => ({ memory: {} }),
30
+ getConfig: () => ({
31
+ ui: {},
32
+ memory: {} }),
31
33
  }));
32
34
 
33
35
  import type { Database } from 'bun:sqlite';
@@ -73,6 +73,8 @@ let mockConflictableKinds: string[] = [
73
73
 
74
74
  mock.module('../config/loader.js', () => ({
75
75
  getConfig: () => ({
76
+ ui: {},
77
+
76
78
  apiKeys: { anthropic: 'test-key' },
77
79
  memory: {
78
80
  conflicts: {
@@ -49,6 +49,16 @@ const getConversationMock = mock((id: string) => {
49
49
  });
50
50
 
51
51
  mock.module('../memory/conversation-store.js', () => ({
52
+ getConversationThreadType: () => 'default',
53
+ setConversationOriginChannelIfUnset: () => {},
54
+ updateConversationContextWindow: () => {},
55
+ deleteMessageById: () => {},
56
+ updateConversationTitle: () => {},
57
+ updateConversationUsage: () => {},
58
+ getMessages: () => [],
59
+ provenanceFromGuardianContext: () => ({ source: 'user', guardianContext: undefined }),
60
+ getConversationOriginInterface: () => null,
61
+ getConversationOriginChannel: () => null,
52
62
  createConversation: createConversationMock,
53
63
  addMessage: addMessageMock,
54
64
  getConversation: getConversationMock,
@@ -49,7 +49,7 @@ describe('handleSendMessage', () => {
49
49
  expect(body.messageId).toBe('msg-legacy-fallback');
50
50
  expect(capturedSourceChannel).toBe('telegram');
51
51
  expect(capturedOptions?.guardianContext).toEqual({
52
- actorRole: 'guardian',
52
+ trustClass: 'guardian',
53
53
  sourceChannel: 'telegram',
54
54
  });
55
55
  });