@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
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Cold-cache guardian voice/phone regression.
3
+ *
4
+ * The sync trust resolver (`resolveActorTrust`) reads the IO-free guardian-
5
+ * delivery cache snapshot (`peekCachedGuardianDelivery`) keyed per channel
6
+ * filter. On a cold process only `vellum` is warmed at daemon startup, so for
7
+ * `phone` the snapshot is empty until some read warms that exact channel key.
8
+ * The voice setup path therefore awaits
9
+ * `getGuardianDeliveryFresh({ channelTypes: ["phone"] })` BEFORE the sync
10
+ * resolve so an inbound guardian call classifies as `guardian` rather than
11
+ * misclassifying during a gateway verdict blip. It reads FRESH because gateway-
12
+ * side binding writes don't invalidate the daemon cache: a stale empty snapshot
13
+ * from an earlier setup would otherwise survive the TTL.
14
+ *
15
+ * This test drives the REAL guardian-delivery reader cache (mocking only the
16
+ * gateway `ipcCall`) so the cold→warm transition is exercised end to end.
17
+ */
18
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
19
+
20
+ const GUARDIAN_PHONE = "+15550100";
21
+
22
+ // Gateway IPC stub: returns the phone guardian delivery. The real reader caches
23
+ // the result under the `phone` key, so a subsequent sync `peek` finds it. When
24
+ // `guardianBound` is false the gateway reports no guardian — simulating the
25
+ // pre-binding state whose empty result the cache would otherwise pin.
26
+ let guardianBound = true;
27
+ let ipcCalls: Array<{ route: string; input: unknown }> = [];
28
+ mock.module("../ipc/gateway-client.js", () => ({
29
+ ipcCall: async (route: string, input: unknown) => {
30
+ ipcCalls.push({ route, input });
31
+ return {
32
+ guardians: guardianBound
33
+ ? [
34
+ {
35
+ channelType: "phone",
36
+ contactId: "guardian-contact",
37
+ principalId: "P_GUARDIAN_COLD",
38
+ address: GUARDIAN_PHONE,
39
+ externalChatId: null,
40
+ status: "active",
41
+ },
42
+ ]
43
+ : [],
44
+ };
45
+ },
46
+ }));
47
+
48
+ // Member lookup is irrelevant to guardian classification (address match on the
49
+ // cached delivery decides it); return null so the member path is a no-op.
50
+ mock.module("../contacts/contact-store.js", () => ({
51
+ findContactByAddress: () => null,
52
+ }));
53
+
54
+ import {
55
+ __resetGuardianDeliveryCacheForTest,
56
+ getGuardianDelivery,
57
+ getGuardianDeliveryFresh,
58
+ peekCachedGuardianDelivery,
59
+ } from "../contacts/guardian-delivery-reader.js";
60
+ import { resolveActorTrust } from "../runtime/actor-trust-resolver.js";
61
+
62
+ describe("voice path warms the phone guardian cache before sync trust", () => {
63
+ beforeEach(() => {
64
+ __resetGuardianDeliveryCacheForTest();
65
+ ipcCalls = [];
66
+ guardianBound = true;
67
+ });
68
+
69
+ test("cold phone cache: guardian call misclassifies until upstream warm", async () => {
70
+ // Precondition: cold cache for phone — the sync peek would miss.
71
+ expect(
72
+ peekCachedGuardianDelivery({ channelTypes: ["phone"] }),
73
+ ).toBeUndefined();
74
+
75
+ // Sync resolve on a cold cache: no guardian snapshot → classified unknown.
76
+ const cold = resolveActorTrust({
77
+ assistantId: "asst-1",
78
+ sourceChannel: "phone",
79
+ conversationExternalId: GUARDIAN_PHONE,
80
+ actorExternalId: GUARDIAN_PHONE,
81
+ });
82
+ expect(cold.trustClass).toBe("unknown");
83
+
84
+ // The voice setup path warms the phone-specific key via the fresh reader.
85
+ await getGuardianDeliveryFresh({ channelTypes: ["phone"] });
86
+ expect(
87
+ ipcCalls.some(
88
+ (c) =>
89
+ c.route === "resolve_guardian_delivery" &&
90
+ JSON.stringify(c.input) ===
91
+ JSON.stringify({ channelTypes: ["phone"] }),
92
+ ),
93
+ ).toBe(true);
94
+
95
+ // The sync resolve, reading the now-warm snapshot, classifies the caller as
96
+ // the guardian — not misclassified as `unknown`.
97
+ const warm = resolveActorTrust({
98
+ assistantId: "asst-1",
99
+ sourceChannel: "phone",
100
+ conversationExternalId: GUARDIAN_PHONE,
101
+ actorExternalId: GUARDIAN_PHONE,
102
+ });
103
+ expect(warm.trustClass).toBe("guardian");
104
+ });
105
+
106
+ test("fresh warm bypasses a stale empty phone snapshot after a gateway-side binding", async () => {
107
+ // An earlier setup cached an empty phone snapshot (no guardian yet). Gateway-
108
+ // side binding writes don't invalidate the daemon cache, so the entry stays
109
+ // warm-but-stale until the TTL.
110
+ guardianBound = false;
111
+ await getGuardianDelivery({ channelTypes: ["phone"] });
112
+ expect(peekCachedGuardianDelivery({ channelTypes: ["phone"] })).toEqual([]);
113
+
114
+ // Guardian binding now exists gateway-side. A non-force read would still
115
+ // return the pinned empty snapshot; the fresh read bypasses it.
116
+ guardianBound = true;
117
+ expect(await getGuardianDelivery({ channelTypes: ["phone"] })).toEqual([]);
118
+ await getGuardianDeliveryFresh({ channelTypes: ["phone"] });
119
+
120
+ // The sync resolve now reads the refreshed snapshot and classifies guardian.
121
+ const warm = resolveActorTrust({
122
+ assistantId: "asst-1",
123
+ sourceChannel: "phone",
124
+ conversationExternalId: GUARDIAN_PHONE,
125
+ actorExternalId: GUARDIAN_PHONE,
126
+ });
127
+ expect(warm.trustClass).toBe("guardian");
128
+ });
129
+
130
+ test("startup vellum-only warm leaves the phone key cold", async () => {
131
+ // Daemon startup warms only `vellum`; that must not populate the `phone` key.
132
+ await getGuardianDelivery({ channelTypes: ["vellum"] });
133
+ expect(
134
+ peekCachedGuardianDelivery({ channelTypes: ["phone"] }),
135
+ ).toBeUndefined();
136
+ });
137
+ });
@@ -14,6 +14,12 @@ const gatewayIpc = {
14
14
  mirrored: boolean;
15
15
  },
