@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
package/ARCHITECTURE.md CHANGED
@@ -392,26 +392,56 @@ A complementary access-granting flow where the guardian proactively creates a sh
392
392
 
393
393
  **Inbound intercept points:** Invite token extraction runs early in the inbound handler, before ACL denial, so valid invites short-circuit the membership check. Two intercept branches handle: (a) non-members — the invite creates their first member record; (b) inactive members (revoked/pending) — the invite reactivates them.
394
394
 
395
- **Deferred channel adapters:**
395
+ **Channel adapter status:**
396
396
 
397
397
  | Channel | Status | Prerequisites |
398
398
  |---------|--------|--------------|
399
399
  | Telegram | Shipped | Bot username resolved from credential metadata or `TELEGRAM_BOT_USERNAME` env |
400
+ | Voice | Shipped | Identity-bound voice code redemption via DTMF/speech in the relay state machine. Gated behind `feature_flags.voice-invite-redemption.enabled` (default OFF). |
400
401
  | SMS | Deferred | Needs a deep-link strategy compatible with SMS (short URL or web redemption page) |
401
402
  | Slack | Deferred | Needs DM-safe ingress — Socket Mode handles channel messages but DM-initiated invite flows need routing |
402
- | Voice | Deferred | Needs DTMF or speech-based token capture integrated with the voice relay state machine |
403
+
404
+ ### Voice Invite Flow (invite_redemption_pending)
405
+
406
+ Voice invites use a short numeric code (4-10 digits, default 6) instead of a URL token. The guardian creates an invite bound to the invitee's E.164 phone number; the invitee redeems it by entering the code during an inbound voice call.
407
+
408
+ **Creation flow:**
409
+ 1. Guardian creates a voice invite via `POST /v1/ingress/invites` with `sourceChannel: "voice"` and `expectedExternalUserId` (E.164 phone).
410
+ 2. `ingress-service.ts` generates a cryptographically random numeric code (`generateVoiceCode`), hashes it with SHA-256 (`hashVoiceCode`), and stores only the hash.
411
+ 3. The one-time plaintext `voiceCode` is returned in the creation response. The raw token is NOT returned for voice invites — redemption uses the identity-bound code flow exclusively.
412
+ 4. Guardian communicates the code to the invitee out-of-band.
413
+
414
+ **Call-time redemption subflow (`invite_redemption_pending`):**
415
+ 1. Unknown caller dials in. `relay-server.ts` resolves trust via `resolveActorTrust`. Caller is `unknown`, no pending guardian challenge.
416
+ 2. If `feature_flags.voice-invite-redemption.enabled` is ON, the relay checks `findActiveVoiceInvites` for invites bound to the caller's phone number.
417
+ 3. If active, non-expired invites exist, the relay enters the `invite_redemption_pending` state (reuses the `verification_pending` connection state) and prompts the caller to enter their invite code via DTMF or speech.
418
+ 4. `redeemVoiceInviteCode` validates: identity match, code hash match, expiry, use count. On success, an active member record is upserted and the call transitions to the normal call flow.
419
+ 5. On failure, the caller gets up to 3 attempts. After max attempts, the call is terminated.
420
+
421
+ **Security invariants:**
422
+ - The plaintext voice code is returned exactly once at creation time and never stored.
423
+ - Voice invites are identity-bound: `expectedExternalUserId` must match the caller's E.164 number. An attacker with the code but the wrong phone number cannot redeem.
424
+ - Failure responses are intentionally generic (`invalid_or_expired`) to prevent oracle attacks.
425
+ - Blocked members cannot bypass the guardian's explicit block via invite redemption.
426
+
427
+ **Feature flag:** `feature_flags.voice-invite-redemption.enabled` (default OFF). When disabled, unknown callers with active voice invites are denied normally — the invite check is skipped entirely.
403
428
 
404
429
  **Key source files:**
405
430
 
406
431
  | File | Purpose |
407
432
  |------|---------|
408
- | `src/runtime/invite-redemption-service.ts` | Core redemption engine — token validation, member creation, discriminated-union outcomes |
433
+ | `src/runtime/invite-redemption-service.ts` | Core redemption engine — token validation, voice code redemption, member creation, discriminated-union outcomes |
409
434
  | `src/runtime/invite-redemption-templates.ts` | Deterministic reply templates for each redemption outcome |
410
435
  | `src/runtime/channel-invite-transport.ts` | Transport adapter registry with `buildShareableInvite` / `extractInboundToken` interface |
