@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
@@ -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> {
@@ -12,13 +12,11 @@ import { getGatewayInternalBaseUrl } from '../config/env.js';
12
12
  import type { ServerMessage } from '../daemon/ipc-contract.js';
13
13
  import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
14
14
  import {
15
- backfillSupersessionMetadata,
16
- expireGuardianActionRequest,
17
- getByPendingQuestionId,
18
- getDeliveriesByRequestId,
19
- getPendingRequestByCallSessionId,
20
- markTimedOutWithReason,
21
- } from '../memory/guardian-action-store.js';
15
+ expireCanonicalGuardianRequest,
16
+ getCanonicalRequestByPendingQuestionId,
17
+ getPendingCanonicalRequestByCallSessionId,
18
+ listCanonicalGuardianDeliveries,
19
+ } from '../memory/canonical-guardian-store.js';
22
20
  import { revokeScopedApprovalGrantsForContext } from '../memory/scoped-approval-grants.js';
23
21
  import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
24
22
  import { getLogger } from '../util/logger.js';
@@ -709,8 +707,7 @@ export class CallController {
709
707
  effectiveToolMeta && this.pendingConsultation.toolApprovalMeta
710
708
  ? effectiveToolMeta.toolName === this.pendingConsultation.toolApprovalMeta.toolName
711
709
  && effectiveToolMeta.inputDigest === this.pendingConsultation.toolApprovalMeta.inputDigest
712
- : !effectiveToolMeta && !this.pendingConsultation.toolApprovalMeta
713
- && questionText === this.pendingConsultation.questionText;
710
+ : !effectiveToolMeta && !this.pendingConsultation.toolApprovalMeta;
714
711
 
715
712
  if (isSameToolAction) {
716
713
  // Same tool/action — coalesce. Keep the existing consultation
@@ -728,11 +725,11 @@ export class CallController {
728
725
  // Expire the previous consultation's storage records so stale
729
726
  // guardian answers cannot match the old request.
730
727
  expirePendingQuestions(this.callSessionId);
731
- const previousRequest = getPendingRequestByCallSessionId(this.callSessionId);
728
+ const previousRequest = getPendingCanonicalRequestByCallSessionId(this.callSessionId);
732
729
  if (previousRequest) {
733
730
  // Immediately expire with 'superseded' reason to prevent
734
731
  // stale answers from resolving the old request.
735
- expireGuardianActionRequest(previousRequest.id, 'superseded');
732
+ expireCanonicalGuardianRequest(previousRequest.id);
736
733
  log.info(
737
734
  { callSessionId: this.callSessionId, requestId: previousRequest.id },
738
735
  'Superseded guardian action request (materially different intent)',
@@ -769,9 +766,9 @@ export class CallController {
769
766
  // a completed call with a dangling pendingQuestion, and guardian
770
767
  // replies are cleanly rejected instead of hitting answerCall failures.
771
768
  expirePendingQuestions(this.callSessionId);
772
- const previousRequest = getPendingRequestByCallSessionId(this.callSessionId);
769
+ const previousRequest = getPendingCanonicalRequestByCallSessionId(this.callSessionId);
773
770
  if (previousRequest) {
774
- expireGuardianActionRequest(previousRequest.id, 'cancelled');
771
+ expireCanonicalGuardianRequest(previousRequest.id);
775
772
  }
776
773
 
777
774
  this.pendingConsultation = null;
@@ -857,7 +854,7 @@ export class CallController {
857
854
  }
858
855
 
859
856
  private isCallerGuardian(): boolean {
860
- return this.guardianContext?.actorRole === 'guardian';
857
+ return this.guardianContext?.trustClass === 'guardian';
861
858
  }
862
859
 
863
860
  /**
@@ -896,11 +893,16 @@ export class CallController {
896
893
  inputDigest: effectiveToolMeta?.inputDigest,
897
894
  }).then(() => {
898
895
  // Backfill supersession chain: now that the new request exists in
899
- // the store, update the old request's superseded_by_request_id.
896
+ // the store, link the old request to the new one.
900
897
  if (supersededRequestId) {
901
- const newRequest = getByPendingQuestionId(stablePendingQuestionId);
898
+ const newRequest = getCanonicalRequestByPendingQuestionId(stablePendingQuestionId);
902
899
  if (newRequest) {
903
- backfillSupersessionMetadata(supersededRequestId, newRequest.id);
900
+ // Canonical store does not track supersession metadata;
901
+ // the old request was already expired above.
902
+ log.info(
903
+ { callSessionId: this.callSessionId, oldRequestId: supersededRequestId, newRequestId: newRequest.id },
904
+ 'Supersession chain: new canonical request created',
905
+ );
904
906
  }
905
907
  }
906
908
  });
@@ -918,17 +920,18 @@ export class CallController {
918
920
  // send expiry notices to guardian destinations. Deliveries
919
921
  // must be captured before markTimedOutWithReason changes
920
922
  // their status.
921
- const pendingActionRequest = getPendingRequestByCallSessionId(this.callSessionId);
923
+ const pendingActionRequest = getPendingCanonicalRequestByCallSessionId(this.callSessionId);
922
924
  if (pendingActionRequest) {
923
- const deliveries = getDeliveriesByRequestId(pendingActionRequest.id);
924
- markTimedOutWithReason(pendingActionRequest.id, 'call_timeout');
925
+ const canonicalDeliveries = listCanonicalGuardianDeliveries(pendingActionRequest.id);
926
+ // Expire the canonical request and its deliveries
927
+ expireCanonicalGuardianRequest(pendingActionRequest.id);
925
928
  log.info(
926
929
  { callSessionId: this.callSessionId, requestId: pendingActionRequest.id },
927
- 'Marked guardian action request as timed out',
930
+ 'Marked canonical guardian request as timed out',
928
931
  );
929
932
  void sendGuardianExpiryNotices(
930
- deliveries,
931
- pendingActionRequest.assistantId,
933
+ canonicalDeliveries,
934
+ this.assistantId,
932
935
  getGatewayInternalBaseUrl(),
933
936
  readHttpToken() ?? undefined,
934
937
  ).catch((err) => {
@@ -10,7 +10,6 @@
10
10
  */
11
11
 
12
12
  import { addMessage } from '../memory/conversation-store.js';
13
- import type { GuardianActionDelivery } from '../memory/guardian-action-store.js';
14
13
  import {
15
14
  expireGuardianActionRequest,
16
15
  getDeliveriesByRequestId,
@@ -37,8 +36,17 @@ let sweepInProgress = false;
37
36
  * Deliveries must be captured *before* their status is changed to 'expired'
38
37
  * so the sent/pending filter still matches.
39
38
  */
39
+ /** Minimal delivery shape used by the expiry notice sender. */
40
+ export interface ExpiryDeliveryInfo {
41
+ id: string;
42
+ status: string;
43
+ destinationChannel: string;
44
+ destinationConversationId: string | null;
45
+ destinationChatId: string | null;
46
+ }
47
+
40
48
  export async function sendGuardianExpiryNotices(
41
- deliveries: GuardianActionDelivery[],
49
+ deliveries: ExpiryDeliveryInfo[],
42
50
  assistantId: string,
43
51
  gatewayBaseUrl: string,
44
52
  bearerToken?: string,