@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.
Files changed (58) hide show
  1. package/openapi.yaml +43 -6
  2. package/package.json +1 -1
  3. package/src/__tests__/attachment-stored-path-annotation.test.ts +151 -0
  4. package/src/__tests__/attachments-store.test.ts +37 -1
  5. package/src/__tests__/attachments.test.ts +53 -0
  6. package/src/__tests__/channel-reply-delivery.test.ts +214 -5
  7. package/src/__tests__/config-loader-backfill.test.ts +16 -36
  8. package/src/__tests__/image-source-path-reinject.test.ts +155 -14
  9. package/src/__tests__/managed-profile-guard.test.ts +2 -0
  10. package/src/__tests__/mtime-cache.test.ts +265 -80
  11. package/src/__tests__/plugin-config-data-migration.test.ts +2 -4
  12. package/src/__tests__/plugin-effective-enabled-set.test.ts +9 -6
  13. package/src/__tests__/workspace-migration-125-repoint-managed-connections-to-vellum.test.ts +140 -0
  14. package/src/agent/attachments.ts +55 -13
  15. package/src/cli/commands/platform/__tests__/credits.test.ts +111 -0
  16. package/src/cli/commands/platform/index.ts +86 -4
  17. package/src/cli/lib/__tests__/confirm-prompt.test.ts +9 -9
  18. package/src/cli/lib/diff-plugin.ts +7 -3
  19. package/src/cli/lib/inspect-plugin.ts +28 -10
  20. package/src/cli/lib/install-from-github.ts +69 -24
  21. package/src/cli/lib/ipc-params.ts +3 -3
  22. package/src/cli/lib/list-installed-plugins.ts +11 -5
  23. package/src/cli/lib/merge-plugin-tree.ts +15 -9
  24. package/src/cli/lib/plugin-fingerprint.ts +0 -6
  25. package/src/cli/lib/publish-plugin.ts +1 -1
  26. package/src/cli/lib/unknown-command.ts +3 -1
  27. package/src/cli/lib/upgrade-plugin.ts +2 -5
  28. package/src/config/__tests__/sync-gated-profiles.test.ts +2 -2
  29. package/src/config/seed-inference-profiles.ts +5 -4
  30. package/src/daemon/conversation-lifecycle.ts +37 -15
  31. package/src/daemon/conversation-messaging.ts +56 -28
  32. package/src/daemon/conversation.ts +2 -2
  33. package/src/heartbeat/__tests__/heartbeat-service.test.ts +6 -0
  34. package/src/hooks/__tests__/hook-live-reload.test.ts +110 -96
  35. package/src/hooks/hook-loader.ts +57 -144
  36. package/src/hooks/registry.ts +13 -9
  37. package/src/persistence/attachments-store.ts +2 -1
  38. package/src/persistence/conversation-crud.ts +24 -0
  39. package/src/plugin-api/types.ts +4 -1
  40. package/src/plugins/mtime-cache.ts +347 -33
  41. package/src/plugins/pipeline.ts +3 -2
  42. package/src/providers/__tests__/inference.test.ts +42 -31
  43. package/src/providers/inference/__tests__/connections-label.test.ts +9 -28
  44. package/src/providers/inference/auth.ts +1 -2
  45. package/src/providers/inference/backfill.ts +7 -5
  46. package/src/providers/inference/connections.ts +54 -30
  47. package/src/runtime/channel-reply-delivery.ts +83 -24
  48. package/src/runtime/channel-retry-sweep.ts +4 -0
  49. package/src/runtime/{slack-no-response.ts → no-response.ts} +26 -6
  50. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +43 -3
  51. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +61 -24
  52. package/src/runtime/routes/conversation-query-routes.ts +18 -1
  53. package/src/runtime/routes/inbound-stages/background-dispatch.ts +1 -1
  54. package/src/runtime/routes/inference-provider-connection-routes.ts +4 -3
  55. package/src/runtime/routes/platform-routes.ts +82 -1
  56. package/src/runtime/slack-reply-session.ts +1 -1
  57. package/src/workspace/migrations/125-repoint-managed-connections-to-vellum.ts +99 -0
  58. package/src/workspace/migrations/registry.ts +2 -0
@@ -1,11 +1,13 @@
1
1
  /**
2
- * Tests for the per-surface mtime cache — the pull-based replacement for
3
- * PluginSourceWatcher.
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, then exercises observable behavior: cache hit (same mtime),
7
- * cache miss (changed mtime → re-import), plugin deletion (eviction),
8
- * and hook collection across multiple plugins.
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 hit: same mtime does not re-import", async () => {
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
- const cacheBefore = _inspectHookCacheForTests();
172
- expect(cacheBefore).toHaveLength(1);
173
-
174
- // Read again — same mtime, no re-import.
175
- await getUserHooksFor("user-prompt-submit");
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 cacheAfter = _inspectHookCacheForTests();
178
- expect(cacheAfter).toHaveLength(1);
179
- expect(cacheAfter[0]?.sourceMtime).toBe(cacheBefore[0]?.sourceMtime);
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("cache miss: editing a hook file triggers re-import", async () => {
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
- const mtimeBefore = _inspectHookCacheForTests()[0]?.sourceMtime;
195
-
196
- // Touch the hook file to bump its mtime.
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
- // Read again — mtime changed, re-import.
200
- await getUserHooksFor("user-prompt-submit");
201
-
202
- const mtimeAfter = _inspectHookCacheForTests()[0]?.sourceMtime;
203
- expect(mtimeAfter).not.toBe(mtimeBefore);
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: removing directory evicts cache entries", async () => {
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
- cacheState.find((c) => c.key.startsWith("deletable-plugin/")),
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 without restart", async () => {
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("editing a tool file triggers re-import on next scan", async () => {
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
- const toolFile = join(dir, "tools", "my-tool.ts");
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
- "my-tool",
305
- `export default { name: "my-tool", description: "v1", parameters: { type: "object", properties: {} } };`,
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: "my-tool", description: "v2", parameters: { type: "object", properties: {} } };`,
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
- const mtimeAfter = _inspectToolCacheForTests()[0]?.sourceMtime;
323
- expect(mtimeAfter).not.toBe(mtimeBefore);
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("editing a workspace hook file triggers re-import", async () => {
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 () => ({ v: 1 });`);
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
- const before = _inspectHookCacheForTests().find((c) =>
475
- c.key.startsWith("__workspace__/"),
476
- );
477
- expect(before).toBeDefined();
478
-
523
+ writeFileSync(hookFile, `export default () => "ws-v2";`);
479
524
  touchFile(hookFile);
480
- await getUserHooksFor("post-model-call");
525
+ publishSourceChanges();
481
526
 
482
- const after = _inspectHookCacheForTests().find((c) =>
483
- c.key.startsWith("__workspace__/"),
484
- );
485
- expect(after?.sourceMtime).not.toBe(before?.sourceMtime);
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 on next read", async () => {
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 without restart", async () => {
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
- * Simulate the per-turn plugin hook dispatch that drives runtime reconciliation
553
- * in production: any `getUserHooksFor` call runs `scanPlugins`, which activates
554
- * newly present plugins and deactivates removed ones. The hook name is
555
- * irrelevant — the scan runs regardless of whether a plugin defines that hook.
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 triggerScan(): Promise<void> {
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 on the next scan", async () => {
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 triggerScan();
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 — repeated scans do not re-run init", async () => {
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 triggerScan();
601
- await triggerScan();
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 triggerScan();
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 triggerScan();
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
- function pluginDirWithHook(name: string): string {
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(