@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
@@ -470,7 +470,7 @@ describe('credential_store tool — oauth2_connect error paths', () => {
470
470
  client_id: 'test-client-id',
471
471
  }, { ..._ctx, isInteractive: false });
472
472
  expect(result.isError).toBe(true);
473
- expect(result.content).toContain('interactive client session');
473
+ expect(result.content).toContain('non-interactive session');
474
474
  });
475
475
 
476
476
  test('resolves gmail alias to integration:gmail', async () => {
@@ -672,7 +672,7 @@ describe('credential_store tool — tool definition', () => {
672
672
  expect(schema.required).toContain('action');
673
673
  const props = schema.properties as Record<string, Record<string, unknown>>;
674
674
  expect(props.action.enum).toEqual(
675
- ['store', 'list', 'delete', 'prompt', 'oauth2_connect'],
675
+ ['store', 'list', 'delete', 'prompt', 'oauth2_connect', 'describe'],
676
676
  );
677
677
  });
678
678
 
@@ -406,18 +406,19 @@ describe('credential_store tool', () => {
406
406
  expect(entries[0].service).toBe('svc-b');
407
407
  });
408
408
 
409
- test('returns error when secure storage is corrupt/unreadable', async () => {
409
+ test('recovers from corrupt secure storage by resetting and returning empty list', async () => {
410
410
  // Store a credential so metadata exists
411
411
  await credentialStoreTool.execute({
412
412
  action: 'store', service: 'svc-x', field: 'key', value: 'val-x',
413
413
  }, _ctx);
414
414
 
415
- // Corrupt the encrypted store file so listKeys() throws
415
+ // Corrupt the encrypted store file the store auto-recovers by
416
+ // backing up the corrupt file and creating a fresh store
416
417
  writeFileSync(STORE_PATH, 'not-valid-json!!!', 'utf-8');
417
418
 
418
419
  const result = await credentialStoreTool.execute({ action: 'list' }, _ctx);
419
- expect(result.isError).toBe(true);
420
- expect(result.content).toContain('failed to read secure storage');
420
+ // Store auto-recovers: list succeeds but the corrupted credentials are lost
421
+ expect(result.isError).toBe(false);
421
422
  });
422
423
  });
423
424
 
@@ -94,6 +94,15 @@ const conversation = {
94
94
  };
95
95
 
96
96
  mock.module('../memory/conversation-store.js', () => ({
97
+ setConversationOriginChannelIfUnset: () => {},
98
+ updateConversationContextWindow: () => {},
99
+ deleteMessageById: () => {},
100
+ updateConversationTitle: () => {},
101
+ updateConversationUsage: () => {},
102
+ addMessage: () => ({ id: 'mock-msg-id' }),
103
+ provenanceFromGuardianContext: () => ({ source: 'user', guardianContext: undefined }),
104
+ getConversationOriginInterface: () => null,
105
+ getConversationOriginChannel: () => null,
97
106
  getLatestConversation: () => conversation,
98
107
  createConversation: () => conversation,
99
108
  getConversation: (id: string) => (id === conversation.id ? conversation : null),
@@ -203,6 +203,8 @@ mock.module('../providers/ratelimit.js', () => ({
203
203
 
204
204
  mock.module('../config/loader.js', () => ({
205
205
  getConfig: () => ({
206
+ ui: {},
207
+
206
208
  provider: 'mock-provider',
207
209
  providerOrder: ['mock-provider'],
208
210
  maxTokens: 4096,
@@ -243,7 +245,31 @@ mock.module('../memory/external-conversation-store.js', () => ({
243
245
  getBindingsForConversations: () => new Map(),
244
246
  }));
245
247
 
248
+ mock.module('../memory/conversation-attention-store.js', () => ({
249
+ getAttentionStateByConversationIds: () => new Map(),
250
+ recordAttentionSignal: () => {},
251
+ recordConversationSeenSignal: () => {},
252
+ }));
253
+
254
+ mock.module('../memory/canonical-guardian-store.js', () => ({
255
+ generateCanonicalRequestCode: () => 'mock-code-0000',
256
+ createCanonicalGuardianRequest: () => ({ requestCode: 'mock-code-0000', status: 'pending' }),
257
+ submitCanonicalRequest: () => ({ requestCode: 'mock-code-0000', status: 'pending' }),
258
+ getCanonicalRequest: () => null,
259
+ resolveCanonicalRequest: () => false,
260
+ listPendingCanonicalRequests: () => [],
261
+ }));
262
+
246
263
  mock.module('../memory/conversation-store.js', () => ({
264
+ setConversationOriginChannelIfUnset: () => {},
265
+ updateConversationContextWindow: () => {},
266
+ deleteMessageById: () => {},
267
+ updateConversationTitle: () => {},
268
+ updateConversationUsage: () => {},
269
+ addMessage: () => ({ id: 'mock-msg-id' }),
270
+ provenanceFromGuardianContext: () => ({ source: 'user', guardianContext: undefined }),
271
+ getConversationOriginInterface: () => null,
272
+ getConversationOriginChannel: () => null,
247
273
  getLatestConversation: () => conversation,
248
274
  createConversation: (titleOrOpts?: string | { title?: string; threadType?: string }) => {
249
275
  lastCreateConversationArgs = titleOrOpts;
@@ -266,6 +292,7 @@ mock.module('../memory/conversation-store.js', () => ({
266
292
  getMessages: () => [],
267
293
  listConversations: () => [conversation],
268
294
  countConversations: () => 1,
295
+ getDisplayMetaForConversations: () => new Map(),
269
296
  }));
270
297
 
271
298
  mock.module('../daemon/session.js', () => ({
@@ -27,6 +27,8 @@ let mockVoiceConfig = {
27
27
 
28
28
  mock.module('../config/loader.js', () => ({
29
29
  getConfig: () => ({
30
+ ui: {},
31
+
30
32
  calls: { voice: mockVoiceConfig },
31
33
  }),
32
34
  }));
@@ -253,24 +253,29 @@ describe('encrypted-store', () => {
253
253
  expect(getKey('test')).toBe('value');
254
254
  });
255
255
 
256
- test('setKey refuses to overwrite a corrupt store file', () => {
256
+ test('setKey recovers from a corrupt store file by backing up and creating fresh store', () => {
257
257
  // Write a valid store first
258
258
  setKey('existing', 'old-secret');
259
259
  // Corrupt the store
260
260
  writeFileSync(STORE_PATH, 'corrupted data');
261
- // setKey should fail rather than overwrite with new salt
261
+ // setKey should recover by backing up corrupt file and creating fresh store
262
262
  const result = setKey('new-key', 'new-value');
263
- expect(result).toBe(false);
263
+ expect(result).toBe(true);
264
+ // Old key is lost but new key works
265
+ expect(getKey('new-key')).toBe('new-value');
266
+ expect(getKey('existing')).toBeUndefined();
264
267
  });
265
268
 
266
- test('setKey refuses to overwrite a store with invalid version', () => {
269
+ test('setKey recovers from a store with invalid version', () => {
267
270
  writeFileSync(STORE_PATH, JSON.stringify({
268
271
  version: 99,
269
272
  salt: 'abc',
270
273
  entries: {},
271
274
  }));
275
+ // setKey should recover by backing up invalid store and creating fresh store
272
276
  const result = setKey('test', 'value');
273
- expect(result).toBe(false);
277
+ expect(result).toBe(true);
278
+ expect(getKey('test')).toBe('value');
274
279
  });
275
280
 
276
281
  test('writeStore enforces 0600 permissions on existing files', () => {
@@ -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';
@@ -243,9 +243,9 @@ describe('gateway-only ingress enforcement', () => {
243
243
  body: makeFormBody({ CallSid: 'CA123', AccountSid: 'AC_test' }),
244
244
  });
245
245
  expect(res.status).toBe(410);
246
- const body = await res.json() as { error: string; code: string };
247
- expect(body.code).toBe('GATEWAY_ONLY');
248
- expect(body.error).toContain('Direct webhook access disabled');
246
+ const body = await res.json() as { error: { code: string; message: string } };
247
+ expect(body.error.code).toBe('GONE');
248
+ expect(body.error.message).toContain('Direct webhook access disabled');
249
249
  });
250
250
 
251
251
  test('POST /webhooks/twilio/status returns 410', async () => {
@@ -255,8 +255,8 @@ describe('gateway-only ingress enforcement', () => {
255
255
  body: makeFormBody({ CallSid: 'CA123', CallStatus: 'completed' }),
256
256
  });
257
257
  expect(res.status).toBe(410);
258
- const body = await res.json() as { error: string; code: string };
259
- expect(body.code).toBe('GATEWAY_ONLY');
258
+ const body = await res.json() as { error: { code: string; message: string } };
259
+ expect(body.error.code).toBe('GONE');
260
260
  });
261
261
 
262
262
  test('POST /webhooks/twilio/connect-action returns 410', async () => {
@@ -266,8 +266,8 @@ describe('gateway-only ingress enforcement', () => {
266
266
  body: makeFormBody({ CallSid: 'CA123' }),
267
267
  });
268
268
  expect(res.status).toBe(410);
269
- const body = await res.json() as { error: string; code: string };
270
- expect(body.code).toBe('GATEWAY_ONLY');
269
+ const body = await res.json() as { error: { code: string; message: string } };
270
+ expect(body.error.code).toBe('GONE');
271
271
  });
272
272
 
273
273
  test('POST /v1/calls/twilio/voice-webhook returns 410', async () => {
@@ -277,8 +277,8 @@ describe('gateway-only ingress enforcement', () => {
277
277
  body: makeFormBody({ CallSid: 'CA123' }),
278
278
  });
279
279
  expect(res.status).toBe(410);
280
- const body = await res.json() as { error: string; code: string };
281
- expect(body.code).toBe('GATEWAY_ONLY');
280
+ const body = await res.json() as { error: { code: string; message: string } };
281
+ expect(body.error.code).toBe('GONE');
282
282
  });
283
283
 
284
284
  test('POST /v1/calls/twilio/status returns 410', async () => {
@@ -288,8 +288,8 @@ describe('gateway-only ingress enforcement', () => {
288
288
  body: makeFormBody({ CallSid: 'CA123', CallStatus: 'completed' }),
289
289
  });
290
290
  expect(res.status).toBe(410);
291
- const body = await res.json() as { error: string; code: string };
292
- expect(body.code).toBe('GATEWAY_ONLY');
291
+ const body = await res.json() as { error: { code: string; message: string } };
292
+ expect(body.error.code).toBe('GONE');
293
293
  });
294
294
  });
295
295
 
@@ -304,9 +304,9 @@ describe('gateway-only ingress enforcement', () => {
304
304
  body: makeFormBody({ Body: 'hello', From: '+15551234567', To: '+15559876543', MessageSid: 'SM123' }),
305
305
  });
306
306
  expect(res.status).toBe(410);
307
- const body = await res.json() as { error: string; code: string };
308
- expect(body.code).toBe('GATEWAY_ONLY');
309
- expect(body.error).toContain('Direct webhook access disabled');
307
+ const body = await res.json() as { error: { code: string; message: string } };
308
+ expect(body.error.code).toBe('GONE');
309
+ expect(body.error.message).toContain('Direct webhook access disabled');
310
310
  });
311
311
 
312
312
  test('POST /v1/calls/twilio/sms returns 410 (legacy path also blocked)', async () => {
@@ -316,8 +316,8 @@ describe('gateway-only ingress enforcement', () => {
316
316
  body: makeFormBody({ Body: 'hello', From: '+15551234567', MessageSid: 'SM456' }),
317
317
  });
318
318
  expect(res.status).toBe(410);
319
- const body = await res.json() as { error: string; code: string };
320
- expect(body.code).toBe('GATEWAY_ONLY');
319
+ const body = await res.json() as { error: { code: string; message: string } };
320
+ expect(body.error.code).toBe('GONE');
321
321
  });
322
322
 
323
323
  test('POST /webhooks/twilio/sms with valid auth still returns 410 (auth does not bypass gateway-only)', async () => {
@@ -331,8 +331,8 @@ describe('gateway-only ingress enforcement', () => {
331
331
  });
332
332
  // The gateway-only guard runs before auth for Twilio webhook paths
333
333
  expect(res.status).toBe(410);
334
- const body = await res.json() as { error: string; code: string };
335
- expect(body.code).toBe('GATEWAY_ONLY');
334
+ const body = await res.json() as { error: { code: string; message: string } };
335
+ expect(body.error.code).toBe('GONE');
336
336
  });
337
337
  });
338
338
 
@@ -407,9 +407,9 @@ describe('gateway-only ingress enforcement', () => {
407
407
  },
408
408
  });
409
409
  expect(res.status).toBe(403);
410
- const body = await res.json() as { error: string; code: string };
411
- expect(body.code).toBe('GATEWAY_ONLY');
412
- expect(body.error).toContain('Direct relay access disabled');
410
+ const body = await res.json() as { error: { code: string; message: string } };
411
+ expect(body.error.code).toBe('FORBIDDEN');
412
+ expect(body.error.message).toContain('Direct relay access disabled');
413
413
  });
414
414
 
415
415
  test('allows request with no origin header (private network peer)', async () => {
@@ -41,7 +41,6 @@ describe('Messaging tool contract', () => {
41
41
  'messaging_read',
42
42
  'messaging_search',
43
43
  'messaging_send',
44
- 'send_notification',
45
44
  'messaging_reply',
46
45
  'messaging_mark_read',
47
46
  'messaging_analyze_activity',
@@ -1,4 +1,4 @@
1
- import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test';
2
2
 
3
3
  import type { ToolExecutionResult, ToolLifecycleEvent, ToolPermissionDeniedEvent } from '../tools/types.js';
4
4
 
@@ -100,7 +100,11 @@ function makePrompter(): PermissionPrompter {
100
100
  } as unknown as PermissionPrompter;
101
101
  }
102
102
 
103
- afterAll(() => { mock.restore(); });
103
+ import { resetDb } from '../memory/db.js';
104
+ import { initializeDb } from '../memory/db-init.js';
105
+
106
+ beforeAll(() => { initializeDb(); });
107
+ afterAll(() => { resetDb(); mock.restore(); });
104
108
 
105
109
  // =====================================================================
106
110
  // Unit tests: isGuardianControlPlaneInvocation
@@ -380,7 +384,7 @@ describe('enforceGuardianOnlyPolicy', () => {
380
384
  test('non-guardian actor denied for guardian endpoint', () => {
381
385
  const result = enforceGuardianOnlyPolicy('bash', {
382
386
  command: 'curl http://localhost:3000/v1/integrations/guardian/outbound/start',
383
- }, 'non-guardian');
387
+ }, 'trusted_contact');
384
388
  expect(result.denied).toBe(true);
385
389
  expect(result.reason).toContain('restricted to guardian users');
386
390
  });
@@ -388,7 +392,7 @@ describe('enforceGuardianOnlyPolicy', () => {
388
392
  test('unverified_channel actor denied for guardian endpoint', () => {
389
393
  const result = enforceGuardianOnlyPolicy('network_request', {
390
394
  url: 'https://api.example.com/v1/integrations/guardian/challenge',
391
- }, 'unverified_channel');
395
+ }, 'unknown');
392
396
  expect(result.denied).toBe(true);
393
397
  expect(result.reason).toContain('restricted to guardian users');
394
398
  });
@@ -419,14 +423,14 @@ describe('enforceGuardianOnlyPolicy', () => {
419
423
  test('non-guardian actor is NOT denied for unrelated endpoint', () => {
420
424
  const result = enforceGuardianOnlyPolicy('bash', {
421
425
  command: 'curl http://localhost:3000/v1/messages',
422
- }, 'non-guardian');
426
+ }, 'trusted_contact');
423
427
  expect(result.denied).toBe(false);
424
428
  });
425
429
 
426
430
  test('non-guardian actor is NOT denied for unrelated tool', () => {
427
431
  const result = enforceGuardianOnlyPolicy('file_read', {
428
432
  path: 'README.md',
429
- }, 'non-guardian');
433
+ }, 'trusted_contact');
430
434
  expect(result.denied).toBe(false);
431
435
  });
432
436
  });
@@ -445,7 +449,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
445
449
  const result = await executor.execute(
446
450
  'bash',
447
451
  { command: 'curl -X POST http://localhost:3000/v1/integrations/guardian/outbound/start' },
448
- makeContext({ guardianActorRole: 'non-guardian' }),
452
+ makeContext({ guardianTrustClass: 'trusted_contact' }),
449
453
  );
450
454
  expect(result.isError).toBe(true);
451
455
  expect(result.content).toContain('restricted to guardian users');
@@ -456,7 +460,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
456
460
  const result = await executor.execute(
457
461
  'network_request',
458
462
  { url: 'https://api.example.com/v1/integrations/guardian/challenge' },
459
- makeContext({ guardianActorRole: 'unverified_channel' }),
463
+ makeContext({ guardianTrustClass: 'unknown' }),
460
464
  );
461
465
  expect(result.isError).toBe(true);
462
466
  expect(result.content).toContain('restricted to guardian users');
@@ -467,18 +471,18 @@ describe('ToolExecutor guardian-only policy gate', () => {
467
471
  const result = await executor.execute(
468
472
  'bash',
469
473
  { command: 'curl -X POST http://localhost:3000/v1/integrations/guardian/outbound/start' },
470
- makeContext({ guardianActorRole: 'guardian' }),
474
+ makeContext({ guardianTrustClass: 'guardian' }),
471
475
  );
472
476
  expect(result.isError).toBe(false);
473
477
  expect(result.content).toBe('ok');
474
478
  });
475
479
 
476
- test('undefined guardianActorRole is NOT blocked from guardian endpoint', async () => {
480
+ test('undefined guardianTrustClass is NOT blocked from guardian endpoint', async () => {
477
481
  const executor = new ToolExecutor(makePrompter());
478
482
  const result = await executor.execute(
479
483
  'bash',
480
484
  { command: 'curl http://localhost:3000/v1/integrations/guardian/status' },
481
- makeContext(), // no guardianActorRole set
485
+ makeContext(), // no guardianTrustClass set
482
486
  );
483
487
  expect(result.isError).toBe(false);
484
488
  expect(result.content).toBe('ok');
@@ -489,7 +493,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
489
493
  const result = await executor.execute(
490
494
  'bash',
491
495
  { command: 'curl http://localhost:3000/v1/messages' },
492
- makeContext({ guardianActorRole: 'non-guardian' }),
496
+ makeContext({ guardianTrustClass: 'trusted_contact' }),
493
497
  );
494
498
  expect(result.isError).toBe(true);
495
499
  expect(result.content).toContain('requires guardian approval');
@@ -500,7 +504,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
500
504
  const result = await executor.execute(
501
505
  'file_read',
502
506
  { path: 'README.md' },
503
- makeContext({ guardianActorRole: 'non-guardian' }),
507
+ makeContext({ guardianTrustClass: 'trusted_contact' }),
504
508
  );
505
509
  expect(result.isError).toBe(false);
506
510
  expect(result.content).toBe('ok');
@@ -513,7 +517,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
513
517
  'bash',
514
518
  { command: 'curl http://localhost:3000/v1/integrations/guardian/outbound/cancel' },
515
519
  makeContext({
516
- guardianActorRole: 'non-guardian',
520
+ guardianTrustClass: 'trusted_contact',
517
521
  onToolLifecycleEvent: (event: ToolLifecycleEvent) => {
518
522
  if (event.type === 'permission_denied') {
519
523
  capturedEvent = event as ToolPermissionDeniedEvent;
@@ -531,7 +535,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
531
535
  const result = await executor.execute(
532
536
  'web_fetch',
533
537
  { url: 'http://localhost:3000/v1/integrations/guardian/outbound/resend' },
534
- makeContext({ guardianActorRole: 'non-guardian' }),
538
+ makeContext({ guardianTrustClass: 'trusted_contact' }),
535
539
  );
536
540
  expect(result.isError).toBe(true);
537
541
  expect(result.content).toContain('restricted to guardian users');
@@ -542,7 +546,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
542
546
  const result = await executor.execute(
543
547
  'browser_navigate',
544
548
  { url: 'http://localhost:3000/v1/integrations/guardian/status' },
545
- makeContext({ guardianActorRole: 'non-guardian' }),
549
+ makeContext({ guardianTrustClass: 'trusted_contact' }),
546
550
  );
547
551
  expect(result.isError).toBe(true);
548
552
  expect(result.content).toContain('restricted to guardian users');
@@ -553,7 +557,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
553
557
  const result = await executor.execute(
554
558
  'host_bash',
555
559
  { command: 'curl -X POST https://internal:8080/v1/integrations/guardian/challenge' },
556
- makeContext({ guardianActorRole: 'non-guardian' }),
560
+ makeContext({ guardianTrustClass: 'trusted_contact' }),
557
561
  );
558
562
  expect(result.isError).toBe(true);
559
563
  expect(result.content).toContain('restricted to guardian users');
@@ -573,7 +577,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
573
577
  const result = await executor.execute(
574
578
  'network_request',
575
579
  { url: `https://api.example.com${path}` },
576
- makeContext({ guardianActorRole: 'non-guardian' }),
580
+ makeContext({ guardianTrustClass: 'trusted_contact' }),
577
581
  );
578
582
  expect(result.isError).toBe(true);
579
583
  expect(result.content).toContain('restricted to guardian users');
@@ -585,7 +589,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
585
589
  const result = await executor.execute(
586
590
  'host_file_read',
587
591
  { path: '/Users/noaflaherty/.ssh/config' },
588
- makeContext({ guardianActorRole: 'non-guardian' }),
592
+ makeContext({ guardianTrustClass: 'trusted_contact' }),
589
593
  );
590
594
  expect(result.isError).toBe(true);
591
595
  expect(result.content).toContain('requires guardian approval');
@@ -596,7 +600,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
596
600
  const result = await executor.execute(
597
601
  'reminder_create',
598
602
  { fire_at: '2026-02-27T12:00:00-05:00', label: 'test', message: 'hello' },
599
- makeContext({ guardianActorRole: 'unverified_channel' }),
603
+ makeContext({ guardianTrustClass: 'unknown' }),
600
604
  );
601
605
  expect(result.isError).toBe(true);
602
606
  expect(result.content).toContain('verified channel identity');
@@ -607,7 +611,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
607
611
  const result = await executor.execute(
608
612
  'reminder_create',
609
613
  { fire_at: '2026-02-27T12:00:00-05:00', label: 'test', message: 'hello' },
610
- makeContext({ guardianActorRole: 'guardian' }),
614
+ makeContext({ guardianTrustClass: 'guardian' }),
611
615
  );
612
616
  expect(result.isError).toBe(false);
613
617
  expect(result.content).toBe('ok');
@@ -42,6 +42,8 @@ mock.module('../memory/channel-guardian-store.js', () => ({
42
42
 
43
43
  mock.module('../config/loader.js', () => ({
44
44
  getConfig: () => ({
45
+ ui: {},
46
+
45
47
  calls: {
46
48
  userConsultTimeoutSeconds: 120,
47
49
  },
@@ -137,7 +137,7 @@ function registerPendingInteraction(
137
137
 
138
138
  function makeGuardianContext(): GuardianContext {
139
139
  return {
140
- actorRole: 'guardian',
140
+ trustClass: 'guardian',
141
141
  denialReason: undefined,
142
142
  };
143
143
  }
@@ -530,3 +530,70 @@ describe('guardian grant minting on tool-approval decisions', () => {
530
530
  composeSpy.mockRestore();
531
531
  });
532
532
  });
533
+
534
+ describe('approval interception trust-class regression coverage', () => {
535
+ let deliverSpy: ReturnType<typeof spyOn>;
536
+ let composeSpy: ReturnType<typeof spyOn>;
537
+
538
+ beforeEach(() => {
539
+ resetTables();
540
+ deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
541
+ composeSpy = spyOn(approvalMessageComposer, 'composeApprovalMessageGenerative')
542
+ .mockResolvedValue('test message');
543
+ });
544
+
545
+ test('identity-known unknown sender does not auto-deny pending approval', async () => {
546
+ const requestId = 'req-unknown-no-auto-deny-1';
547
+ const sessionMock = registerPendingInteraction(requestId, CONVERSATION_ID, TOOL_NAME, TOOL_INPUT);
548
+ createTestGuardianApproval(requestId);
549
+
550
+ const result = await handleApprovalInterception({
551
+ conversationId: CONVERSATION_ID,
552
+ content: 'approve',
553
+ externalChatId: REQUESTER_CHAT,
554
+ sourceChannel: 'telegram',
555
+ senderExternalUserId: 'intruder-user-1',
556
+ replyCallbackUrl: 'https://gateway.test/deliver',
557
+ guardianCtx: {
558
+ trustClass: 'unknown',
559
+ },
560
+ assistantId: ASSISTANT_ID,
561
+ });
562
+
563
+ expect(result.handled).toBe(true);
564
+ expect(result.type).toBe('assistant_turn');
565
+ expect(sessionMock).not.toHaveBeenCalled();
566
+
567
+ deliverSpy.mockRestore();
568
+ composeSpy.mockRestore();
569
+ });
570
+
571
+ test('legacy unverified sender still auto-denies pending approval', async () => {
572
+ const requestId = 'req-unknown-auto-deny-1';
573
+ const sessionMock = registerPendingInteraction(requestId, CONVERSATION_ID, TOOL_NAME, TOOL_INPUT);
574
+ createTestGuardianApproval(requestId);
575
+
576
+ const result = await handleApprovalInterception({
577
+ conversationId: CONVERSATION_ID,
578
+ content: 'approve',
579
+ externalChatId: REQUESTER_CHAT,
580
+ sourceChannel: 'telegram',
581
+ senderExternalUserId: undefined,
582
+ replyCallbackUrl: 'https://gateway.test/deliver',
583
+ guardianCtx: {
584
+ trustClass: 'unknown',
585
+ denialReason: 'no_identity',
586
+ },
587
+ assistantId: ASSISTANT_ID,
588
+ });
589
+
590
+ expect(result.handled).toBe(true);
591
+ expect(result.type).toBe('decision_applied');
592
+ expect(sessionMock).toHaveBeenCalled();
593
+ expect(sessionMock.mock.calls[0]?.[0]).toBe(requestId);
594
+ expect(sessionMock.mock.calls[0]?.[1]).toBe('deny');
595
+
596
+ deliverSpy.mockRestore();
597
+ composeSpy.mockRestore();
598
+ });
599
+ });
@@ -364,15 +364,16 @@ describe('HTTP route: handleStartOutbound', () => {
364
364
  const req = jsonRequest({ destination: '+15551234567' });
365
365
  const resp = await handleStartOutbound(req);
366
366
  expect(resp.status).toBe(400);
367
- const body = await resp.json() as Record<string, unknown>;
368
- expect(body.error).toBe('missing_channel');
367
+ const body = await resp.json() as { error: { message: string; code: string } };
368
+ expect(body.error.code).toBe('BAD_REQUEST');
369
+ expect(body.error.message).toContain('channel');
369
370
  });
370
371
 
371
372
  test('returns 400 for missing destination (SMS)', async () => {
372
373
  const req = jsonRequest({ channel: 'sms' });
373
374
  const resp = await handleStartOutbound(req);
374
375
  expect(resp.status).toBe(400);
375
- const body = await resp.json() as Record<string, unknown>;
376
+ const body = await resp.json() as { error?: string };
376
377
  expect(body.error).toBe('missing_destination');
377
378
  });
378
379
 
@@ -391,15 +392,16 @@ describe('HTTP route: handleResendOutbound', () => {
391
392
  const req = jsonRequest({});
392
393
  const resp = await handleResendOutbound(req);
393
394
  expect(resp.status).toBe(400);
394
- const body = await resp.json() as Record<string, unknown>;
395
- expect(body.error).toBe('missing_channel');
395
+ const body = await resp.json() as { error: { message: string; code: string } };
396
+ expect(body.error.code).toBe('BAD_REQUEST');
397
+ expect(body.error.message).toContain('channel');
396
398
  });
397
399
 
398
400
  test('returns 400 for no_active_session', async () => {
399
401
  const req = jsonRequest({ channel: 'sms', assistantId: 'resend-no-session' });
400
402
  const resp = await handleResendOutbound(req);
401
403
  expect(resp.status).toBe(400);
402
- const body = await resp.json() as Record<string, unknown>;
404
+ const body = await resp.json() as { error?: string };
403
405
  expect(body.error).toBe('no_active_session');
404
406
  });
405
407
 
@@ -432,15 +434,16 @@ describe('HTTP route: handleCancelOutbound', () => {
432
434
  const req = jsonRequest({});
433
435
  const resp = await handleCancelOutbound(req);
434
436
  expect(resp.status).toBe(400);
435
- const body = await resp.json() as Record<string, unknown>;
436
- expect(body.error).toBe('missing_channel');
437
+ const body = await resp.json() as { error: { message: string; code: string } };
438
+ expect(body.error.code).toBe('BAD_REQUEST');
439
+ expect(body.error.message).toContain('channel');
437
440
  });
438
441
 
439
442
  test('returns 400 for no_active_session', async () => {
440
443
  const req = jsonRequest({ channel: 'sms', assistantId: 'cancel-no-session' });
441
444
  const resp = await handleCancelOutbound(req);
442
445
  expect(resp.status).toBe(400);
443
- const body = await resp.json() as Record<string, unknown>;
446
+ const body = await resp.json() as { error?: string };
444
447
  expect(body.error).toBe('no_active_session');
445
448
  });
446
449