@vellumai/assistant 0.10.4-dev.202607021059.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.
- package/openapi.yaml +115 -0
- package/package.json +1 -1
- package/src/__tests__/conversation-app-control-instantiation.test.ts +2 -0
- package/src/__tests__/conversation-crud-enabled-plugins.test.ts +60 -0
- package/src/__tests__/conversation-routes-enabled-plugins.test.ts +389 -0
- package/src/__tests__/conversation-skill-tools.test.ts +73 -0
- package/src/__tests__/plugin-effective-enabled-set.test.ts +206 -0
- package/src/__tests__/schedule-store.test.ts +3 -1
- package/src/__tests__/scheduler-worker-standdown.test.ts +220 -0
- package/src/__tests__/skill-load-plugin-scope.test.ts +238 -0
- package/src/__tests__/skill-projection-feature-flag.test.ts +2 -0
- package/src/__tests__/subagent-call-site-routing.test.ts +72 -0
- package/src/__tests__/subagent-spawn-and-await.test.ts +1 -0
- package/src/__tests__/subagent-tool-gate-mode.test.ts +78 -1
- package/src/agent/loop.ts +44 -4
- package/src/cli/commands/__tests__/schedules.test.ts +1 -0
- package/src/cli/commands/schedules-worker.ts +175 -0
- package/src/cli/commands/schedules.ts +123 -40
- package/src/config/schema.ts +2 -0
- package/src/config/schemas/schedules.ts +35 -0
- package/src/config/skills-plugin-filter.test.ts +96 -0
- package/src/config/skills.ts +26 -0
- package/src/daemon/__tests__/conversation-tool-setup-plugin-scope.test.ts +122 -0
- package/src/daemon/conversation-agent-loop.ts +2 -0
- package/src/daemon/conversation-runtime-assembly.ts +21 -2
- package/src/daemon/conversation-skill-tools.ts +19 -3
- package/src/daemon/conversation-tool-setup.test.ts +45 -0
- package/src/daemon/conversation-tool-setup.ts +95 -1
- package/src/daemon/conversation.ts +18 -0
- package/src/daemon/tool-setup-types.ts +8 -0
- package/src/hooks/hook-loader.ts +12 -0
- package/src/hooks/registry.ts +18 -4
- package/src/monitoring/control.ts +31 -174
- package/src/persistence/conversation-crud.ts +56 -1
- package/src/persistence/migrations/314-add-conversation-enabled-plugins.ts +25 -0
- package/src/persistence/schema/conversations.ts +2 -0
- package/src/persistence/steps.ts +2 -0
- package/src/persistence/worker-control.ts +45 -238
- package/src/plugins/defaults/default-plugin-names.ts +60 -0
- package/src/plugins/injector-registry.ts +13 -2
- package/src/plugins/mtime-cache.ts +10 -1
- package/src/plugins/pipeline.ts +5 -1
- package/src/runtime/routes/__tests__/schedule-worker-routes.test.ts +161 -0
- package/src/runtime/routes/conversation-routes.ts +29 -0
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/schedule-worker-routes.ts +210 -0
- package/src/runtime/services/__tests__/conversation-serializer.test.ts +1 -0
- package/src/schedule/__tests__/worker-control.test.ts +241 -0
- package/src/schedule/scheduler.ts +125 -41
- package/src/schedule/worker-control.ts +118 -0
- package/src/schedule/worker.ts +131 -0
- package/src/subagent/manager.ts +11 -0
- package/src/tools/skills/find-similar-skills.test.ts +152 -1
- package/src/tools/skills/find-similar-skills.ts +30 -4
- package/src/tools/skills/load.ts +36 -0
- package/src/tools/types.ts +12 -0
- package/src/util/platform.ts +5 -0
- 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
|
@@ -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
|
// ---------------------------------------------------------------------------
|