@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
@@ -31,16 +31,33 @@ import {
31
31
 
32
32
  // ── Mock state ────────────────────────────────────────────────────
33
33
 
34
+ interface GuardianDeliveryStub {
35
+ channelType: string;
36
+ address: string;
37
+ status: string;
38
+ }
39
+
34
40
  let mockWorkspaceDir: string = "";
35
- let mockVellumGuardian: {
36
- contact: { userFile: string | null };
37
- channel: Record<string, unknown>;
38
- } | null = null;
39
- let mockAnyGuardian: {
40
- contact: { userFile: string | null };
41
- channels: Record<string, unknown>[];
42
- } | null = null;
41
+ // Gateway guardian delivery cache, keyed by the same source the production
42
+ // peek reads; the guardian's userFile (local INFO) is joined separately via
43
+ // findContactByAddress on the delivery's address.
44
+ let mockGuardianDeliveries: GuardianDeliveryStub[] = [];
43
45
  let mockContactsByAddress: Record<string, { userFile: string | null }> = {};
46
+ // Simulates a cold sync cache: the sync `peek` returns nothing until the async
47
+ // `getGuardianDelivery` warm runs and populates `mockGuardianDeliveries`. The
48
+ // pending list is what the warm reveals.
49
+ let pendingWarmDeliveries: GuardianDeliveryStub[] | null = null;
50
+
51
+ /**
52
+ * Seed a vellum guardian: a gateway delivery for the vellum channel plus the
53
+ * local contact (by address) carrying its userFile.
54
+ */
55
+ function seedVellumGuardian(userFile: string | null): void {
56
+ mockGuardianDeliveries = [
57
+ { channelType: "vellum", address: "vellum:self", status: "active" },
58
+ ];
59
+ mockContactsByAddress["vellum:vellum:self"] = { userFile };
60
+ }
44
61
 
45
62
  // ── Mock modules (must precede imports from the module under test) ──
46
63
 
@@ -51,13 +68,32 @@ mock.module("../util/platform.js", () => ({
51
68
  mock.module("../contacts/contact-store.js", () => ({
52
69
  findContactByAddress: (channelType: string, address: string) =>
53
70
  mockContactsByAddress[`${channelType}:${address}`] ?? null,
54
- findGuardianForChannel: (channelType: string) =>
55
- channelType === "vellum" ? mockVellumGuardian : null,
56
- listGuardianChannels: () => mockAnyGuardian,
71
+ }));
72
+
73
+ mock.module("../contacts/guardian-delivery-reader.js", () => ({
74
+ peekCachedGuardianDelivery: (input?: { channelTypes?: string[] }) => {
75
+ if (!input?.channelTypes) return mockGuardianDeliveries;
76
+ return mockGuardianDeliveries.filter((g) =>
77
+ input.channelTypes!.includes(g.channelType),
78
+ );
79
+ },
80
+ // Warming the cache: reveals the pending guardian to the sync peek above,
81
+ // mirroring how the production single-flight read populates the cache key.
82
+ getGuardianDelivery: async (_input?: { channelTypes?: string[] }) => {
83
+ if (pendingWarmDeliveries) {
84
+ mockGuardianDeliveries = pendingWarmDeliveries;
85
+ pendingWarmDeliveries = null;
86
+ }
87
+ return mockGuardianDeliveries;
88
+ },
89
+ guardianForChannel: (list: GuardianDeliveryStub[], channelType: string) =>
90
+ list.find((g) => g.channelType === channelType && g.status === "active"),
91
+ anyGuardian: (list: GuardianDeliveryStub[]) => list[0],
57
92
  }));
58
93
 
59
94
  // Import AFTER mocks so the module under test binds to the stubbed
60
95
  // implementations.
96
+ import { getGuardianDelivery } from "../contacts/guardian-delivery-reader.js";
61
97
  import type { TrustContext } from "../daemon/trust-context.js";
62
98
  import {
63
99
  ensureGuardianPersonaFile,
@@ -83,9 +119,9 @@ afterAll(() => {
83
119
  beforeEach(() => {
84
120
  // Fresh workspace per test, so filesystem state doesn't leak.
85
121
  mockWorkspaceDir = mkdtempSync(join(testRoot, "ws-"));
86
- mockVellumGuardian = null;
87
- mockAnyGuardian = null;
122
+ mockGuardianDeliveries = [];
88
123
  mockContactsByAddress = {};
124
+ pendingWarmDeliveries = null;
89
125
  });
90
126
 
91
127
  afterEach(() => {
@@ -96,21 +132,35 @@ afterEach(() => {
96
132
 
97
133
  describe("resolveGuardianPersonaPath", () => {
98
134
  test("returns null when no guardian exists", () => {
99
- mockVellumGuardian = null;
100
- mockAnyGuardian = null;
101
135
 
102
136
  expect(resolveGuardianPersonaPath()).toBeNull();
103
137
  });
104
138
 
105
139
  test("returns absolute path when guardian has userFile set", () => {
106
- mockVellumGuardian = {
107
- contact: { userFile: "alice.md" },
108
- channel: {},
109
- };
140
+ seedVellumGuardian("alice.md");
110
141
 
111
142
  const result = resolveGuardianPersonaPath();
112
143
  expect(result).toBe(join(mockWorkspaceDir, "users", "alice.md"));
113
144
  });
145
+
146
+ test("falls back to default (null path) on a cold cache, but resolves the guardian after a warm", async () => {
147
+ // Cold start: the guardian binding exists upstream but the sync cache is
148
+ // empty, so a bare sync resolution misses it and falls back to default.
149
+ pendingWarmDeliveries = [
150
+ { channelType: "vellum", address: "vellum:self", status: "active" },
151
+ ];
152
+ mockContactsByAddress["vellum:vellum:self"] = { userFile: "alice.md" };
153
+
154
+ expect(resolveGuardianPersonaPath()).toBeNull();
155
+
156
+ // Async callers warm the vellum guardian-delivery cache before the sync
157
+ // resolution; afterwards the guardian slug resolves instead of default.md.
158
+ await getGuardianDelivery({ channelTypes: ["vellum"] });
159
+
160
+ expect(resolveGuardianPersonaPath()).toBe(
161
+ join(mockWorkspaceDir, "users", "alice.md"),
162
+ );
163
+ });
114
164
  });
115
165
 
116
166
  // ── ensureGuardianPersonaFile ──────────────────────────────────────
@@ -156,17 +206,12 @@ describe("ensureGuardianPersonaFile", () => {
156
206
 
157
207
  describe("resolveGuardianPersonaStrict", () => {
158
208
  test("returns null when no guardian contact exists", () => {
159
- mockVellumGuardian = null;
160
- mockAnyGuardian = null;
161
209
 
162
210
  expect(resolveGuardianPersonaStrict()).toBeNull();
163
211
  });
164
212
 
165
213
  test("returns null when the guardian's own file is missing, even if default.md exists", () => {
166
- mockVellumGuardian = {
167
- contact: { userFile: "alice.md" },
168
- channel: {},
169
- };
214
+ seedVellumGuardian("alice.md");
170
215
 
171
216
  // default.md is populated but alice.md is not on disk.
172
217
  const usersDir = join(mockWorkspaceDir, "users");
@@ -185,10 +230,7 @@ describe("resolveGuardianPersonaStrict", () => {
185
230
  });
186
231
 
187
232
  test("returns guardian file content when present", () => {
188
- mockVellumGuardian = {
189
- contact: { userFile: "alice.md" },
190
- channel: {},
191
- };
233
+ seedVellumGuardian("alice.md");
192
234
 
193
235
  const usersDir = join(mockWorkspaceDir, "users");
194
236
  mkdirSync(usersDir, { recursive: true });
@@ -260,10 +302,7 @@ describe("isGuardianPersonaCustomized", () => {
260
302
 
261
303
  describe("resolveUserSlug (guardian trust, no requester identity)", () => {
262
304
  test("guardian trust context without requesterExternalUserId resolves the guardian user file", () => {
263
- mockVellumGuardian = {
264
- contact: { userFile: "alice.md" },
265
- channel: {},
266
- };
305
+ seedVellumGuardian("alice.md");
267
306
 
268
307
  const trustContext = {
269
308
  sourceChannel: "vellum",
@@ -274,10 +313,7 @@ describe("resolveUserSlug (guardian trust, no requester identity)", () => {
274
313
  });
275
314
 
276
315
  test("non-guardian trust context without requesterExternalUserId does not borrow the guardian persona", () => {
277
- mockVellumGuardian = {
278
- contact: { userFile: "alice.md" },
279
- channel: {},
280
- };
316
+ seedVellumGuardian("alice.md");
281
317
 
282
318
  const trustContext = {
283
319
  sourceChannel: "vellum",
@@ -291,10 +327,7 @@ describe("resolveUserSlug (guardian trust, no requester identity)", () => {
291
327
  // The verdict-bound guardian is looked up by its address, not by the
292
328
  // most-recently-verified channel guardian, so a different channel guardian
293
329
  // does not shadow the verdict's binding.
294
- mockVellumGuardian = {
295
- contact: { userFile: "wrong-guardian.md" },
296
- channel: {},
297
- };
330
+ seedVellumGuardian("wrong-guardian.md");
298
331
  mockContactsByAddress["telegram:guardian-tg"] = {
299
332
  userFile: "alice.md",
300
333
  };
@@ -309,10 +342,7 @@ describe("resolveUserSlug (guardian trust, no requester identity)", () => {
309
342
  });
310
343
 
311
344
  test("falls back to the channel guardian when the verdict carries no guardian identity", () => {
312
- mockVellumGuardian = {
313
- contact: { userFile: "alice.md" },
314
- channel: {},
315
- };
345
+ seedVellumGuardian("alice.md");
316
346
 
317
347
  const trustContext = {
318
348
  sourceChannel: "vellum",
@@ -475,11 +475,13 @@ describe("plugin bootstrap", () => {
475
475
  //
476
476
  // A plugin is disabled when a `.disabled` file exists at
477
477
  // <workspace>/plugins/<manifest-name>/.disabled. The bootstrap must
478
- // skip the plugin entirely — no init, no tools, no routes, no shutdown
479
- // hook and remove it from the registry, mirroring the requiresFlag
480
- // gate.
478
+ // skip the plugin's init, tools, routes, and shutdown hook. Unlike the
479
+ // requiresFlag gate, the plugin is NOT removed from the registry its
480
+ // hooks stay registered and are filtered at read time by
481
+ // `isPluginDisabled` in `getHooksFor`, so `assistant plugins enable`
482
+ // takes effect on the next turn without a restart.
481
483
 
482
- test(".disabled sentinel: init does not fire and plugin is unregistered", async () => {
484
+ test(".disabled sentinel: init does not fire and hooks are filtered at read time", async () => {
483
485
  let initFired = false;
484
486
  const plugin = buildPlugin("sentinel-off", {
485
487
  async init() {
@@ -497,8 +499,14 @@ describe("plugin bootstrap", () => {
497
499
  await bootstrapPlugins();
498
500
 
499
501
  expect(initFired).toBe(false);
502
+ // The plugin stays in the registry (not unregistered) so its hooks can
503
+ // be re-enabled at runtime by removing the sentinel.
500
504
  const names = getRegisteredPlugins().map((p) => p.manifest.name);
501
- expect(names).not.toContain("sentinel-off");
505
+ expect(names).toContain("sentinel-off");
506
+ // But its hooks are filtered out at read time by `isPluginDisabled`.
507
+ const { getHooksFor } = await import("../plugins/registry.js");
508
+ const hooks = await getHooksFor("init");
509
+ expect(hooks).toHaveLength(0);
502
510
 
503
511
  await rm(sentinelDir, { recursive: true, force: true });
504
512
  });
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Tests for per-surface plugin disabled-state filtering.
3
+ *
4
+ * Verifies that `assistant plugins disable default-*` takes effect on the
5
+ * next turn without a daemon restart. The `.disabled` sentinel is checked at
6
+ * read time by each surface (`getHooksFor` for hooks,
7
+ * `getPluginToolDefinitions` for tools) rather than at boot, so toggling the
8
+ * sentinel file at runtime is immediately reflected.
9
+ */
10
+
11
+ import { existsSync } from "node:fs";
12
+ import { mkdir, rm, writeFile } from "node:fs/promises";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
16
+
17
+ import { RiskLevel } from "../permissions/types.js";
18
+ import {
19
+ getHooksFor,
20
+ registerPlugin,
21
+ resetPluginRegistryForTests,
22
+ unregisterPlugin,
23
+ } from "../plugins/registry.js";
24
+ import { type HookFunction, type Plugin } from "../plugins/types.js";
25
+ import {
26
+ getPluginToolDefinitions,
27
+ registerPluginTools,
28
+ } from "../tools/registry.js";
29
+ import type { Tool, ToolContext, ToolExecutionResult } from "../tools/types.js";
30
+
31
+ const TEST_WORKSPACE_DIR = join(
32
+ tmpdir(),
33
+ `vellum-plugin-disabled-state-test-${process.pid}-${Date.now()}`,
34
+ );
35
+ process.env.VELLUM_WORKSPACE_DIR = TEST_WORKSPACE_DIR;
36
+
37
+ async function createSentinel(name: string): Promise<void> {
38
+ const dir = join(TEST_WORKSPACE_DIR, "plugins", name);
39
+ await mkdir(dir, { recursive: true });
40
+ await writeFile(join(dir, ".disabled"), "");
41
+ }
42
+
43
+ async function removeSentinel(name: string): Promise<void> {
44
+ const dir = join(TEST_WORKSPACE_DIR, "plugins", name);
45
+ await rm(dir, { recursive: true, force: true });
46
+ }
47
+
48
+ function buildPlugin(
49
+ name: string,
50
+ hooks: Record<string, HookFunction> = {},
51
+ ): Plugin {
52
+ return {
53
+ manifest: { name, version: "1.0.0" },
54
+ hooks,
55
+ };
56
+ }
57
+
58
+ function makeFakeTool(name: string): Tool {
59
+ return {
60
+ name,
61
+ description: `Fake ${name}`,
62
+ defaultRiskLevel: RiskLevel.Low,
63
+ executionTarget: "sandbox",
64
+ input_schema: { type: "object", properties: {}, required: [] },
65
+ category: "plugin",
66
+ async execute(
67
+ _input: Record<string, unknown>,
68
+ _context: ToolContext,
69
+ ): Promise<ToolExecutionResult> {
70
+ return { content: "ok", isError: false };
71
+ },
72
+ };
73
+ }
74
+
75
+ beforeEach(() => {
76
+ resetPluginRegistryForTests();
77
+ });
78
+
79
+ afterEach(async () => {
80
+ // Clean up any sentinel files created during the test.
81
+ const pluginsDir = join(TEST_WORKSPACE_DIR, "plugins");
82
+ if (existsSync(pluginsDir)) {
83
+ await rm(pluginsDir, { recursive: true, force: true });
84
+ }
85
+ });
86
+
87
+ describe("per-surface disabled-state filtering", () => {
88
+ test("getHooksFor filters out hooks from a disabled plugin", async () => {
89
+ const plugin = buildPlugin("default-test-hook", {
90
+ "user-prompt-submit": () => Promise.resolve(),
91
+ });
92
+ registerPlugin(plugin);
93
+
94
+ // Before disabling: hook is included.
95
+ const hooksBefore = await getHooksFor("user-prompt-submit");
96
+ expect(hooksBefore).toHaveLength(1);
97
+
98
+ // Disable via sentinel.
99
+ await createSentinel("default-test-hook");
100
+
101
+ // After disabling: hook is filtered out at read time.
102
+ const hooksAfter = await getHooksFor("user-prompt-submit");
103
+ expect(hooksAfter).toHaveLength(0);
104
+
105
+ // Clean up.
106
+ await removeSentinel("default-test-hook");
107
+ });
108
+
109
+ test("getHooksFor re-includes hooks when a disabled plugin is re-enabled", async () => {
110
+ const plugin = buildPlugin("default-test-reenable", {
111
+ "user-prompt-submit": () => Promise.resolve(),
112
+ });
113
+ registerPlugin(plugin);
114
+
115
+ // Disable.
116
+ await createSentinel("default-test-reenable");
117
+ let hooks = await getHooksFor("user-prompt-submit");
118
+ expect(hooks).toHaveLength(0);
119
+
120
+ // Re-enable by removing the sentinel.
121
+ await removeSentinel("default-test-reenable");
122
+ hooks = await getHooksFor("user-prompt-submit");
123
+ expect(hooks).toHaveLength(1);
124
+ });
125
+
126
+ test("getPluginToolDefinitions filters out tools from a disabled plugin", async () => {
127
+ const plugin: Plugin = {
128
+ manifest: { name: "default-test-tools", version: "1.0.0" },
129
+ tools: [makeFakeTool("test_tool")],
130
+ };
131
+ registerPlugin(plugin);
132
+ registerPluginTools("default-test-tools", plugin.tools!);
133
+
134
+ // Before disabling: tool is visible.
135
+ let defs = getPluginToolDefinitions();
136
+ expect(defs.some((d) => d.name === "test_tool")).toBe(true);
137
+
138
+ // Disable via sentinel.
139
+ await createSentinel("default-test-tools");
140
+
141
+ // After disabling: tool is filtered out.
142
+ defs = getPluginToolDefinitions();
143
+ expect(defs.some((d) => d.name === "test_tool")).toBe(false);
144
+
145
+ // Re-enable.
146
+ await removeSentinel("default-test-tools");
147
+ defs = getPluginToolDefinitions();
148
+ expect(defs.some((d) => d.name === "test_tool")).toBe(true);
149
+ });
150
+
151
+ test("disabling one plugin does not affect others", async () => {
152
+ const pluginA = buildPlugin("default-test-alpha", {
153
+ "user-prompt-submit": () => Promise.resolve(),
154
+ });
155
+ const pluginB = buildPlugin("default-test-beta", {
156
+ "user-prompt-submit": () => Promise.resolve(),
157
+ });
158
+ registerPlugin(pluginA);
159
+ registerPlugin(pluginB);
160
+
161
+ // Both visible.
162
+ let hooks = await getHooksFor("user-prompt-submit");
163
+ expect(hooks).toHaveLength(2);
164
+
165
+ // Disable only alpha.
166
+ await createSentinel("default-test-alpha");
167
+ hooks = await getHooksFor("user-prompt-submit");
168
+ expect(hooks).toHaveLength(1);
169
+
170
+ // Re-enable alpha.
171
+ await removeSentinel("default-test-alpha");
172
+ hooks = await getHooksFor("user-prompt-submit");
173
+ expect(hooks).toHaveLength(2);
174
+ });
175
+
176
+ test("unregisterPlugin removes hooks from the hook registry", async () => {
177
+ const plugin = buildPlugin("default-test-unreg", {
178
+ "user-prompt-submit": () => Promise.resolve(),
179
+ });
180
+ registerPlugin(plugin);
181
+
182
+ let hooks = await getHooksFor("user-prompt-submit");
183
+ expect(hooks).toHaveLength(1);
184
+
185
+ unregisterPlugin("default-test-unreg");
186
+
187
+ hooks = await getHooksFor("user-prompt-submit");
188
+ expect(hooks).toHaveLength(0);
189
+ });
190
+ });
@@ -234,7 +234,7 @@ describe("native web-search capability survives the wrapper chain", () => {
234
234
  // UsageTracking → leaf. The advisor consult reads the flag off the top.
235
235
  const wrapped = new CallSiteConfiguredProvider(
236
236
  new UsageTrackingProvider(leaf(true)),
237
- "advisor",
237
+ "subagentSpawn",
238
238
  );
239
239
  expect(wrapped.supportsNativeWebSearch).toBe(true);
240
240
  });
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Cold-cache guardian-reaction regression.
3
+ *
4
+ * The sync trust resolver reads the IO-free guardian-delivery cache snapshot
5
+ * (`peekCachedGuardianDelivery`). On a cold process only `vellum` is warmed at
6
+ * daemon startup, so for `slack` the snapshot is empty until some read warms
7
+ * that exact channel key. `handleSlackReactionIntercept` therefore awaits
8
+ * `getGuardianDelivery({ channelTypes: ["slack"] })` BEFORE the sync resolve so
9
+ * a guardian's approval reaction classifies as `guardian` rather than dropping
10
+ * as `unknown`.
11
+ *
12
+ * This test drives the REAL guardian-delivery reader cache (mocking only the
13
+ * gateway `ipcCall`) so the cold→warm transition is exercised end to end.
14
+ */
15
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
16
+
17
+ const GUARDIAN_USER_ID = "U_GUARDIAN_COLD";
18
+ const SLACK_CHANNEL_ID = "C0COLD";
19
+
20
+ // Gateway IPC stub: returns the slack guardian delivery. The real reader caches
21
+ // the result under the `slack` key, so a subsequent sync `peek` finds it.
22
+ let ipcCalls: Array<{ route: string; input: unknown }> = [];
23
+ mock.module("../ipc/gateway-client.js", () => ({
24
+ ipcCall: async (route: string, input: unknown) => {
25
+ ipcCalls.push({ route, input });
26
+ return {
27
+ guardians: [
28
+ {
29
+ channelType: "slack",
30
+ contactId: "guardian-contact",
31
+ principalId: GUARDIAN_USER_ID,
32
+ address: GUARDIAN_USER_ID,
33
+ externalChatId: SLACK_CHANNEL_ID,
34
+ status: "active",
35
+ },
36
+ ],
37
+ };
38
+ },
39
+ }));
40
+
41
+ // Contact lookup is irrelevant to guardian classification (address match on the
42
+ // cached delivery decides it); return null so member lookup is a no-op.
43
+ mock.module("../contacts/contact-store.js", () => ({
44
+ findContactByAddress: () => null,
45
+ }));
46
+
47
+ // Stub downstream side effects so the test isolates trust classification.
48
+ mock.module("../memory/conversation-crud.js", () => ({
49
+ addMessage: async () => ({ id: "msg-1" }),
50
+ }));
51
+ mock.module("../memory/delivery-crud.js", () => ({
52
+ recordInbound: () => ({
53
+ eventId: "evt-1",
54
+ conversationId: "conv-1",
55
+ accepted: true,
56
+ duplicate: false,
57
+ }),
58
+ clearPayload: () => {},
59
+ linkMessage: () => {},
60
+ }));
61
+ mock.module("../memory/delivery-status.js", () => ({
62
+ markProcessed: () => {},
63
+ }));
64
+ mock.module("../memory/external-conversation-store.js", () => ({
65
+ upsertBinding: () => {},
66
+ }));
67
+ mock.module("../daemon/disk-pressure-guard.js", () => ({
68
+ getDiskPressureStatus: () => ({ level: "ok" }),
69
+ }));
70
+ mock.module("../daemon/disk-pressure-policy.js", () => ({
71
+ classifyDiskPressureTurnPolicy: () => ({ action: "allow" }),
72
+ }));
73
+
74
+ // Capture the trustClass the guardian decision pipeline receives — this is the
75
+ // classification produced by the sync resolve after the upstream warm.
76
+ let receivedTrustClass: string | undefined;
77
+ mock.module("../runtime/routes/inbound-stages/guardian-reply-intercept.js", () => ({
78
+ handleGuardianReplyIntercept: async (params: { trustClass: string }) => {
79
+ receivedTrustClass = params.trustClass;
80
+ return { response: { accepted: true, canonicalRouter: "applied" } };
81
+ },
82
+ }));
83
+
84
+ import {
85
+ __resetGuardianDeliveryCacheForTest,
86
+ peekCachedGuardianDelivery,
87
+ } from "../contacts/guardian-delivery-reader.js";
88
+ import { handleSlackReactionIntercept } from "../runtime/routes/inbound-stages/reaction-intercept.js";
89
+
90
+ function buildParams() {
91
+ return {
92
+ callbackData: "reaction:white_check_mark",
93
+ sourceChannel: "slack" as const,
94
+ sourceInterface: "slack" as const,
95
+ conversationExternalId: SLACK_CHANNEL_ID,
96
+ externalMessageId: `${SLACK_CHANNEL_ID}:1700000000.1:cold`,
97
+ canonicalAssistantId: "assistant-1",
98
+ rawSenderId: GUARDIAN_USER_ID,
99
+ canonicalSenderId: GUARDIAN_USER_ID,
100
+ actorDisplayName: "Guardian",
101
+ actorUsername: undefined,
102
+ replyCallbackUrl: "http://localhost:7830/deliver/slack",
103
+ sourceMetadata: { messageId: "1700000000.1", chatType: "channel" } as never,
104
+ slackChannelName: "general",
105
+ approvalConversationGenerator: undefined,
106
+ };
107
+ }
108
+
109
+ describe("reaction intercept warms the channel guardian cache before sync trust", () => {
110
+ beforeEach(() => {
111
+ __resetGuardianDeliveryCacheForTest();
112
+ ipcCalls = [];
113
+ receivedTrustClass = undefined;
114
+ });
115
+
116
+ test("cold slack cache: guardian reaction classifies as guardian after upstream warm", async () => {
117
+ // Precondition: cold cache for slack — the sync peek would miss.
118
+ expect(peekCachedGuardianDelivery({ channelTypes: ["slack"] })).toBeUndefined();
119
+
120
+ await handleSlackReactionIntercept(buildParams());
121
+
122
+ // The intercept warmed the slack-specific key via the async reader.
123
+ expect(
124
+ ipcCalls.some(
125
+ (c) =>
126
+ c.route === "resolve_guardian_delivery" &&
127
+ JSON.stringify(c.input) === JSON.stringify({ channelTypes: ["slack"] }),
128
+ ),
129
+ ).toBe(true);
130
+
131
+ // The sync resolve, reading the now-warm snapshot, classified the reactor as
132
+ // the guardian — not dropped as `unknown`.
133
+ expect(receivedTrustClass).toBe("guardian");
134
+ });
135
+ });