@vellumai/assistant 0.10.3-staging.2 → 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
@@ -31,20 +31,18 @@
31
31
  * sized generously enough that a typical refresh round-trip (~1-3s)
32
32
  * is well within window.
33
33
  *
34
- * Persisted-seq map: alongside the live counter and ring, this module
35
- * tracks, per conversation, the `seq` of the last event whose content is
36
- * durably committed to the message rows (`persistedSeqByConversation`).
37
- * The `/messages` snapshot returns this value so a client can align the
38
- * snapshot with the stream: "these rows reflect all of this
39
- * conversation's events through `seq = S`." It is recorded at each
40
- * persistence flush (assistant rows persist incrementally, debounced, so
41
- * the snapshot can lag the live counter) -- never the live counter
42
- * itself, which would over-claim events that have streamed but not yet
43
- * been written. The map is in-memory and clears on restart; because the
44
- * counter resumes above the persisted reservation, a value recorded by
45
- * a previous process could only ever be lower than any seq the new
46
- * process assigns -- never ambiguous against it. The map is LRU-bounded; an
47
- * evicted conversation reports no seq and the client cold-starts.
34
+ * Persisted seq: alongside the live counter and ring, the `seq` of the last
35
+ * event whose content is durably committed to a conversation's message rows
36
+ * is stored on the `conversations.seq` column (see `conversation-crud`). The
37
+ * `/messages` snapshot returns it so a client can align the snapshot with the
38
+ * stream: "these rows reflect all of this conversation's events through
39
+ * `seq = S`." It is written at each persistence flush (assistant rows persist
40
+ * incrementally, debounced, so the snapshot can lag the live counter) -- never
41
+ * the live counter itself, which would over-claim events that have streamed
42
+ * but not yet been written. Because it lives in the database it survives a
43
+ * restart; and because the counter resumes above the persisted reservation, a
44
+ * value written by a previous process could only ever be lower than any seq
45
+ * the new process assigns -- never ambiguous against it.
48
46
  */
49
47
 
50
48
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
@@ -66,16 +64,6 @@ const RING_COUNT_LIMIT = SSE_REPLAY_RING_COUNT_LIMIT;
66
64
  const RING_SIZE_LIMIT_BYTES = 256 * 1024;
67
65
  const RING_AGE_LIMIT_MS = SSE_REPLAY_RING_AGE_LIMIT_MS;
68
66
 
69
- /**
70
- * Cap on how many conversations retain a persisted-seq entry. Unlike the
71
- * ring (which the live stream needs only briefly), the persisted-seq map
72
- * grows with the number of conversations that have ever streamed in this
73
- * process. Bound it LRU so it can't grow without limit; an evicted
74
- * conversation simply reports no seq on its next `/messages` and the
75
- * client cold-starts, which is harmless.
76
- */
77
- const PERSISTED_SEQ_CONVERSATION_LIMIT = 1024;
78
-
79
67
  /**
80
68
  * How many seq values are reserved per persisted write. The counter can
81
69
  * hand out seqs up to the persisted ceiling without touching disk, so
@@ -141,13 +129,6 @@ interface AssistantStreamState {
141
129
  firstStampedSeq: number;
142
130
  ring: RingEntry[];
143
131
  totalSizeBytes: number;
144
- /**
145
- * Per-conversation `seq` of the last event durably committed to the
146
- * message rows. Insertion order is maintained as an LRU recency list:
147
- * the oldest key is evicted first once the map exceeds
148
- * {@link PERSISTED_SEQ_CONVERSATION_LIMIT}.
149
- */
150
- persistedSeqByConversation: Map<string, number>;
151
132
  }
152
133
 
153
134
  // ── State ────────────────────────────────────────────────────────────
@@ -159,7 +140,6 @@ const state: AssistantStreamState = {
159
140
  firstStampedSeq: 0,
160
141
  ring: [],
161
142
  totalSizeBytes: 0,
162
- persistedSeqByConversation: new Map(),
163
143
  };
164
144
 
165
145
  // ── Public API ───────────────────────────────────────────────────────
