@vellumai/assistant 0.3.27 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (247) hide show
  1. package/ARCHITECTURE.md +81 -4
  2. package/Dockerfile +2 -2
  3. package/bun.lock +4 -1
  4. package/docs/trusted-contact-access.md +9 -2
  5. package/package.json +6 -3
  6. package/scripts/ipc/generate-swift.ts +9 -5
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +80 -0
  8. package/src/__tests__/agent-loop-thinking.test.ts +1 -1
  9. package/src/__tests__/agent-loop.test.ts +119 -0
  10. package/src/__tests__/approval-routes-http.test.ts +13 -5
  11. package/src/__tests__/asset-materialize-tool.test.ts +2 -0
  12. package/src/__tests__/asset-search-tool.test.ts +2 -0
  13. package/src/__tests__/assistant-events-sse-hardening.test.ts +4 -2
  14. package/src/__tests__/attachments-store.test.ts +2 -0
  15. package/src/__tests__/browser-skill-endstate.test.ts +3 -3
  16. package/src/__tests__/bundled-asset.test.ts +107 -0
  17. package/src/__tests__/call-controller.test.ts +30 -29
  18. package/src/__tests__/call-routes-http.test.ts +34 -32
  19. package/src/__tests__/call-start-guardian-guard.test.ts +2 -0
  20. package/src/__tests__/canonical-guardian-store.test.ts +636 -0
  21. package/src/__tests__/channel-approval-routes.test.ts +174 -1
  22. package/src/__tests__/channel-invite-transport.test.ts +6 -6
  23. package/src/__tests__/channel-reply-delivery.test.ts +19 -0
  24. package/src/__tests__/channel-retry-sweep.test.ts +130 -0
  25. package/src/__tests__/clarification-resolver.test.ts +2 -0
  26. package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
  27. package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
  28. package/src/__tests__/commit-message-enrichment-service.test.ts +9 -1
  29. package/src/__tests__/computer-use-session-lifecycle.test.ts +2 -0
  30. package/src/__tests__/computer-use-session-working-dir.test.ts +1 -0
  31. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +2 -0
  32. package/src/__tests__/config-schema.test.ts +5 -5
  33. package/src/__tests__/config-watcher.test.ts +3 -1
  34. package/src/__tests__/connection-policy.test.ts +14 -5
  35. package/src/__tests__/contacts-tools.test.ts +3 -1
  36. package/src/__tests__/contradiction-checker.test.ts +2 -0
  37. package/src/__tests__/conversation-pairing.test.ts +10 -0
  38. package/src/__tests__/conversation-routes.test.ts +1 -1
  39. package/src/__tests__/credential-security-invariants.test.ts +16 -6
  40. package/src/__tests__/credential-vault-unit.test.ts +2 -2
  41. package/src/__tests__/credential-vault.test.ts +5 -4
  42. package/src/__tests__/daemon-lifecycle.test.ts +9 -0
  43. package/src/__tests__/daemon-server-session-init.test.ts +27 -0
  44. package/src/__tests__/elevenlabs-config.test.ts +2 -0
  45. package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
  46. package/src/__tests__/encrypted-store.test.ts +10 -5
  47. package/src/__tests__/followup-tools.test.ts +3 -1
  48. package/src/__tests__/gateway-only-enforcement.test.ts +21 -21
  49. package/src/__tests__/gmail-integration.test.ts +0 -1
  50. package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
  51. package/src/__tests__/guardian-control-plane-policy.test.ts +19 -19
  52. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
  53. package/src/__tests__/guardian-dispatch.test.ts +21 -19
  54. package/src/__tests__/guardian-grant-minting.test.ts +68 -1
  55. package/src/__tests__/guardian-outbound-http.test.ts +12 -9
  56. package/src/__tests__/guardian-routing-invariants.test.ts +1092 -0
  57. package/src/__tests__/handle-user-message-secret-resume.test.ts +1 -0
  58. package/src/__tests__/handlers-slack-config.test.ts +3 -1
  59. package/src/__tests__/handlers-telegram-config.test.ts +3 -1
  60. package/src/__tests__/handlers-twilio-config.test.ts +3 -1
  61. package/src/__tests__/handlers-twitter-config.test.ts +3 -1
  62. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +318 -0
  63. package/src/__tests__/heartbeat-service.test.ts +20 -0
  64. package/src/__tests__/inbound-invite-redemption.test.ts +33 -0
  65. package/src/__tests__/ingress-reconcile.test.ts +3 -1
  66. package/src/__tests__/ingress-routes-http.test.ts +231 -4
  67. package/src/__tests__/intent-routing.test.ts +2 -0
  68. package/src/__tests__/ipc-snapshot.test.ts +13 -0
  69. package/src/__tests__/mcp-cli.test.ts +77 -0
  70. package/src/__tests__/media-generate-image.test.ts +21 -0
  71. package/src/__tests__/media-reuse-story.e2e.test.ts +2 -0
  72. package/src/__tests__/memory-regressions.test.ts +20 -20
  73. package/src/__tests__/non-member-access-request.test.ts +212 -36
  74. package/src/__tests__/notification-decision-fallback.test.ts +63 -3
  75. package/src/__tests__/notification-decision-strategy.test.ts +78 -0
  76. package/src/__tests__/notification-guardian-path.test.ts +15 -15
  77. package/src/__tests__/oauth-connect-handler.test.ts +3 -1
  78. package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
  79. package/src/__tests__/onboarding-starter-tasks.test.ts +4 -4
  80. package/src/__tests__/onboarding-template-contract.test.ts +116 -21
  81. package/src/__tests__/pairing-routes.test.ts +171 -0
  82. package/src/__tests__/playbook-execution.test.ts +3 -1
  83. package/src/__tests__/playbook-tools.test.ts +3 -1
  84. package/src/__tests__/provider-error-scenarios.test.ts +59 -8
  85. package/src/__tests__/proxy-approval-callback.test.ts +2 -0
  86. package/src/__tests__/recording-handler.test.ts +11 -0
  87. package/src/__tests__/recording-intent-handler.test.ts +15 -0
  88. package/src/__tests__/recording-state-machine.test.ts +13 -2
  89. package/src/__tests__/registry.test.ts +7 -3
  90. package/src/__tests__/relay-server.test.ts +148 -28
  91. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  92. package/src/__tests__/runtime-events-sse-parity.test.ts +21 -0
  93. package/src/__tests__/runtime-events-sse.test.ts +4 -2
  94. package/src/__tests__/sandbox-diagnostics.test.ts +2 -0
  95. package/src/__tests__/schedule-tools.test.ts +3 -1
  96. package/src/__tests__/secret-scanner-executor.test.ts +59 -0
  97. package/src/__tests__/secret-scanner.test.ts +8 -0
  98. package/src/__tests__/send-endpoint-busy.test.ts +4 -0
  99. package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
  100. package/src/__tests__/session-abort-tool-results.test.ts +23 -0
  101. package/src/__tests__/session-agent-loop.test.ts +16 -0
  102. package/src/__tests__/session-conflict-gate.test.ts +21 -0
  103. package/src/__tests__/session-load-history-repair.test.ts +27 -17
  104. package/src/__tests__/session-pre-run-repair.test.ts +23 -0
  105. package/src/__tests__/session-profile-injection.test.ts +21 -0
  106. package/src/__tests__/session-provider-retry-repair.test.ts +20 -0
  107. package/src/__tests__/session-queue.test.ts +23 -0
  108. package/src/__tests__/session-runtime-assembly.test.ts +126 -59
  109. package/src/__tests__/session-skill-tools.test.ts +27 -5
  110. package/src/__tests__/session-slash-known.test.ts +23 -0
  111. package/src/__tests__/session-slash-queue.test.ts +23 -0
  112. package/src/__tests__/session-slash-unknown.test.ts +23 -0
  113. package/src/__tests__/session-workspace-cache-state.test.ts +7 -0
  114. package/src/__tests__/session-workspace-injection.test.ts +21 -0
  115. package/src/__tests__/session-workspace-tool-tracking.test.ts +21 -0
  116. package/src/__tests__/shell-credential-ref.test.ts +2 -0
  117. package/src/__tests__/skill-feature-flags-integration.test.ts +6 -6
  118. package/src/__tests__/skill-load-feature-flag.test.ts +5 -4
  119. package/src/__tests__/skill-projection-feature-flag.test.ts +22 -0
  120. package/src/__tests__/skills.test.ts +8 -4
  121. package/src/__tests__/slack-channel-config.test.ts +3 -1
  122. package/src/__tests__/subagent-tools.test.ts +19 -0
  123. package/src/__tests__/swarm-recursion.test.ts +2 -0
  124. package/src/__tests__/swarm-session-integration.test.ts +2 -0
  125. package/src/__tests__/swarm-tool.test.ts +2 -0
  126. package/src/__tests__/system-prompt.test.ts +3 -1
  127. package/src/__tests__/task-compiler.test.ts +3 -1
  128. package/src/__tests__/task-management-tools.test.ts +3 -1
  129. package/src/__tests__/task-tools.test.ts +3 -1
  130. package/src/__tests__/terminal-sandbox.test.ts +13 -12
  131. package/src/__tests__/terminal-tools.test.ts +2 -0
  132. package/src/__tests__/tool-approval-handler.test.ts +15 -15
  133. package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -0
  134. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -0
  135. package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
  136. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +48 -0
  137. package/src/__tests__/trusted-contact-multichannel.test.ts +22 -19
  138. package/src/__tests__/trusted-contact-verification.test.ts +91 -0
  139. package/src/__tests__/twilio-routes-elevenlabs.test.ts +2 -0
  140. package/src/__tests__/twitter-auth-handler.test.ts +3 -1
  141. package/src/__tests__/twitter-cli-routing.test.ts +3 -1
  142. package/src/__tests__/view-image-tool.test.ts +3 -1
  143. package/src/__tests__/voice-invite-redemption.test.ts +329 -0
  144. package/src/__tests__/voice-scoped-grant-consumer.test.ts +7 -5
  145. package/src/__tests__/voice-session-bridge.test.ts +10 -10
  146. package/src/__tests__/work-item-output.test.ts +3 -1
  147. package/src/__tests__/workspace-lifecycle.test.ts +13 -2
  148. package/src/agent/loop.ts +46 -3
  149. package/src/approvals/guardian-decision-primitive.ts +285 -0
  150. package/src/approvals/guardian-request-resolvers.ts +539 -0
  151. package/src/calls/call-controller.ts +26 -23
  152. package/src/calls/guardian-action-sweep.ts +10 -2
  153. package/src/calls/guardian-dispatch.ts +46 -40
  154. package/src/calls/relay-server.ts +358 -24
  155. package/src/calls/types.ts +1 -1
  156. package/src/calls/voice-session-bridge.ts +3 -3
  157. package/src/cli.ts +12 -0
  158. package/src/config/agent-schema.ts +14 -3
  159. package/src/config/calls-schema.ts +6 -6
  160. package/src/config/core-schema.ts +3 -3
  161. package/src/config/feature-flag-registry.json +8 -0
  162. package/src/config/mcp-schema.ts +1 -1
  163. package/src/config/memory-schema.ts +27 -19
  164. package/src/config/schema.ts +21 -21
  165. package/src/config/skills-schema.ts +7 -7
  166. package/src/config/system-prompt.ts +2 -1
  167. package/src/config/templates/BOOTSTRAP.md +47 -31
  168. package/src/config/templates/USER.md +5 -0
  169. package/src/config/update-bulletin-template-path.ts +4 -1
  170. package/src/config/vellum-skills/trusted-contacts/SKILL.md +149 -21
  171. package/src/daemon/handlers/config-inbox.ts +4 -4
  172. package/src/daemon/handlers/guardian-actions.ts +45 -66
  173. package/src/daemon/handlers/sessions.ts +148 -4
  174. package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
  175. package/src/daemon/ipc-contract/messages.ts +16 -0
  176. package/src/daemon/ipc-contract-inventory.json +1 -0
  177. package/src/daemon/lifecycle.ts +22 -16
  178. package/src/daemon/pairing-store.ts +86 -3
  179. package/src/daemon/server.ts +18 -0
  180. package/src/daemon/session-agent-loop-handlers.ts +5 -4
  181. package/src/daemon/session-agent-loop.ts +33 -6
  182. package/src/daemon/session-lifecycle.ts +25 -17
  183. package/src/daemon/session-memory.ts +2 -2
  184. package/src/daemon/session-process.ts +68 -326
  185. package/src/daemon/session-runtime-assembly.ts +119 -25
  186. package/src/daemon/session-tool-setup.ts +3 -2
  187. package/src/daemon/session.ts +4 -3
  188. package/src/home-base/prebuilt/seed.ts +2 -1
  189. package/src/hooks/templates.ts +2 -1
  190. package/src/memory/canonical-guardian-store.ts +586 -0
  191. package/src/memory/channel-guardian-store.ts +2 -0
  192. package/src/memory/conversation-crud.ts +7 -7
  193. package/src/memory/db-init.ts +20 -0
  194. package/src/memory/embedding-local.ts +257 -39
  195. package/src/memory/embedding-runtime-manager.ts +471 -0
  196. package/src/memory/guardian-action-store.ts +7 -60
  197. package/src/memory/guardian-approvals.ts +9 -4
  198. package/src/memory/guardian-bindings.ts +25 -1
  199. package/src/memory/indexer.ts +3 -3
  200. package/src/memory/ingress-invite-store.ts +45 -0
  201. package/src/memory/job-handlers/backfill.ts +16 -9
  202. package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
  203. package/src/memory/migrations/037-voice-invite-columns.ts +16 -0
  204. package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
  205. package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
  206. package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
  207. package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
  208. package/src/memory/migrations/index.ts +5 -0
  209. package/src/memory/migrations/registry.ts +5 -0
  210. package/src/memory/qdrant-client.ts +31 -22
  211. package/src/memory/schema-migration.ts +1 -0
  212. package/src/memory/schema.ts +56 -0
  213. package/src/notifications/copy-composer.ts +31 -4
  214. package/src/notifications/decision-engine.ts +57 -0
  215. package/src/permissions/defaults.ts +2 -0
  216. package/src/runtime/access-request-helper.ts +173 -0
  217. package/src/runtime/actor-trust-resolver.ts +221 -0
  218. package/src/runtime/channel-guardian-service.ts +12 -4
  219. package/src/runtime/channel-invite-transports/voice.ts +58 -0
  220. package/src/runtime/channel-retry-sweep.ts +18 -6
  221. package/src/runtime/guardian-context-resolver.ts +38 -71
  222. package/src/runtime/guardian-decision-types.ts +6 -0
  223. package/src/runtime/guardian-reply-router.ts +717 -0
  224. package/src/runtime/http-server.ts +8 -0
  225. package/src/runtime/ingress-service.ts +80 -3
  226. package/src/runtime/invite-redemption-service.ts +141 -2
  227. package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
  228. package/src/runtime/routes/channel-route-shared.ts +1 -1
  229. package/src/runtime/routes/channel-routes.ts +1 -1
  230. package/src/runtime/routes/conversation-routes.ts +20 -2
  231. package/src/runtime/routes/guardian-action-routes.ts +100 -109
  232. package/src/runtime/routes/guardian-approval-interception.ts +17 -6
  233. package/src/runtime/routes/inbound-message-handler.ts +205 -529
  234. package/src/runtime/routes/ingress-routes.ts +52 -4
  235. package/src/runtime/routes/pairing-routes.ts +3 -0
  236. package/src/runtime/tool-grant-request-helper.ts +195 -0
  237. package/src/tools/executor.ts +13 -1
  238. package/src/tools/guardian-control-plane-policy.ts +2 -2
  239. package/src/tools/sensitive-output-placeholders.ts +203 -0
  240. package/src/tools/tool-approval-handler.ts +53 -10
  241. package/src/tools/types.ts +13 -2
  242. package/src/util/bundled-asset.ts +31 -0
  243. package/src/util/canonicalize-identity.ts +52 -0
  244. package/src/util/logger.ts +20 -8
  245. package/src/util/platform.ts +10 -0
  246. package/src/util/voice-code.ts +29 -0
  247. package/src/daemon/guardian-invite-intent.ts +0 -124
