@vellumai/assistant 0.10.3 → 0.10.4-staging.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) hide show
  1. package/openapi.yaml +73 -56
  2. package/package.json +1 -1
  3. package/src/__tests__/actor-trust-resolver-address-fallback.test.ts +83 -31
  4. package/src/__tests__/assistant-stream-state.test.ts +3 -76
  5. package/src/__tests__/background-workers-disk-pressure.test.ts +4 -2
  6. package/src/__tests__/channel-approval-routes.test.ts +21 -26
  7. package/src/__tests__/channel-delivery-store.test.ts +28 -0
  8. package/src/__tests__/channel-guardian.test.ts +82 -32
  9. package/src/__tests__/channel-inbound-disk-pressure.test.ts +11 -19
  10. package/src/__tests__/channel-reply-delivery.test.ts +6 -2
  11. package/src/__tests__/compaction-ledger-store.test.ts +128 -0
  12. package/src/__tests__/config-loader-backfill.test.ts +148 -0
  13. package/src/__tests__/consult-deadline.test.ts +60 -0
  14. package/src/__tests__/contact-store-interaction-info.test.ts +156 -0
  15. package/src/__tests__/contact-store-user-file.test.ts +7 -10
  16. package/src/__tests__/contacts-relay-reads.test.ts +6 -9
  17. package/src/__tests__/contacts-write.test.ts +0 -2
  18. package/src/__tests__/conversation-agent-loop-overflow.test.ts +4 -2
  19. package/src/__tests__/conversation-agent-loop.test.ts +98 -7
  20. package/src/__tests__/conversation-attention-telegram.test.ts +9 -11
  21. package/src/__tests__/conversation-error.test.ts +18 -0
  22. package/src/__tests__/conversation-fork-crud.test.ts +354 -24
  23. package/src/__tests__/conversation-title-service.test.ts +222 -201
  24. package/src/__tests__/db-compaction-events-migration.test.ts +129 -0
  25. package/src/__tests__/delete-propagation.test.ts +5 -3
  26. package/src/__tests__/dm-backfill.test.ts +6 -4
  27. package/src/__tests__/emit-signal-routing-intent.test.ts +2 -6
  28. package/src/__tests__/guardian-binding-drift-heal.test.ts +43 -23
  29. package/src/__tests__/guardian-dispatch.test.ts +50 -5
  30. package/src/__tests__/guardian-routing-state.test.ts +6 -10
  31. package/src/__tests__/helpers/channel-test-adapter.ts +45 -12
  32. package/src/__tests__/helpers/create-guardian-binding.ts +15 -23
  33. package/src/__tests__/helpers/mock-logger.ts +1 -0
  34. package/src/__tests__/helpers/seed-contact-channel.ts +96 -0
  35. package/src/__tests__/inbound-invite-redemption.test.ts +87 -10
  36. package/src/__tests__/invite-redemption-service.test.ts +273 -53
  37. package/src/__tests__/invite-routes-http.test.ts +34 -0
  38. package/src/__tests__/invite-service-ipc.test.ts +65 -2
  39. package/src/__tests__/list-messages-page-latest.test.ts +173 -4
  40. package/src/__tests__/mcp-config-secret-boundary.test.ts +3 -0
  41. package/src/__tests__/non-member-access-request.test.ts +15 -13
  42. package/src/__tests__/onboarding-persona-write.test.ts +52 -22
  43. package/src/__tests__/persist-onboarding-artifacts.test.ts +1 -0
  44. package/src/__tests__/persona-resolver.test.ts +75 -45
  45. package/src/__tests__/plugin-bootstrap.test.ts +13 -5
  46. package/src/__tests__/plugin-disabled-state.test.ts +190 -0
  47. package/src/__tests__/provider-usage-tracking.test.ts +1 -1
  48. package/src/__tests__/reaction-intercept-cold-cache-warm.test.ts +135 -0
  49. package/src/__tests__/reaction-intercept-member-verdict-warm.test.ts +158 -0
  50. package/src/__tests__/reaction-persistence.test.ts +51 -4
  51. package/src/__tests__/relay-server.test.ts +88 -31
  52. package/src/__tests__/runtime-attachment-metadata.test.ts +9 -11
  53. package/src/__tests__/settings-routes.test.ts +32 -0
  54. package/src/__tests__/slack-block-formatting.test.ts +1 -38
  55. package/src/__tests__/sse-actor-principal-guardian-source.test.ts +13 -36
  56. package/src/__tests__/stt-hints.test.ts +6 -3
  57. package/src/__tests__/subagent-fork-prompt-role.test.ts +195 -0
  58. package/src/__tests__/subagent-fork-spawn.test.ts +6 -7
  59. package/src/__tests__/subagent-role-registry.test.ts +17 -4
  60. package/src/__tests__/subagent-spawn-and-await.test.ts +546 -0
  61. package/src/__tests__/subagent-tools.test.ts +398 -3
  62. package/src/__tests__/thread-backfill.test.ts +3 -3
  63. package/src/__tests__/tool-preview-lifecycle.test.ts +26 -10
  64. package/src/__tests__/tool-start-timestamp.test.ts +4 -3
  65. package/src/__tests__/trusted-contact-approval-notifier.test.ts +37 -51
  66. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +2 -2
  67. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +9 -7
  68. package/src/__tests__/trusted-contact-multichannel.test.ts +16 -7
  69. package/src/__tests__/trusted-contact-verification.test.ts +79 -54
  70. package/src/__tests__/voice-guardian-cold-cache-warm.test.ts +137 -0
  71. package/src/__tests__/voice-invite-redemption.test.ts +183 -20
  72. package/src/__tests__/workspace-migration-102-preserve-heartbeat-enabled-for-existing-workspaces.test.ts +3 -3
  73. package/src/__tests__/workspace-migration-111-prune-seeded-callsite-defaults.test.ts +2 -2
  74. package/src/__tests__/workspace-migration-112-remove-advisor-callsite-override.test.ts +170 -0
  75. package/src/__tests__/workspace-migration-drop-user-md.test.ts +196 -238
  76. package/src/a2a/__tests__/e2e-a2a-channel.test.ts +35 -47
  77. package/src/agent/loop-exclusive-tool.test.ts +19 -15
  78. package/src/agent/loop-native-web-search.test.ts +200 -0
  79. package/src/agent/loop.ts +108 -1
  80. package/src/api/responses/conversation-message.ts +9 -0
  81. package/src/approvals/guardian-request-resolvers.ts +16 -4
  82. package/src/calls/__tests__/relay-setup-router.test.ts +10 -18
  83. package/src/calls/guardian-dispatch.ts +14 -11
  84. package/src/calls/inbound-trust-reader.ts +7 -1
  85. package/src/calls/relay-access-wait.ts +6 -6
  86. package/src/calls/relay-server.ts +22 -2
  87. package/src/calls/relay-setup-router.ts +10 -10
  88. package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -0
  89. package/src/cli/commands/contacts.ts +10 -7
  90. package/src/cli/commands/memory/__tests__/worker.test.ts +147 -17
  91. package/src/cli/commands/memory/worker.ts +97 -30
  92. package/src/cli/commands/plugins.ts +3 -146
  93. package/src/cli/lib/__tests__/list-installed-plugins.test.ts +17 -17
  94. package/src/cli/lib/__tests__/publish-plugin.test.ts +98 -0
  95. package/src/cli/lib/publish-plugin.ts +231 -1
  96. package/src/config/__tests__/sync-gated-profiles.test.ts +5 -7
  97. package/src/config/bundled-skills/subagent/SKILL.md +16 -1
  98. package/src/config/bundled-skills/subagent/TOOLS.json +5 -4
  99. package/src/config/call-site-defaults.ts +0 -6
  100. package/src/config/llm-resolver.ts +0 -3
  101. package/src/config/schemas/call-site-catalog.ts +0 -7
  102. package/src/config/schemas/heartbeat.ts +2 -5
  103. package/src/config/schemas/llm.ts +3 -12
  104. package/src/config/schemas/memory-lifecycle.ts +1 -1
  105. package/src/config/seed-inference-profiles.ts +76 -35
  106. package/src/config/sync-gated-profiles.ts +0 -3
  107. package/src/contacts/__tests__/contacts-write-revoke-relay.test.ts +7 -8
  108. package/src/contacts/__tests__/member-write-relay.test.ts +35 -11
  109. package/src/contacts/contact-store.ts +27 -237
  110. package/src/contacts/contacts-write.ts +18 -58
  111. package/src/contacts/gateway-channel-read.ts +51 -0
  112. package/src/contacts/member-write-relay.ts +25 -31
  113. package/src/contacts/types.ts +3 -15
  114. package/src/daemon/__tests__/conversation-tool-setup.test.ts +0 -44
  115. package/src/daemon/conversation-agent-loop-handlers.ts +29 -10
  116. package/src/daemon/conversation-agent-loop.ts +68 -61
  117. package/src/daemon/conversation-error.ts +7 -10
  118. package/src/daemon/conversation-tool-setup.ts +0 -10
  119. package/src/daemon/conversation.ts +10 -0
  120. package/src/daemon/external-plugins-bootstrap.ts +8 -2
  121. package/src/daemon/handlers/__tests__/config-a2a-accept.test.ts +0 -1
  122. package/src/daemon/handlers/__tests__/config-a2a-complete.test.ts +0 -2
  123. package/src/daemon/handlers/__tests__/config-a2a-redeem.test.ts +0 -2
  124. package/src/daemon/handlers/__tests__/config-channels.test.ts +9 -14
  125. package/src/daemon/handlers/config-channels.ts +14 -29
  126. package/src/daemon/lifecycle.ts +16 -4
  127. package/src/daemon/message-types/surfaces.ts +2 -0
  128. package/src/heartbeat/heartbeat-service.ts +5 -0
  129. package/src/home/relationship-state-writer.ts +5 -0
  130. package/src/memory/__tests__/embedding-cache.test.ts +136 -0
  131. package/src/memory/compaction-ledger-store.ts +107 -0
  132. package/src/memory/conversation-crud.ts +136 -61
  133. package/src/memory/conversation-title-service.ts +173 -24
  134. package/src/memory/embedding-backend.ts +8 -1
  135. package/src/memory/embedding-cache.ts +139 -0
  136. package/src/memory/jobs-worker.ts +75 -29
  137. package/src/memory/memory-retrospective-job.ts +5 -0
  138. package/src/memory/migrations/209-strip-thinking-from-consolidated.ts +27 -5
  139. package/src/memory/migrations/302-create-compaction-events.ts +107 -0
  140. package/src/memory/migrations/303-add-conversation-creation-seq.ts +33 -0
  141. package/src/memory/migrations/__tests__/209-strip-thinking-from-consolidated.test.ts +79 -6
  142. package/src/memory/schema/contacts.ts +6 -2
  143. package/src/memory/schema/conversations.ts +39 -0
  144. package/src/memory/steps.ts +1090 -367
  145. package/src/memory/worker-control.ts +104 -18
  146. package/src/memory/worker-process.ts +17 -0
  147. package/src/messaging/channel-binding-metadata.ts +31 -0
  148. package/src/messaging/channel-binding-schema.ts +51 -0
  149. package/src/messaging/providers/__tests__/callback-routing.test.ts +45 -0
  150. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +195 -0
  151. package/src/messaging/providers/a2a/__tests__/deliver.test.ts +11 -0
  152. package/src/messaging/providers/a2a/deliver.ts +5 -1
  153. package/src/messaging/providers/a2a/transport.ts +10 -0
  154. package/src/messaging/providers/callback-routing.ts +48 -0
  155. package/src/messaging/providers/channel-transport.ts +55 -0
  156. package/src/messaging/providers/index.ts +65 -241
  157. package/src/messaging/providers/slack/binding-metadata.ts +62 -0
  158. package/src/messaging/providers/slack/transport.ts +92 -0
  159. package/src/messaging/providers/telegram-bot/transport.ts +51 -0
  160. package/src/messaging/providers/whatsapp/transport.ts +38 -0
  161. package/src/notifications/__tests__/broadcaster.test.ts +0 -8
  162. package/src/notifications/__tests__/connected-channels.test.ts +8 -36
  163. package/src/notifications/__tests__/destination-resolver.test.ts +12 -117
  164. package/src/notifications/destination-resolver.ts +7 -23
  165. package/src/notifications/emit-signal.ts +5 -11
  166. package/src/plugins/defaults/index.ts +0 -35
  167. package/src/plugins/defaults/memory-v3-shadow/__tests__/dense.test.ts +11 -0
  168. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-dense-store.test.ts +243 -2
  169. package/src/plugins/defaults/memory-v3-shadow/section-dense-store.ts +167 -14
  170. package/src/plugins/disabled-state.ts +31 -0
  171. package/src/plugins/registry.ts +55 -12
  172. package/src/prompts/persona-resolver.ts +43 -11
  173. package/src/providers/call-site-routing.ts +41 -0
  174. package/src/providers/provider-send-message.ts +6 -0
  175. package/src/providers/ratelimit.ts +6 -0
  176. package/src/providers/registry.ts +1 -1
  177. package/src/providers/retry.ts +6 -0
  178. package/src/providers/types.ts +13 -0
  179. package/src/providers/usage-tracking.ts +6 -0
  180. package/src/runtime/__tests__/guardian-vellum-migration.test.ts +30 -27
  181. package/src/runtime/__tests__/local-principal-trust.test.ts +16 -18
  182. package/src/runtime/__tests__/member-verdict-cache.test.ts +119 -0
  183. package/src/runtime/__tests__/trust-verdict-consumer.test.ts +115 -168
  184. package/src/runtime/access-request-helper.ts +1 -2
  185. package/src/runtime/actor-trust-resolver.ts +44 -17
  186. package/src/runtime/anchored-guardian.test.ts +7 -54
  187. package/src/runtime/anchored-guardian.ts +4 -53
  188. package/src/runtime/assistant-stream-state.ts +12 -74
  189. package/src/runtime/channel-reply-delivery.ts +3 -8
  190. package/src/runtime/guardian-vellum-migration.ts +18 -16
  191. package/src/runtime/invite-redemption-service.ts +25 -10
  192. package/src/runtime/local-actor-identity.test.ts +108 -0
  193. package/src/runtime/local-actor-identity.ts +27 -20
  194. package/src/runtime/member-verdict-cache.ts +0 -0
  195. package/src/runtime/routes/__tests__/contact-routes.test.ts +100 -7
  196. package/src/runtime/routes/__tests__/global-search-routes.test.ts +1 -2
  197. package/src/runtime/routes/__tests__/surface-action-routes.test.ts +2 -1
  198. package/src/runtime/routes/contact-routes.ts +40 -25
  199. package/src/runtime/routes/conversation-list-routes.ts +1 -29
  200. package/src/runtime/routes/conversation-routes.ts +27 -7
  201. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +0 -10
  202. package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -8
  203. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +19 -0
  204. package/src/runtime/routes/settings-routes.ts +8 -3
  205. package/src/runtime/services/conversation-serializer.ts +6 -49
  206. package/src/runtime/slack-block-formatting.ts +0 -15
  207. package/src/runtime/trust-verdict-consumer.ts +36 -41
  208. package/src/subagent/__tests__/consult-prompt.test.ts +35 -0
  209. package/src/{plugins/defaults/advisor/__tests__/transcript.test.ts → subagent/__tests__/consult-transcript.test.ts} +47 -10
  210. package/src/{plugins/defaults/advisor/steering.ts → subagent/consult-prompt.ts} +17 -39
  211. package/src/{plugins/defaults/advisor/transcript.ts → subagent/consult-transcript.ts} +18 -8
  212. package/src/subagent/index.ts +1 -1
  213. package/src/subagent/manager.ts +245 -33
  214. package/src/subagent/types.ts +8 -1
  215. package/src/tools/registry.ts +10 -3
  216. package/src/tools/subagent/consult-deadline.ts +49 -0
  217. package/src/tools/subagent/spawn.ts +234 -5
  218. package/src/util/logger.ts +9 -0
  219. package/src/util/platform.ts +14 -0
  220. package/src/workspace/migrations/031-drop-user-md.ts +232 -148
  221. package/src/workspace/migrations/112-remove-advisor-callsite-override.ts +64 -0
  222. package/src/workspace/migrations/registry.ts +2 -0
  223. package/src/plugins/defaults/advisor/__tests__/advisor-gate.test.ts +0 -56
  224. package/src/plugins/defaults/advisor/__tests__/advisor-state-store.test.ts +0 -43
  225. package/src/plugins/defaults/advisor/__tests__/agent-loop-integration.test.ts +0 -137
  226. package/src/plugins/defaults/advisor/__tests__/consult.test.ts +0 -314
  227. package/src/plugins/defaults/advisor/__tests__/context-pack-gating.test.ts +0 -106
  228. package/src/plugins/defaults/advisor/__tests__/context-pack.test.ts +0 -60
  229. package/src/plugins/defaults/advisor/__tests__/hooks.test.ts +0 -138
  230. package/src/plugins/defaults/advisor/advisor-gate.ts +0 -29
  231. package/src/plugins/defaults/advisor/advisor-state-store.ts +0 -94
  232. package/src/plugins/defaults/advisor/config.ts +0 -21
  233. package/src/plugins/defaults/advisor/consult.ts +0 -197
  234. package/src/plugins/defaults/advisor/context-pack.ts +0 -288
  235. package/src/plugins/defaults/advisor/hooks/post-model-call.ts +0 -34
  236. package/src/plugins/defaults/advisor/hooks/pre-model-call.ts +0 -30
  237. package/src/plugins/defaults/advisor/hooks/user-prompt-submit.ts +0 -19
  238. package/src/plugins/defaults/advisor/package.json +0 -14
  239. package/src/plugins/defaults/advisor/tools/advisor.ts +0 -92
