@vellumai/assistant 0.3.28 → 0.4.1

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 (201) 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 +25 -21
  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 +288 -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/response-tier.ts +6 -5
  157. package/src/daemon/session-agent-loop.ts +5 -5
  158. package/src/daemon/session-lifecycle.ts +25 -17
  159. package/src/daemon/session-memory.ts +2 -2
  160. package/src/daemon/session-process.ts +1 -20
  161. package/src/daemon/session-runtime-assembly.ts +28 -22
  162. package/src/daemon/session-tool-setup.ts +2 -2
  163. package/src/daemon/session.ts +3 -3
  164. package/src/memory/canonical-guardian-store.ts +63 -1
  165. package/src/memory/channel-guardian-store.ts +1 -0
  166. package/src/memory/conversation-crud.ts +7 -7
  167. package/src/memory/db-init.ts +4 -0
  168. package/src/memory/embedding-local.ts +257 -39
  169. package/src/memory/embedding-runtime-manager.ts +471 -0
  170. package/src/memory/guardian-bindings.ts +25 -1
  171. package/src/memory/indexer.ts +3 -3
  172. package/src/memory/ingress-invite-store.ts +45 -0
  173. package/src/memory/job-handlers/backfill.ts +16 -9
  174. package/src/memory/migrations/037-voice-invite-columns.ts +16 -0
  175. package/src/memory/migrations/index.ts +1 -0
  176. package/src/memory/qdrant-client.ts +31 -22
  177. package/src/memory/schema.ts +4 -0
  178. package/src/notifications/copy-composer.ts +15 -0
  179. package/src/runtime/access-request-helper.ts +43 -7
  180. package/src/runtime/actor-trust-resolver.ts +46 -50
  181. package/src/runtime/channel-invite-transports/voice.ts +58 -0
  182. package/src/runtime/channel-retry-sweep.ts +18 -6
  183. package/src/runtime/guardian-context-resolver.ts +38 -96
  184. package/src/runtime/guardian-reply-router.ts +31 -1
  185. package/src/runtime/ingress-service.ts +80 -3
  186. package/src/runtime/invite-redemption-service.ts +141 -2
  187. package/src/runtime/routes/channel-route-shared.ts +1 -1
  188. package/src/runtime/routes/channel-routes.ts +1 -1
  189. package/src/runtime/routes/conversation-routes.ts +166 -2
  190. package/src/runtime/routes/guardian-approval-interception.ts +17 -6
  191. package/src/runtime/routes/inbound-message-handler.ts +41 -10
  192. package/src/runtime/routes/ingress-routes.ts +52 -4
  193. package/src/runtime/routes/pairing-routes.ts +3 -0
  194. package/src/tools/guardian-control-plane-policy.ts +2 -2
  195. package/src/tools/reminder/reminder-store.ts +10 -14
  196. package/src/tools/tool-approval-handler.ts +11 -11
  197. package/src/tools/types.ts +2 -2
  198. package/src/util/logger.ts +20 -8
  199. package/src/util/platform.ts +10 -0
  200. package/src/util/voice-code.ts +29 -0
  201. package/src/daemon/guardian-invite-intent.ts +0 -124
@@ -52,6 +52,7 @@ import {
52
52
  revokeMember,
53
53
  upsertMember,
54
54
  } from '../memory/ingress-member-store.js';