package/ARCHITECTURE.md CHANGED
@@ -18,7 +18,7 @@ This document owns assistant-runtime architecture details. The repo-level archit
18
18
  - The same resolver is used by:
19
19
  - `/channels/inbound` (Telegram/SMS/WhatsApp path) before run orchestration.
20
20
  - Inbound Twilio voice setup (`RelayConnection.handleSetup`) to seed call-time actor context.
21
- - Runtime channel runs pass this as `guardianContext`, and session runtime assembly injects `<guardian_context>` into provider-facing prompts.
21
+ - Runtime channel runs pass this as `guardianContext`, and session runtime assembly injects `<inbound_actor_context>` (via `inboundActorContextFromGuardian()`) into provider-facing prompts.
22
22
  - Voice calls mirror the same prompt contract: `CallController` receives guardian context on setup and refreshes it immediately after successful voice challenge verification, so the first post-verification turn is grounded as `actor_role: guardian`.
23
23
  - Voice-specific behavior (DTMF/speech verification flow, relay state machine) remains voice-local; only actor-role resolution is shared.
24
24
 
@@ -62,6 +62,41 @@ All guardian approval decisions — regardless of how they arrive — route thro
62
62
  | `src/daemon/ipc-contract/guardian-actions.ts` | IPC message type definitions for guardian action requests/responses |