@@ -1,11 +1,14 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { basename, dirname, join } from "node:path";
3
3
 
4
+ import type { GuardianDelivery } from "@vellumai/gateway-client";
5
+
6
+ import { findContactByAddress } from "../contacts/contact-store.js";
4
7
  import {
5
- findContactByAddress,
6
- findGuardianForChannel,
7
- listGuardianChannels,
8
- } from "../contacts/contact-store.js";
8
+ anyGuardian,
9
+ guardianForChannel,
10
+ peekCachedGuardianDelivery,
11
+ } from "../contacts/guardian-delivery-reader.js";
9
12
  import type { ChannelCapabilities } from "../daemon/conversation-runtime-assembly.js";
10
13
  import type { TrustContext } from "../daemon/trust-context.js";
11
14
  import { getLogger } from "../util/logger.js";
@@ -87,8 +90,38 @@ function resolveGuardianUserFile(trustContext: TrustContext): string | null {
87
90
  return guardianContact.userFile ?? "guardian.md";
88
91
  }
89
92
  }
90
- const guardian = findGuardianForChannel(trustContext.sourceChannel);
91
- return guardian ? (guardian.contact.userFile ?? "guardian.md") : null;
93
+ const guardian = peekGuardianForChannel(trustContext.sourceChannel);
94
+ return guardian ? (guardianDeliveryUserFile(guardian) ?? "guardian.md") : null;
95
+ }
96
+
97
+ /**
98
+ * Resolve the local INFO `userFile` for a gateway guardian delivery. The
99
+ * gateway carries identity (channel + address) but not local INFO, so we join
100
+ * the local contact by the guardian's channel address. Returns `undefined` when
101
+ * no local contact matches.
102
+ */
103
+ function guardianDeliveryUserFile(
104
+ guardian: GuardianDelivery,
105
+ ): string | undefined {
106
+ const contact = findContactByAddress(
107
+ guardian.channelType,
108
+ guardian.address,
109
+ );
110
+ return contact?.userFile ?? undefined;
111
+ }
112
+
113
+ /** Active guardian for a channel from the IO-free delivery cache. */
114
+ function peekGuardianForChannel(
115
+ channelType: string,
116
+ ): GuardianDelivery | undefined {
117
+ const cached = peekCachedGuardianDelivery({ channelTypes: [channelType] });
118
+ return cached ? guardianForChannel(cached, channelType) : undefined;
119
+ }
120
+
121
+ /** First guardian across all channels from the IO-free delivery cache. */
122
+ function peekAnyGuardian(): GuardianDelivery | undefined {
123
+ const cached = peekCachedGuardianDelivery();
124
+ return cached ? anyGuardian(cached) : undefined;
92
125
  }