55
+ import { resolveActorTrust } from '../runtime/actor-trust-resolver.js';
55
56
  import {
56
57
  createOutboundSession,
57
58
  validateAndConsumeChallenge,
@@ -143,6 +144,96 @@ describe('trusted contact verification → member activation', () => {
143
144
  expect(member!.sourceChannel).toBe('telegram');
144
145
  });
145
146
 
147
+ test('resolveActorTrust surfaces member displayName when sender displayName is missing', () => {
148
+ upsertMember({
149
+ assistantId: 'self',
150
+ sourceChannel: 'telegram',
151
+ externalUserId: 'requester-user-jeff',
152
+ externalChatId: 'requester-chat-jeff',
153
+ status: 'active',
154
+ policy: 'allow',
155
+ displayName: 'Jeff',
156
+ username: 'jeff_handle',
157
+ });
158
+
159
+ const trust = resolveActorTrust({
160
+ assistantId: 'self',
161
+ sourceChannel: 'telegram',
162
+ externalChatId: 'requester-chat-jeff',
163
+ senderExternalUserId: 'requester-user-jeff',
164
+ });
165
+
166
+ expect(trust.trustClass).toBe('trusted_contact');
167
+ expect(trust.actorMetadata.displayName).toBe('Jeff');
168
+ expect(trust.actorMetadata.senderDisplayName).toBeUndefined();
169
+ expect(trust.actorMetadata.memberDisplayName).toBe('Jeff');
170
+ expect(trust.actorMetadata.identifier).toBe('@jeff_handle');
171
+ });
172
+
173
+ test('resolveActorTrust prioritizes member displayName over sender displayName', () => {
174
+ upsertMember({
175
+ assistantId: 'self',
176
+ sourceChannel: 'telegram',
177
+ externalUserId: 'requester-user-jeff-priority',
178
+ externalChatId: 'requester-chat-jeff-priority',
179
+ status: 'active',
180
+ policy: 'allow',
181
+ displayName: 'Jeff',
182
+ username: 'jeff_handle',
183
+ });
184
+
185
+ const trust = resolveActorTrust({
186
+ assistantId: 'self',
187
+ sourceChannel: 'telegram',
188
+ externalChatId: 'requester-chat-jeff-priority',
189
+ senderExternalUserId: 'requester-user-jeff-priority',
190
+ senderUsername: 'jeffrey_telegram',
191
+ senderDisplayName: 'Jeffrey',
192
+ });
193
+
194
+ expect(trust.trustClass).toBe('trusted_contact');
195
+ expect(trust.actorMetadata.displayName).toBe('Jeff');
196
+ expect(trust.actorMetadata.senderDisplayName).toBe('Jeffrey');
197
+ expect(trust.actorMetadata.memberDisplayName).toBe('Jeff');
198
+ expect(trust.actorMetadata.username).toBe('jeff_handle');
199
+ expect(trust.actorMetadata.identifier).toBe('@jeff_handle');
200
+ });
201
+
202
+ test('resolveActorTrust falls back to sender metadata when member record matches chat but not sender (group chat)', () => {
203
+ // Simulate a group chat: member record exists for a different user who
204
+ // shares the same externalChatId (e.g., Telegram group).
205
+ upsertMember({
206
+ assistantId: 'self',
207
+ sourceChannel: 'telegram',
208
+ externalUserId: 'other-user-in-group',
209
+ externalChatId: 'shared-group-chat',
210
+ status: 'active',
211
+ policy: 'allow',
212
+ displayName: 'Other User',
213
+ username: 'other_handle',
214
+ });
215
+
216
+ // A different sender sends a message in the same group chat
217
+ const trust = resolveActorTrust({
218
+ assistantId: 'self',
219
+ sourceChannel: 'telegram',
220
+ externalChatId: 'shared-group-chat',
221
+ senderExternalUserId: 'actual-sender-in-group',
222
+ senderUsername: 'actual_sender_handle',
223
+ senderDisplayName: 'Actual Sender',
224
+ });
225
+
226
+ // The member record returned by findMember matched on chatId but belongs
227
+ // to a different user, so member metadata should NOT be used and trust
228
+ // should NOT be elevated to trusted_contact.
229
+ expect(trust.trustClass).toBe('unknown');
230
+ expect(trust.actorMetadata.displayName).toBe('Actual Sender');
231
+ expect(trust.actorMetadata.senderDisplayName).toBe('Actual Sender');
232
+ expect(trust.actorMetadata.memberDisplayName).toBeUndefined();
233
+ expect(trust.actorMetadata.username).toBe('actual_sender_handle');
234
+ expect(trust.actorMetadata.identifier).toBe('@actual_sender_handle');
235
+ });
236
+
146
237
  test('post-verify message is accepted (ACL check passes)', () => {
147
238
  // Create and verify a trusted contact
148
239
  const session = createOutboundSession({
@@ -34,6 +34,8 @@ mock.module('../util/logger.js', () => ({
34
34
 
35
35
  mock.module('../config/loader.js', () => ({
36
36
  getConfig: () => ({
37
+ ui: {},
38
+
37
39
  model: 'test',
38
40
  provider: 'test',
39
41
  apiKeys: {},
@@ -12,7 +12,9 @@ let rawConfigStore: Record<string, unknown> = {};
12
12
  let mockIngressPublicBaseUrl: string | undefined = 'https://test.example.com';
13
13
 
14
14
  mock.module('../config/loader.js', () => ({
15
- getConfig: () => ({}),
15
+ getConfig: () => ({
16
+ ui: {},
17
+ }),
16
18
  loadConfig: () => ({ ingress: { publicBaseUrl: mockIngressPublicBaseUrl } }),
17
19
  loadRawConfig: () => ({ ...rawConfigStore }),
18
20
  saveRawConfig: (cfg: Record<string, unknown>) => {
@@ -20,7 +20,9 @@ mock.module('../config/loader.js', () => ({
20
20
  loadConfig: () => ({}),
21
21
  saveConfig: () => {},
22
22
  saveRawConfig: () => {},
23
- getConfig: () => ({}),
23
+ getConfig: () => ({
24
+ ui: {},
25
+ }),
24
26
  invalidateConfigCache: () => {},
25
27
  getNestedValue: () => undefined,
26
28
  setNestedValue: () => {},
@@ -29,7 +29,9 @@ mock.module('../util/logger.js', () => ({
29
29
  }));
30
30
 
31
31
  mock.module('../config/loader.js', () => ({
32
- getConfig: () => ({ memory: {} }),
32
+ getConfig: () => ({
33
+ ui: {},
34
+ memory: {} }),
33
35
  }));
34
36
 
35
37
  await import('../tools/filesystem/view-image.js');
@@ -0,0 +1,329 @@
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
+
7
+ const testDir = mkdtempSync(join(tmpdir(), 'voice-invite-redemption-test-'));
8
+
9
+ mock.module('../util/platform.js', () => ({
10
+ getDataDir: () => testDir,
11
+ isMacOS: () => process.platform === 'darwin',
12
+ isLinux: () => process.platform === 'linux',
13
+ isWindows: () => process.platform === 'win32',
14
+ getSocketPath: () => join(testDir, 'test.sock'),
15
+ getPidPath: () => join(testDir, 'test.pid'),
16
+ getDbPath: () => join(testDir, 'test.db'),
17
+ getLogPath: () => join(testDir, 'test.log'),
18
+ ensureDataDir: () => {},
19
+ }));
20
+
21
+ mock.module('../util/logger.js', () => ({
22
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
23
+ get: () => () => {},
24
+ }),
25
+ }));
26
+
27
+ import { getSqlite, initializeDb, resetDb } from '../memory/db.js';
28
+ import { createInvite, revokeInvite } from '../memory/ingress-invite-store.js';
29
+ import { upsertMember } from '../memory/ingress-member-store.js';
30
+ import { redeemVoiceInviteCode } from '../runtime/invite-redemption-service.js';
31
+ import { generateVoiceCode, hashVoiceCode } from '../util/voice-code.js';
32
+
33
+ initializeDb();
34
+
35
+ afterAll(() => {
36
+ resetDb();
37
+ try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
38
+ });
39
+
40
+ function resetTables() {
41
+ getSqlite().run('DELETE FROM assistant_ingress_members');
42
+ getSqlite().run('DELETE FROM assistant_ingress_invites');
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // generateVoiceCode
47
+ // ---------------------------------------------------------------------------
48
+
49
+ describe('generateVoiceCode', () => {
50
+ test('generates a code with the default 6 digits', () => {
51
+ const code = generateVoiceCode();
52
+ expect(code.length).toBe(6);
53
+ expect(/^\d{6}$/.test(code)).toBe(true);
54
+ });
55
+
56
+ test('generates a code with the requested digit count', () => {
57
+ for (const digits of [4, 5, 6, 7, 8, 9, 10]) {
58
+ const code = generateVoiceCode(digits);
59
+ expect(code.length).toBe(digits);
60
+ expect(new RegExp(`^\\d{${digits}}$`).test(code)).toBe(true);
61
+ }
62
+ });
63
+
64
+ test('throws for digit count below 4', () => {
65
+ expect(() => generateVoiceCode(3)).toThrow(/between 4 and 10/);
66
+ });
67
+
68
+ test('throws for digit count above 10', () => {
69
+ expect(() => generateVoiceCode(11)).toThrow(/between 4 and 10/);
70
+ });
71
+
72
+ test('produces different codes across multiple calls (randomness)', () => {
73
+ // Generate many codes and check that we don't get the same one every time.
74
+ // With 6 digits there are 900,000 possibilities, so getting 10 identical
75
+ // codes would be astronomically unlikely.
76
+ const codes = new Set<string>();
77
+ for (let i = 0; i < 10; i++) {
78
+ codes.add(generateVoiceCode());
79
+ }
80
+ // At least 2 distinct values in 10 tries
81
+ expect(codes.size).toBeGreaterThanOrEqual(2);
82
+ });
83
+
84
+ test('generated code is within the valid numeric range', () => {
85
+ for (let i = 0; i < 20; i++) {
86
+ const code = generateVoiceCode(6);
87
+ const num = parseInt(code, 10);
88
+ // 6 digits: range [100000, 999999]
89
+ expect(num).toBeGreaterThanOrEqual(100000);
90
+ expect(num).toBeLessThanOrEqual(999999);
91
+ }
92
+ });
93
+ });
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // hashVoiceCode
97
+ // ---------------------------------------------------------------------------
98
+
99
+ describe('hashVoiceCode', () => {
100
+ test('produces a deterministic hash', () => {
101
+ const code = '123456';
102
+ const hash1 = hashVoiceCode(code);
103
+ const hash2 = hashVoiceCode(code);
104
+ expect(hash1).toBe(hash2);
105
+ });
106
+
107
+ test('produces a hex-encoded SHA-256 hash (64 chars)', () => {
108
+ const hash = hashVoiceCode('654321');
109
+ expect(hash.length).toBe(64);
110
+ expect(/^[0-9a-f]{64}$/.test(hash)).toBe(true);
111
+ });
112
+
113
+ test('different codes produce different hashes', () => {
114
+ const hash1 = hashVoiceCode('111111');
115
+ const hash2 = hashVoiceCode('222222');
116
+ expect(hash1).not.toBe(hash2);
117
+ });
118
+ });
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // redeemVoiceInviteCode
122
+ // ---------------------------------------------------------------------------
123
+
124
+ describe('redeemVoiceInviteCode', () => {
125
+ beforeEach(resetTables);
126
+
127
+ /**
128
+ * Helper: create a voice invite with a known code and return the
129
+ * invite record plus the plaintext code.
130
+ */
131
+ function createVoiceInvite(opts: {
132
+ callerPhone?: string;
133
+ maxUses?: number;
134
+ expiresInMs?: number;
135
+ voiceCodeDigits?: number;
136
+ assistantId?: string;
137
+ } = {}) {
138
+ const digits = opts.voiceCodeDigits ?? 6;
139
+ const code = generateVoiceCode(digits);
140
+ const codeHash = hashVoiceCode(code);
141
+
142
+ const { invite } = createInvite({
143
+ assistantId: opts.assistantId ?? 'self',
144
+ sourceChannel: 'voice',
145
+ maxUses: opts.maxUses ?? 1,
146
+ expiresInMs: opts.expiresInMs,
147
+ expectedExternalUserId: opts.callerPhone ?? '+15551234567',
148
+ voiceCodeHash: codeHash,
149
+ voiceCodeDigits: digits,
150
+ });
151
+
152
+ return { invite, code };
153
+ }
154
+
155
+ test('happy path: correct caller + correct code redeems successfully', () => {
156
+ const phone = '+15551234567';
157
+ const { code } = createVoiceInvite({ callerPhone: phone });
158
+
159
+ const result = redeemVoiceInviteCode({
160
+ callerExternalUserId: phone,
161
+ sourceChannel: 'voice',
162
+ code,
163
+ });
164
+
165
+ expect(result.ok).toBe(true);
166
+ expect(result).toMatchObject({
167
+ ok: true,
168
+ type: 'redeemed',
169
+ memberId: expect.any(String),
170
+ inviteId: expect.any(String),
171
+ });
172
+ });
173
+
174
+ test('wrong caller identity fails with generic error', () => {
175
+ const { code } = createVoiceInvite({ callerPhone: '+15551234567' });
176
+
177
+ const result = redeemVoiceInviteCode({
178
+ callerExternalUserId: '+19999999999',
179
+ sourceChannel: 'voice',
180
+ code,
181
+ });
182
+
183
+ expect(result).toEqual({ ok: false, reason: 'invalid_or_expired' });
184
+ });
185
+
186
+ test('wrong code fails with generic error', () => {
187
+ createVoiceInvite({ callerPhone: '+15551234567' });
188
+
189
+ const result = redeemVoiceInviteCode({
190
+ callerExternalUserId: '+15551234567',
191
+ sourceChannel: 'voice',
192
+ code: '000000',
193
+ });
194
+
195
+ expect(result).toEqual({ ok: false, reason: 'invalid_or_expired' });
196
+ });
197
+
198
+ test('expired invite fails', () => {
199
+ const phone = '+15551234567';
200
+ const { code } = createVoiceInvite({ callerPhone: phone, expiresInMs: -1 });
201
+
202
+ const result = redeemVoiceInviteCode({
203
+ callerExternalUserId: phone,
204
+ sourceChannel: 'voice',
205
+ code,
206
+ });
207
+
208
+ expect(result).toEqual({ ok: false, reason: 'invalid_or_expired' });
209
+ });
210
+
211
+ test('max uses exhausted fails', () => {
212
+ const phone = '+15551234567';
213
+ const { code } = createVoiceInvite({ callerPhone: phone, maxUses: 1 });
214
+
215
+ // First redemption succeeds
216
+ const first = redeemVoiceInviteCode({
217
+ callerExternalUserId: phone,
218
+ sourceChannel: 'voice',
219
+ code,
220
+ });
221
+ expect(first.ok).toBe(true);
222
+
223
+ // Second redemption fails — max uses exhausted
224
+ const second = redeemVoiceInviteCode({
225
+ callerExternalUserId: phone,
226
+ sourceChannel: 'voice',
227
+ code,
228
+ });
229
+ expect(second).toEqual({ ok: false, reason: 'invalid_or_expired' });
230
+ });
231
+
232
+ test('revoked invite fails', () => {
233
+ const phone = '+15551234567';
234
+ const { invite, code } = createVoiceInvite({ callerPhone: phone });
235
+
236
+ revokeInvite(invite.id);
237
+
238
+ const result = redeemVoiceInviteCode({
239
+ callerExternalUserId: phone,
240
+ sourceChannel: 'voice',
241
+ code,
242
+ });
243
+
244
+ expect(result).toEqual({ ok: false, reason: 'invalid_or_expired' });
245
+ });
246
+
247
+ test('voice-only invite cannot be redeemed if sourceChannel on invite is not voice', () => {
248
+ // Create a non-voice invite with voice code metadata to simulate a
249
+ // hypothetical misconfiguration. The redemption service filters by
250
+ // sourceChannel='voice', so non-voice invites are invisible.
251
+ const code = generateVoiceCode(6);
252
+ const codeHash = hashVoiceCode(code);
253
+
254
+ createInvite({
255
+ sourceChannel: 'telegram',
256
+ maxUses: 1,
257
+ expectedExternalUserId: '+15551234567',
258
+ voiceCodeHash: codeHash,
259
+ voiceCodeDigits: 6,
260
+ });
261
+
262
+ const result = redeemVoiceInviteCode({
263
+ callerExternalUserId: '+15551234567',
264
+ sourceChannel: 'voice',
265
+ code,
266
+ });
267
+
268
+ // findActiveVoiceInvites filters by sourceChannel='voice', so the
269
+ // telegram invite won't be found.
270
+ expect(result).toEqual({ ok: false, reason: 'invalid_or_expired' });
271
+ });
272
+
273
+ test('already-member caller gets already_member outcome', () => {
274
+ const phone = '+15551234567';
275
+ const { code } = createVoiceInvite({ callerPhone: phone });
276
+
277
+ // Pre-create an active member for this phone on voice channel
278
+ upsertMember({
279
+ sourceChannel: 'voice',
280
+ externalUserId: phone,
281
+ status: 'active',
282
+ policy: 'allow',
283
+ });
284
+
285
+ const result = redeemVoiceInviteCode({
286
+ callerExternalUserId: phone,
287
+ sourceChannel: 'voice',
288
+ code,
289
+ });
290
+
291
+ expect(result.ok).toBe(true);
292
+ expect(result).toMatchObject({
293
+ ok: true,
294
+ type: 'already_member',
295
+ memberId: expect.any(String),
296
+ });
297
+ });
298
+
299
+ test('blocked member gets generic failure to avoid leaking membership status', () => {
300
+ const phone = '+15551234567';
301
+ const { code } = createVoiceInvite({ callerPhone: phone });
302
+
303
+ upsertMember({
304
+ sourceChannel: 'voice',
305
+ externalUserId: phone,
306
+ status: 'blocked',
307
+ policy: 'deny',
308
+ });
309
+
310
+ const result = redeemVoiceInviteCode({
311
+ callerExternalUserId: phone,
312
+ sourceChannel: 'voice',
313
+ code,
314
+ });
315
+
316
+ expect(result).toEqual({ ok: false, reason: 'invalid_or_expired' });
317
+ });
318
+
319
+ test('empty callerExternalUserId fails', () => {
320
+ const result = redeemVoiceInviteCode({
321
+ callerExternalUserId: '',
322
+ sourceChannel: 'voice',
323
+ code: '123456',
324
+ });
325
+
326
+ expect(result).toEqual({ ok: false, reason: 'invalid_or_expired' });
327
+ });
328
+
329
+ });
@@ -53,6 +53,8 @@ mock.module('../util/logger.js', () => ({
53
53
 
54
54
  mock.module('../config/loader.js', () => ({
55
55
  getConfig: () => ({
56
+ ui: {},
57
+
56
58
  provider: 'anthropic',
57
59
  providerOrder: ['anthropic'],
58
60
  apiKeys: { anthropic: 'test-key' },
@@ -266,7 +268,7 @@ describe('voice bridge confirmation handling (grant consumption via primitive)',
266
268
 
267
269
  const guardianContext: GuardianRuntimeContext = {
268
270
  sourceChannel: 'voice',
269
- actorRole: 'non-guardian',
271
+ trustClass: 'trusted_contact',
270
272
  requesterExternalUserId: 'caller-123',
271
273
  };
272
274
 
@@ -307,7 +309,7 @@ describe('voice bridge confirmation handling (grant consumption via primitive)',
307
309
 
308
310
  const guardianContext: GuardianRuntimeContext = {
309
311
  sourceChannel: 'voice',
310
- actorRole: 'non-guardian',
312
+ trustClass: 'trusted_contact',
311
313
  requesterExternalUserId: 'caller-123',
312
314
  };
313
315
 
@@ -343,7 +345,7 @@ describe('voice bridge confirmation handling (grant consumption via primitive)',
343
345
 
344
346
  const guardianContext: GuardianRuntimeContext = {
345
347
  sourceChannel: 'voice',
346
- actorRole: 'non-guardian',
348
+ trustClass: 'trusted_contact',
347
349
  };
348
350
 
349
351
  await startVoiceTurn({
@@ -373,7 +375,7 @@ describe('voice bridge confirmation handling (grant consumption via primitive)',
373
375
 
374
376
  const guardianContext: GuardianRuntimeContext = {
375
377
  sourceChannel: 'voice',
376
- actorRole: 'guardian',
378
+ trustClass: 'guardian',
377
379
  };
378
380
 
379
381
  await startVoiceTurn({
@@ -407,7 +409,7 @@ describe('voice bridge confirmation handling (grant consumption via primitive)',
407
409
 
408
410
  const guardianContext: GuardianRuntimeContext = {
409
411
  sourceChannel: 'voice',
410
- actorRole: 'non-guardian',
412
+ trustClass: 'trusted_contact',
411
413
  requesterExternalUserId: 'caller-123',
412
414
  };
413
415
 
@@ -304,7 +304,7 @@ describe('voice-session-bridge', () => {
304
304
  isInbound: true,
305
305
  guardianContext: {
306
306
  sourceChannel: 'voice',
307
- actorRole: 'non-guardian',
307
+ trustClass: 'trusted_contact',
308
308
  guardianExternalUserId: '+15550009999',
309
309
  guardianChatId: '+15550009999',
310
310
  requesterExternalUserId: '+15550002222',
@@ -340,7 +340,7 @@ describe('voice-session-bridge', () => {
340
340
  isInbound: true,
341
341
  guardianContext: {
342
342
  sourceChannel: 'voice',
343
- actorRole: 'unverified_channel',
343
+ trustClass: 'unknown',
344
344
  denialReason: 'no_binding',
345
345
  },
346
346
  onTextDelta: () => {},
@@ -374,7 +374,7 @@ describe('voice-session-bridge', () => {
374
374
  isInbound: true,
375
375
  guardianContext: {
376
376
  sourceChannel: 'voice',
377
- actorRole: 'guardian',
377
+ trustClass: 'guardian',
378
378
  guardianExternalUserId: '+15550001111',
379
379
  guardianChatId: '+15550001111',
380
380
  },
@@ -407,7 +407,7 @@ describe('voice-session-bridge', () => {
407
407
 
408
408
  const guardianCtx = {
409
409
  sourceChannel: 'voice' as const,
410
- actorRole: 'guardian' as const,
410
+ trustClass: 'guardian' as const,
411
411
  guardianExternalUserId: '+15550001111',
412
412
  guardianChatId: '+15550001111',
413
413
  };
@@ -450,7 +450,7 @@ describe('voice-session-bridge', () => {
450
450
  isInbound: true,
451
451
  guardianContext: {
452
452
  sourceChannel: 'voice',
453
- actorRole: 'non-guardian',
453
+ trustClass: 'trusted_contact',
454
454
  },
455
455
  onTextDelta: () => {},
456
456
  onComplete: () => {},
@@ -499,7 +499,7 @@ describe('voice-session-bridge', () => {
499
499
  isInbound: true,
500
500
  guardianContext: {
501
501
  sourceChannel: 'voice',
502
- actorRole: 'non-guardian',
502
+ trustClass: 'trusted_contact',
503
503
  },
504
504
  onTextDelta: () => {},
505
505
  onComplete: () => {},
@@ -574,7 +574,7 @@ describe('voice-session-bridge', () => {
574
574
  isInbound: true,
575
575
  guardianContext: {
576
576
  sourceChannel: 'voice',
577
- actorRole: 'non-guardian',
577
+ trustClass: 'trusted_contact',
578
578
  guardianExternalUserId: '+15550009999',
579
579
  guardianChatId: '+15550009999',
580
580
  requesterExternalUserId: '+15550002222',
@@ -644,7 +644,7 @@ describe('voice-session-bridge', () => {
644
644
  isInbound: true,
645
645
  guardianContext: {
646
646
  sourceChannel: 'voice',
647
- actorRole: 'unverified_channel',
647
+ trustClass: 'unknown',
648
648
  denialReason: 'no_binding',
649
649
  },
650
650
  onTextDelta: () => {},
@@ -768,7 +768,7 @@ describe('voice-session-bridge', () => {
768
768
  isInbound: true,
769
769
  guardianContext: {
770
770
  sourceChannel: 'voice',
771
- actorRole: 'guardian',
771
+ trustClass: 'guardian',
772
772
  guardianExternalUserId: '+15550001111',
773
773
  guardianChatId: '+15550001111',
774
774
  },
@@ -835,7 +835,7 @@ describe('voice-session-bridge', () => {
835
835
  isInbound: true,
836
836
  guardianContext: {
837
837
  sourceChannel: 'voice',
838
- actorRole: 'guardian',
838
+ trustClass: 'guardian',
839
839
  guardianExternalUserId: '+15550001111',
840
840
  guardianChatId: '+15550001111',
841
841
  },
@@ -29,7 +29,9 @@ mock.module('../util/logger.js', () => ({
29
29
  }));
30
30
 
31
31
  mock.module('../config/loader.js', () => ({
32
- getConfig: () => ({ memory: {} }),
32
+ getConfig: () => ({
33
+ ui: {},
34
+ memory: {} }),
33
35
  }));
34
36
 
35
37
  mock.module('./indexer.js', () => ({
@@ -10,8 +10,12 @@ import { existsSync,mkdirSync, rmSync, writeFileSync } from 'node:fs';
10
10
  import { tmpdir } from 'node:os';
11
11
  import { join } from 'node:path';
12
12
 
13
- import { afterEach,beforeEach, describe, expect, test } from 'bun:test';
13
+ import { afterAll, afterEach, beforeEach, describe, expect, test } from 'bun:test';
14
14
 
15
+ import {
16
+ _resetEnrichmentService,
17
+ getEnrichmentService,
18
+ } from '../workspace/commit-message-enrichment-service.js';
15
19
  import {
16
20
  _resetGitServiceRegistry,
17
21
  getWorkspaceGitService,
@@ -36,12 +40,19 @@ describe('Workspace git lifecycle (integration)', () => {
36
40
  _resetHeartbeatState();
37
41
  });
38
42
 
39
- afterEach(() => {
43
+ afterEach(async () => {
44
+ try { await getEnrichmentService().shutdown(); } catch { /* ignore */ }
45
+ _resetEnrichmentService();
40
46
  if (existsSync(testDir)) {
41
47
  rmSync(testDir, { recursive: true, force: true });
42
48
  }
43
49
  });
44
50
 
51
+ afterAll(async () => {
52
+ try { await getEnrichmentService().shutdown(); } catch { /* ignore */ }
53
+ _resetEnrichmentService();
54
+ });
55
+
45
56
  // Build a clean git env: strip all GIT_* env vars that CI runners
46
57
  // inject, then set GIT_CEILING_DIRECTORIES to isolate test repos.
47
58
  function gitEnv(cwd: string): Record<string, string> {