411
436
  | `src/runtime/channel-invite-transports/telegram.ts` | Telegram adapter — `t.me/<bot>?start=iv_<token>` deep links, `/start iv_<token>` extraction |
437
+ | `src/runtime/channel-invite-transports/voice.ts` | Voice transport adapter — code-based redemption metadata |
412
438
  | `src/daemon/guardian-invite-intent.ts` | Intent detection — routes create/list/revoke requests into the trusted-contacts skill |
413
439
  | `src/runtime/ingress-service.ts` | Shared business logic for invite/member operations (used by both HTTP routes and IPC) |
440
+ | `src/runtime/routes/ingress-routes.ts` | HTTP API handlers for member/invite management including voice invite creation and redemption |
414
441
  | `src/runtime/routes/inbound-message-handler.ts` | Invite token intercept in the inbound flow (non-member and inactive-member branches) |
442
+ | `src/calls/relay-server.ts` | Voice relay state machine — `invite_redemption_pending` subflow, feature flag gate |
443
+ | `src/util/voice-code.ts` | Cryptographic voice code generation and SHA-256 hashing |
444
+ | `src/memory/ingress-invite-store.ts` | Invite persistence including `findActiveVoiceInvites` for identity-bound lookup |
415
445
 
416
446
  ### Update Bulletin System
417
447
 
package/bun.lock CHANGED
@@ -8,7 +8,6 @@
8
8
  "@anthropic-ai/claude-agent-sdk": "^0.2.42",
9
9
  "@anthropic-ai/sdk": "^0.39.0",
10
10
  "@google/genai": "^1.40.0",
11
- "@huggingface/transformers": "^3.8.1",
12
11
  "@modelcontextprotocol/sdk": "^1.15.1",
13
12
  "@qdrant/js-client-rest": "^1.16.2",
14
13
  "@sentry/node": "^10.38.0",
@@ -35,6 +34,7 @@
35
34
  "zod": "^4.3.6",
36
35
  },
37
36
  "devDependencies": {
37
+ "@huggingface/transformers": "^3.8.1",
38
38
  "@pydantic/logfire-node": "^0.13.0",
39
39
  "@types/archiver": "^7.0.0",
40
40
  "@types/bun": "^1.2.4",
@@ -46,6 +46,7 @@
46
46
  "eslint-plugin-simple-import-sort": "^12.1.1",
47
47
  "fast-check": "^4.5.3",
48
48
  "knip": "^5.83.1",
49
+ "prettier": "^3.8.1",
49
50
  "quicktype-core": "^23.2.6",
50
51
  "typescript": "^5.7.3",
51
52
  "typescript-eslint": "^8.54.0",
@@ -1136,6 +1137,8 @@
1136
1137
 
1137
1138
  "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
1138
1139
 
1140
+ "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
1141
+
1139
1142
  "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="],
1140
1143
 
1141
1144
  "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
@@ -13,8 +13,15 @@ Design doc defining how unknown users gain access to a Vellum assistant via chan
13
13
  ## User Journey
14
14
 
15
15
  1. **Unknown user messages the assistant** on Telegram (or SMS, or any channel).
16
- 2. **Assistant rejects the message** via the ingress ACL in `inbound-message-handler.ts`. The user has no `assistant_ingress_members` record, so the handler replies: *"Sorry, you haven't been approved to message this assistant. You can ask its Guardian for an invite."* and returns `{ denied: true, reason: 'not_a_member' }`.
17
- 3. **Notification pipeline alerts the guardian.** The rejection triggers `emitNotificationSignal()` with `sourceEventName: 'ingress.access_request'`, routing through the decision engine to all connected channels (vellum macOS app, Telegram, etc.). The guardian sees who is requesting access.
16
+ 2. **Assistant rejects the message** via the ingress ACL in `inbound-message-handler.ts`. The user has no `assistant_ingress_members` record, so the handler replies: *"Hmm looks like you don't have access to talk to me. I'll let them know you tried talking to me and get back to you."* and returns `{ denied: true, reason: 'not_a_member' }`.
17
+ 3. **Notification pipeline alerts the guardian.** The rejection triggers `notifyGuardianOfAccessRequest()` which creates a canonical access request and calls `emitNotificationSignal()` with `sourceEventName: 'ingress.access_request'`. The notification routes through the decision engine to all connected channels (vellum macOS app, Telegram, etc.). The guardian sees who is requesting access, including a request code for approve/reject and an `open invite flow` option to start the Trusted Contacts invite flow.
18
+
19
+ **Guardian binding resolution for access requests** uses a fallback strategy:
20
+ 1. Source-channel active binding first (e.g., Telegram binding for a Telegram access request).
21
+ 2. Any active binding for the assistant on another channel (deterministic: most recently verified first, then alphabetical by channel).
22
+ 3. No guardian identity — the notification pipeline delivers via trusted/vellum channels even when no channel binding exists.
23
+
24
+ This ensures unknown inbound access attempts always trigger guardian notification, even when the requester's source channel has no guardian binding.
18
25
  4. **Guardian approves the request.** The guardian responds to the notification (via Telegram inline button, macOS app, or IPC). On approval, the assistant creates a verification session via `createOutboundSession()` and generates a 6-digit verification code.