63
63
  | `src/runtime/channel-approval-types.ts` | Channel-facing approval action types and `toApprovalActionOptions` bridge |
64
64
 
65
+ ### Canonical Guardian Request System
66
+
67
+ The canonical guardian request system provides a channel-agnostic, unified domain for all guardian approval and question flows. It replaces the fragmented per-channel storage with a single source of truth that works identically for voice calls, Telegram/SMS/WhatsApp, and desktop UI.
68
+
69
+ **Architecture layers:**
70
+
71
+ 1. **Canonical domain (single source of truth):** All guardian requests — tool approvals, pending questions, access requests — are persisted in the `canonical_guardian_requests` table (`src/memory/canonical-guardian-store.js`). Each request has a unique ID, a short human-readable request code, and a status that follows a CAS (compare-and-swap) lifecycle: `pending` -> `approved` | `denied` | `expired` | `cancelled`. Deliveries (notifications sent to guardians) are tracked in `canonical_guardian_deliveries`.
72
+
73
+ 2. **Unified apply primitive (single write path):** `applyCanonicalGuardianDecision()` in `src/approvals/guardian-decision-primitive.ts` is the single write path for all guardian decisions. It enforces identity validation, expiry checks, CAS resolution, `approve_always` downgrade (guardian-on-behalf invariant), kind-specific resolver dispatch via the resolver registry, and scoped grant minting. All callers — HTTP API, IPC handlers, inbound channel router, desktop session — route decisions through this function.
74
+
75
+ 3. **Shared reply router (priority-ordered routing):** `routeGuardianReply()` in `src/runtime/guardian-reply-router.ts` provides a single entry point for all inbound guardian reply processing across channels. It routes through a priority-ordered pipeline: (a) deterministic callback parsing (button presses with `apr:<requestId>:<action>`), (b) request code parsing (6-char alphanumeric prefix), (c) NL classification via the conversational approval engine. All decisions flow through `applyCanonicalGuardianDecision`.
76
+
77
+ 4. **Deterministic API (prompt listing and decision endpoints):** Desktop clients and API consumers use `GET /v1/guardian-actions/pending` and `POST /v1/guardian-actions/decision` (HTTP) or the equivalent IPC messages. These endpoints surface canonical requests alongside legacy pending interactions and channel approval records, with deduplication to avoid double-rendering.
78
+
79
+ 5. **Dual-mode (deterministic + conversational):** Guardians can respond via structured button UIs (deterministic path) or free-text conversation (NL path). Both paths converge on the same canonical primitive. Code-only messages (just a request code without decision text) return clarification instead of auto-approving. Disambiguation with multiple pending requests stays fail-closed — no auto-resolve when the target is ambiguous.
80
+
81
+ **Resolver registry:** Kind-specific resolvers (`src/approvals/guardian-request-resolvers.ts`) handle side effects after CAS resolution. Built-in resolvers: `tool_approval` (channel/desktop approval path) and `pending_question` (voice call question path). New request kinds register resolvers without touching the core primitive.
82
+
83
+ **Expiry sweeps:** Three complementary sweeps run on 60-second intervals to clean up stale requests:
84
+ - `src/calls/guardian-action-sweep.ts` — voice call guardian action requests
85
+ - `src/runtime/routes/guardian-expiry-sweep.ts` — channel guardian approval requests
86
+ - `src/runtime/routes/canonical-guardian-expiry-sweep.ts` — canonical guardian requests (CAS-safe)
87
+
88
+ **Key source files:**
89
+
90
+ | File | Purpose |
91
+ |------|---------|
92
+ | `src/memory/canonical-guardian-store.ts` | Canonical request and delivery persistence (CRUD, CAS resolve, list with filters) |
93
+ | `src/approvals/guardian-decision-primitive.ts` | Unified decision primitive: `applyCanonicalGuardianDecision` (canonical) and `applyGuardianDecision` (legacy) |
94
+ | `src/approvals/guardian-request-resolvers.ts` | Resolver registry: kind-specific side-effect dispatch after CAS resolution |
95
+ | `src/runtime/guardian-reply-router.ts` | Shared inbound router: callback -> code -> NL classification pipeline |
96
+ | `src/runtime/routes/guardian-action-routes.ts` | HTTP endpoints for prompt listing and decision submission |
97
+ | `src/daemon/handlers/guardian-actions.ts` | IPC handlers for desktop socket clients |
98
+ | `src/runtime/routes/canonical-guardian-expiry-sweep.ts` | Canonical request expiry sweep |
99
+
65
100
  ### Outbound Guardian Verification (HTTP Endpoints)
