@vellumai/assistant 0.10.6-staging.1 → 0.10.6
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 +43 -6
- package/package.json +1 -1
- package/src/__tests__/attachment-stored-path-annotation.test.ts +151 -0
- package/src/__tests__/attachments-store.test.ts +37 -1
- package/src/__tests__/attachments.test.ts +53 -0
- package/src/__tests__/channel-reply-delivery.test.ts +214 -5
- package/src/__tests__/config-loader-backfill.test.ts +16 -36
- package/src/__tests__/image-source-path-reinject.test.ts +155 -14
- package/src/__tests__/managed-profile-guard.test.ts +2 -0
- package/src/__tests__/mtime-cache.test.ts +265 -80
- package/src/__tests__/plugin-config-data-migration.test.ts +2 -4
- package/src/__tests__/plugin-effective-enabled-set.test.ts +9 -6
- package/src/__tests__/workspace-migration-125-repoint-managed-connections-to-vellum.test.ts +140 -0
- package/src/agent/attachments.ts +55 -13
- package/src/cli/commands/platform/__tests__/credits.test.ts +111 -0
- package/src/cli/commands/platform/index.ts +86 -4
- package/src/cli/lib/__tests__/confirm-prompt.test.ts +9 -9
- package/src/cli/lib/diff-plugin.ts +7 -3
- package/src/cli/lib/inspect-plugin.ts +28 -10
- package/src/cli/lib/install-from-github.ts +69 -24
- package/src/cli/lib/ipc-params.ts +3 -3
- package/src/cli/lib/list-installed-plugins.ts +11 -5
- package/src/cli/lib/merge-plugin-tree.ts +15 -9
- package/src/cli/lib/plugin-fingerprint.ts +0 -6
- package/src/cli/lib/publish-plugin.ts +1 -1
- package/src/cli/lib/unknown-command.ts +3 -1
- package/src/cli/lib/upgrade-plugin.ts +2 -5
- package/src/config/__tests__/sync-gated-profiles.test.ts +2 -2
- package/src/config/seed-inference-profiles.ts +5 -4
- package/src/daemon/conversation-lifecycle.ts +37 -15
- package/src/daemon/conversation-messaging.ts +56 -28
- package/src/daemon/conversation.ts +2 -2
- package/src/heartbeat/__tests__/heartbeat-service.test.ts +6 -0
- package/src/hooks/__tests__/hook-live-reload.test.ts +110 -96
- package/src/hooks/hook-loader.ts +57 -144
- package/src/hooks/registry.ts +13 -9
- package/src/persistence/attachments-store.ts +2 -1
- package/src/persistence/conversation-crud.ts +24 -0
- package/src/plugin-api/types.ts +4 -1
- package/src/plugins/mtime-cache.ts +347 -33
- package/src/plugins/pipeline.ts +3 -2
- package/src/providers/__tests__/inference.test.ts +42 -31
- package/src/providers/inference/__tests__/connections-label.test.ts +9 -28
- package/src/providers/inference/auth.ts +1 -2
- package/src/providers/inference/backfill.ts +7 -5
- package/src/providers/inference/connections.ts +54 -30
- package/src/runtime/channel-reply-delivery.ts +83 -24
- package/src/runtime/channel-retry-sweep.ts +4 -0
- package/src/runtime/{slack-no-response.ts → no-response.ts} +26 -6
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +43 -3
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +61 -24
- package/src/runtime/routes/conversation-query-routes.ts +18 -1
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +1 -1
- package/src/runtime/routes/inference-provider-connection-routes.ts +4 -3
- package/src/runtime/routes/platform-routes.ts +82 -1
- package/src/runtime/slack-reply-session.ts +1 -1
- package/src/workspace/migrations/125-repoint-managed-connections-to-vellum.ts +99 -0
- package/src/workspace/migrations/registry.ts +2 -0
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for the
|
|
3
|
-
*
|
|
2
|
+
* Tests for the plugin cache orchestrator: boot discovery plus the
|
|
3
|
+
* source-versions reconcile that drives every steady-state change.
|
|
4
4
|
*
|
|
5
5
|
* Each test materializes a synthetic plugin directory under a per-file
|
|
6
|
-
* tempdir
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* tempdir. Changes are published the way production publishes them — by
|
|
7
|
+
* running the resource monitor's watcher pass over the same workspace — and
|
|
8
|
+
* observed the way production observes them: a hook dispatch, whose
|
|
9
|
+
* sentinel check applies the published diff. This makes the suite an
|
|
10
|
+
* end-to-end exercise of detector → sentinel → reconcile → redeploy.
|
|
9
11
|
*/
|
|
10
12
|
import {
|
|
11
13
|
existsSync,
|
|
@@ -27,6 +29,11 @@ import {
|
|
|
27
29
|
} from "bun:test";
|
|
28
30
|
|
|
29
31
|
import { _inspectHookCacheForTests } from "../hooks/hook-loader.js";
|
|
32
|
+
import {
|
|
33
|
+
createSourceWatchState,
|
|
34
|
+
runSourceWatchPass,
|
|
35
|
+
type SourceWatchState,
|
|
36
|
+
} from "../monitoring/plugin-source-watch.js";
|
|
30
37
|
import {
|
|
31
38
|
_inspectToolCacheForTests,
|
|
32
39
|
getCachedUserTools,
|
|
@@ -34,6 +41,7 @@ import {
|
|
|
34
41
|
populateCacheAtBoot,
|
|
35
42
|
resetPluginCacheForTests,
|
|
36
43
|
} from "../plugins/mtime-cache.js";
|
|
44
|
+
import { getSourceVersionsPath } from "../plugins/source-versions.js";
|
|
37
45
|
import {
|
|
38
46
|
getAllToolDefinitions,
|
|
39
47
|
getPluginToolDefinitions,
|
|
@@ -123,6 +131,33 @@ const SIMPLE_PKG = {
|
|
|
123
131
|
peerDependencies: { "@vellumai/plugin-api": "*" },
|
|
124
132
|
};
|
|
125
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Watcher state shared by a test's publishes, reset per test so each test's
|
|
136
|
+
* first publish takes a fresh baseline (adopting any sentinel on disk).
|
|
137
|
+
*/
|
|
138
|
+
let watchState: SourceWatchState | null = null;
|
|
139
|
+
|
|
140
|
+
/** Strictly increasing mtime offset for sentinel touches within one test. */
|
|
141
|
+
let sentinelTouchSeq = 0;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Publish pending source changes exactly the way production does: one pass
|
|
145
|
+
* of the resource monitor's watcher over this workspace. When the pass
|
|
146
|
+
* rewrites the sentinel, its mtime is bumped to a strictly increasing
|
|
147
|
+
* timestamp so the daemon's one-stat gate observes every publish even when
|
|
148
|
+
* two land inside the same filesystem-timestamp granule.
|
|
149
|
+
*/
|
|
150
|
+
function publishSourceChanges(): boolean {
|
|
151
|
+
if (watchState === null) {
|
|
152
|
+
watchState = createSourceWatchState();
|
|
153
|
+
}
|
|
154
|
+
const wrote = runSourceWatchPass(watchState);
|
|
155
|
+
if (wrote) {
|
|
156
|
+
touchFile(getSourceVersionsPath(), (sentinelTouchSeq += 2));
|
|
157
|
+
}
|
|
158
|
+
return wrote;
|
|
159
|
+
}
|
|
160
|
+
|
|
126
161
|
// ─── Setup / teardown ────────────────────────────────────────────────────────
|
|
127
162
|
|
|
128
163
|
beforeAll(() => {
|
|
@@ -132,6 +167,9 @@ beforeAll(() => {
|
|
|
132
167
|
beforeEach(() => {
|
|
133
168
|
ensurePluginsDir();
|
|
134
169
|
ensureWorkspaceHooksDir();
|
|
170
|
+
rmSync(getSourceVersionsPath(), { force: true });
|
|
171
|
+
watchState = null;
|
|
172
|
+
sentinelTouchSeq = 0;
|
|
135
173
|
resetPluginCacheForTests();
|
|
136
174
|
});
|
|
137
175
|
|
|
@@ -157,53 +195,54 @@ describe("plugin mtime cache (per-surface)", () => {
|
|
|
157
195
|
expect(hooks).toHaveLength(1);
|
|
158
196
|
});
|
|
159
197
|
|
|
160
|
-
test("cache
|
|
198
|
+
test("dispatch serves the cache and ignores disk changes until published", async () => {
|
|
161
199
|
const dir = freshPluginDir("cached-plugin");
|
|
162
200
|
writePackageJson(dir, { ...SIMPLE_PKG, name: "cached-plugin" });
|
|
163
|
-
writeHook(
|
|
164
|
-
dir,
|
|
165
|
-
"user-prompt-submit",
|
|
166
|
-
`export default () => ({ count: 1 });`,
|
|
167
|
-
);
|
|
201
|
+
writeHook(dir, "user-prompt-submit", `export default () => "v1";`);
|
|
168
202
|
|
|
169
203
|
await populateCacheAtBoot();
|
|
204
|
+
const first = (await getUserHooksFor("user-prompt-submit"))[0];
|
|
170
205
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
206
|
+
// Edit lands on disk but the watcher hasn't published — dispatch keeps
|
|
207
|
+
// serving the exact cached function, proving it never re-scans.
|
|
208
|
+
const hookFile = join(dir, "hooks", "user-prompt-submit.ts");
|
|
209
|
+
writeFileSync(hookFile, `export default () => "v2";`);
|
|
210
|
+
touchFile(hookFile);
|
|
176
211
|
|
|
177
|
-
const
|
|
178
|
-
expect(
|
|
179
|
-
expect(
|
|
212
|
+
const again = (await getUserHooksFor("user-prompt-submit"))[0];
|
|
213
|
+
expect(again).toBe(first!);
|
|
214
|
+
expect((again as unknown as () => string)()).toBe("v1");
|
|
180
215
|
});
|
|
181
216
|
|
|
182
|
-
test("
|
|
217
|
+
test("an edited hook takes effect once the watcher publishes", async () => {
|
|
183
218
|
const dir = freshPluginDir("rebuild-plugin");
|
|
184
219
|
writePackageJson(dir, { ...SIMPLE_PKG, name: "rebuild-plugin" });
|
|
185
220
|
const hookFile = join(dir, "hooks", "user-prompt-submit.ts");
|
|
186
|
-
writeHook(
|
|
187
|
-
dir,
|
|
188
|
-
"user-prompt-submit",
|
|
189
|
-
`export default () => ({ count: 1 });`,
|
|
190
|
-
);
|
|
221
|
+
writeHook(dir, "user-prompt-submit", `export default () => "v1";`);
|
|
191
222
|
|
|
192
223
|
await populateCacheAtBoot();
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
224
|
+
expect(
|
|
225
|
+
(
|
|
226
|
+
(
|
|
227
|
+
await getUserHooksFor("user-prompt-submit")
|
|
228
|
+
)[0] as unknown as () => string
|
|
229
|
+
)(),
|
|
230
|
+
).toBe("v1");
|
|
231
|
+
|
|
232
|
+
writeFileSync(hookFile, `export default () => "v2";`);
|
|
197
233
|
touchFile(hookFile);
|
|
234
|
+
expect(publishSourceChanges()).toBe(true);
|
|
198
235
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
236
|
+
expect(
|
|
237
|
+
(
|
|
238
|
+
(
|
|
239
|
+
await getUserHooksFor("user-prompt-submit")
|
|
240
|
+
)[0] as unknown as () => string
|
|
241
|
+
)(),
|
|
242
|
+
).toBe("v2");
|
|
204
243
|
});
|
|
205
244
|
|
|
206
|
-
test("plugin deletion:
|
|
245
|
+
test("plugin deletion: a published removal evicts cache entries", async () => {
|
|
207
246
|
const dir = freshPluginDir("deletable-plugin");
|
|
208
247
|
writePackageJson(dir, { ...SIMPLE_PKG, name: "deletable-plugin" });
|
|
209
248
|
writeHook(
|
|
@@ -215,16 +254,16 @@ describe("plugin mtime cache (per-surface)", () => {
|
|
|
215
254
|
await populateCacheAtBoot();
|
|
216
255
|
expect(await getUserHooksFor("user-prompt-submit")).toHaveLength(1);
|
|
217
256
|
|
|
218
|
-
// Delete the plugin directory.
|
|
219
257
|
rmSync(dir, { recursive: true, force: true });
|
|
258
|
+
publishSourceChanges();
|
|
220
259
|
|
|
221
|
-
// Read again — plugin gone, hooks evicted.
|
|
222
260
|
const hooks = await getUserHooksFor("user-prompt-submit");
|
|
223
261
|
expect(hooks).toHaveLength(0);
|
|
224
262
|
|
|
225
|
-
const cacheState = _inspectHookCacheForTests();
|
|
226
263
|
expect(
|
|
227
|
-
|
|
264
|
+
_inspectHookCacheForTests().find((key) =>
|
|
265
|
+
key.startsWith("deletable-plugin/"),
|
|
266
|
+
),
|
|
228
267
|
).toBeUndefined();
|
|
229
268
|
});
|
|
230
269
|
|
|
@@ -261,7 +300,7 @@ describe("plugin mtime cache (per-surface)", () => {
|
|
|
261
300
|
expect(hooks).toHaveLength(0);
|
|
262
301
|
});
|
|
263
302
|
|
|
264
|
-
test("getUserHooksFor detects newly added plugin
|
|
303
|
+
test("getUserHooksFor detects a newly added plugin once published", async () => {
|
|
265
304
|
const dir1 = freshPluginDir("existing-plugin");
|
|
266
305
|
writePackageJson(dir1, { ...SIMPLE_PKG, name: "existing-plugin" });
|
|
267
306
|
writeHook(dir1, "user-prompt-submit", `export default () => ({ v: 1 });`);
|
|
@@ -269,12 +308,12 @@ describe("plugin mtime cache (per-surface)", () => {
|
|
|
269
308
|
await populateCacheAtBoot();
|
|
270
309
|
expect(await getUserHooksFor("user-prompt-submit")).toHaveLength(1);
|
|
271
310
|
|
|
272
|
-
// Add a new plugin directory after boot.
|
|
311
|
+
// Add a new plugin directory after boot and publish it.
|
|
273
312
|
const dir2 = freshPluginDir("new-plugin");
|
|
274
313
|
writePackageJson(dir2, { ...SIMPLE_PKG, name: "new-plugin" });
|
|
275
314
|
writeHook(dir2, "user-prompt-submit", `export default () => ({ v: 2 });`);
|
|
315
|
+
publishSourceChanges();
|
|
276
316
|
|
|
277
|
-
// The next read should pick it up.
|
|
278
317
|
const hooks = await getUserHooksFor("user-prompt-submit");
|
|
279
318
|
expect(hooks).toHaveLength(2);
|
|
280
319
|
});
|
|
@@ -295,32 +334,37 @@ describe("plugin mtime cache (per-surface)", () => {
|
|
|
295
334
|
expect(tools[0]?.name).toBe("my-tool");
|
|
296
335
|
});
|
|
297
336
|
|
|
298
|
-
test("
|
|
337
|
+
test("an edited tool serves its new definition once published", async () => {
|
|
299
338
|
const dir = freshPluginDir("tool-edit-plugin");
|
|
300
339
|
writePackageJson(dir, { ...SIMPLE_PKG, name: "tool-edit-plugin" });
|
|
301
|
-
|
|
340
|
+
// Unique tool name: the global tool registry is process-wide state that
|
|
341
|
+
// survives the per-test cache reset, so a name shared with another test
|
|
342
|
+
// would resolve to that test's stale registration.
|
|
343
|
+
const toolFile = join(dir, "tools", "edited-tool.ts");
|
|
302
344
|
writeTool(
|
|
303
345
|
dir,
|
|
304
|
-
"
|
|
305
|
-
`export default { name: "
|
|
346
|
+
"edited-tool",
|
|
347
|
+
`export default { name: "edited-tool", description: "v1", parameters: { type: "object", properties: {} } };`,
|
|
306
348
|
);
|
|
307
349
|
|
|
308
350
|
await populateCacheAtBoot();
|
|
351
|
+
expect(getCachedUserTools()[0]?.description).toBe("v1");
|
|
309
352
|
|
|
310
|
-
const mtimeBefore = _inspectToolCacheForTests()[0]?.sourceMtime;
|
|
311
|
-
|
|
312
|
-
// Edit the tool file and bump mtime.
|
|
313
353
|
writeFileSync(
|
|
314
354
|
toolFile,
|
|
315
|
-
`export default { name: "
|
|
355
|
+
`export default { name: "edited-tool", description: "v2", parameters: { type: "object", properties: {} } };`,
|
|
316
356
|
);
|
|
317
357
|
touchFile(toolFile);
|
|
318
|
-
|
|
319
|
-
// Trigger a scan by calling getUserHooksFor (which calls scanPlugins).
|
|
358
|
+
publishSourceChanges();
|
|
320
359
|
await getUserHooksFor("user-prompt-submit");
|
|
321
360
|
|
|
322
|
-
|
|
323
|
-
|
|
361
|
+
// Both the cache and the global registry serve the fresh definition —
|
|
362
|
+
// the redeploy unregistered the old tool and registered the new one.
|
|
363
|
+
expect(getCachedUserTools()[0]?.description).toBe("v2");
|
|
364
|
+
expect(
|
|
365
|
+
getAllToolDefinitions().find((t) => t.name === "edited-tool")
|
|
366
|
+
?.description,
|
|
367
|
+
).toBe("v2");
|
|
324
368
|
});
|
|
325
369
|
|
|
326
370
|
test("plugin with no package.json is skipped", async () => {
|
|
@@ -465,27 +509,29 @@ describe("workspace hooks (<workspace>/hooks/)", () => {
|
|
|
465
509
|
expect(results[1]!.tag).toBe("workspace");
|
|
466
510
|
});
|
|
467
511
|
|
|
468
|
-
test("
|
|
512
|
+
test("an edited workspace hook takes effect once published", async () => {
|
|
469
513
|
const hookFile = join(WORKSPACE_HOOKS_DIR, "post-model-call.ts");
|
|
470
|
-
writeWorkspaceHook("post-model-call", `export default () =>
|
|
514
|
+
writeWorkspaceHook("post-model-call", `export default () => "ws-v1";`);
|
|
471
515
|
|
|
472
516
|
await populateCacheAtBoot();
|
|
517
|
+
expect(
|
|
518
|
+
(
|
|
519
|
+
(await getUserHooksFor("post-model-call"))[0] as unknown as () => string
|
|
520
|
+
)(),
|
|
521
|
+
).toBe("ws-v1");
|
|
473
522
|
|
|
474
|
-
|
|
475
|
-
c.key.startsWith("__workspace__/"),
|
|
476
|
-
);
|
|
477
|
-
expect(before).toBeDefined();
|
|
478
|
-
|
|
523
|
+
writeFileSync(hookFile, `export default () => "ws-v2";`);
|
|
479
524
|
touchFile(hookFile);
|
|
480
|
-
|
|
525
|
+
publishSourceChanges();
|
|
481
526
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
527
|
+
expect(
|
|
528
|
+
(
|
|
529
|
+
(await getUserHooksFor("post-model-call"))[0] as unknown as () => string
|
|
530
|
+
)(),
|
|
531
|
+
).toBe("ws-v2");
|
|
486
532
|
});
|
|
487
533
|
|
|
488
|
-
test("deleting a workspace hook file evicts it
|
|
534
|
+
test("deleting a workspace hook file evicts it once published", async () => {
|
|
489
535
|
const hookFile = join(WORKSPACE_HOOKS_DIR, "stop.ts");
|
|
490
536
|
writeWorkspaceHook("stop", `export default () => ({ v: 1 });`);
|
|
491
537
|
|
|
@@ -493,16 +539,18 @@ describe("workspace hooks (<workspace>/hooks/)", () => {
|
|
|
493
539
|
expect(await getUserHooksFor("stop")).toHaveLength(1);
|
|
494
540
|
|
|
495
541
|
rmSync(hookFile, { force: true });
|
|
542
|
+
publishSourceChanges();
|
|
496
543
|
|
|
497
544
|
const hooks = await getUserHooksFor("stop");
|
|
498
545
|
expect(hooks).toHaveLength(0);
|
|
499
546
|
});
|
|
500
547
|
|
|
501
|
-
test("a newly added workspace hook is picked up
|
|
548
|
+
test("a newly added workspace hook is picked up once published", async () => {
|
|
502
549
|
await populateCacheAtBoot();
|
|
503
550
|
expect(await getUserHooksFor("post-compact")).toHaveLength(0);
|
|
504
551
|
|
|
505
552
|
writeWorkspaceHook("post-compact", `export default () => ({ v: 1 });`);
|
|
553
|
+
publishSourceChanges();
|
|
506
554
|
|
|
507
555
|
expect(await getUserHooksFor("post-compact")).toHaveLength(1);
|
|
508
556
|
});
|
|
@@ -549,17 +597,19 @@ const TOOL_SRC = (name: string) =>
|
|
|
549
597
|
`export default { name: ${JSON.stringify(name)}, description: "test", parameters: { type: "object", properties: {} } };`;
|
|
550
598
|
|
|
551
599
|
/**
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
555
|
-
* irrelevant — the
|
|
600
|
+
* Publish pending source changes through the watcher, then dispatch — the
|
|
601
|
+
* production sequence: the monitor's pass rewrites the sentinel, and the
|
|
602
|
+
* next hook dispatch's one-stat gate applies the diff. The hook name is
|
|
603
|
+
* irrelevant — the reconcile runs regardless of whether a plugin defines
|
|
604
|
+
* that hook.
|
|
556
605
|
*/
|
|
557
|
-
async function
|
|
606
|
+
async function publishAndDispatch(): Promise<void> {
|
|
607
|
+
publishSourceChanges();
|
|
558
608
|
await getUserHooksFor("user-prompt-submit");
|
|
559
609
|
}
|
|
560
610
|
|
|
561
611
|
describe("plugin runtime activation", () => {
|
|
562
|
-
test("a plugin installed after boot becomes live
|
|
612
|
+
test("a plugin installed after boot becomes live once published", async () => {
|
|
563
613
|
await populateCacheAtBoot(); // empty plugins dir
|
|
564
614
|
expect(getAllToolDefinitions().some((t) => t.name === "late-tool")).toBe(
|
|
565
615
|
false,
|
|
@@ -571,7 +621,7 @@ describe("plugin runtime activation", () => {
|
|
|
571
621
|
const initMarker = join(ROOT, "late-init.log");
|
|
572
622
|
writeMarkerHook(dir, "init", initMarker, "init");
|
|
573
623
|
|
|
574
|
-
await
|
|
624
|
+
await publishAndDispatch();
|
|
575
625
|
|
|
576
626
|
// Registered into the global registry as a plugin-owned tool, and exposed
|
|
577
627
|
// to the per-turn resolver via getPluginToolDefinitions().
|
|
@@ -589,7 +639,7 @@ describe("plugin runtime activation", () => {
|
|
|
589
639
|
expect(readFileSync(initMarker, "utf8").trim().split("\n")).toHaveLength(1);
|
|
590
640
|
});
|
|
591
641
|
|
|
592
|
-
test("activation is idempotent —
|
|
642
|
+
test("activation is idempotent — republishing without changes does not re-run init", async () => {
|
|
593
643
|
const dir = freshPluginDir("idem-plugin");
|
|
594
644
|
writePackageJson(dir, { ...SIMPLE_PKG, name: "idem-plugin" });
|
|
595
645
|
writeTool(dir, "idem-tool", TOOL_SRC("idem-tool"));
|
|
@@ -597,8 +647,8 @@ describe("plugin runtime activation", () => {
|
|
|
597
647
|
writeMarkerHook(dir, "init", initMarker, "init");
|
|
598
648
|
|
|
599
649
|
await populateCacheAtBoot();
|
|
600
|
-
await
|
|
601
|
-
await
|
|
650
|
+
await publishAndDispatch();
|
|
651
|
+
await publishAndDispatch();
|
|
602
652
|
|
|
603
653
|
expect(readFileSync(initMarker, "utf8").trim().split("\n")).toHaveLength(1);
|
|
604
654
|
// Registered exactly once (no refcount inflation from re-registration).
|
|
@@ -619,7 +669,7 @@ describe("plugin runtime activation", () => {
|
|
|
619
669
|
expect(getToolOwner("temp-tool")?.kind).toBe("plugin");
|
|
620
670
|
|
|
621
671
|
rmSync(dir, { recursive: true, force: true });
|
|
622
|
-
await
|
|
672
|
+
await publishAndDispatch();
|
|
623
673
|
|
|
624
674
|
expect(getToolOwner("temp-tool")).toBeUndefined();
|
|
625
675
|
expect(getPluginToolDefinitions().some((t) => t.name === "temp-tool")).toBe(
|
|
@@ -656,8 +706,143 @@ describe("plugin runtime activation", () => {
|
|
|
656
706
|
expect(getToolOwner("disable-tool")?.kind).toBe("plugin");
|
|
657
707
|
|
|
658
708
|
writeFileSync(join(dir, ".disabled"), "");
|
|
659
|
-
await
|
|
709
|
+
await publishAndDispatch();
|
|
660
710
|
|
|
661
711
|
expect(getToolOwner("disable-tool")).toBeUndefined();
|
|
662
712
|
});
|
|
663
713
|
});
|
|
714
|
+
|
|
715
|
+
// ─── Live reload (sentinel-driven redeploy) ──────────────────────────────────
|
|
716
|
+
|
|
717
|
+
/** Write a helper module at `relPath` inside a plugin dir. */
|
|
718
|
+
function writeLibFile(dir: string, relPath: string, body: string): string {
|
|
719
|
+
const path = join(dir, relPath);
|
|
720
|
+
mkdirSync(join(path, ".."), { recursive: true });
|
|
721
|
+
writeFileSync(path, body);
|
|
722
|
+
return path;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** Dispatch `hookName` and return the single expected hook's result. */
|
|
726
|
+
async function dispatchFirst(hookName: string): Promise<unknown> {
|
|
727
|
+
const hooks = await getUserHooksFor(hookName);
|
|
728
|
+
expect(hooks).toHaveLength(1);
|
|
729
|
+
return (hooks[0] as unknown as () => unknown)();
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
describe("live reload (sentinel-driven redeploy)", () => {
|
|
733
|
+
test("editing a helper imported by a hook redeploys the plugin, repeatably", async () => {
|
|
734
|
+
const dir = freshPluginDir("helper-plugin");
|
|
735
|
+
writePackageJson(dir, { ...SIMPLE_PKG, name: "helper-plugin" });
|
|
736
|
+
const helperPath = writeLibFile(
|
|
737
|
+
dir,
|
|
738
|
+
join("lib", "helper.ts"),
|
|
739
|
+
`export const value = "v1";`,
|
|
740
|
+
);
|
|
741
|
+
writeHook(
|
|
742
|
+
dir,
|
|
743
|
+
"helper-reload",
|
|
744
|
+
`import { value } from "../lib/helper.ts";\nexport default () => value;`,
|
|
745
|
+
);
|
|
746
|
+
|
|
747
|
+
await populateCacheAtBoot();
|
|
748
|
+
expect(await dispatchFirst("helper-reload")).toBe("v1");
|
|
749
|
+
|
|
750
|
+
// Only the helper changes — the hook file's own mtime never moves, so
|
|
751
|
+
// any per-entry-file scheme would keep serving v1 here. And a reloaded
|
|
752
|
+
// plugin must itself stay reloadable.
|
|
753
|
+
for (const marker of ["v2", "v3"]) {
|
|
754
|
+
writeFileSync(
|
|
755
|
+
helperPath,
|
|
756
|
+
`export const value = ${JSON.stringify(marker)};`,
|
|
757
|
+
);
|
|
758
|
+
touchFile(helperPath, sentinelTouchSeq + 2);
|
|
759
|
+
publishSourceChanges();
|
|
760
|
+
expect(await dispatchFirst("helper-reload")).toBe(marker);
|
|
761
|
+
}
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
test("editing a transitive helper (hook → a → b) redeploys consistently", async () => {
|
|
765
|
+
const dir = freshPluginDir("transitive-plugin");
|
|
766
|
+
writePackageJson(dir, { ...SIMPLE_PKG, name: "transitive-plugin" });
|
|
767
|
+
const bPath = writeLibFile(
|
|
768
|
+
dir,
|
|
769
|
+
join("lib", "b.ts"),
|
|
770
|
+
`export const leaf = "b1";`,
|
|
771
|
+
);
|
|
772
|
+
writeLibFile(
|
|
773
|
+
dir,
|
|
774
|
+
join("lib", "a.ts"),
|
|
775
|
+
`import { leaf } from "./b.ts";\nexport const mid = "a:" + leaf;`,
|
|
776
|
+
);
|
|
777
|
+
writeHook(
|
|
778
|
+
dir,
|
|
779
|
+
"transitive-reload",
|
|
780
|
+
`import { mid } from "../lib/a.ts";\nexport default () => mid;`,
|
|
781
|
+
);
|
|
782
|
+
|
|
783
|
+
await populateCacheAtBoot();
|
|
784
|
+
expect(await dispatchFirst("transitive-reload")).toBe("a:b1");
|
|
785
|
+
|
|
786
|
+
// The intermediate module `a` is untouched. Whole-plugin eviction is
|
|
787
|
+
// what keeps a re-imported hook from pairing with a's stale cached
|
|
788
|
+
// binding to the old `b`.
|
|
789
|
+
writeFileSync(bPath, `export const leaf = "b2";`);
|
|
790
|
+
touchFile(bPath, sentinelTouchSeq + 2);
|
|
791
|
+
publishSourceChanges();
|
|
792
|
+
expect(await dispatchFirst("transitive-reload")).toBe("a:b2");
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
test("a reload runs the old shutdown (reason: reload) and the new init", async () => {
|
|
796
|
+
const dir = freshPluginDir("lifecycle-plugin");
|
|
797
|
+
writePackageJson(dir, { ...SIMPLE_PKG, name: "lifecycle-plugin" });
|
|
798
|
+
const helperPath = writeLibFile(
|
|
799
|
+
dir,
|
|
800
|
+
join("lib", "helper.ts"),
|
|
801
|
+
`export const value = 1;`,
|
|
802
|
+
);
|
|
803
|
+
const initMarker = join(ROOT, "reload-init.log");
|
|
804
|
+
const shutdownMarker = join(ROOT, "reload-shutdown.log");
|
|
805
|
+
rmSync(initMarker, { force: true });
|
|
806
|
+
rmSync(shutdownMarker, { force: true });
|
|
807
|
+
writeMarkerHook(dir, "init", initMarker, "init");
|
|
808
|
+
writeHook(
|
|
809
|
+
dir,
|
|
810
|
+
"shutdown",
|
|
811
|
+
`import { appendFileSync } from "node:fs";\nexport default (ctx: { reason: string }) => { appendFileSync(${JSON.stringify(shutdownMarker)}, ctx.reason + "\\n"); };`,
|
|
812
|
+
);
|
|
813
|
+
|
|
814
|
+
await populateCacheAtBoot();
|
|
815
|
+
expect(readFileSync(initMarker, "utf8").trim().split("\n")).toHaveLength(1);
|
|
816
|
+
expect(existsSync(shutdownMarker)).toBe(false);
|
|
817
|
+
|
|
818
|
+
writeFileSync(helperPath, `export const value = 2;`);
|
|
819
|
+
touchFile(helperPath, sentinelTouchSeq + 2);
|
|
820
|
+
await publishAndDispatch();
|
|
821
|
+
|
|
822
|
+
// The edit is a redeploy: old version torn down with reason "reload",
|
|
823
|
+
// new version brought up through the same init path as boot.
|
|
824
|
+
expect(readFileSync(shutdownMarker, "utf8").trim().split("\n")).toEqual([
|
|
825
|
+
"reload",
|
|
826
|
+
]);
|
|
827
|
+
expect(readFileSync(initMarker, "utf8").trim().split("\n")).toHaveLength(2);
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
test("boot adopts a pre-existing sentinel without spurious redeploys", async () => {
|
|
831
|
+
const dir = freshPluginDir("adopt-plugin");
|
|
832
|
+
writePackageJson(dir, { ...SIMPLE_PKG, name: "adopt-plugin" });
|
|
833
|
+
writeLibFile(dir, join("lib", "helper.ts"), `export const v = 1;`);
|
|
834
|
+
const initMarker = join(ROOT, "adopt-init.log");
|
|
835
|
+
rmSync(initMarker, { force: true });
|
|
836
|
+
writeMarkerHook(dir, "init", initMarker, "init");
|
|
837
|
+
|
|
838
|
+
// The watcher published before the daemon booted.
|
|
839
|
+
publishSourceChanges();
|
|
840
|
+
|
|
841
|
+
await populateCacheAtBoot();
|
|
842
|
+
await getUserHooksFor("user-prompt-submit");
|
|
843
|
+
await getUserHooksFor("user-prompt-submit");
|
|
844
|
+
|
|
845
|
+
// One init: the sentinel matching boot's own walk must not redeploy.
|
|
846
|
+
expect(readFileSync(initMarker, "utf8").trim().split("\n")).toHaveLength(1);
|
|
847
|
+
});
|
|
848
|
+
});
|
|
@@ -26,15 +26,13 @@ import {
|
|
|
26
26
|
test,
|
|
27
27
|
} from "bun:test";
|
|
28
28
|
|
|
29
|
-
import {
|
|
30
|
-
computeFingerprint,
|
|
31
|
-
PRESERVED_ENTRIES,
|
|
32
|
-
} from "../cli/lib/plugin-fingerprint.js";
|
|
29
|
+
import { computeFingerprint } from "../cli/lib/plugin-fingerprint.js";
|
|
33
30
|
import { resetHookCacheForTests } from "../hooks/hook-loader.js";
|
|
34
31
|
import {
|
|
35
32
|
populateCacheAtBoot,
|
|
36
33
|
resetPluginCacheForTests,
|
|
37
34
|
} from "../plugins/mtime-cache.js";
|
|
35
|
+
import { PRESERVED_ENTRIES } from "../plugins/plugin-tree-walk.js";
|
|
38
36
|
|
|
39
37
|
const ROOT = join(
|
|
40
38
|
tmpdir(),
|
|
@@ -34,7 +34,7 @@ mock.module("../daemon/conversation-plugin-scope.js", () => ({
|
|
|
34
34
|
resolveConversationPluginScope: (id: string) => resolveScopeMock(id),
|
|
35
35
|
}));
|
|
36
36
|
|
|
37
|
-
import { collectUserHooks } from "../hooks/hook-loader.js";
|
|
37
|
+
import { collectUserHooks, preImportHooksDir } from "../hooks/hook-loader.js";
|
|
38
38
|
import { getHooksFor } from "../hooks/registry.js";
|
|
39
39
|
import {
|
|
40
40
|
registerPlugin,
|
|
@@ -149,7 +149,9 @@ describe("collectUserHooks per-chat plugin scope (user-land hooks)", () => {
|
|
|
149
149
|
rmSync(root, { recursive: true, force: true });
|
|
150
150
|
});
|
|
151
151
|
|
|
152
|
-
|
|
152
|
+
// Hooks enter the dispatch cache at owner activation (preImportHooksDir),
|
|
153
|
+
// so the fixture activates each plugin the way the orchestrator would.
|
|
154
|
+
async function pluginDirWithHook(name: string): Promise<string> {
|
|
153
155
|
const dir = join(root, name);
|
|
154
156
|
const hooksDir = join(dir, "hooks");
|
|
155
157
|
mkdirSync(hooksDir, { recursive: true });
|
|
@@ -157,13 +159,14 @@ describe("collectUserHooks per-chat plugin scope (user-land hooks)", () => {
|
|
|
157
159
|
join(hooksDir, "user-prompt-submit.ts"),
|
|
158
160
|
"export default () => ({});\n",
|
|
159
161
|
);
|
|
162
|
+
await preImportHooksDir(hooksDir, name);
|
|
160
163
|
return dir;
|
|
161
164
|
}
|
|
162
165
|
|
|
163
166
|
test("null set runs both user plugins' hooks (unchanged)", async () => {
|
|
164
167
|
const dirs: Array<readonly [string, string]> = [
|
|
165
|
-
[pluginDirWithHook("uplug-a"), "uplug-a"],
|
|
166
|
-
[pluginDirWithHook("uplug-b"), "uplug-b"],
|
|
168
|
+
[await pluginDirWithHook("uplug-a"), "uplug-a"],
|
|
169
|
+
[await pluginDirWithHook("uplug-b"), "uplug-b"],
|
|
167
170
|
];
|
|
168
171
|
|
|
169
172
|
expect(await collectUserHooks("user-prompt-submit", dirs)).toHaveLength(2);
|
|
@@ -174,8 +177,8 @@ describe("collectUserHooks per-chat plugin scope (user-land hooks)", () => {
|
|
|
174
177
|
|
|
175
178
|
test("a set excluding plugin b drops b's user-land hook", async () => {
|
|
176
179
|
const dirs: Array<readonly [string, string]> = [
|
|
177
|
-
[pluginDirWithHook("uplug-a"), "uplug-a"],
|
|
178
|
-
[pluginDirWithHook("uplug-b"), "uplug-b"],
|
|
180
|
+
[await pluginDirWithHook("uplug-a"), "uplug-a"],
|
|
181
|
+
[await pluginDirWithHook("uplug-b"), "uplug-b"],
|
|
179
182
|
];
|
|
180
183
|
|
|
181
184
|
expect(
|