19
26
  5. **Guardian receives the verification code.** The assistant delivers the code to the guardian's verified channel (Telegram chat, SMS, etc.).
20
27
  6. **Guardian gives the code to the requester out-of-band** (in person, text message, phone call, etc.). This out-of-band transfer is the trust anchor: it proves the requester has a real-world relationship with the guardian.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.3.28",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "vellum": "./src/index.ts"
@@ -14,6 +14,8 @@
14
14
  "ipc:inventory:update": "bun run scripts/ipc/check-contract-inventory.ts --update",
15
15
  "generate:ipc": "bun run scripts/ipc/generate-swift.ts",
16
16
  "check:ipc-generated": "bun run scripts/ipc/generate-swift.ts --check",
17
+ "format": "prettier --write .",
18
+ "format:check": "prettier --check .",
17
19
  "ipc:check-swift-drift": "bun run scripts/ipc/check-swift-decoder-drift.ts",
18
20
  "lint": "eslint",
19
21
  "typecheck": "bunx tsc --noEmit",
@@ -28,7 +30,6 @@
28
30
  "@anthropic-ai/sdk": "^0.39.0",
29
31
  "@google/genai": "^1.40.0",
30
32
  "@modelcontextprotocol/sdk": "^1.15.1",
31
- "@huggingface/transformers": "^3.8.1",
32
33
  "@qdrant/js-client-rest": "^1.16.2",
33
34
  "@sentry/node": "^10.38.0",
34
35
  "agentmail": "^0.1.0",
@@ -65,9 +66,11 @@
65
66
  "eslint-plugin-simple-import-sort": "^12.1.1",
66
67
  "fast-check": "^4.5.3",
67
68
  "knip": "^5.83.1",
69
+ "prettier": "^3.8.1",
68
70
  "quicktype-core": "^23.2.6",
69
71
  "typescript": "^5.7.3",
70
72
  "typescript-eslint": "^8.54.0",
71
- "typescript-json-schema": "^0.67.1"
73
+ "typescript-json-schema": "^0.67.1",
74
+ "@huggingface/transformers": "^3.8.1"
72
75
  }
73
76
  }