66
101
 
67
102
  Guardian verification can be initiated through gateway HTTP endpoints (which forward to runtime handlers) as an alternative to the legacy IPC-only flow. This enables chat-first verification where the assistant guides the user through guardian setup via normal conversation.
@@ -357,26 +392,56 @@ A complementary access-granting flow where the guardian proactively creates a sh
357
392
 
358
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.
359
394
 
360
- **Deferred channel adapters:**
395
+ **Channel adapter status:**
361
396
 
362
397
  | Channel | Status | Prerequisites |
363
398
  |---------|--------|--------------|
364
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). |
365
401
  | SMS | Deferred | Needs a deep-link strategy compatible with SMS (short URL or web redemption page) |
366
402
  | Slack | Deferred | Needs DM-safe ingress — Socket Mode handles channel messages but DM-initiated invite flows need routing |
367
- | 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.
368
428
 
369
429
  **Key source files:**
370
430
 
371
431
  | File | Purpose |
372
432
  |------|---------|
373
- | `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 |
374
434
  | `src/runtime/invite-redemption-templates.ts` | Deterministic reply templates for each redemption outcome |
375
435
  | `src/runtime/channel-invite-transport.ts` | Transport adapter registry with `buildShareableInvite` / `extractInboundToken` interface |
376
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 |
377
438
  | `src/daemon/guardian-invite-intent.ts` | Intent detection — routes create/list/revoke requests into the trusted-contacts skill |
378
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 |
379
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 |
380
445
 
381
446
  ### Update Bulletin System
382
447
 
@@ -1874,6 +1939,18 @@ Connected channels are resolved at signal emission time: vellum is always includ
1874
1939
 
1875
1940
 
1876
1941
 
1942
+ ### Sensitive Tool Output Placeholder Substitution
1943
+
1944
+ Some tool outputs contain values that must reach the user's final reply but should never be visible to the LLM (e.g., invite tokens). The system handles this with a three-stage pipeline:
1945
+
1946
+ 1. **Directive extraction** (`src/tools/sensitive-output-placeholders.ts`): Tool output may include `<vellum-sensitive-output kind="invite_code" value="<raw>" />` directives. The executor strips directives, replaces raw values with deterministic placeholders (`VELLUM_ASSISTANT_INVITE_CODE_<shortId>`), and attaches `sensitiveBindings` metadata to the tool result.
1947
+
1948
+ 2. **Placeholder-only model context**: The agent loop stores placeholder->value bindings in a per-run `substitutionMap`. Tool results sent to the LLM contain only placeholders — the model generates conversational text referencing them without ever seeing the real values.
1949
+
1950
+ 3. **Post-generation substitution** (`src/agent/loop.ts`): Before emitting streamed `text_delta` events and before building the final `assistantMessage`, all placeholders are deterministically replaced with their real values. The substitution is chunk-safe for streaming (buffering partial placeholder prefixes across deltas).
1951
+
1952
+ Key files: `src/tools/sensitive-output-placeholders.ts`, `src/tools/executor.ts` (extraction hook), `src/agent/loop.ts` (substitution), `src/config/vellum-skills/trusted-contacts/SKILL.md` (invite flow adoption).
1953
+
1877
1954
  ### Notifications
1878
1955
 
1879
1956
  For full notification developer guidance and lifecycle details, see [`assistant/src/notifications/README.md`](src/notifications/README.md).
package/Dockerfile CHANGED
@@ -1,5 +1,5 @@
1
1
  # Use debian as base image
2
- FROM debian:bookworm AS builder
2
+ FROM debian:trixie@sha256:3615a749858a1cba49b408fb49c37093db813321355a9ab7c1f9f4836341e9db AS builder
3
3
 
4
4
  WORKDIR /app
5
5
 
@@ -26,7 +26,7 @@ RUN bun install --frozen-lockfile
26
26
  COPY . .
27
27
 
28
28
  # Final stage
29
- FROM debian:bookworm AS runner
29
+ FROM debian:trixie@sha256:3615a749858a1cba49b408fb49c37093db813321355a9ab7c1f9f4836341e9db AS runner
30
30
 
31
31
  WORKDIR /app
32
32
 
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.27",
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
 
@@ -411,14 +411,18 @@ function emitStruct(s: SwiftStruct): string {
411
411
  const lines: string[] = [];
412
412
 
413
413
  if (s.doc) {
414
- lines.push(`/// ${s.doc}`);
414
+ for (const docLine of s.doc.split('\n')) {
415
+ lines.push(`/// ${docLine}`);
416
+ }
415
417
  }
