@vellumai/assistant 0.10.4-dev.202607021153.195c867 → 0.10.4-dev.202607021247.94bb87f

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 (58) hide show
  1. package/openapi.yaml +115 -0
  2. package/package.json +1 -1
  3. package/src/__tests__/conversation-app-control-instantiation.test.ts +2 -0
  4. package/src/__tests__/conversation-crud-enabled-plugins.test.ts +60 -0
  5. package/src/__tests__/conversation-routes-enabled-plugins.test.ts +389 -0
  6. package/src/__tests__/conversation-skill-tools.test.ts +73 -0
  7. package/src/__tests__/plugin-effective-enabled-set.test.ts +206 -0
  8. package/src/__tests__/schedule-store.test.ts +3 -1
  9. package/src/__tests__/scheduler-worker-standdown.test.ts +220 -0
  10. package/src/__tests__/skill-load-plugin-scope.test.ts +238 -0
  11. package/src/__tests__/skill-projection-feature-flag.test.ts +2 -0
  12. package/src/__tests__/subagent-call-site-routing.test.ts +72 -0
  13. package/src/__tests__/subagent-spawn-and-await.test.ts +1 -0
  14. package/src/__tests__/subagent-tool-gate-mode.test.ts +78 -1
  15. package/src/agent/loop.ts +44 -4
  16. package/src/cli/commands/__tests__/schedules.test.ts +1 -0
  17. package/src/cli/commands/schedules-worker.ts +175 -0
  18. package/src/cli/commands/schedules.ts +123 -40
  19. package/src/config/schema.ts +2 -0
  20. package/src/config/schemas/schedules.ts +35 -0
  21. package/src/config/skills-plugin-filter.test.ts +96 -0
  22. package/src/config/skills.ts +26 -0
  23. package/src/daemon/__tests__/conversation-tool-setup-plugin-scope.test.ts +122 -0
  24. package/src/daemon/conversation-agent-loop.ts +2 -0
  25. package/src/daemon/conversation-runtime-assembly.ts +21 -2
  26. package/src/daemon/conversation-skill-tools.ts +19 -3
  27. package/src/daemon/conversation-tool-setup.test.ts +45 -0
  28. package/src/daemon/conversation-tool-setup.ts +95 -1
  29. package/src/daemon/conversation.ts +18 -0
  30. package/src/daemon/tool-setup-types.ts +8 -0
  31. package/src/hooks/hook-loader.ts +12 -0
  32. package/src/hooks/registry.ts +18 -4
  33. package/src/monitoring/control.ts +31 -174
  34. package/src/persistence/conversation-crud.ts +56 -1
  35. package/src/persistence/migrations/314-add-conversation-enabled-plugins.ts +25 -0
  36. package/src/persistence/schema/conversations.ts +2 -0
  37. package/src/persistence/steps.ts +2 -0
  38. package/src/persistence/worker-control.ts +45 -238
  39. package/src/plugins/defaults/default-plugin-names.ts +60 -0
  40. package/src/plugins/injector-registry.ts +13 -2
  41. package/src/plugins/mtime-cache.ts +10 -1
  42. package/src/plugins/pipeline.ts +5 -1
  43. package/src/runtime/routes/__tests__/schedule-worker-routes.test.ts +161 -0
  44. package/src/runtime/routes/conversation-routes.ts +29 -0
  45. package/src/runtime/routes/index.ts +2 -0
  46. package/src/runtime/routes/schedule-worker-routes.ts +210 -0
  47. package/src/runtime/services/__tests__/conversation-serializer.test.ts +1 -0
  48. package/src/schedule/__tests__/worker-control.test.ts +241 -0
  49. package/src/schedule/scheduler.ts +125 -41
  50. package/src/schedule/worker-control.ts +118 -0
  51. package/src/schedule/worker.ts +131 -0
  52. package/src/subagent/manager.ts +11 -0
  53. package/src/tools/skills/find-similar-skills.test.ts +152 -1
  54. package/src/tools/skills/find-similar-skills.ts +30 -4
  55. package/src/tools/skills/load.ts +36 -0
  56. package/src/tools/types.ts +12 -0
  57. package/src/util/platform.ts +5 -0
  58. package/src/util/worker-process.ts +271 -0