93
126
 
94
127
  /**
@@ -102,12 +135,11 @@ function resolveUserFilename(
102
135
 
103
136
  try {
104
137
  if (trustContext === undefined) {
105
- // Desktop / native (no gateway) — resolve via guardian contact,
106
- // preferring the vellum-channel guardian when multiple exist.
107
- const vellumGuardian = findGuardianForChannel("vellum");
108
- const guardian = vellumGuardian ?? listGuardianChannels();
138
+ // Desktop / native — resolve via the gateway guardian delivery cache,
139
+ // preferring the vellum-channel guardian, then any guardian.
140
+ const guardian = peekGuardianForChannel("vellum") ?? peekAnyGuardian();
109
141
  if (guardian) {
110
- filename = guardian.contact.userFile ?? "guardian.md";
142
+ filename = guardianDeliveryUserFile(guardian) ?? "guardian.md";
111
143
  }
112
144
  } else if (trustContext.requesterExternalUserId) {
113
145
  // Channel-routed request — look up contact by channel identity
@@ -34,6 +34,7 @@ import {
34
34
  } from "./connection-resolution.js";
35
35
  import { listConnections } from "./inference/connections.js";
36
36
  import type { ProvidersConfig } from "./registry.js";
37
+ import { shouldUseNativeWebSearch } from "./registry.js";
37
38
  import type {
38
39
  Message,
39
40
  Provider,
@@ -130,6 +131,46 @@ export class CallSiteRoutingProvider implements Provider {
130
131
  : doSend();
131
132
  }
132
133
 
134
+ /**
135
+ * Native web-search capability of the provider/model THIS call routes to.
136
+ *
137
+ * `selectProvider` picks the transport from the routed connection, but each
138
+ * leaf provider's static `supportsNativeWebSearch` was fixed to the DEFAULT
139
+ * (provider, model) at boot. Resolving the call-site here — same
140
+ * `resolveCallSiteConfig` inputs `selectProvider` uses — and recomputing
141
+ * `shouldUseNativeWebSearch(resolved.provider, resolved.model)` yields the
142
+ * capability of the routed target instead of the construction-time default.
143
+ *
144
+ * Falls back to the default provider's static flag when no `callSite` is set
145
+ * (the legacy short-circuit `selectProvider` also takes).
146
+ *
147
+ * Known limitation: this reports the *resolved* target's capability and does
148
+ * not replay `selectProvider`'s async soft-credential fallback. If the routed
149
+ * connection has a transient credential failure at send time, `selectProvider`
150
+ * falls back to the default provider while this probe still reports the routed
151
+ * target — so a non-native default + native routed target with a credential
152
+ * blip can attach `web_search` to the fallback non-native provider. The probe
153
+ * stays sync (the loop assembles tools synchronously) and the worst case is
154
+ * bounded: the advisor consult that hits it degrades benignly (the unhandled
155
+ * tool surfaces as a caught failure → "(advisor unavailable)"), not a crash.
156
+ */
157
+ supportsNativeWebSearchFor(options?: SendMessageOptions): boolean {
158
+ const callSite = options?.config?.callSite;
159
+ if (!callSite) {
160
+ return this.defaultProvider.supportsNativeWebSearch === true;
161
+ }
162
+ const resolved = resolveCallSiteConfig(callSite, getConfig().llm, {
163
+ overrideProfile: options?.config?.overrideProfile,
164
+ forceOverrideProfile: options?.config?.forceOverrideProfile,
165
+ selectionSeed: options?.config?.selectionSeed,
166
+ });
167
+ return shouldUseNativeWebSearch(
168
+ getConfig(),
169
+ resolved.provider,
170
+ resolved.model,
171
+ );
172
+ }
173
+
133
174
  /**
134
175
  * Pick the provider to route this call through.
135
176
  *
@@ -58,6 +58,12 @@ export class CallSiteConfiguredProvider implements Provider {
58
58
  this.supportsNativeWebSearch = inner.supportsNativeWebSearch;
59
59
  }
60
60
 
61
+ supportsNativeWebSearchFor(options?: SendMessageOptions): boolean {
62
+ return this.inner.supportsNativeWebSearchFor
63
+ ? this.inner.supportsNativeWebSearchFor(options)
64
+ : this.inner.supportsNativeWebSearch === true;
65
+ }
66
+
61
67
  sendMessage(
62
68
  messages: Message[],
63
69
  options?: SendMessageOptions,
@@ -27,6 +27,12 @@ export class RateLimitProvider implements Provider {
27
27
  return this.inner.supportsNativeWebSearch;
28
28
  }
29
29
 
30
+ supportsNativeWebSearchFor(options?: SendMessageOptions): boolean {
31
+ return this.inner.supportsNativeWebSearchFor
32
+ ? this.inner.supportsNativeWebSearchFor(options)
33
+ : this.inner.supportsNativeWebSearch === true;
34
+ }
35
+
30
36
  private requestTimestamps: number[];
31
37
 
32
38
  // Forward the optional token-counting endpoint so the capability survives
@@ -118,7 +118,7 @@ export function isNativeWebSearchCapableProvider(
118
118
  return false;
119
119
  }
120
120
 
121
- function shouldUseNativeWebSearch(
121
+ export function shouldUseNativeWebSearch(
122
122
  config: ProvidersConfig,
123
123
  providerName: string,
124
124
  model: string,
@@ -622,6 +622,12 @@ export class RetryProvider implements Provider {
622
622
  return this.inner.supportsNativeWebSearch;
623
623
  }
624
624
 
625
+ supportsNativeWebSearchFor(options?: SendMessageOptions): boolean {
626
+ return this.inner.supportsNativeWebSearchFor
627
+ ? this.inner.supportsNativeWebSearchFor(options)
628
+ : this.inner.supportsNativeWebSearch === true;
629
+ }
630
+
625
631
  // Forward the optional token-counting endpoint so the capability survives
626
632
  // the wrapper chain (callers gate on its presence). Bound straight to the
627
633
  // inner provider — count_tokens is a cheap separate endpoint and its caller
@@ -274,6 +274,19 @@ export interface Provider {
274
274
  * unexecutable client tool call. Absent/false on providers without it.
275
275
  */