@@ -94,9 +94,9 @@ function generateSchemas(): Record<string, SchemaDef> {
94
94
 
95
95
  const program = TJS.getProgramFromFiles(contractFiles, {
96
96
  strict: true,
97
- target: 99,
98
- module: 199,
99
- moduleResolution: 99,
97
+ target: 'es2022',
98
+ module: 'commonjs',
99
+ moduleResolution: 'node',
100
100
  skipLibCheck: true,
101
101
  });
102
102
 
@@ -1302,6 +1302,15 @@ exports[`IPC message snapshots ServerMessage types message_complete serializes t
1302
1302
  }
1303
1303
  `;
1304
1304
 
1305
+ exports[`IPC message snapshots ServerMessage types message_request_complete serializes to expected JSON 1`] = `
1306
+ {
1307
+ "requestId": "req-inline-001",
1308
+ "runStillActive": true,
1309
+ "sessionId": "sess-001",
1310
+ "type": "message_request_complete",
1311
+ }
1312
+ `;
1313
+
1305
1314
  exports[`IPC message snapshots ServerMessage types session_info serializes to expected JSON 1`] = `
1306
1315
  {
1307
1316
  "correlationId": "corr-001",
@@ -3112,3 +3121,74 @@ exports[`IPC message snapshots ServerMessage types generate_avatar_response seri
3112
3121
  "type": "generate_avatar_response",
3113
3122
  }
3114
3123
  `;
3124
+
3125
+ exports[`IPC message snapshots ClientMessage types guardian_actions_pending_request serializes to expected JSON 1`] = `
3126
+ {
3127
+ "conversationId": "conv-guardian-001",
3128
+ "type": "guardian_actions_pending_request",
3129
+ }
3130
+ `;
3131
+
3132
+ exports[`IPC message snapshots ClientMessage types guardian_action_decision serializes to expected JSON 1`] = `
3133
+ {
3134
+ "action": "approve_once",
3135
+ "conversationId": "conv-guardian-001",
3136
+ "requestId": "req-guardian-001",
3137
+ "type": "guardian_action_decision",
3138
+ }
3139
+ `;
3140
+
3141
+ exports[`IPC message snapshots ClientMessage types reorder_threads serializes to expected JSON 1`] = `
3142
+ {
3143
+ "type": "reorder_threads",
3144
+ "updates": [
3145
+ {
3146
+ "displayOrder": 0,
3147
+ "isPinned": false,
3148
+ "sessionId": "sess-001",
3149
+ },
3150
+ {
3151
+ "displayOrder": 1,
3152
+ "isPinned": true,
3153
+ "sessionId": "sess-002",
3154
+ },
3155
+ ],
3156
+ }
3157
+ `;
3158
+
3159
+ exports[`IPC message snapshots ServerMessage types guardian_actions_pending_response serializes to expected JSON 1`] = `
3160
+ {
3161
+ "conversationId": "conv-guardian-001",
3162
+ "prompts": [
3163
+ {
3164
+ "actions": [
3165
+ {
3166
+ "action": "approve_once",
3167
+ "label": "Approve once",
3168
+ },
3169
+ {
3170
+ "action": "reject",
3171
+ "label": "Reject",
3172
+ },
3173
+ ],
3174
+ "callSessionId": null,
3175
+ "conversationId": "conv-guardian-001",
3176
+ "expiresAt": 1700100000000,
3177
+ "questionText": "Approve tool: bash",
3178
+ "requestCode": "REQ-GU",
3179
+ "requestId": "req-guardian-001",
3180
+ "state": "pending",
3181
+ "toolName": "bash",
3182
+ },
3183
+ ],
3184
+ "type": "guardian_actions_pending_response",
3185
+ }
3186
+ `;
3187
+
3188
+ exports[`IPC message snapshots ServerMessage types guardian_action_decision_response serializes to expected JSON 1`] = `
3189
+ {
3190
+ "applied": true,
3191
+ "requestId": "req-guardian-001",
3192
+ "type": "guardian_action_decision_response",
3193
+ }
3194
+ `;
@@ -44,7 +44,7 @@ describe('AgentLoop thinking budget clamping', () => {
44
44
  const config = lastConfig()!;
45
45
  const thinking = config.thinking as { type: string; budget_tokens: number };
46
46
  expect(thinking.type).toBe('enabled');
47
- expect(thinking.budget_tokens).toBe(4095); // clamped to maxTokens - 1
47
+ expect(thinking.budget_tokens).toBe(3072); // clamped to floor(maxTokens * 0.75)
48
48
  });
49
49
 
50
50
  test('does not clamp when budget_tokens is within max_tokens', async () => {
@@ -36,6 +36,8 @@ mock.module('../util/logger.js', () => ({
36
36
 
37
37
  mock.module('../config/loader.js', () => ({
38
38
  getConfig: () => ({
39
+ ui: {},
40
+
39
41
  model: 'test',
40
42
  provider: 'test',
41
43
  apiKeys: {},
@@ -79,8 +81,11 @@ function makeIdleSession(opts?: {
79
81
  setAssistantId: () => {},
80
82
  setGuardianContext: () => {},
81
83
  setCommandIntent: () => {},
84
+ setTurnChannelContext: () => {},
85
+ setTurnInterfaceContext: () => {},
82
86
  updateClient: () => {},
83
87
  enqueueMessage: () => ({ queued: false, requestId: 'noop' }),
88
+ hasAnyPendingConfirmation: () => false,
84
89
  runAgentLoop: async (_content: string, _messageId: string, onEvent: (msg: ServerMessage) => void) => {
85
90
  onEvent({ type: 'assistant_text_delta', text: 'Hello!' });
86
91
  onEvent({ type: 'message_complete', sessionId: 'test-session' });
@@ -118,8 +123,11 @@ function makeConfirmationEmittingSession(opts?: {
118
123
  setAssistantId: () => {},
119
124
  setGuardianContext: () => {},
120
125
  setCommandIntent: () => {},
126
+ setTurnChannelContext: () => {},
127
+ setTurnInterfaceContext: () => {},
121
128
  updateClient: () => {},
122
129
  enqueueMessage: () => ({ queued: false, requestId: 'noop' }),
130
+ hasAnyPendingConfirmation: () => false,
123
131
  runAgentLoop: async (_content: string, _messageId: string, onEvent: (msg: ServerMessage) => void) => {
124
132
  // Emit confirmation_request — this triggers the hub publisher to register
125
133
  // the pending interaction
@@ -549,8 +557,8 @@ describe('standalone approval endpoints — HTTP layer', () => {
549
557
  });
550
558
 
551
559
  expect(res.status).toBe(403);
552
- const body = await res.json() as { error: string };
553
- expect(body.error).toContain('pattern');
560
+ const body = await res.json() as { error: { message: string; code?: string } };
561
+ expect(body.error.message).toContain('pattern');
554
562
 
555
563
  await stopServer();
556
564
  });
@@ -579,8 +587,8 @@ describe('standalone approval endpoints — HTTP layer', () => {
579
587
  });
580
588
 
581
589
  expect(res.status).toBe(403);
582
- const body = await res.json() as { error: string };
583
- expect(body.error).toContain('scope');
590
+ const body = await res.json() as { error: { message: string; code?: string } };
591
+ expect(body.error.message).toContain('scope');
584
592
 
585
593
  await stopServer();
586
594
  });
@@ -637,7 +645,7 @@ describe('standalone approval endpoints — HTTP layer', () => {
637
645
  const res = await fetch(url('messages'), {
638
646
  method: 'POST',
639
647
  headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
640
- body: JSON.stringify({ conversationKey: 'conv-auto', content: 'Run ls', sourceChannel: 'vellum' }),
648
+ body: JSON.stringify({ conversationKey: 'conv-auto', content: 'Run ls', sourceChannel: 'vellum', interface: 'macos' }),
641
649
  });
642
650
  expect(res.status).toBe(202);
643
651
 
@@ -30,6 +30,8 @@ mock.module('../util/logger.js', () => ({
30
30
 
31
31
  mock.module('../config/loader.js', () => ({
32
32
  getConfig: () => ({
33
+ ui: {},
34
+
33
35
  model: 'test',
34
36
  provider: 'test',
35
37
  apiKeys: {},
@@ -29,6 +29,8 @@ mock.module('../util/logger.js', () => ({
29
29
 
30
30
  mock.module('../config/loader.js', () => ({
31
31
  getConfig: () => ({
32
+ ui: {},
33
+
32
34
  model: 'test',
33
35
  provider: 'test',
34
36
  apiKeys: {},
@@ -37,6 +37,8 @@ mock.module('../util/logger.js', () => ({
37
37
 
38
38
  mock.module('../config/loader.js', () => ({
39
39
  getConfig: () => ({
40
+ ui: {},
41
+
40
42
  model: 'test',
41
43
  provider: 'test',
42
44
  apiKeys: {},
@@ -181,8 +183,8 @@ describe('SSE route — capacity limit', () => {
181
183
 
182
184
  const response = handleSubscribeAssistantEvents(req, new URL(req.url), { hub });
183
185
  expect(response.status).toBe(503);
184
- const body = await response.json() as { error: string };
185
- expect(body.error).toMatch(/Too many concurrent connections/);
186
+ const body = await response.json() as { error: { message: string; code?: string } };
187
+ expect(body.error.message).toMatch(/Too many concurrent connections/);
186
188
  });
187
189
 
188
190
  test('returns 200 when hub has remaining capacity', () => {
@@ -27,6 +27,8 @@ mock.module('../util/logger.js', () => ({
27
27
 
28
28
  mock.module('../config/loader.js', () => ({
29
29
  getConfig: () => ({
30
+ ui: {},
31
+
30
32
  model: 'test',
31
33
  provider: 'test',
32
34
  apiKeys: {},
@@ -64,10 +64,10 @@ describe('browser skill migration end-state', () => {
64
64
 
65
65
  test('startup tool definition count is reduced (no browser tools)', () => {
66
66
  const definitions = getAllToolDefinitions();
67
- // Startup has ~31 definitions (no browser tools).
67
+ // Startup has ~15 eager + ~11 explicit definitions (no browser tools).
68
68
  // Allow wider drift for unrelated tool additions while still failing if
69
- // browser tools are reintroduced at startup (+10 definitions).
70
- expect(definitions.length).toBeGreaterThanOrEqual(25);
69
+ // browser tools are reintroduced at startup (+14 definitions).
70
+ expect(definitions.length).toBeGreaterThanOrEqual(10);
71
71
  expect(definitions.length).toBeLessThanOrEqual(50);
72
72
 
73
73
  const defNames = definitions.map((d) => d.name);
@@ -32,6 +32,8 @@ mock.module('../util/logger.js', () => ({
32
32
 
33
33
  mock.module('../config/loader.js', () => ({
34
34
  getConfig: () => ({
35
+ ui: {},
36
+
35
37
  provider: 'anthropic',
36
38
  providerOrder: ['anthropic'],
37
39
  apiKeys: { anthropic: 'test-key' },
@@ -47,6 +49,7 @@ mock.module('../config/loader.js', () => ({
47
49
  model: undefined,
48
50
  },
49
51
  memory: { enabled: false },
52
+ notifications: { decisionModelIntent: 'latency-optimized' },
50
53
  }),
51
54
  }));
52
55
 
@@ -127,11 +130,11 @@ import {
127
130
  updateCallSession,
128
131
  } from '../calls/call-store.js';
129
132
  import type { RelayConnection } from '../calls/relay-server.js';
130
- import { getDb, initializeDb, resetDb } from '../memory/db.js';
131
133
  import {
132
- getGuardianActionRequest,
133
- getPendingRequestByCallSessionId,
134
- } from '../memory/guardian-action-store.js';
134
+ getCanonicalGuardianRequest,
135
+ getPendingCanonicalRequestByCallSessionId,
136
+ } from '../memory/canonical-guardian-store.js';
137
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
135
138
  import { conversations } from '../memory/schema.js';
136
139
 
137
140
  initializeDb();
@@ -192,6 +195,8 @@ function ensureConversation(id: string): void {
192
195
 
193
196
  function resetTables() {
194
197
  const db = getDb();
198
+ db.run('DELETE FROM canonical_guardian_deliveries');
199
+ db.run('DELETE FROM canonical_guardian_requests');
195
200
  db.run('DELETE FROM guardian_action_deliveries');
196
201
  db.run('DELETE FROM guardian_action_requests');
197
202
  db.run('DELETE FROM call_pending_questions');
@@ -631,7 +636,7 @@ describe('call-controller', () => {
631
636
  test('handleCallerUtterance: passes guardian context to startVoiceTurn', async () => {
632
637
  const guardianCtx = {
633
638
  sourceChannel: 'voice' as const,
634
- actorRole: 'non-guardian' as const,
639
+ trustClass: 'trusted_contact' as const,
635
640
  guardianExternalUserId: '+15550009999',
636
641
  guardianChatId: '+15550009999',
637
642
  requesterExternalUserId: '+15550002222',
@@ -683,13 +688,13 @@ describe('call-controller', () => {
683
688
  test('setGuardianContext: subsequent turns use updated guardian context', async () => {
684
689
  const initialCtx = {
685
690
  sourceChannel: 'voice' as const,
686
- actorRole: 'unverified_channel' as const,
691
+ trustClass: 'unknown' as const,
687
692
  denialReason: 'no_binding' as const,
688
693
  };
689
694
 
690
695
  const upgradedCtx = {
691
696
  sourceChannel: 'voice' as const,
692
- actorRole: 'guardian' as const,
697
+ trustClass: 'guardian' as const,
693
698
  guardianExternalUserId: '+15550003333',
694
699
  guardianChatId: '+15550003333',
695
700
  };
@@ -1163,7 +1168,7 @@ describe('call-controller', () => {
1163
1168
  await new Promise((r) => setTimeout(r, 10));
1164
1169
 
1165
1170
  // Verify a guardian action request was created
1166
- const pendingRequest = getPendingRequestByCallSessionId(session.id);
1171
+ const pendingRequest = getPendingCanonicalRequestByCallSessionId(session.id);
1167
1172
  expect(pendingRequest).not.toBeNull();
1168
1173
  expect(pendingRequest!.status).toBe('pending');
1169
1174
 
@@ -1175,11 +1180,10 @@ describe('call-controller', () => {
1175
1180
  // Wait for the consultation timeout
1176
1181
  await new Promise((r) => setTimeout(r, 200));
1177
1182
 
1178
- // The guardian action request should now be expired with call_timeout reason
1179
- const timedOutRequest = getGuardianActionRequest(pendingRequest!.id);
1183
+ // The canonical guardian request should now be expired
1184
+ const timedOutRequest = getCanonicalGuardianRequest(pendingRequest!.id);
1180
1185
  expect(timedOutRequest).not.toBeNull();
1181
1186
  expect(timedOutRequest!.status).toBe('expired');
1182
- expect(timedOutRequest!.expiredReason).toBe('call_timeout');
1183
1187
 
1184
1188
  // Event should be recorded
1185
1189
  const events = getCallEvents(session.id);
@@ -1277,7 +1281,7 @@ describe('call-controller', () => {
1277
1281
  expect(question!.questionText).toBe('Allow send_email to bob@example.com?');
1278
1282
 
1279
1283
  // Verify the guardian action request has tool metadata
1280
- const pendingRequest = getPendingRequestByCallSessionId(session.id);
1284
+ const pendingRequest = getPendingCanonicalRequestByCallSessionId(session.id);
1281
1285
  expect(pendingRequest).not.toBeNull();
1282
1286
  expect(pendingRequest!.toolName).toBe('send_email');
1283
1287
  expect(pendingRequest!.inputDigest).not.toBeNull();
@@ -1305,7 +1309,7 @@ describe('call-controller', () => {
1305
1309
  await controller.handleCallerUtterance('Send it');
1306
1310
  await new Promise((r) => setTimeout(r, 50));
1307
1311
 
1308
- const request1 = getPendingRequestByCallSessionId(session.id);
1312
+ const request1 = getPendingCanonicalRequestByCallSessionId(session.id);
1309
1313
  expect(request1).not.toBeNull();
1310
1314
 
1311
1315
  // Compute expected digest independently using the same utility
@@ -1326,7 +1330,7 @@ describe('call-controller', () => {
1326
1330
  await new Promise((r) => setTimeout(r, 50));
1327
1331
 
1328
1332
  // Verify the guardian action request has NO tool metadata
1329
- const pendingRequest = getPendingRequestByCallSessionId(session.id);
1333
+ const pendingRequest = getPendingCanonicalRequestByCallSessionId(session.id);
1330
1334
  expect(pendingRequest).not.toBeNull();
1331
1335
  expect(pendingRequest!.toolName).toBeNull();
1332
1336
  expect(pendingRequest!.inputDigest).toBeNull();
@@ -1384,7 +1388,7 @@ describe('call-controller', () => {
1384
1388
  expect(question!.questionText).toBe('Allow send_message?');
1385
1389
 
1386
1390
  // Verify tool metadata was parsed correctly
1387
- const pendingRequest = getPendingRequestByCallSessionId(session.id);
1391
+ const pendingRequest = getPendingCanonicalRequestByCallSessionId(session.id);
1388
1392
  expect(pendingRequest).not.toBeNull();
1389
1393
  expect(pendingRequest!.toolName).toBe('send_message');
1390
1394
  expect(pendingRequest!.inputDigest).not.toBeNull();
@@ -1411,7 +1415,7 @@ describe('call-controller', () => {
1411
1415
  await controller.handleCallerUtterance('Do something');
1412
1416
  await new Promise((r) => setTimeout(r, 50));
1413
1417
 
1414
- const pendingRequest = getPendingRequestByCallSessionId(session.id);
1418
+ const pendingRequest = getPendingCanonicalRequestByCallSessionId(session.id);
1415
1419
  expect(pendingRequest).not.toBeNull();
1416
1420
  expect(pendingRequest!.questionText).toBe('Fallback question?');
1417
1421
  // Tool metadata should be null since the approval marker was malformed
@@ -1547,7 +1551,7 @@ describe('call-controller', () => {
1547
1551
 
1548
1552
  const firstQuestionId = controller.getPendingConsultationQuestionId();
1549
1553
  expect(firstQuestionId).not.toBeNull();
1550
- const firstRequest = getPendingRequestByCallSessionId(session.id);
1554
+ const firstRequest = getPendingCanonicalRequestByCallSessionId(session.id);
1551
1555
  expect(firstRequest).not.toBeNull();
1552
1556
 
1553
1557
  // Repeated ASK_GUARDIAN with same informational question (no tool metadata)
@@ -1559,7 +1563,7 @@ describe('call-controller', () => {
1559
1563
 
1560
1564
  // Should coalesce: same consultation ID, same request
1561
1565
  expect(controller.getPendingConsultationQuestionId()).toBe(firstQuestionId);
1562
- const currentRequest = getPendingRequestByCallSessionId(session.id);
1566
+ const currentRequest = getPendingCanonicalRequestByCallSessionId(session.id);
1563
1567
  expect(currentRequest).not.toBeNull();
1564
1568
  expect(currentRequest!.id).toBe(firstRequest!.id);
1565
1569
  expect(currentRequest!.status).toBe('pending');
@@ -1589,7 +1593,7 @@ describe('call-controller', () => {
1589
1593
 
1590
1594
  const firstQuestionId = controller.getPendingConsultationQuestionId();
1591
1595
  expect(firstQuestionId).not.toBeNull();
1592
- const firstRequest = getPendingRequestByCallSessionId(session.id);
1596
+ const firstRequest = getPendingCanonicalRequestByCallSessionId(session.id);
1593
1597
  expect(firstRequest).not.toBeNull();
1594
1598
 
1595
1599
  // Repeated ASK_GUARDIAN_APPROVAL with same tool/input
@@ -1601,7 +1605,7 @@ describe('call-controller', () => {
1601
1605
 
1602
1606
  // Should coalesce: same consultation, same request
1603
1607
  expect(controller.getPendingConsultationQuestionId()).toBe(firstQuestionId);
1604
- const currentRequest = getPendingRequestByCallSessionId(session.id);
1608
+ const currentRequest = getPendingCanonicalRequestByCallSessionId(session.id);
1605
1609
  expect(currentRequest!.id).toBe(firstRequest!.id);
1606
1610
  expect(currentRequest!.status).toBe('pending');
1607
1611
 
@@ -1623,7 +1627,7 @@ describe('call-controller', () => {
1623
1627
  await controller.handleCallerUtterance('Send email');
1624
1628
  await new Promise((r) => setTimeout(r, 50));
1625
1629
 
1626
- const firstRequest = getPendingRequestByCallSessionId(session.id);
1630
+ const firstRequest = getPendingCanonicalRequestByCallSessionId(session.id);
1627
1631
  expect(firstRequest).not.toBeNull();
1628
1632
  expect(firstRequest!.toolName).toBe('send_email');
1629
1633
 
@@ -1640,18 +1644,15 @@ describe('call-controller', () => {
1640
1644
  await new Promise((r) => setTimeout(r, 100));
1641
1645
 
1642
1646
  // New consultation should be active
1643
- const secondRequest = getPendingRequestByCallSessionId(session.id);
1647
+ const secondRequest = getPendingCanonicalRequestByCallSessionId(session.id);
1644
1648
  expect(secondRequest).not.toBeNull();
1645
1649
  expect(secondRequest!.id).not.toBe(firstRequest!.id);
1646
1650
  expect(secondRequest!.toolName).toBe('calendar_create');
1647
1651
 
1648
- // Old request should be expired with 'superseded' reason
1649
- const expiredRequest = getGuardianActionRequest(firstRequest!.id);
1652
+ // Old request should be expired (superseded by the new one)
1653
+ const expiredRequest = getCanonicalGuardianRequest(firstRequest!.id);
1650
1654
  expect(expiredRequest).not.toBeNull();
1651
1655
  expect(expiredRequest!.status).toBe('expired');
1652
- expect(expiredRequest!.expiredReason).toBe('superseded');
1653
- expect(expiredRequest!.supersededByRequestId).toBe(secondRequest!.id);
1654
- expect(expiredRequest!.supersededAt).not.toBeNull();
1655
1656
 
1656
1657
  controller.destroy();
1657
1658
  });
@@ -1671,7 +1672,7 @@ describe('call-controller', () => {
1671
1672
  await controller.handleCallerUtterance('Send email to Bob');
1672
1673
  await new Promise((r) => setTimeout(r, 50));
1673
1674
 
1674
- const firstRequest = getPendingRequestByCallSessionId(session.id);
1675
+ const firstRequest = getPendingCanonicalRequestByCallSessionId(session.id);
1675
1676
  expect(firstRequest).not.toBeNull();
1676
1677
  expect(firstRequest!.toolName).toBe('send_email');
1677
1678
 
@@ -1685,7 +1686,7 @@ describe('call-controller', () => {
1685
1686
  await new Promise((r) => setTimeout(r, 50));
1686
1687
 
1687
1688
  // Should coalesce: the inherited tool metadata matches the existing consultation
1688
- const currentRequest = getPendingRequestByCallSessionId(session.id);
1689
+ const currentRequest = getPendingCanonicalRequestByCallSessionId(session.id);
1689
1690
  expect(currentRequest!.id).toBe(firstRequest!.id);
1690
1691
  expect(currentRequest!.status).toBe('pending');
1691
1692