package/openapi.yaml CHANGED
@@ -18196,6 +18196,17 @@ paths:
18196
18196
  anyOf:
18197
18197
  - type: string
18198
18198
  - type: "null"
18199
+ enabledPlugins:
18200
+ description:
18201
+ Plugin ids that scope this conversation to a subset of installed plugins (first-party defaults are always
18202
+ available). When present on a message, it sets/updates the conversation's plugin scope (the web
18203
+ client sends it only on the first message of a new chat). null clears the scope to default (all
18204
+ enabled plugins); omitting the field leaves the existing scope unchanged.
18205
+ anyOf:
18206
+ - type: array
18207
+ items:
18208
+ type: string
18209
+ - type: "null"
18199
18210
  riskThreshold:
18200
18211
  type: string
18201
18212
  enum:
@@ -24432,6 +24443,110 @@ paths:
24432
24443
  required:
24433
24444
  - summaries
24434
24445
  additionalProperties: false
24446
+ /v1/schedules/worker/start:
24447
+ post:
24448
+ operationId: schedules_worker_start_post
24449
+ summary: Start the schedule worker
24450
+ description:
24451
+ Spawns (or reuses) the schedule worker process as a child of the daemon and enables
24452
+ schedules.worker.enabled, so scheduled jobs run out of process.
24453
+ tags:
24454
+ - system
24455
+ responses:
24456
+ "200":
24457
+ description: Successful response
24458
+ content:
24459
+ application/json:
24460
+ schema:
24461
+ type: object
24462
+ properties:
24463
+ pid:
24464
+ type: number
24465
+ alreadyRunning:
24466
+ type: boolean
24467
+ workerEnabled:
24468
+ type: boolean
24469
+ const: true
24470
+ pidPath:
24471
+ type: string
24472
+ required:
24473
+ - pid
24474
+ - alreadyRunning
24475
+ - workerEnabled
24476
+ - pidPath
24477
+ additionalProperties: false
24478
+ /v1/schedules/worker/status:
24479
+ get:
24480
+ operationId: schedules_worker_status_get
24481
+ summary: Schedule worker status
24482
+ description:
24483
+ Reports the schedule worker process state, schedules.worker.enabled, and whether the daemon's in-process
24484
+ scheduler is currently executing schedules.
24485
+ tags:
24486
+ - system
24487
+ responses:
24488
+ "200":
24489
+ description: Successful response
24490
+ content:
24491
+ application/json:
24492
+ schema:
24493
+ type: object
24494
+ properties:
24495
+ status:
24496
+ type: string
24497
+ enum:
24498
+ - running
24499
+ - not_running
24500
+ pid:
24501
+ type: number
24502
+ workerEnabled:
24503
+ type: boolean
24504
+ inProcessScheduler:
24505
+ type: object
24506
+ properties:
24507
+ status:
24508
+ type: string
24509
+ enum:
24510
+ - running
24511
+ - not_running
24512
+ pid:
24513
+ type: number
24514
+ required:
24515
+ - status
24516
+ additionalProperties: false
24517
+ required:
24518
+ - status
24519
+ - workerEnabled
24520
+ - inProcessScheduler
24521
+ additionalProperties: false
24522
+ /v1/schedules/worker/stop:
24523
+ post:
24524
+ operationId: schedules_worker_stop_post
24525
+ summary: Stop the schedule worker
24526
+ description:
24527
+ Disables schedules.worker.enabled (handing schedule execution back to the in-process scheduler) and
24528
+ SIGTERMs the schedule worker process if it is running.
24529
+ tags:
24530
+ - system
24531
+ responses:
24532
+ "200":
24533
+ description: Successful response
24534
+ content:
24535
+ application/json:
24536
+ schema:
24537
+ type: object
24538
+ properties:
24539
+ workerWasRunning:
24540
+ type: boolean
24541
+ pid:
24542
+ type: number
24543
+ workerEnabled:
24544
+ type: boolean
24545
+ const: false
24546
+ required:
24547
+ - workerWasRunning
24548
+ - workerEnabled
24549
+ additionalProperties: false
24435
24550
  /v1/search:
24436
24551
  get:
24437
24552
  operationId: search_get
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.4-dev.202607021153.195c867",
3
+ "version": "0.10.4-dev.202607021247.94bb87f",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -79,6 +79,8 @@ const mockSkillRefCount = new Map<string, number>();
79
79
 
80
80
  mock.module("../config/skills.js", () => ({
81
81
  loadSkillCatalog: () => mockCatalog,
82
+ // Pass-through: these tests don't exercise per-chat plugin scoping.
83
+ filterSkillsByEnabledPlugins: (skills: unknown) => skills,
82
84
  }));
83
85
 
84
86
  mock.module("../skills/active-skill-tools.js", () => ({
@@ -0,0 +1,60 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ import { makeMockLogger } from "./helpers/mock-logger.js";
4
+
5
+ mock.module("../util/logger.js", () => ({
6
+ getLogger: () => makeMockLogger(),
7
+ }));
8
+
9
+ import {
10
+ addMessage,
11
+ createConversation,
12
+ forkConversation,
13
+ getConversation,
14
+ getConversationEnabledPlugins,
15
+ setConversationEnabledPlugins,
16
+ } from "../persistence/conversation-crud.js";
17
+ import { getDb } from "../persistence/db-connection.js";
18
+ import { initializeDb } from "../persistence/db-init.js";
19
+ await initializeDb();
20
+
21
+ describe("getConversationEnabledPlugins / setConversationEnabledPlugins", () => {
22
+ beforeEach(() => {
23
+ const db = getDb();
24
+ db.run(`DELETE FROM messages`);
25
+ db.run(`DELETE FROM conversations`);
26
+ });
27
+
28
+ test("defaults to null on a fresh conversation", () => {
29
+ const conv = createConversation("enabled-plugins-default");
30
+ expect(getConversationEnabledPlugins(conv.id)).toBeNull();
31
+ expect(getConversation(conv.id)?.enabledPlugins).toBeNull();
32
+ });
33
+
34
+ test("round-trips a plugin list and clears it with null", () => {
35
+ const conv = createConversation("enabled-plugins-roundtrip");
36
+
37
+ setConversationEnabledPlugins(conv.id, ["a", "b"]);
38
+ expect(getConversationEnabledPlugins(conv.id)).toEqual(["a", "b"]);
39
+ expect(getConversation(conv.id)?.enabledPlugins).toEqual(["a", "b"]);
40
+
41
+ setConversationEnabledPlugins(conv.id, null);
42
+ expect(getConversationEnabledPlugins(conv.id)).toBeNull();
43
+ expect(getConversation(conv.id)?.enabledPlugins).toBeNull();
44
+ });
45
+
46
+ test("stores an empty list distinctly from null", () => {
47
+ const conv = createConversation("enabled-plugins-empty");
48
+ setConversationEnabledPlugins(conv.id, []);
49
+ expect(getConversationEnabledPlugins(conv.id)).toEqual([]);
50
+ });
51
+
52
+ test("a forked conversation carries the plugin selection", async () => {
53
+ const conv = createConversation("enabled-plugins-fork-source");
54
+ setConversationEnabledPlugins(conv.id, ["x", "y"]);
55
+ await addMessage(conv.id, "user", "hi");
56
+
57
+ const forked = forkConversation({ conversationId: conv.id });
58
+ expect(getConversationEnabledPlugins(forked.id)).toEqual(["x", "y"]);
59
+ });
60
+ });
@@ -0,0 +1,389 @@
1
+ /**
2
+ * Tests for per-conversation `enabledPlugins` wiring in the POST /v1/messages
3
+ * handler.
4
+ *
5
+ * Validates that when a message mints/targets a conversation:
6
+ * - `enabledPlugins: [...]` is persisted (setConversationEnabledPlugins) AND
7
+ * applied to the live conversation (setEnabledPlugins), observable via
8
+ * getEffectiveEnabledPluginSet.
9
+ * - `[]` scopes the chat to no plugins (empty set).
10
+ * - explicit `null` clears to the default (no per-chat restriction).
11
+ * - omitting the field leaves the stored value untouched (no persist call).
12
+ */
13
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
14
+
15
+ mock.module("../config/env.js", () => ({ isHttpAuthDisabled: () => true }));
16
+
17
+ mock.module("../config/loader.js", () => ({
18
+ getConfig: () => ({
19
+ ui: {},
20
+ model: "claude-opus-4-7",
21
+ provider: "anthropic",
22
+ memory: { enabled: false },
23
+ rateLimit: { maxRequestsPerMinute: 0 },
24
+ secretDetection: { enabled: false },
25
+ contextWindow: { maxInputTokens: 200000 },
26
+ llm: {
27
+ default: {
28
+ provider: "anthropic",
29
+ model: "claude-opus-4-7",
30
+ maxTokens: 64000,
31
+ effort: "max" as const,
32
+ speed: "standard" as const,
33
+ temperature: null,
34
+ thinking: { enabled: true, streamThinking: true },
35
+ contextWindow: {
36
+ enabled: true,
37
+ maxInputTokens: 200000,
38
+ targetBudgetRatio: 0.3,
39
+ compactThreshold: 0.8,
40
+ summaryBudgetRatio: 0.05,
41
+ },
42
+ },
43
+ profiles: {},
44
+ callSites: {},
45
+ pricingOverrides: [],
46
+ },
47
+ services: {
48
+ inference: {
49
+ mode: "your-own",
50
+ provider: "anthropic",
51
+ model: "claude-opus-4-7",
52
+ },
53
+ "image-generation": {
54
+ mode: "your-own",
55
+ provider: "gemini",
56
+ model: "gemini-3.1-flash-image-preview",
57
+ },
58
+ "web-search": { mode: "your-own", provider: "inference-provider-native" },
59
+ },
60
+ }),
61
+ }));
62
+
63
+ const addMessageMock = mock(async (_conversationId: string, role: string) => ({
64
+ id: role === "user" ? "persisted-user-id" : "persisted-assistant-id",
65
+ deduplicated: false,
66
+ }));
67
+
68
+ const setConversationEnabledPluginsMock = mock(
69
+ (_conversationId: string, _plugins: string[] | null) => {},
70
+ );
71
+
72
+ mock.module("../util/logger.js", () => ({
73
+ getLogger: () =>
74
+ new Proxy({} as Record<string, unknown>, {
75
+ get: () => () => {},
76
+ }),
77
+ }));
78
+
79
+ mock.module("../persistence/conversation-key-store.js", () => ({
80
+ getOrCreateConversation: () => ({ conversationId: "conv-plugins-test" }),
81
+ getConversationByKey: () => null,
82
+ }));
83
+
84
+ mock.module("../runtime/guardian-reply-router.js", () => ({
85
+ routeGuardianReply: async () => ({
86
+ consumed: false,
87
+ decisionApplied: false,
88
+ type: "not_consumed",
89
+ }),
90
+ }));
91
+
92
+ mock.module("../contacts/canonical-guardian-store.js", () => ({
93
+ createCanonicalGuardianRequest: () => ({
94
+ id: "canonical-id",
95
+ requestCode: "ABC123",
96
+ }),
97
+ generateCanonicalRequestCode: () => "ABC123",
98
+ listPendingCanonicalGuardianRequestsByDestinationConversation: () => [],
99
+ listCanonicalGuardianRequests: () => [],
100
+ listPendingRequestsByConversationScope: () => [],
101
+ }));
102
+
103
+ mock.module("../runtime/confirmation-request-guardian-bridge.js", () => ({
104
+ bridgeConfirmationRequestToGuardian: async () => undefined,
105
+ }));
106
+
107
+ mock.module("../persistence/conversation-crud.js", () => ({
108
+ setConversationProcessingStartedAt: () => {},
109
+ isConversationProcessing: () => false,
110
+ addMessage: (conversationId: string, role: string) =>
111
+ addMessageMock(conversationId, role),
112
+ extractImageSourcePaths: () => undefined,
113
+ getConversation: () => null,
114
+ getConversationOverrideProfile: () => undefined,
115
+ getMessages: () => [],
116
+ provenanceFromTrustContext: (ctx: unknown) =>
117
+ ctx
118
+ ? { provenanceTrustClass: (ctx as Record<string, unknown>).trustClass }
119
+ : { provenanceTrustClass: "unknown" },
120
+ setConversationOriginChannelIfUnset: () => {},
121
+ setConversationOriginInterfaceIfUnset: () => {},
122
+ setConversationInferenceProfile: () => {},
123
+ setConversationEnabledPlugins: (
124
+ conversationId: string,
125
+ plugins: string[] | null,
126
+ ) => setConversationEnabledPluginsMock(conversationId, plugins),
127
+ reserveMessage: mock(async () => ({ id: "msg-reserve" })),
128
+ }));
129
+
130
+ mock.module("../persistence/conversation-disk-view.js", () => ({
131
+ syncMessageToDisk: () => {},
132
+ updateMetaFile: () => {},
133
+ }));
134
+
135
+ mock.module("../persistence/attachments-store.js", () => ({
136
+ getAttachmentsByIds: () => [],
137
+ getSourcePathsForAttachments: () => new Map(),
138
+ attachmentExists: () => false,
139
+ linkAttachmentToMessage: () => {},
140
+ attachInlineAttachmentToMessage: () => {},
141
+ validateAttachmentUpload: () => ({ ok: true }),
142
+ }));
143
+
144
+ mock.module("../daemon/conversation-process.js", () => ({
145
+ buildModelInfoEvent: () => ({
146
+ type: "model_info",
147
+ model: "claude-opus-4-7",
148
+ provider: "anthropic",
149
+ configuredProviders: ["anthropic"],
150
+ }),
151
+ isModelSlashCommand: () => false,
152
+ formatCompactResult: () => "",
153
+ }));
154
+
155
+ mock.module("../runtime/local-actor-identity.js", () => ({
156
+ resolveLocalTrustContext: () => ({
157
+ trustClass: "guardian",
158
+ sourceChannel: "vellum",
159
+ }),
160
+ }));
161
+
162
+ mock.module("../runtime/trust-context-resolver.js", () => ({
163
+ resolveTrustContext: () => ({
164
+ trustClass: "guardian",
165
+ sourceChannel: "vellum",
166
+ }),
167
+ withSourceChannel: (sourceChannel: unknown, ctx: unknown) => ({
168
+ ...(ctx as Record<string, unknown>),
169
+ sourceChannel,
170
+ }),
171
+ }));
172
+
173
+ mock.module("../contacts/guardian-delivery-reader.js", () => ({
174
+ getGuardianDelivery: async () => [
175
+ {
176
+ channelType: "vellum",
177
+ contactId: "guardian-contact",
178
+ principalId: "test-user",
179
+ address: "test-user",
180
+ status: "active",
181
+ },
182
+ ],
183
+ }));
184
+
185
+ const ipcCallMock = mock(
186
+ async (): Promise<Record<string, unknown> | undefined> => ({ ok: true }),
187
+ );
188
+ mock.module("../ipc/gateway-client.js", () => ({
189
+ ipcCall: ipcCallMock,
190
+ }));
191
+
192
+ import { getEffectiveEnabledPluginSet } from "../daemon/conversation-tool-setup.js";
193
+ import { handleSendMessage } from "../runtime/routes/conversation-routes.js";
194
+ import { callHandler } from "./helpers/call-route-handler.js";
195
+
196
+ function makeConversation() {
197
+ const runAgentLoop = mock(async () => undefined);
198
+ const persistUserMessage = mock(async () => ({
199
+ id: "persisted-user-id",
200
+ deduplicated: false,
201
+ }));
202
+ const messages: unknown[] = [];
203
+ let processing = false;
204
+ let enabledPlugins: string[] | null = null;
205
+ const conversation = {
206
+ conversationId: "conv-plugins-test",
207
+ messages,
208
+ get enabledPlugins(): string[] | null {
209
+ return enabledPlugins;
210
+ },
211
+ set enabledPlugins(value: string[] | null) {
212
+ enabledPlugins = value;
213
+ },
214
+ abortController: null,
215
+ currentRequestId: undefined,
216
+ queue: { length: 0 },
217
+ setTrustContext: () => {},
218
+ updateClient: () => {},
219
+ emitConfirmationStateChanged: () => {},
220
+ emitActivityState: () => {},
221
+ setTurnChannelContext: () => {},
222
+ setTurnInterfaceContext: () => {},
223
+ getTurnChannelContext: () => null,
224
+ getTurnInterfaceContext: () => null,
225
+ ensureActorScopedHistory: async () => {},
226
+ isProcessing: () => processing,
227
+ setProcessing: (value: boolean) => {
228
+ processing = value;
229
+ },
230
+ setEnabledPlugins: (plugins: string[] | null) => {
231
+ enabledPlugins = plugins;
232
+ },
233
+ hasAnyPendingConfirmation: () => false,
234
+ denyAllPendingConfirmations: () => {},
235
+ enqueueMessage: () => ({ queued: true, requestId: "queued-id" }),
236
+ persistUserMessage,
237
+ runAgentLoop,
238
+ setPreactivatedSkillIds: () => {},
239
+ drainQueue: async () => {},
240
+ warmPromptCache: () => {},
241
+ getMessages: () => messages,
242
+ assistantId: "self",
243
+ trustContext: undefined,
244
+ hasPendingConfirmation: () => false,
245
+ setHostBrowserProxy: () => {},
246
+ setHostCuProxy: () => {},
247
+ setHostAppControlProxy: () => {},
248
+ addPreactivatedSkillId: () => {},
249
+ usageStats: { inputTokens: 1000, outputTokens: 500, estimatedCost: 0.05 },
250
+ } as unknown as import("../daemon/conversation.js").Conversation;
251
+ return { conversation, runAgentLoop };
252
+ }
253
+
254
+ function makeRequest(extras: Record<string, unknown> = {}) {
255
+ return new Request("http://localhost/v1/messages", {
256
+ method: "POST",
257
+ headers: {
258
+ "Content-Type": "application/json",
259
+ "x-vellum-actor-principal-id": "test-user",
260
+ "x-vellum-principal-type": "actor",
261
+ },
262
+ body: JSON.stringify({
263
+ conversationKey: "plugins-test-key",
264
+ content: "hello there",
265
+ sourceChannel: "vellum",
266
+ interface: "macos",
267
+ ...extras,
268
+ }),
269
+ });
270
+ }
271
+
272
+ function makeDeps(
273
+ conversation: import("../daemon/conversation.js").Conversation,
274
+ ) {
275
+ return {
276
+ sendMessageDeps: {
277
+ getOrCreateConversation: async () => conversation,
278
+ assistantEventHub: { publish: async () => {} } as never,
279
+ resolveAttachments: () => [],
280
+ },
281
+ };
282
+ }
283
+
284
+ describe("handleSendMessage enabledPlugins", () => {
285
+ beforeEach(() => {
286
+ addMessageMock.mockClear();
287
+ setConversationEnabledPluginsMock.mockClear();
288
+ ipcCallMock.mockClear();
289
+ });
290
+
291
+ afterEach(() => {
292
+ setConversationEnabledPluginsMock.mockClear();
293
+ });
294
+
295
+ test("persists and applies enabledPlugins when minting a conversation", async () => {
296
+ const { conversation } = makeConversation();
297
+ const res = await callHandler(
298
+ (args) => handleSendMessage(args, makeDeps(conversation)),
299
+ makeRequest({ enabledPlugins: ["a", "b"] }),
300
+ undefined,
301
+ 202,
302
+ );
303
+
304
+ expect(res.status).toBe(202);
305
+
306
+ // Persisted to the conversation row.
307
+ expect(setConversationEnabledPluginsMock).toHaveBeenCalledTimes(1);
308
+ expect(setConversationEnabledPluginsMock).toHaveBeenCalledWith(
309
+ "conv-plugins-test",
310
+ ["a", "b"],
311
+ );
312
+
313
+ // Applied to the live conversation instance: the user's selection, unioned
314
+ // with the always-on first-party defaults (which the pills never list).
315
+ const effective = getEffectiveEnabledPluginSet(conversation);
316
+ expect(effective?.has("a")).toBe(true);
317
+ expect(effective?.has("b")).toBe(true);
318
+ expect(effective?.has("default-memory")).toBe(true);
319
+ });
320
+
321
+ test("empty array scopes the chat to no plugins", async () => {
322
+ const { conversation } = makeConversation();
323
+ const res = await callHandler(
324
+ (args) => handleSendMessage(args, makeDeps(conversation)),
325
+ makeRequest({ enabledPlugins: [] }),
326
+ undefined,
327
+ 202,
328
+ );
329
+
330
+ expect(res.status).toBe(202);
331
+ expect(setConversationEnabledPluginsMock).toHaveBeenCalledWith(
332
+ "conv-plugins-test",
333
+ [],
334
+ );
335
+ // No user plugins are in scope, but the always-on first-party defaults are
336
+ // never filtered out — core runtime infra must keep running.
337
+ const effective = getEffectiveEnabledPluginSet(conversation);
338
+ expect(effective).not.toBeNull();
339
+ expect(effective?.has("default-memory")).toBe(true);
340
+ expect(effective?.has("a")).toBe(false);
341
+ });
342
+
343
+ test("explicit null clears to the default (no per-chat restriction)", async () => {
344
+ const { conversation } = makeConversation();
345
+ conversation.enabledPlugins = ["pre-existing"];
346
+ const res = await callHandler(
347
+ (args) => handleSendMessage(args, makeDeps(conversation)),
348
+ makeRequest({ enabledPlugins: null }),
349
+ undefined,
350
+ 202,
351
+ );
352
+
353
+ expect(res.status).toBe(202);
354
+ expect(setConversationEnabledPluginsMock).toHaveBeenCalledWith(
355
+ "conv-plugins-test",
356
+ null,
357
+ );
358
+ expect(getEffectiveEnabledPluginSet(conversation)).toBeNull();
359
+ });
360
+
361
+ test("omitting the field leaves the stored value untouched", async () => {
362
+ const { conversation } = makeConversation();
363
+ const res = await callHandler(
364
+ (args) => handleSendMessage(args, makeDeps(conversation)),
365
+ makeRequest(),
366
+ undefined,
367
+ 202,
368
+ );
369
+
370
+ expect(res.status).toBe(202);
371
+ expect(setConversationEnabledPluginsMock).not.toHaveBeenCalled();
372
+ expect(getEffectiveEnabledPluginSet(conversation)).toBeNull();
373
+ });
374
+
375
+ test("rejects non-array, non-null enabledPlugins values", async () => {
376
+ const { conversation } = makeConversation();
377
+ const res = await callHandler(
378
+ (args) => handleSendMessage(args, makeDeps(conversation)),
379
+ makeRequest({ enabledPlugins: "not-an-array" }),
380
+ undefined,
381
+ 202,
382
+ );
383
+
384
+ expect(res.status).toBe(400);
385
+ const text = await res.text();
386
+ expect(text).toContain("enabledPlugins");
387
+ expect(setConversationEnabledPluginsMock).not.toHaveBeenCalled();
388
+ });
389
+ });
@@ -47,6 +47,19 @@ let recordedLastUsedTouches: Array<{ skillDir: string; today: string }> = [];
47
47
 
48
48
  mock.module("../config/skills.js", () => ({
49
49
  loadSkillCatalog: () => mockCatalog,
50
+ // Faithful reimplementation of the real plugin-scope filter — the
51
+ // per-chat plugin scope suite below depends on its drop behavior.
52
+ filterSkillsByEnabledPlugins: (
53
+ skills: SkillSummary[],
54
+ effectiveEnabledPluginSet: Set<string> | null,
55
+ ) =>
56
+ effectiveEnabledPluginSet === null
57
+ ? skills
58
+ : skills.filter((skill) => {
59
+ const owner = skill.owner;
60
+ if (owner?.kind !== "plugin") return true;
61
+ return effectiveEnabledPluginSet.has(owner.id);
62
+ }),
50
63
  }));
51
64
 
52
65
  mock.module("../skills/active-skill-tools.js", () => {
@@ -3007,6 +3020,66 @@ describe("includes metadata does not auto-activate child skill tools", () => {
3007
3020
  });
3008
3021
  });
3009
3022
 
3023
+ // ---------------------------------------------------------------------------
3024
+ // Per-chat plugin scope — effectiveEnabledPluginSet drops plugin skills
3025
+ // ---------------------------------------------------------------------------
3026
+
3027
+ describe("projectSkillTools per-chat plugin scope", () => {
3028
+ function makePluginSkill(id: string, pluginId: string): SkillSummary {
3029
+ return {
3030
+ ...makeSkill(id, `/skills/${id}`, "plugin"),
3031
+ owner: { kind: "plugin", id: pluginId },
3032
+ };
3033
+ }
3034
+
3035
+ beforeEach(() => {
3036
+ mockCatalog = [makeSkill("deploy"), makePluginSkill("plug_skill", "b")];
3037
+ mockManifests = {
3038
+ deploy: makeManifest(["deploy_run"]),
3039
+ plug_skill: makeManifest(["plug_action"]),
3040
+ };
3041
+ mockRegisteredTools = new Map();
3042
+ mockUnregisteredSkillIds = [];
3043
+ mockSkillRefCount = new Map();
3044
+ mockVersionHashes = {};
3045
+ mockVersionHashErrors = new Set();
3046
+ mockRegisterFailures = new Set();
3047
+ });
3048
+
3049
+ test("set excluding the owning plugin drops that plugin's skill tools", () => {
3050
+ const result = projectSkillTools([], {
3051
+ preactivatedSkillIds: ["deploy", "plug_skill"],
3052
+ previouslyActiveSkillIds: new Map<string, string>(),
3053
+ effectiveEnabledPluginSet: new Set(["a"]),
3054
+ });
3055
+
3056
+ expect(result.allowedToolNames.has("deploy_run")).toBe(true);
3057
+ expect(result.allowedToolNames.has("plug_action")).toBe(false);
3058
+ });
3059
+
3060
+ test("null set leaves plugin skill resolution unchanged", () => {
3061
+ const result = projectSkillTools([], {
3062
+ preactivatedSkillIds: ["deploy", "plug_skill"],
3063
+ previouslyActiveSkillIds: new Map<string, string>(),
3064
+ effectiveEnabledPluginSet: null,
3065
+ });
3066
+
3067
+ expect(result.allowedToolNames.has("deploy_run")).toBe(true);
3068
+ expect(result.allowedToolNames.has("plug_action")).toBe(true);
3069
+ });
3070
+
3071
+ test("set including the owning plugin keeps that plugin's skill tools", () => {
3072
+ const result = projectSkillTools([], {
3073
+ preactivatedSkillIds: ["deploy", "plug_skill"],
3074
+ previouslyActiveSkillIds: new Map<string, string>(),
3075
+ effectiveEnabledPluginSet: new Set(["b"]),
3076
+ });
3077
+
3078
+ expect(result.allowedToolNames.has("deploy_run")).toBe(true);
3079
+ expect(result.allowedToolNames.has("plug_action")).toBe(true);
3080
+ });
3081
+ });
3082
+
3010
3083
  // ---------------------------------------------------------------------------
3011
3084
  // Skill load harness — validates shared test helpers
3012
3085
  // ---------------------------------------------------------------------------