416
418
 
417
419
  lines.push(`public struct ${s.name}: Codable, Sendable {`);
418
420
 
419
421
  for (const p of s.properties) {
420
422
  if (p.doc) {
421
- lines.push(` /// ${p.doc}`);
423
+ for (const docLine of p.doc.split('\n')) {
424
+ lines.push(` /// ${docLine}`);
425
+ }
422
426
  }
423
427
  lines.push(` public let ${p.swiftName}: ${p.swiftType}`);
424
428
  }
@@ -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 () => {
@@ -1167,4 +1167,123 @@ describe('AgentLoop', () => {
1167
1167
  expect(sentBlock).toBeDefined();
1168
1168
  expect(sentBlock!.content).toBe(smallContent);
1169
1169
  });
1170
+
1171
+ // ---------------------------------------------------------------------------
1172
+ // Sensitive output placeholder substitution tests
1173
+ // ---------------------------------------------------------------------------
1174
+
1175
+ // 32. Tool results with sensitiveBindings populate substitution map and
1176
+ // final assistant message text is resolved with real values.
1177
+ test('resolves sensitive output placeholders in final assistant message', async () => {
1178
+ const placeholder = 'VELLUM_ASSISTANT_INVITE_CODE_TEST1234';
1179
+ const realToken = 'realInviteToken999';
1180
+
1181
+ const { provider, calls } = createMockProvider([
1182
+ toolUseResponse('t1', 'bash', { command: 'create invite' }),
1183
+ // The LLM responds using the placeholder (it never saw the real token)
1184
+ textResponse(`Here is your invite link: https://t.me/bot?start=iv_${placeholder}`),
1185
+ ]);
1186
+
1187
+ const toolExecutor = async () => ({
1188
+ content: `https://t.me/bot?start=iv_${placeholder}`,
1189
+ isError: false,
1190
+ sensitiveBindings: [{ kind: 'invite_code' as const, placeholder, value: realToken }],
1191
+ });
1192
+
1193
+ const loop = new AgentLoop(provider, 'system', {}, dummyTools, toolExecutor);
1194
+ const events: AgentEvent[] = [];
1195
+ const history = await loop.run([userMessage], collectEvents(events));
1196
+
1197
+ // The final assistant message in HISTORY should retain placeholders
1198
+ // (so the model never sees real values on subsequent turns)
1199
+ const lastAssistant = history[history.length - 1];
1200
+ expect(lastAssistant.role).toBe('assistant');
1201
+ const historyTextBlock = lastAssistant.content.find(
1202
+ (b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text',
1203
+ );
1204
+ expect(historyTextBlock).toBeDefined();
1205
+ expect(historyTextBlock!.text).toContain(placeholder);
1206
+ expect(historyTextBlock!.text).not.toContain(realToken);
1207
+
1208
+ // The message_complete EVENT should also retain placeholders (persisted
1209
+ // to conversation store; real values leak on session reload otherwise)
1210
+ const completeEvents = events.filter(
1211
+ (e): e is Extract<AgentEvent, { type: 'message_complete' }> => e.type === 'message_complete',
1212
+ );
1213
+ const lastComplete = completeEvents[completeEvents.length - 1];
1214
+ const completeText = lastComplete.message.content.find(
1215
+ (b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text',
1216
+ );
1217
+ expect(completeText!.text).toContain(placeholder);
1218
+ expect(completeText!.text).not.toContain(realToken);
1219
+
1220
+ // The tool result content in provider history should contain the PLACEHOLDER,
1221
+ // NOT the raw token (model never sees the real value)
1222
+ const secondCallMessages = calls[1].messages;
1223
+ const toolResultMsg = secondCallMessages.find(
1224
+ (m) => m.role === 'user' && m.content.some((b) => b.type === 'tool_result'),
1225
+ );
1226
+ expect(toolResultMsg).toBeDefined();
1227
+ const toolResultBlock = toolResultMsg!.content.find(
1228
+ (b): b is Extract<ContentBlock, { type: 'tool_result' }> => b.type === 'tool_result',
1229
+ );
1230
+ expect(toolResultBlock!.content).toContain(placeholder);
1231
+ expect(toolResultBlock!.content).not.toContain(realToken);
1232
+ });
1233
+
1234
+ // 33. Streamed text_delta events have placeholders resolved to real values
1235
+ test('resolves sensitive output placeholders in streamed text_delta events', async () => {
1236
+ const placeholder = 'VELLUM_ASSISTANT_INVITE_CODE_STRM5678';
1237
+ const realToken = 'streamedRealToken';
1238
+
1239
+ const { provider } = createMockProvider([
1240
+ toolUseResponse('t1', 'bash', { command: 'invite' }),
1241
+ // Response text includes the placeholder
1242
+ textResponse(`Link: https://t.me/bot?start=iv_${placeholder}`),
1243
+ ]);
1244
+
1245
+ const toolExecutor = async () => ({
1246
+ content: `https://t.me/bot?start=iv_${placeholder}`,
1247
+ isError: false,
1248
+ sensitiveBindings: [{ kind: 'invite_code' as const, placeholder, value: realToken }],
1249
+ });
1250
+
1251
+ const loop = new AgentLoop(provider, 'system', {}, dummyTools, toolExecutor);
1252
+ const events: AgentEvent[] = [];
1253
+ await loop.run([userMessage], collectEvents(events));
1254
+
1255
+ // Collect all text_delta events from the final turn (after tool result)
1256
+ const textDeltas = events.filter(
1257
+ (e): e is Extract<AgentEvent, { type: 'text_delta' }> => e.type === 'text_delta',
1258
+ );
1259
+ const allStreamedText = textDeltas.map((e) => e.text).join('');
1260
+
1261
+ // Streamed text should contain the real token, not the placeholder
1262
+ expect(allStreamedText).toContain(realToken);
1263
+ expect(allStreamedText).not.toContain(placeholder);
1264
+ });
1265
+
1266
+ // 34. Without sensitive bindings, text passes through unchanged
1267
+ test('text passes through unchanged when no sensitive bindings exist', async () => {
1268
+ const { provider } = createMockProvider([
1269
+ toolUseResponse('t1', 'read_file', { path: '/test.txt' }),
1270
+ textResponse('Normal response with no placeholders.'),
1271
+ ]);
1272
+
1273
+ const toolExecutor = async () => ({
1274
+ content: 'file contents',
1275
+ isError: false,
1276
+ // No sensitiveBindings
1277
+ });
1278
+
1279
+ const loop = new AgentLoop(provider, 'system', {}, dummyTools, toolExecutor);
1280
+ const events: AgentEvent[] = [];
1281
+ const history = await loop.run([userMessage], collectEvents(events));
1282
+
1283
+ const lastAssistant = history[history.length - 1];
1284
+ const textBlock = lastAssistant.content.find(
1285
+ (b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text',
1286
+ );
1287
+ expect(textBlock!.text).toBe('Normal response with no placeholders.');
1288
+ });
1170
1289
  });
@@ -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);