@@ -270,46 +250,6 @@ export function getCurrentSeq(): number {
270
250
  return state.nextSeq - 1;
271
251
  }
272
252
 
273
- /**
274
- * Record that conversation `conversationId` has durably persisted all of
275
- * its events through `seq`. Called at each persistence flush with the
276
- * `seq` of the last event whose content the write committed.
277
- *
278
- * Monotonic: a lower `seq` never regresses a higher one (out-of-order
279
- * async commits are clamped). LRU-bounded by
280
- * {@link PERSISTED_SEQ_CONVERSATION_LIMIT}: re-recording refreshes
281
- * recency, and the oldest conversation is evicted once the cap is
282
- * exceeded. Non-positive or non-finite `seq` values are ignored.
283
- */
284
- export function recordPersistedSeq(conversationId: string, seq: number): void {
285
- if (!Number.isFinite(seq) || seq <= 0) return;
286
-
287
- const map = state.persistedSeqByConversation;
288
- const prev = map.get(conversationId);
289
- if (prev !== undefined) {
290
- // Re-insert to move this key to the most-recently-used end.
291
- map.delete(conversationId);
292
- map.set(conversationId, Math.max(prev, seq));
293
- return;
294
- }
295
-
296
- map.set(conversationId, seq);
297
- if (map.size > PERSISTED_SEQ_CONVERSATION_LIMIT) {
298
- const oldestKey = map.keys().next().value;
299
- if (oldestKey !== undefined) map.delete(oldestKey);
300
- }
301
- }
302
-
303
- /**
304
- * Highest `seq` durably persisted for `conversationId`, or `null` when
305
- * none has been recorded in this process (cold conversation, or evicted
306
- * from the LRU map). Returned by `/messages` so a client can align the
307
- * snapshot with the live stream.
308
- */
309
- export function getPersistedSeq(conversationId: string): number | null {
310
- return state.persistedSeqByConversation.get(conversationId) ?? null;
311
- }
312
-
313
253
  /**
314
254
  * Reset all stream state. Test-only.
315
255
  */
@@ -323,7 +263,6 @@ export function _resetStreamStateForTesting(): void {
323
263
  state.firstStampedSeq = 0;
324
264
  state.ring = [];
325
265
  state.totalSizeBytes = 0;
326
- state.persistedSeqByConversation.clear();
327
266
  }
328
267
 
329
268
  /**
@@ -337,7 +276,6 @@ export function _simulateRestartForTesting(): void {
337
276
  state.firstStampedSeq = 0;
338
277
  state.ring = [];
339
278
  state.totalSizeBytes = 0;
340
- state.persistedSeqByConversation.clear();
341
279
  }
342
280
 
343
281
  /**
@@ -12,10 +12,6 @@ import { getLogger } from "../util/logger.js";
12
12
  import type { ChannelDeliveryResult } from "./gateway-client.js";
13
13
  import { deliverChannelReply } from "./gateway-client.js";
14
14
  import type { RuntimeAttachmentMetadata } from "./http-types.js";
15
- import {
16
- isSlackCallbackUrl,
17
- textToSlackBlocks,
18
- } from "./slack-block-formatting.js";
19
15
 
20
16
  const log = getLogger("channel-reply-delivery");
21
17
 
@@ -167,8 +163,6 @@ export async function deliverRenderedReplyViaCallback(
167
163
  return;
168
164
  }
169
165
 
170
- const isSlack = isSlackCallbackUrl(callbackUrl);
171
-
172
166
  // Only the first segment uses messageTs for in-place update;
173
167
  // subsequent segments are posted as new messages.
174
168
  let currentMessageTs = messageTs;
@@ -177,13 +171,14 @@ export async function deliverRenderedReplyViaCallback(
177
171
  const isLastSegment = i === deliverableSegments.length - 1;
178
172
  const isFirstSegment = i === startFromSegment;
179
173
  const segmentText = deliverableSegments[i];
180
- const blocks = isSlack ? textToSlackBlocks(segmentText) : undefined;
181
174
  const result: ChannelDeliveryResult = await deliverChannelReply(
182
175
  callbackUrl,
183
176
  {
184
177
  chatId,
185
178
  text: segmentText,
186
- blocks,
179
+ // Ask the channel to render richly; each channel's adapter decides how
180
+ // (Slack → Block Kit). Channels without rich rendering send plain text.
181
+ useBlocks: true,
187
182
  attachments: isLastSegment ? replyAttachments : undefined,
188
183
  assistantId,
189
184
  ephemeral,
@@ -9,7 +9,7 @@
9
9
 
10
10
  import type { ChannelId } from "../channels/types.js";
11
11
  import {
12
- findGuardianForChannel,
12
+ findContactByAddress,
13
13
  updateContactPrincipalAndChannel,
14
14
  } from "../contacts/contact-store.js";
15
15
  import {
@@ -44,8 +44,7 @@ const log = getLogger("guardian-vellum-migration");
44
44
  * Returns true if healing occurred, false otherwise.
45
45
  *
46
46
  * The gateway binding supplies the authoritative principal; the local
47
- * assistant-mirror row is repaired whenever it diverges from the JWT
48
- * principal — even when the gateway binding already matches — because the
47
+ * assistant-mirror row is repaired to match the JWT principal because the
49
48
  * /v1/messages trust path still resolves against the local mirror in this
50
49
  * plan. A stale mirror must be repaired or valid guardians stay `unknown`.
51
50
  */