276
276
  supportsNativeWebSearch?: boolean;
277
+ /**
278
+ * Per-call native web-search capability for the provider/model this specific
279
+ * request will route to. Unlike the static {@link supportsNativeWebSearch}
280
+ * flag — fixed to the DEFAULT provider/model at construction — this consults
281
+ * the resolved call-site (`options.config.callSite` + `overrideProfile`) so a
282
+ * routing wrapper reports the ROUTED target's capability. Callers that gate a
283
+ * `web_search` server tool on a possibly-routed call (e.g. the advisor
284
+ * consult, whose `advisorProfile` may point at a different provider/model)
285
+ * must use this rather than the construction-time snapshot. Optional: wrappers
286
+ * forward it to their inner provider; leaf providers may omit it, in which
287
+ * case callers fall back to {@link supportsNativeWebSearch}.
288
+ */
289
+ supportsNativeWebSearchFor?(options?: SendMessageOptions): boolean;
277
290
  sendMessage(
278
291
  messages: Message[],
279
292
  options?: SendMessageOptions,
@@ -35,6 +35,12 @@ export class UsageTrackingProvider implements Provider {
35
35
  }
36
36
  }
37
37
 
38
+ supportsNativeWebSearchFor(options?: SendMessageOptions): boolean {
39
+ return this.inner.supportsNativeWebSearchFor
40
+ ? this.inner.supportsNativeWebSearchFor(options)
41
+ : this.inner.supportsNativeWebSearch === true;
42
+ }
43
+
38
44
  async sendMessage(
39
45
  messages: Message[],
40
46
  options?: SendMessageOptions,
@@ -3,11 +3,13 @@
3
3
  * `reResolveTrustOnResetDrift`.
4
4
  *
5
5
  * The real helper runs against mocked leaf deps: the gateway guardian read
6
- * (`getGuardianDelivery`/`guardianForChannel`), the local-mirror heal
7
- * (`findGuardianForChannel`/`updateContactPrincipalAndChannel`, which the real
8
- * `healGuardianBindingDrift` drives), and the local trust resolver
9
- * (`resolveTrustContext`). Heal invocations are observed via the contact-store
10
- * write mock.
6
+ * (`getGuardianDelivery`/`guardianForChannel`) supplies the authoritative
7
+ * principal, the local-mirror heal target is resolved via
8
+ * `findContactByAddress` (keyed on the gateway guardian's channel address) and
9
+ * written via `updateContactPrincipalAndChannel` (the real
10
+ * `healGuardianBindingDrift` drives this), and the local trust resolver
11
+ * (`resolveTrustContext`) closes the loop. Heal invocations are observed via
12
+ * the contact-store write mock.
11
13
  */
12
14
  import { beforeEach, describe, expect, mock, test } from "bun:test";
13
15
 
@@ -26,16 +28,17 @@ mock.module("../../contacts/guardian-delivery-reader.js", () => ({
26
28
  ) => list.find((g) => g.channelType === channelType && g.status === "active"),
27
29
  }));
28
30
 
29
- // Local mirror the real heal reads/writes. `findGuardianForChannel` returns the
30
- // stored guardian; `updateContactPrincipalAndChannel` records heal writes.
31
- let mockLocalGuardian: {
32
- contact: { id: string; principalId: string };
33
- channel: { id: string };
31
+ // Local mirror the real heal repairs. `findContactByAddress` returns the local
32
+ // contact (with its vellum channel) the heal writes to;
33
+ // `updateContactPrincipalAndChannel` records heal writes.
34
+ let mockLocalContact: {
35
+ id: string;
36
+ channels: Array<{ id: string; type: string }>;
34
37
  } | null = null;
35
38
  const healWrites: Array<{ principalId: string }> = [];
36
39
 
37
40
  mock.module("../../contacts/contact-store.js", () => ({
38
- findGuardianForChannel: () => mockLocalGuardian,
41
+ findContactByAddress: () => mockLocalContact,
39
42
  updateContactPrincipalAndChannel: (
40
43
  _contactId: string,
41
44
  _channelId: string,
@@ -74,25 +77,25 @@ function gatewayGuardian(principalId: string): Record<string, unknown> {
74
77
  };
75
78
  }
76
79
 
77
- function localGuardian(principalId: string) {
80
+ function localGuardian() {
78
81
  return {
79
- contact: { id: "contact-1", principalId },
80
- channel: { id: "channel-1" },
82
+ id: "contact-1",
83
+ channels: [{ id: "channel-1", type: "vellum" }],
81
84
  };
82
85
  }
83
86
 
84
87
  describe("reResolveTrustOnResetDrift", () => {
85
88
  beforeEach(() => {
86
89
  mockGuardianList = [];
87
- mockLocalGuardian = null;
90
+ mockLocalContact = null;
88
91
  healWrites.length = 0;
89
92
  });
90
93
 
91
94
  test("reset drift: heals and returns the re-resolved guardian ctx", async () => {
92
- // Stale local mirror still holds the pre-reset principal; the incoming JWT
93
- // carries the old one. Heal repairs the mirror toward the incoming actor.
95
+ // Gateway principal diverges from the incoming JWT; heal repairs the local
96
+ // mirror toward the incoming actor.
94
97
  mockGuardianList = [gatewayGuardian("vellum-principal-new")];
95
- mockLocalGuardian = localGuardian("vellum-principal-stale");
98
+ mockLocalContact = localGuardian();
96
99
 
97
100
  const ctx = await reResolveTrustOnResetDrift(
98
101
  "vellum-principal-old",
@@ -103,11 +106,11 @@ describe("reResolveTrustOnResetDrift", () => {
103
106
  expect(healWrites).toEqual([{ principalId: "vellum-principal-old" }]);
104
107
  });
105
108
 
106
- test("repeat drift where heal no-ops still returns the guardian ctx", async () => {
107
- // Local mirror already matches the incoming principal, so heal's write is
108
- // skipped, but the gate still passes and the re-resolve yields guardian.
109
+ test("no local mirror to repair still returns the guardian ctx", async () => {
110
+ // The gate passes and the re-resolve yields guardian even when there is no
111
+ // local mirror row for heal to write.
109
112
  mockGuardianList = [gatewayGuardian("vellum-principal-new")];
110
- mockLocalGuardian = localGuardian("vellum-principal-old");
113
+ mockLocalContact = null;
111
114
 
112
115
  const ctx = await reResolveTrustOnResetDrift(
113
116
  "vellum-principal-old",
@@ -120,7 +123,7 @@ describe("reResolveTrustOnResetDrift", () => {
120
123
 
121
124
  test("gateway unreachable (null): returns null, heal not called", async () => {
122
125
  mockGuardianList = null;
123
- mockLocalGuardian = localGuardian("vellum-principal-old");
126
+ mockLocalContact = localGuardian();
124
127
 
125
128
  const ctx = await reResolveTrustOnResetDrift(
126
129
  "vellum-principal-old",
@@ -133,7 +136,7 @@ describe("reResolveTrustOnResetDrift", () => {
133
136
 
134
137
  test("empty/revoked gateway (no active guardian): returns null, heal not called", async () => {
135
138
  mockGuardianList = [];
136
- mockLocalGuardian = localGuardian("vellum-principal-old");
139
+ mockLocalContact = localGuardian();
137
140
 
138
141
  const ctx = await reResolveTrustOnResetDrift(
139
142
  "vellum-principal-old",
@@ -146,7 +149,7 @@ describe("reResolveTrustOnResetDrift", () => {
146
149
 
147
150
  test("gateway guardian is a real (non vellum-principal-*) id: returns null", async () => {
148
151
  mockGuardianList = [gatewayGuardian("user@example.com")];
149
- mockLocalGuardian = localGuardian("vellum-principal-old");
152
+ mockLocalContact = localGuardian();
150
153
 
151
154
  const ctx = await reResolveTrustOnResetDrift(
152
155
  "vellum-principal-old",
@@ -159,7 +162,7 @@ describe("reResolveTrustOnResetDrift", () => {
159
162
 
160
163
  test("incoming principal is not vellum-principal-*: returns null", async () => {
161
164
  mockGuardianList = [gatewayGuardian("vellum-principal-new")];
162
- mockLocalGuardian = localGuardian("vellum-principal-old");
165
+ mockLocalContact = localGuardian();
163
166
 
164
167
  const ctx = await reResolveTrustOnResetDrift("user@example.com", "vellum");
165
168
 
@@ -169,7 +172,7 @@ describe("reResolveTrustOnResetDrift", () => {
169
172
 
170
173
  test("threads sourceChannel into the returned ctx", async () => {
171
174
  mockGuardianList = [gatewayGuardian("vellum-principal-new")];
172
- mockLocalGuardian = localGuardian("vellum-principal-old");
175
+ mockLocalContact = localGuardian();
173
176
 
174
177
  const ctx = await reResolveTrustOnResetDrift(
175
178
  "vellum-principal-old",
@@ -6,20 +6,28 @@ import type { ChannelId } from "../../channels/types.js";
6
6
  // unreachable), [] = authoritatively no guardian, one active entry = bound.
7
7
  let mockGuardianList: Array<Record<string, unknown>> | null = [];
8
8
 
9
+ // Both the async path (resolveLocalPrincipalTrustContext) and the sync
10
+ // resolveActorTrust read the same gateway delivery list — async via
11
+ // getGuardianDelivery, sync via the peek snapshot.
9
12
  mock.module("../../contacts/guardian-delivery-reader.js", () => ({
10
13
  getGuardianDelivery: (_input?: { channelTypes?: string[] }) =>
11
14
  Promise.resolve(mockGuardianList),
15
+ peekCachedGuardianDelivery: (input?: { channelTypes?: string[] }) => {
16
+ if (mockGuardianList == null) return undefined;
17
+ if (!input?.channelTypes) return mockGuardianList;
18
+ return mockGuardianList.filter((g) =>
19
+ input.channelTypes!.includes(g.channelType as string),
20
+ );
21
+ },
22
+ guardianForChannel: (
23
+ list: Array<Record<string, unknown>>,
24
+ channelType: string,
25
+ ) => list.find((g) => g.channelType === channelType && g.status === "active"),
12
26
  }));
13
27
 
14
- // Local-resolver guardian binding, used to construct the expected guardian
15
- // TrustContext via the existing resolver and prove equivalence.
16
- let mockGuardianRecord: {
17
- contact: Record<string, unknown>;
18
- channel: Record<string, unknown>;
19
- } | null = null;
20
-
28
+ // Member ACL rides on memberRecord, sourced from the member-verdict cache; this
29
+ // suite only exercises the gateway-guardian path, so no member resolves.
21
30
  mock.module("../../contacts/contact-store.js", () => ({
22
- findGuardianForChannel: (_channelType: string) => mockGuardianRecord,
23
31
  findContactByAddress: (_channelType: string, _address: string) => null,
24
32
  }));
25
33
 
@@ -39,7 +47,6 @@ const GUARDIAN_CHAT_ID = "guardian-chat";
39
47
  describe("resolveLocalPrincipalTrustContext", () => {
40
48
  beforeEach(() => {
41
49
  mockGuardianList = [];
42
- mockGuardianRecord = null;
43
50
  });
44
51
 
45
52
  test("principal matching the gateway guardian → guardian ctx", async () => {
@@ -83,15 +90,6 @@ describe("resolveLocalPrincipalTrustContext", () => {
83
90
  status: "active",
84
91
  },
85
92
  ];
86
- mockGuardianRecord = {
87
- contact: { id: "contact-1", principalId: GUARDIAN_ADDRESS },
88
- channel: {
89
- type: "vellum",
90
- address: GUARDIAN_ADDRESS,
91
- externalChatId: GUARDIAN_CHAT_ID,
92
- status: "active",
93
- },
94
- };
95
93
 
96
94
  const expected = toTrustContext(
97
95
  resolveActorTrust({
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Unit tests for the in-memory member-verdict cache: set→get round-trip, TTL
3
+ * expiry, memberless verdicts not cached, and max-size eviction.
4
+ */
5
+
6
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
7
+
8
+ import type { TrustVerdict } from "@vellumai/gateway-client";
9
+
10
+ mock.module("../../util/logger.js", () => ({
11
+ getLogger: () =>
12
+ new Proxy({} as Record<string, unknown>, { get: () => () => {} }),
13
+ }));
14
+
15
+ import {
16
+ __resetMemberVerdictCacheForTest,
17
+ getCachedMemberAcl,
18
+ setMemberVerdict,
19
+ } from "../member-verdict-cache.js";
20
+
21
+ const PHONE = "+15559871234";
22
+
23
+ function memberVerdict(
24
+ overrides: Partial<TrustVerdict> = {},
25
+ ): TrustVerdict {
26
+ return {
27
+ trustClass: "trusted_contact",
28
+ canonicalSenderId: PHONE,
29
+ contactId: "contact-1",
30
+ channelId: "ch-1",
31
+ status: "active",
32
+ policy: "allow",
33
+ ...overrides,
34
+ };
35
+ }
36
+
37
+ const realNow = Date.now;
38
+
39
+ describe("member-verdict-cache", () => {
40
+ beforeEach(() => {
41
+ __resetMemberVerdictCacheForTest();
42
+ });
43
+
44
+ afterEach(() => {
45
+ Date.now = realNow;
46
+ });
47
+
48
+ test("set then get returns the derived ACL view", () => {
49
+ setMemberVerdict("phone", PHONE, memberVerdict());
50
+ expect(getCachedMemberAcl("phone", PHONE)).toEqual({
51
+ status: "active",
52
+ policy: "allow",
53
+ role: "contact",
54
+ });
55
+ });
56
+
57
+ test("guardian trustClass derives the guardian role", () => {
58
+ setMemberVerdict("phone", PHONE, memberVerdict({ trustClass: "guardian" }));
59
+ expect(getCachedMemberAcl("phone", PHONE)?.role).toBe("guardian");
60
+ });
61
+
62
+ test("read canonicalizes the actor id like the write", () => {
63
+ // Phone numbers normalize to E.164; a raw-format write is readable by the
64
+ // same raw-format read.
65
+ setMemberVerdict("phone", "(555) 987-1234", memberVerdict());
66
+ expect(getCachedMemberAcl("phone", "(555) 987-1234")).toBeDefined();
67
+ });
68
+
69
+ test("empty actor id is a no-op on set and get", () => {
70
+ setMemberVerdict("phone", undefined, memberVerdict());
71
+ expect(getCachedMemberAcl("phone", undefined)).toBeUndefined();
72
+ expect(getCachedMemberAcl("phone", " ")).toBeUndefined();
73
+ });
74
+
75
+ test("memberless verdict is not cached", () => {
76
+ setMemberVerdict(
77
+ "phone",
78
+ PHONE,
79
+ memberVerdict({ contactId: undefined, channelId: undefined }),
80
+ );
81
+ expect(getCachedMemberAcl("phone", PHONE)).toBeUndefined();
82
+ });
83
+
84
+ test("memberless verdict clears a stale active entry for the actor", () => {
85
+ setMemberVerdict("phone", PHONE, memberVerdict());
86
+ expect(getCachedMemberAcl("phone", PHONE)).toBeDefined();
87
+ // A later memberless verdict (deleted contact / stranger) must invalidate
88
+ // the stale active ACL, not leave it readable for the rest of the TTL.
89
+ setMemberVerdict(
90
+ "phone",
91
+ PHONE,
92
+ memberVerdict({ contactId: undefined, channelId: undefined }),
93
+ );
94
+ expect(getCachedMemberAcl("phone", PHONE)).toBeUndefined();
95
+ });
96
+
97
+ test("expired entry returns undefined", () => {
98
+ const t0 = realNow();
99
+ Date.now = () => t0;
100
+ setMemberVerdict("phone", PHONE, memberVerdict());
101
+ Date.now = () => t0 + 300_001;
102
+ expect(getCachedMemberAcl("phone", PHONE)).toBeUndefined();
103
+ });
104
+
105
+ test("evicts the oldest-expiring entry past the bound", () => {
106
+ const t0 = realNow();
107
+ // telegram IDs pass through unchanged, so each key is distinct. Fill past
108
+ // capacity with monotonically increasing expiry stamps.
109
+ for (let i = 0; i < 2001; i++) {
110
+ Date.now = () => t0 + i;
111
+ setMemberVerdict("telegram", `tg-${i}`, memberVerdict());
112
+ }
113
+ Date.now = () => t0 + 2001;
114
+ // The oldest-expiring entry is evicted on the over-capacity insert; the
115
+ // latest survives.
116
+ expect(getCachedMemberAcl("telegram", "tg-0")).toBeUndefined();
117
+ expect(getCachedMemberAcl("telegram", "tg-2000")).toBeDefined();
118
+ });
119
+ });