16
16
  claimThrows: false,
17
+ // When set, contacts_get_rich throws (gateway read unreachable) so the
18
+ // gate-status fallback must fail open.
19
+ richThrows: false,
20
+ // When set, overrides the contacts_get_rich response (e.g. a gateway row
21
+ // under a divergent UUID for the same (type,address)).
22
+ richOverride: null as ((contactId: string | undefined) => unknown) | null,
17
23
  // Drives the upsert_verified_channel relay verdict; false refuses the actor.
18
24
  activationVerified: true,
19
25
  calls: [] as { method: string; params?: Record<string, unknown> }[],
@@ -25,6 +31,13 @@ mock.module("../ipc/gateway-client.js", () => ({
25
31
  params?: Record<string, unknown>,
26
32
  ) => {
27
33
  gatewayIpc.calls.push({ method, params });
34
+ if (method === "contacts_get_rich") {
35
+ if (gatewayIpc.richThrows) throw new Error("gateway read unreachable");
36
+ if (gatewayIpc.richOverride) {
37
+ return gatewayIpc.richOverride(params?.contactId as string);
38
+ }
39
+ return richContactForId(params?.contactId as string);
40
+ }
28
41
  if (method === "record_invite_redemption") {
29
42
  if (gatewayIpc.claimThrows) throw new Error("gateway unreachable");
30
43
  return gatewayIpc.claim;
@@ -39,9 +52,90 @@ mock.module("../ipc/gateway-client.js", () => ({
39
52
  },
40
53
  }));
41
54
 
55
+ // Serves contacts_get_rich (the gateway ACL read backing the gate-status
56
+ // fallback) from the seeded local contact, so gate resolution sources status
57
+ // from the gateway path rather than the local channel column.
58
+ function richContactForId(contactId: string | undefined) {
59
+ if (!contactId) return undefined;
60
+ const contact = getContact(contactId);
61
+ if (!contact) return undefined;
62
+ // ACL columns live on the still-present DB rows, not the slimmed interfaces;
63
+ // read them raw to build the gateway-rich response the production read parses.
64
+ const contactRole = (
65
+ getSqlite()
66
+ .query("SELECT role FROM contacts WHERE id = ?")
67
+ .get(contact.id) as { role: string } | undefined
68
+ )?.role;
69
+ return {
70
+ ok: true,
71
+ contact: {
72
+ id: contact.id,
73
+ displayName: contact.displayName,
74
+ role: contactRole ?? "contact",
75
+ interactionCount: contact.interactionCount,
76
+ createdAt: contact.createdAt,
77
+ updatedAt: contact.updatedAt,
78
+ channels: contact.channels.map((c) => {
79
+ const acl = rawChannelAcl(c.id);
80
+ return {
81
+ id: c.id,
82
+ contactId: c.contactId,
83
+ type: c.type,
84
+ address: c.address,
85
+ isPrimary: c.isPrimary,
86
+ externalUserId: c.externalChatId,
87
+ status: acl.status,
88
+ policy: acl.policy,
89
+ verifiedAt: acl.verifiedAt,
90
+ verifiedVia: acl.verifiedVia,
91
+ lastSeenAt: acl.lastSeenAt,
92
+ interactionCount: acl.interactionCount,
93
+ lastInteraction: acl.lastInteraction,
94
+ revokedReason: acl.revokedReason,
95
+ blockedReason: acl.blockedReason,
96
+ };
97
+ }),
98
+ },
99
+ };
100
+ }
101
+
102
+ /** Read a channel's ACL columns straight off the still-present DB row. */
103
+ function rawChannelAcl(channelId: string) {
104
+ return getSqlite()
105
+ .query(
106
+ `SELECT status, policy, verified_at AS verifiedAt, verified_via AS verifiedVia,
107
+ last_seen_at AS lastSeenAt, interaction_count AS interactionCount,
108
+ last_interaction AS lastInteraction, revoked_reason AS revokedReason,
109
+ blocked_reason AS blockedReason
110
+ FROM contact_channels WHERE id = ?`,
111
+ )
112
+ .get(channelId) as {
113
+ status: string;
114
+ policy: string;
115
+ verifiedAt: number | null;
116
+ verifiedVia: string | null;
117
+ lastSeenAt: number | null;
118
+ interactionCount: number;
119
+ lastInteraction: number | null;
120
+ revokedReason: string | null;
121
+ blockedReason: string | null;
122
+ };
123
+ }
124
+
125
+ /** Read a contact's local role column (dropped from the Contact interface). */
126
+ function localContactRole(contactId: string): string | undefined {
127
+ return (
128
+ getSqlite()
129
+ .query("SELECT role FROM contacts WHERE id = ?")
130
+ .get(contactId) as { role: string } | undefined
131
+ )?.role;
132
+ }
133
+
42
134
  function resetGatewayIpc() {
43
135
  gatewayIpc.claim = { ok: true, updated: true, mirrored: true };
44
136
  gatewayIpc.claimThrows = false;
137
+ gatewayIpc.richThrows = false;
138
+ gatewayIpc.richOverride = null;
45
139
  gatewayIpc.activationVerified = true;
46
140
  gatewayIpc.calls = [];
47
141
  }
@@ -51,12 +145,12 @@ import {
51
145
  getContact,
52
146
  upsertContact,
53
147
  } from "../contacts/contact-store.js";
54
- import { upsertContactChannel } from "../contacts/contacts-write.js";
55
148
  import { getSqlite } from "../memory/db-connection.js";
56
149
  import { initializeDb } from "../memory/db-init.js";
57
150
  import { createInvite, revokeInvite } from "../memory/invite-store.js";
58
151
  import { redeemVoiceInviteCode } from "../runtime/invite-redemption-service.js";
59
152
  import { generateVoiceCode, hashVoiceCode } from "../util/voice-code.js";
153
+ import { seedContactChannel } from "./helpers/seed-contact-channel.js";
60
154
 
61
155
  await initializeDb();
62
156
 
@@ -292,6 +386,83 @@ describe("redeemVoiceInviteCode", () => {
292
386
  ).not.toBeNull();
293
387
  });
294
388
 
389
+ test("matches an active member by (type,address) when the gateway row has a divergent uuid", async () => {
390
+ const phone = "+15551234567";
391
+ const member = seedContactChannel({
392
+ sourceChannel: "phone",
393
+ externalUserId: phone,
394
+ status: "active",
395
+ });
396
+ const { code } = createVoiceInvite({
397
+ callerPhone: phone,
398
+ contactId: member.contactId,
399
+ });
400
+
401
+ // Gateway row for the same (type,address) under a DIFFERENT id.
402
+ gatewayIpc.richOverride = () => ({
403
+ ok: true,
404
+ contact: {
405
+ id: member.contactId,
406
+ displayName: phone,
407
+ role: "contact",
408
+ interactionCount: 0,
409
+ createdAt: 1,
410
+ updatedAt: 1,
411
+ channels: [
412
+ {
413
+ id: "gateway-divergent-uuid",
414
+ contactId: member.contactId,
415
+ type: "phone",
416
+ address: phone,
417
+ isPrimary: false,
418
+ externalUserId: null,
419
+ status: "active",
420
+ policy: "allow",
421
+ verifiedAt: 1,
422
+ verifiedVia: "invite",
423
+ lastSeenAt: null,
424
+ interactionCount: 0,
425
+ lastInteraction: null,
426
+ revokedReason: null,
427
+ blockedReason: null,
428
+ },
429
+ ],
430
+ },
431
+ });
432
+
433
+ const result = await redeemVoiceInviteCode({
434
+ callerExternalUserId: phone,
435
+ sourceChannel: "phone",
436
+ code,
437
+ });
438
+
439
+ expect(result.ok).toBe(true);
440
+ expect((result as { type: string }).type).toBe("already_member");
441
+ });
442
+
443
+ test("fails open (no throw) when the gateway gate-status read is unreachable", async () => {
444
+ const phone = "+15551234567";
445
+ const member = seedContactChannel({
446
+ sourceChannel: "phone",
447
+ externalUserId: phone,
448
+ status: "revoked",
449
+ });
450
+ const { code } = createVoiceInvite({
451
+ callerPhone: phone,
452
+ contactId: member.contactId,
453
+ });
454
+
455
+ gatewayIpc.richThrows = true;
456
+
457
+ const result = await redeemVoiceInviteCode({
458
+ callerExternalUserId: phone,
459
+ sourceChannel: "phone",
460
+ code,
461
+ });
462
+
463
+ expect(result.ok).toBe(true);
464
+ });
465
+
295
466
  test("marks channel as verified via invite on voice redemption", async () => {
296
467
  const phone = "+15551234567";
297
468
  const { code } = createVoiceInvite({ callerPhone: phone });
@@ -304,15 +475,12 @@ describe("redeemVoiceInviteCode", () => {
304
475
 
305
476
  expect(result.ok).toBe(true);
306
477
 
478
+ // The gateway owns the verified ACL verdict; the local mirror is identity-only.
307
479
  const channelResult = findContactChannel({
308
480
  channelType: "phone",
309
481
  address: phone,
310
482
  });
311
-
312
483
  expect(channelResult).not.toBeNull();
313
- expect(channelResult!.channel.verifiedAt).toBeGreaterThan(0);
314
- expect(channelResult!.channel.verifiedVia).toBe("invite");
315
- expect(channelResult!.channel.status).toBe("active");
316
484
  });
317
485
 
318
486
  test("wrong caller identity fails with generic error", async () => {
@@ -420,7 +588,7 @@ describe("redeemVoiceInviteCode", () => {
420
588
  const phone = "+15551234567";
421
589
 
422
590
  // Pre-create an active member for this phone on voice channel
423
- const member = upsertContactChannel({
591
+ const member = seedContactChannel({
424
592
  sourceChannel: "phone",
425
593
  externalUserId: phone,
426
594
  status: "active",
@@ -430,7 +598,7 @@ describe("redeemVoiceInviteCode", () => {
430
598
  // Create a voice invite targeting the same contact that owns the channel
431
599
  const { code } = createVoiceInvite({
432
600
  callerPhone: phone,
433
- contactId: member!.contact.id,
601
+ contactId: member.contactId,
434
602
  });
435
603
 
436
604
  const result = await redeemVoiceInviteCode({
@@ -456,7 +624,7 @@ describe("redeemVoiceInviteCode", () => {
456
624
  const phone = "+15551234567";
457
625
 
458
626
  // Pre-create a blocked member and find their contact
459
- const member = upsertContactChannel({
627
+ const member = seedContactChannel({
460
628
  sourceChannel: "phone",
461
629
  externalUserId: phone,
462
630
  status: "blocked",
@@ -466,7 +634,7 @@ describe("redeemVoiceInviteCode", () => {
466
634
  // Create a voice invite targeting the same contact that owns the channel
467
635
  const { code } = createVoiceInvite({
468
636
  callerPhone: phone,
469
- contactId: member!.contact.id,
637
+ contactId: member.contactId,
470
638
  });
471
639
 
472
640
  const result = await redeemVoiceInviteCode({
@@ -492,16 +660,12 @@ describe("redeemVoiceInviteCode", () => {
492
660
  const phone = "+15559998888";
493
661
 
494
662
  // Pre-create a guardian contact with a revoked phone channel
495
- const guardianContact = upsertContact({
663
+ const guardianSeed = seedContactChannel({
664
+ sourceChannel: "phone",
665
+ externalUserId: phone,
496
666
  displayName: "Guardian",
497
667
  role: "guardian",
498
- channels: [
499
- {
500
- type: "phone",
501
- address: phone,
502
- status: "revoked",
503
- },
504
- ],
668
+ status: "revoked",
505
669
  });
506
670
 
507
671
  // Create a separate target contact "Mom"
@@ -533,11 +697,10 @@ describe("redeemVoiceInviteCode", () => {
533
697
  });
534
698
  expect(contactResult).not.toBeNull();
535
699
  expect(contactResult!.contact.id).toBe(momContact.id);
536
- expect(contactResult!.channel.status).toBe("active");
537
700
 
538
701
  // Verify the original guardian contact was NOT modified
539
- const guardian = getContact(guardianContact.id);
702
+ const guardian = getContact(guardianSeed.contactId);
540
703
  expect(guardian).not.toBeNull();
541
- expect(guardian!.role).toBe("guardian");
704
+ expect(localContactRole(guardianSeed.contactId)).toBe("guardian");
542
705
  });
543
706
  });
@@ -55,11 +55,11 @@ describe("102-preserve-heartbeat-enabled-for-existing-workspaces migration", ()
55
55
  ).toContain("heartbeat.enabled");
56
56
  });
57
57
 
58
- test("schema default is disabled (the flip this migration compensates for)", () => {
59
- expect(HeartbeatConfigSchema.parse({}).enabled).toBe(false);
58
+ test("schema default is enabled", () => {
59
+ expect(HeartbeatConfigSchema.parse({}).enabled).toBe(true);
60
60
  });
61
61
 
62
- test("skips fresh workspaces so new users get the opt-in default", () => {
62
+ test("skips fresh workspaces so new users inherit the schema default", () => {
63
63
  preserveHeartbeatEnabledForExistingWorkspacesMigration.run(
64
64
  workspaceDir,
65
65
  NEW_WORKSPACE_CTX,
@@ -110,8 +110,8 @@ afterEach(() => {
110
110
  });
111
111
 
112
112
  describe("111-prune-seeded-callsite-defaults migration", () => {
113
- test("is registered last", () => {
114
- expect(WORKSPACE_MIGRATIONS.at(-1)).toBe(
113
+ test("is registered", () => {
114
+ expect(WORKSPACE_MIGRATIONS).toContain(
115
115
  pruneSeededCallsiteDefaultsMigration,
116
116
  );
117
117
  });
@@ -0,0 +1,170 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
11
+
12
+ import { removeAdvisorCallsiteOverrideMigration } from "../workspace/migrations/112-remove-advisor-callsite-override.js";
13
+
14
+ let workspaceDir: string;
15
+
16
+ function writeConfig(data: Record<string, unknown>): void {
17
+ writeFileSync(
18
+ join(workspaceDir, "config.json"),
19
+ JSON.stringify(data, null, 2) + "\n",
20
+ );
21
+ }
22
+
23
+ function readConfig(): Record<string, unknown> {
24
+ return JSON.parse(readFileSync(join(workspaceDir, "config.json"), "utf-8"));
25
+ }
26
+
27
+ beforeEach(() => {
28
+ workspaceDir = join(
29
+ tmpdir(),
30
+ `vellum-migration-111-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
31
+ );
32
+ mkdirSync(workspaceDir, { recursive: true });
33
+ });
34
+
35
+ afterEach(() => {
36
+ if (existsSync(workspaceDir)) {
37
+ rmSync(workspaceDir, { recursive: true, force: true });
38
+ }
39
+ });
40
+
41
+ describe("112-remove-advisor-callsite-override migration", () => {
42
+ test("has correct migration id and description", () => {
43
+ expect(removeAdvisorCallsiteOverrideMigration.id).toBe(
44
+ "112-remove-advisor-callsite-override",
45
+ );
46
+ expect(removeAdvisorCallsiteOverrideMigration.description).toBe(
47
+ "Remove the stale advisor entry from llm.callSites (advisor call site removed)",
48
+ );
49
+ });
50
+
51
+ // ─── No-op cases ────────────────────────────────────────────────────────
52
+
53
+ test("no-op when config.json does not exist", () => {
54
+ removeAdvisorCallsiteOverrideMigration.run(workspaceDir);
55
+ expect(existsSync(join(workspaceDir, "config.json"))).toBe(false);
56
+ });
57
+
58
+ test("gracefully handles invalid JSON", () => {
59
+ writeFileSync(join(workspaceDir, "config.json"), "not-valid-json");
60
+ removeAdvisorCallsiteOverrideMigration.run(workspaceDir);
61
+ expect(readFileSync(join(workspaceDir, "config.json"), "utf-8")).toBe(
62
+ "not-valid-json",
63
+ );
64
+ });
65
+
66
+ test("no-op when config has no llm.callSites", () => {
67
+ const original = { llm: { default: { provider: "anthropic" } } };
68
+ writeConfig(original);
69
+ removeAdvisorCallsiteOverrideMigration.run(workspaceDir);
70
+ expect(readConfig()).toEqual(original);
71
+ });
72
+
73
+ test("no-op when llm.callSites has no advisor key", () => {
74
+ const original = {
75
+ llm: {
76
+ callSites: {
77
+ mainAgent: { profile: "quality-optimized" },
78
+ memoryRouter: { profile: "latency-optimized" },
79
+ },
80
+ },
81
+ };
82
+ writeConfig(original);
83
+ removeAdvisorCallsiteOverrideMigration.run(workspaceDir);
84
+ expect(readConfig()).toEqual(original);
85
+ });
86
+
87
+ test("gracefully handles non-object llm / callSites shapes", () => {
88
+ const original = { llm: { callSites: 42 } };
89
+ writeConfig(original);
90
+ removeAdvisorCallsiteOverrideMigration.run(workspaceDir);
91
+ expect(readConfig()).toEqual(original);
92
+ });
93
+
94
+ // ─── Removal ────────────────────────────────────────────────────────────
95
+
96
+ test("removes advisor and prunes the now-empty callSites map", () => {
97
+ writeConfig({
98
+ llm: {
99
+ callSites: {
100
+ advisor: { profile: "quality-optimized" },
101
+ },
102
+ advisorProfile: "frontier",
103
+ },
104
+ });
105
+
106
+ removeAdvisorCallsiteOverrideMigration.run(workspaceDir);
107
+
108
+ const llm = readConfig().llm as Record<string, unknown>;
109
+ expect("callSites" in llm).toBe(false);
110
+ // The unrelated top-level advisor profile selection is untouched.
111
+ expect(llm.advisorProfile).toBe("frontier");
112
+ });
113
+
114
+ test("removes advisor but preserves other callSites keys", () => {
115
+ writeConfig({
116
+ llm: {
117
+ callSites: {
118
+ advisor: { profile: "quality-optimized" },
119
+ mainAgent: { profile: "opus" },
120
+ memoryRouter: { profile: "latency-optimized" },
121
+ },
122
+ },
123
+ });
124
+
125
+ removeAdvisorCallsiteOverrideMigration.run(workspaceDir);
126
+
127
+ const callSites = (readConfig().llm as Record<string, unknown>)
128
+ .callSites as Record<string, unknown>;
129
+ expect("advisor" in callSites).toBe(false);
130
+ expect(callSites.mainAgent).toEqual({ profile: "opus" });
131
+ expect(callSites.memoryRouter).toEqual({ profile: "latency-optimized" });
132
+ });
133
+
134
+ // ─── Idempotency ────────────────────────────────────────────────────────
135
+
136
+ test("idempotency: re-running yields no further mutation", () => {
137
+ writeConfig({
138
+ llm: {
139
+ callSites: {
140
+ advisor: { profile: "quality-optimized" },
141
+ mainAgent: { profile: "opus" },
142
+ },
143
+ },
144
+ });
145
+
146
+ removeAdvisorCallsiteOverrideMigration.run(workspaceDir);
147
+ const afterFirst = readConfig();
148
+
149
+ removeAdvisorCallsiteOverrideMigration.run(workspaceDir);
150
+ const afterSecond = readConfig();
151
+
152
+ expect(afterSecond).toEqual(afterFirst);
153
+ });
154
+
155
+ test("idempotency: writes nothing on a config without the advisor key", () => {
156
+ writeConfig({
157
+ llm: { callSites: { mainAgent: { profile: "opus" } } },
158
+ });
159
+ const beforeContent = readFileSync(
160
+ join(workspaceDir, "config.json"),
161
+ "utf-8",
162
+ );
163
+
164
+ removeAdvisorCallsiteOverrideMigration.run(workspaceDir);
165
+
166
+ expect(readFileSync(join(workspaceDir, "config.json"), "utf-8")).toBe(
167
+ beforeContent,
168
+ );
169
+ });
170
+ });