@@ -62,28 +61,31 @@ export async function healGuardianBindingDrift(
62
61
  if (!guardian) return false;
63
62
 
64
63
  const currentPrincipalId = guardian.principalId;
64
+ // Only repair auto-generated principals — never overwrite a real one.
65
65
  if (!currentPrincipalId?.startsWith("vellum-principal-")) return false;
66
+ // No-op when the principal already matches the JWT principal.
67
+ if (currentPrincipalId === incomingPrincipalId) return false;
66
68
 
67
- // Resolve the assistant-mirror row whose principal drives local trust.
68
- const guardianResult = findGuardianForChannel("vellum");
69
- if (!guardianResult) return false;
70
-
71
- const localPrincipalId = guardianResult.contact.principalId;
72
- // Only repair auto-generated local principals — never overwrite a real one.
73
- if (!localPrincipalId?.startsWith("vellum-principal-")) return false;
74
- // No-op when the local mirror already matches the JWT principal.
75
- if (localPrincipalId === incomingPrincipalId) return false;
69
+ // Resolve the assistant-mirror row to repair so local trust resolution
70
+ // converges on the JWT principal. The gateway delivery supplies the guardian
71
+ // identity (channel + address) but not the local channel UUID write target,
72
+ // so resolve that locally by the guardian's vellum-channel address.
73
+ const localContact = findContactByAddress("vellum", guardian.address);
74
+ const localChannel = localContact?.channels.find(
75
+ (c) => c.type === "vellum",
76
+ );
77
+ if (!localContact || !localChannel) return false;
76
78
 
77
79
  const updated = updateContactPrincipalAndChannel(
78
- guardianResult.contact.id,
79
- guardianResult.channel.id,
80
+ localContact.id,
81
+ localChannel.id,
80
82
  incomingPrincipalId,
81
83
  );
82
84
 
83
85
  if (!updated) {
84
86
  log.warn(
85
87
  {
86
- oldPrincipalId: localPrincipalId,
88
+ oldPrincipalId: currentPrincipalId,
87
89
  newPrincipalId: incomingPrincipalId,
88
90
  },
89
91
  "Skipped guardian binding drift heal — address collision on contact_channels",
@@ -93,7 +95,7 @@ export async function healGuardianBindingDrift(
93
95
 
94
96
  log.info(
95
97
  {
96
- oldPrincipalId: localPrincipalId,
98
+ oldPrincipalId: currentPrincipalId,
97
99
  newPrincipalId: incomingPrincipalId,
98
100
  },
99
101
  "Healed vellum guardian binding drift — updated local mirror principalId to match JWT actor",
@@ -13,8 +13,9 @@ import {
13
13
  } from "../calls/inbound-trust-reader.js";
14
14
  import type { ChannelId } from "../channels/types.js";
15
15
  import { findContactChannel, getContact } from "../contacts/contact-store.js";
16
+ import { gatewayContactChannelState } from "../contacts/gateway-channel-read.js";
16
17
  import { activateMemberChannel } from "../contacts/member-write-relay.js";
17
- import type { ChannelStatus } from "../contacts/types.js";
18
+ import type { ChannelStatus, ContactChannel } from "../contacts/types.js";
18
19
  import { ipcCallPersistent } from "../ipc/gateway-client.js";
19
20
  import {
20
21
  findActiveVoiceInvites,
@@ -33,19 +34,33 @@ const log = getLogger("invite-redemption-service");
33
34
 
34
35
  /**
35
36
  * Resolve the sender's existing member status for the already_member/blocked
36
- * gate from the gateway trust verdict. Falls back to the local channel status
37
- * when the verdict is absent or carries no resolvable member status (e.g. an
38
- * externalChatId-only match or a resolutionFailed verdict), so a locally
39
- * blocked contact can't bypass the gate.
37
+ * gate from the gateway trust verdict. Falls back to the gateway-sourced channel
38
+ * status when the verdict is absent or carries no resolvable member status (e.g.
39
+ * an externalChatId-only match or a resolutionFailed verdict), so a blocked
40
+ * contact can't bypass the gate.
40
41
  */
41
42
  export async function resolveMemberGateStatus(
42
43
  verdict: Awaited<ReturnType<typeof getInboundTrustVerdict>>,
43
- localChannelStatus: ChannelStatus | null,
44
+ fallbackStatus: ChannelStatus | null,
44
45
  ): Promise<ChannelStatus | null> {
45
46
  const memberStatus = verdict
46
47
  ? verdictMemberFromVerdict(verdict)?.status
47
48
  : null;
48
- return memberStatus ?? localChannelStatus;
49
+ return memberStatus ?? fallbackStatus;
50
+ }
51
+
52
+ /**
53
+ * Gateway-sourced status for an existing local channel, used as the gate-status
54
+ * fallback when the verdict resolves no member. The local row is only located by
55
+ * identity; its status is read from the gateway (ACL source of truth), never the
56
+ * local column.
57
+ */
58
+ async function gatewayFallbackStatus(
59
+ channel: Pick<ContactChannel, "contactId" | "type" | "address"> | null,
60
+ ): Promise<ChannelStatus | null> {
61
+ if (!channel) return null;
62
+ const state = await gatewayContactChannelState(channel);
63
+ return (state?.status as ChannelStatus | undefined) ?? null;
49
64
  }
50
65
 
51
66
  // ---------------------------------------------------------------------------
@@ -245,7 +260,7 @@ export async function redeemInvite(params: {
245
260
  channelType: sourceChannel as ChannelId,
246
261
  actorExternalId: canonicalUserId,
247
262
  }),
248
- existingChannel?.status ?? null,
263
+ await gatewayFallbackStatus(existingChannel),
249
264
  );
250
265
 
251
266
  if (existingChannel && gateStatus === "active" && !targetMismatch) {
@@ -481,7 +496,7 @@ export async function redeemVoiceInviteCode(params: {
481
496
 
482
497
  const gateStatus = await resolveMemberGateStatus(
483
498
  await getPhoneCallerVerdict(canonicalCallerId),
484
- existingVoiceChannel?.status ?? null,
499
+ await gatewayFallbackStatus(existingVoiceChannel),
485
500
  );
486
501
 
487
502
  if (existingVoiceChannel && gateStatus === "active" && !targetMismatch) {
@@ -657,7 +672,7 @@ export async function redeemInviteByCode(params: {
657
672
  channelType: sourceChannel as ChannelId,
658
673
  actorExternalId: canonicalUserId,
659
674
  }),
660
- existingChannel?.status ?? null,
675
+ await gatewayFallbackStatus(existingChannel),
661
676
  );
662
677
 
663
678
  if (existingChannel && gateStatus === "active" && !targetMismatch) {
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Tests for the local-actor-identity cache-warm path.
3
+ *
4
+ * The SSE eager-subscribe path resolves the local actor principal
5
+ * synchronously from the IO-free guardian-delivery cache snapshot. A cold
6
+ * cache returns undefined, so the daemon warms it at startup
7
+ * (`warmLocalGuardianPrincipalCache`) before clients register. These tests pin
8
+ * that the sync read is cold before the warm and resolves the gateway-owned
9
+ * principal after it.
10
+ */
11
+
12
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
13
+
14
+ import type { GuardianDelivery } from "@vellumai/gateway-client";
15
+
16
+ // ── Controllable IPC mock ──────────────────────────────────────────────────
17
+
18
+ type IpcHandler = (params?: Record<string, unknown>) => unknown;
19
+ const ipcHandlers = new Map<string, IpcHandler>();
20
+
21
+ mock.module("../ipc/gateway-client.js", () => ({
22
+ ipcCall: async (method: string, params?: Record<string, unknown>) => {
23
+ const handler = ipcHandlers.get(method);
24
+ return handler ? handler(params) : undefined;
25
+ },
26
+ ipcCallPersistent: async () => undefined,
27
+ resetPersistentClient: () => {},
28
+ }));
29
+
30
+ let httpAuthDisabled = false;
31
+ mock.module("../config/env.js", () => ({
32
+ isHttpAuthDisabled: () => httpAuthDisabled,
33
+ }));
34
+
35
+ import { __resetGuardianDeliveryCacheForTest } from "../contacts/guardian-delivery-reader.js";
36
+ import {
37
+ findLocalGuardianPrincipalIdFromStore,
38
+ resolveActorPrincipalIdForLocalGuardianSync,
39
+ warmLocalGuardianPrincipalCache,
40
+ } from "./local-actor-identity.js";
41
+
42
+ const METHOD = "resolve_guardian_delivery";
43
+
44
+ const vellumGuardian: GuardianDelivery = {
45
+ channelType: "vellum",
46
+ contactId: "contact-1",
47
+ address: "self",
48
+ status: "active",
49
+ principalId: "principal-abc",
50
+ };
51
+
52
+ describe("warmLocalGuardianPrincipalCache", () => {
53
+ beforeEach(() => {
54
+ __resetGuardianDeliveryCacheForTest();
55
+ ipcHandlers.clear();
56
+ httpAuthDisabled = false;
57
+ });
58
+
59
+ afterEach(() => {
60
+ __resetGuardianDeliveryCacheForTest();
61
+ });
62
+
63
+ test("sync read is cold before warming", () => {
64
+ ipcHandlers.set(METHOD, () => ({ guardians: [vellumGuardian] }));
65
+
66
+ // No warm yet — the cache snapshot is empty.
67
+ expect(findLocalGuardianPrincipalIdFromStore()).toBeUndefined();
68
+ });
69
+
70
+ test("warming populates the cache for the sync read", async () => {
71
+ ipcHandlers.set(METHOD, () => ({ guardians: [vellumGuardian] }));
72
+
73
+ await warmLocalGuardianPrincipalCache();
74
+
75
+ expect(findLocalGuardianPrincipalIdFromStore()).toBe("principal-abc");
76
+ });
77
+
78
+ test("cold-start SSE registration resolves the principal after warm", async () => {
79
+ httpAuthDisabled = true;
80
+ ipcHandlers.set(METHOD, () => ({ guardians: [vellumGuardian] }));
81
+
82
+ // Cold cache: dev-bypass header resolves to no principal.
83
+ expect(
84
+ resolveActorPrincipalIdForLocalGuardianSync("dev-bypass"),
85
+ ).toBeUndefined();
86
+
87
+ await warmLocalGuardianPrincipalCache();
88
+
89
+ // Warmed: the SSE sync path now resolves the gateway-owned principal.
90
+ expect(resolveActorPrincipalIdForLocalGuardianSync("dev-bypass")).toBe(
91
+ "principal-abc",
92
+ );
93
+ });
94
+
95
+ test("warm tolerates an unreachable gateway without caching a failure", async () => {
96
+ ipcHandlers.set(METHOD, () => {
97
+ throw new Error("gateway down");
98
+ });
99
+
100
+ await warmLocalGuardianPrincipalCache();
101
+
102
+ // Failure not cached; a later successful read warms the cache.
103
+ expect(findLocalGuardianPrincipalIdFromStore()).toBeUndefined();
104
+ ipcHandlers.set(METHOD, () => ({ guardians: [vellumGuardian] }));
105
+ await warmLocalGuardianPrincipalCache();
106
+ expect(findLocalGuardianPrincipalIdFromStore()).toBe("principal-abc");
107
+ });
108
+ });
@@ -13,7 +13,6 @@
13
13
 
14
14
  import type { ChannelId } from "../channels/types.js";
15
15
  import { isHttpAuthDisabled } from "../config/env.js";
16
- import { findGuardianForChannel } from "../contacts/contact-store.js";
17
16
  import {
18
17
  getGuardianDelivery,
19
18
  guardianForChannel,
@@ -54,24 +53,37 @@ export function buildLocalAuthContext(conversationId: string): AuthContext {
54
53
  *
55
54
  * The gateway owns guardian binding; this reads it through the cached
56
55
  * `getGuardianDelivery` reader (PR-3 TTL + single-flight) so hot paths don't
57
- * storm the IPC. Falls back to the local contacts table for the bootstrap /
58
- * first-run window where the gateway has no guardian yet or is unreachable
59
- * (the reader returns `null`).
56
+ * storm the IPC.
60
57
  *
61
- * Returns `undefined` when no vellum guardian binding exists in either source
62
- * (e.g. fresh install before bootstrap). Callers should treat that case as
63
- * "not yet available" and either fall back or proceed without a principalId.
58
+ * Returns `undefined` when no vellum guardian binding exists (e.g. fresh
59
+ * install before bootstrap, or the gateway is unreachable). Callers should
60
+ * treat that case as "not yet available" and proceed without a principalId.
64
61
  */
65
62
  export async function findLocalGuardianPrincipalId(): Promise<
66
63
  string | undefined
67
64
  > {
68
65
  const list = await getGuardianDelivery({ channelTypes: ["vellum"] });
69
- if (list) {
70
- const principalId = guardianForChannel(list, "vellum")?.principalId;
71
- if (principalId) return principalId;
72
- }
66
+ if (!list) return undefined;
67
+ return guardianForChannel(list, "vellum")?.principalId ?? undefined;
68
+ }
73
69
 
74
- return findLocalGuardianPrincipalIdFromStore();
70
+ /**
71
+ * Eagerly warm the gateway guardian-delivery cache for the vellum channel.
72
+ *
73
+ * The SSE eager-subscribe path resolves the actor principal synchronously via
74
+ * {@link findLocalGuardianPrincipalIdFromStore}, which reads only the IO-free
75
+ * cache snapshot. On a cold cache (auth-disabled / local startup, before any
76
+ * async `getGuardianDelivery` has run) it returns undefined, so the FIRST SSE
77
+ * registration would carry no `actorPrincipalId` and host-proxy same-user
78
+ * targeting would regress until a later reconnect warms the cache.
79
+ *
80
+ * Called during daemon startup (after the gateway IPC is reachable) so the
81
+ * cache is populated before clients register. Best-effort: a cold gateway
82
+ * leaves the cache empty (failures aren't cached), and the async hot paths
83
+ * warm it on their next read.
84
+ */
85
+ export async function warmLocalGuardianPrincipalCache(): Promise<void> {
86
+ await findLocalGuardianPrincipalId();
75
87
  }
76
88
 
77
89
  /**
@@ -82,17 +94,12 @@ export async function findLocalGuardianPrincipalId(): Promise<
82
94
  * Reads the same gateway-owned binding as the async path via a sync, IO-free
83
95
  * snapshot of the guardian-delivery cache (kept fresh by the async hot paths
84
96
  * and event-driven invalidation), so SSE registers the SAME principal the
85
- * send/result routes resolve. Falls back to the local store when the cache is
86
- * cold — the same fallback the async path lands on during bootstrap.
97
+ * send/result routes resolve.
87
98
  */
88
99
  export function findLocalGuardianPrincipalIdFromStore(): string | undefined {
89
100
  const cached = peekCachedGuardianDelivery({ channelTypes: ["vellum"] });
90
- if (cached) {
91
- const principalId = guardianForChannel(cached, "vellum")?.principalId;
92
- if (principalId) return principalId;
93
- }
94
-
95
- return findGuardianForChannel("vellum")?.contact.principalId ?? undefined;
101
+ if (!cached) return undefined;
102
+ return guardianForChannel(cached, "vellum")?.principalId ?? undefined;
96
103
  }
97
104
 
98
105
  /**
@@ -14,6 +14,7 @@
14
14
  import { beforeEach, describe, expect, mock, test } from "bun:test";
15
15
 
16
16
  import { IpcCallError } from "@vellumai/gateway-client/ipc-client";
17
+ import { z } from "zod";
17
18
 
18
19
  let ipcCalls: { method: string; params?: Record<string, unknown> }[] = [];
19
20
  let ipcResult: unknown = {};
@@ -44,16 +45,53 @@ const contactStoreReadGuard = mock(() => {
44
45
  );
45
46
  });
46
47
 
48
+ // Filtered/native reads (search) legitimately go to the assistant DB. Drive
49
+ // them deterministically so the daemon-native response shape can be asserted.
50
+ let searchContactsResult: unknown[] = [];
51
+ const searchContactsMock = mock(() => searchContactsResult);
52
+
47
53
  mock.module("../../../contacts/contact-store.js", () => ({
48
54
  ...actualContactStore,
49
55
  getContact: contactStoreReadGuard,
50
56
  listContacts: contactStoreReadGuard,
51
57
  getAssistantContactMetadata: contactStoreReadGuard,
58
+ searchContacts: searchContactsMock,
52
59
  }));
53
60
 
54
- const { handleListContacts, handleGetContact, ROUTES } = await import(
55
- "../contact-routes.js"
56
- );
61
+ const { handleListContacts, handleGetContact, ROUTES } =
62
+ await import("../contact-routes.js");
63
+
64
+ // Daemon-native contact: INFO is hydrated locally; channel-level ACL fields
65
+ // (status/policy/verification) are gateway-owned and absent on native reads.
66
+ // Contact-level `role` is stored locally (NOT NULL) and always returned.
67
+ const nativeContact = {
68
+ id: "ct_2",
69
+ displayName: "Bob",
70
+ notes: null,
71
+ role: "contact",
72
+ contactType: "human",
73
+ lastInteraction: 4200,
74
+ interactionCount: 4,
75
+ createdAt: 1000,
76
+ updatedAt: 1500,
77
+ userFile: "bob.md",
78
+ channels: [
79
+ {
80
+ id: "ch_2",
81
+ contactId: "ct_2",
82
+ type: "phone",
83
+ address: "+15550200",
84
+ isPrimary: true,
85
+ externalChatId: null,
86
+ inviteId: null,
87
+ lastSeenAt: 4100,
88
+ interactionCount: 4,
89
+ lastInteraction: 4200,
90
+ updatedAt: 1500,
91
+ createdAt: 1000,
92
+ },
93
+ ],
94
+ };
57
95
 
58
96
  const gatewayChannel = {
59
97
  id: "ch_1",
@@ -76,7 +114,7 @@ const gatewayChannel = {
76
114
  const gatewayContact = {
77
115
  id: "ct_1",
78
116
  displayName: "Alice",
79
- role: "member",
117
+ role: "guardian",
80
118
  notes: "a note",
81
119
  contactType: "human",
82
120
  lastInteraction: 1900,
@@ -93,6 +131,8 @@ describe("contacts read API relays from the gateway", () => {
93
131
  ipcError = undefined;
94
132
  ipcCallPersistentMock.mockClear();
95
133
  contactStoreReadGuard.mockClear();
134
+ searchContactsResult = [];
135
+ searchContactsMock.mockClear();
96
136
  });
97
137
 
98
138
  test("list relays to contacts_list_rich and serializes the gateway ACL fields", async () => {
@@ -108,7 +148,7 @@ describe("contacts read API relays from the gateway", () => {
108
148
 
109
149
  const [contact] = result.contacts;
110
150
  // ACL fields are gateway-sourced and reach the web client unchanged.
111
- expect(contact.role).toBe("member");
151
+ expect((contact as { role?: string }).role).toBe("guardian");
112
152
  expect(contact.interactionCount).toBe(7);
113
153
  expect(contact.lastInteraction).toBe(1900);
114
154
  const channel = contact.channels[0] as Record<string, unknown>;
@@ -159,7 +199,7 @@ describe("contacts read API relays from the gateway", () => {
159
199
  { method: "contacts_get_rich", params: { contactId: "ct_1" } },
160
200
  ]);
161
201
  expect(result.ok).toBe(true);
162
- expect(result.contact.role).toBe("member");
202
+ expect(result.contact.role).toBe("guardian");
163
203
  expect(result.contact.interactionCount).toBe(7);
164
204
  const channel = result.contact.channels[0] as Record<string, unknown>;
165
205
  expect(channel.status).toBe("active");
@@ -204,9 +244,62 @@ describe("contacts read API relays from the gateway", () => {
204
244
  expect(ipcCalls).toEqual([
205
245
  { method: "contacts_list_rich", params: { limit: 50 } },
206
246
  ]);
207
- expect(contacts[0].role).toBe("member");
247
+ expect(contacts[0].role).toBe("guardian");
208
248
  expect(contacts[0].interactionCount).toBe(7);
209
249
  expect(contacts[0].channels[0].status).toBe("active");
210
250
  expect(contactStoreReadGuard).not.toHaveBeenCalled();
211
251
  });
212
252
  });
253
+
254
+ describe("filtered/native contact reads stay daemon-native", () => {
255
+ const listRoute = ROUTES.find((r) => r.operationId === "listContacts")!;
256
+ const listResponseSchema = listRoute.responseBody as z.ZodTypeAny;
257
+ const searchRoute = ROUTES.find((r) => r.operationId === "search_contacts")!;
258
+ const searchResponseSchema = searchRoute.responseBody as z.ZodTypeAny;
259
+
260
+ beforeEach(() => {
261
+ ipcCalls = [];
262
+ ipcResult = {};
263
+ ipcError = undefined;
264
+ ipcCallPersistentMock.mockClear();
265
+ contactStoreReadGuard.mockClear();
266
+ searchContactsResult = [];
267
+ searchContactsMock.mockClear();
268
+ });
269
+
270
+ test("query-filtered list serves daemon-native INFO and validates against the response schema", async () => {
271
+ searchContactsResult = [nativeContact];
272
+
273
+ const result = await handleListContacts({ query: "Bob", limit: "10" });
274
+
275
+ // No gateway relay for a true search.
276
+ expect(ipcCalls).toEqual([]);
277
+ expect(searchContactsMock).toHaveBeenCalled();
278
+
279
+ const [contact] = result.contacts;
280
+ // INFO telemetry is present (re-hydrated locally, not dropped).
281
+ expect(contact.interactionCount).toBe(4);
282
+ expect(contact.lastInteraction).toBe(4200);
283
+ const channel = contact.channels[0] as Record<string, unknown>;
284
+ expect(channel.interactionCount).toBe(4);
285
+ expect(channel.lastSeenAt).toBe(4100);
286
+ expect(channel.externalUserId).toBe("+15550200");
287
+ // Contact-level `role` is locally stored (NOT NULL) and always present.
288
+ expect((contact as { role: string }).role).toBe("contact");
289
+ // Channel-level ACL fields (status/policy) are gateway-owned and absent.
290
+ expect("status" in channel).toBe(false);
291
+ expect(() => listResponseSchema.parse(result)).not.toThrow();
292
+ });
293
+
294
+ test("POST search with a filter validates against the response schema", async () => {
295
+ searchContactsResult = [nativeContact];
296
+
297
+ const contacts = (await searchRoute.handler({
298
+ body: { query: "Bob" },
299
+ })) as unknown[];
300
+
301
+ expect(ipcCalls).toEqual([]);
302
+ expect(searchContactsMock).toHaveBeenCalled();
303
+ expect(() => searchResponseSchema.parse(contacts)).not.toThrow();
304
+ });
305
+ });