@vellumai/assistant 0.10.2-dev.202606241651.2d2b40d → 0.10.2-dev.202606241743.799fce7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.2-dev.202606241651.2d2b40d",
3
+ "version": "0.10.2-dev.202606241743.799fce7",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -7,7 +7,7 @@
7
7
  * cache miss (changed mtime → re-import), plugin deletion (eviction),
8
8
  * and hook collection across multiple plugins.
9
9
  */
10
- import { mkdirSync, rmSync, utimesSync,writeFileSync } from "node:fs";
10
+ import { mkdirSync, rmSync, utimesSync, writeFileSync } from "node:fs";
11
11
  import { tmpdir } from "node:os";
12
12
  import { join } from "node:path";
13
13
  import {
@@ -19,8 +19,8 @@ import {
19
19
  test,
20
20
  } from "bun:test";
21
21
 
22
+ import { _inspectHookCacheForTests } from "../hooks/hook-loader.js";
22
23
  import {
23
- _inspectHookCacheForTests,
24
24
  _inspectToolCacheForTests,
25
25
  getCachedUserTools,
26
26
  getUserHooksFor,
@@ -62,7 +62,15 @@ function writeHook(dir: string, hookName: string, body: string): void {
62
62
  function writeInstallMeta(dir: string, installedAt: string): void {
63
63
  writeFileSync(
64
64
  join(dir, "install-meta.json"),
65
- JSON.stringify({ name: "test", installedAt, source: { kind: "github", owner: "test", repo: "test", ref: "main" } }, null, 2),
65
+ JSON.stringify(
66
+ {
67
+ name: "test",
68
+ installedAt,
69
+ source: { kind: "github", owner: "test", repo: "test", ref: "main" },
70
+ },
71
+ null,
72
+ 2,
73
+ ),
66
74
  );
67
75
  }
68
76
 
@@ -72,6 +80,20 @@ function writeTool(dir: string, toolName: string, body: string): void {
72
80
  writeFileSync(join(toolsDir, `${toolName}.ts`), body);
73
81
  }
74
82
 
83
+ /** The standalone workspace hooks directory (`<workspace>/hooks/`). */
84
+ const WORKSPACE_HOOKS_DIR = join(ROOT, "hooks");
85
+
86
+ function ensureWorkspaceHooksDir(): void {
87
+ rmSync(WORKSPACE_HOOKS_DIR, { recursive: true, force: true });
88
+ mkdirSync(WORKSPACE_HOOKS_DIR, { recursive: true });
89
+ }
90
+
91
+ /** Write a standalone hook file directly under `<workspace>/hooks/`. */
92
+ function writeWorkspaceHook(hookName: string, body: string): void {
93
+ mkdirSync(WORKSPACE_HOOKS_DIR, { recursive: true });
94
+ writeFileSync(join(WORKSPACE_HOOKS_DIR, `${hookName}.ts`), body);
95
+ }
96
+
75
97
  /**
76
98
  * Bump a file's mtime forward by ~2 seconds so the mtime cache detects a
77
99
  * change. Using utimesSync avoids race conditions with filesystem mtime
@@ -97,6 +119,7 @@ beforeAll(() => {
97
119
 
98
120
  beforeEach(() => {
99
121
  ensurePluginsDir();
122
+ ensureWorkspaceHooksDir();
100
123
  resetPluginCacheForTests();
101
124
  });
102
125
 
@@ -338,7 +361,9 @@ describe("plugin mtime cache (per-surface)", () => {
338
361
  expect(hooks).toHaveLength(2);
339
362
 
340
363
  // beta was installed earlier (Jan 1) so it should come first.
341
- const results = hooks.map((fn) => (fn as unknown as () => { tag: string })());
364
+ const results = hooks.map((fn) =>
365
+ (fn as unknown as () => { tag: string })(),
366
+ );
342
367
  expect(results[0]!.tag).toBe("beta");
343
368
  expect(results[1]!.tag).toBe("alpha");
344
369
  });
@@ -368,8 +393,124 @@ describe("plugin mtime cache (per-surface)", () => {
368
393
  const hooks = await getUserHooksFor("user-prompt-submit");
369
394
  expect(hooks).toHaveLength(2);
370
395
 
371
- const results = hooks.map((fn) => (fn as unknown as () => { tag: string })());
396
+ const results = hooks.map((fn) =>
397
+ (fn as unknown as () => { tag: string })(),
398
+ );
372
399
  expect(results[0]!.tag).toBe("dated");
373
400
  expect(results[1]!.tag).toBe("undated");
374
401
  });
375
402
  });
403
+
404
+ describe("workspace hooks (<workspace>/hooks/)", () => {
405
+ test("getUserHooksFor loads a standalone workspace hook", async () => {
406
+ writeWorkspaceHook(
407
+ "user-prompt-submit",
408
+ `export default () => ({ ws: 1 });`,
409
+ );
410
+
411
+ await populateCacheAtBoot();
412
+
413
+ const hooks = await getUserHooksFor("user-prompt-submit");
414
+ expect(hooks).toHaveLength(1);
415
+ });
416
+
417
+ test("workspace hooks load even when no plugins directory exists", async () => {
418
+ rmSync(PLUGINS_DIR, { recursive: true, force: true });
419
+ writeWorkspaceHook("post-tool-use", `export default () => ({ ws: 1 });`);
420
+
421
+ await populateCacheAtBoot();
422
+
423
+ const hooks = await getUserHooksFor("post-tool-use");
424
+ expect(hooks).toHaveLength(1);
425
+ expect(getCachedUserTools()).toHaveLength(0);
426
+ });
427
+
428
+ // NB: each test below uses a distinct hook event name so the workspace
429
+ // hook file path is unique. Bun caches dynamic import() by URL and does not
430
+ // bust on content change, so reusing `<workspace>/hooks/<name>.ts` across
431
+ // tests would return a stale module (the existing plugin tests dodge this
432
+ // by using a fresh plugin directory per test).
433
+ test("plugin hooks run before the workspace hook for the same event", async () => {
434
+ const dir = freshPluginDir("ordering-plugin");
435
+ writePackageJson(dir, { ...SIMPLE_PKG, name: "ordering-plugin" });
436
+ writeHook(
437
+ dir,
438
+ "pre-model-call",
439
+ `export default () => ({ tag: "plugin" });`,
440
+ );
441
+ writeWorkspaceHook(
442
+ "pre-model-call",
443
+ `export default () => ({ tag: "workspace" });`,
444
+ );
445
+
446
+ await populateCacheAtBoot();
447
+
448
+ const hooks = await getUserHooksFor("pre-model-call");
449
+ expect(hooks).toHaveLength(2);
450
+ const results = hooks.map((fn) =>
451
+ (fn as unknown as () => { tag: string })(),
452
+ );
453
+ expect(results[0]!.tag).toBe("plugin");
454
+ expect(results[1]!.tag).toBe("workspace");
455
+ });
456
+
457
+ test("editing a workspace hook file triggers re-import", async () => {
458
+ const hookFile = join(WORKSPACE_HOOKS_DIR, "post-model-call.ts");
459
+ writeWorkspaceHook("post-model-call", `export default () => ({ v: 1 });`);
460
+
461
+ await populateCacheAtBoot();
462
+
463
+ const before = _inspectHookCacheForTests().find((c) =>
464
+ c.key.startsWith("__workspace__/"),
465
+ );
466
+ expect(before).toBeDefined();
467
+
468
+ touchFile(hookFile);
469
+ await getUserHooksFor("post-model-call");
470
+
471
+ const after = _inspectHookCacheForTests().find((c) =>
472
+ c.key.startsWith("__workspace__/"),
473
+ );
474
+ expect(after?.sourceMtime).not.toBe(before?.sourceMtime);
475
+ });
476
+
477
+ test("deleting a workspace hook file evicts it on next read", async () => {
478
+ const hookFile = join(WORKSPACE_HOOKS_DIR, "stop.ts");
479
+ writeWorkspaceHook("stop", `export default () => ({ v: 1 });`);
480
+
481
+ await populateCacheAtBoot();
482
+ expect(await getUserHooksFor("stop")).toHaveLength(1);
483
+
484
+ rmSync(hookFile, { force: true });
485
+
486
+ const hooks = await getUserHooksFor("stop");
487
+ expect(hooks).toHaveLength(0);
488
+ });
489
+
490
+ test("a newly added workspace hook is picked up without restart", async () => {
491
+ await populateCacheAtBoot();
492
+ expect(await getUserHooksFor("post-compact")).toHaveLength(0);
493
+
494
+ writeWorkspaceHook("post-compact", `export default () => ({ v: 1 });`);
495
+
496
+ expect(await getUserHooksFor("post-compact")).toHaveLength(1);
497
+ });
498
+
499
+ test("a workspace init hook runs once at boot", async () => {
500
+ // The init hook writes a sentinel file so we can observe it ran exactly
501
+ // once during populateCacheAtBoot.
502
+ const sentinel = join(ROOT, "ws-init-ran.txt");
503
+ rmSync(sentinel, { force: true });
504
+ writeWorkspaceHook(
505
+ "init",
506
+ `import { appendFileSync } from "node:fs";
507
+ export default () => { appendFileSync(${JSON.stringify(sentinel)}, "x"); };`,
508
+ );
509
+
510
+ await populateCacheAtBoot();
511
+
512
+ const { readFileSync: rf, existsSync: ex } = await import("node:fs");
513
+ expect(ex(sentinel)).toBe(true);
514
+ expect(rf(sentinel, "utf8")).toBe("x");
515
+ });
516
+ });
@@ -11,7 +11,7 @@ let hooksDir: string;
11
11
  function freshWorkspace(): void {
12
12
  workspaceDir = join(
13
13
  tmpdir(),
14
- `vellum-migration-046-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
14
+ `vellum-migration-048-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
15
15
  );
16
16
  hooksDir = join(workspaceDir, "hooks");
17
17
  mkdirSync(workspaceDir, { recursive: true });
@@ -30,29 +30,22 @@ afterEach(() => {
30
30
  }
31
31
  });
32
32
 
33
- describe("048-remove-workspace-hooks migration", () => {
33
+ describe("048-remove-workspace-hooks migration (retained no-op)", () => {
34
34
  test("has correct migration id", () => {
35
35
  expect(removeWorkspaceHooksMigration.id).toBe("048-remove-workspace-hooks");
36
36
  });
37
37
 
38
- test("removes a populated hooks directory", () => {
38
+ test("preserves a populated hooks directory now that it is a supported surface", () => {
39
39
  mkdirSync(hooksDir, { recursive: true });
40
- mkdirSync(join(hooksDir, "my-hook"), { recursive: true });
41
- writeFileSync(join(hooksDir, "my-hook", "manifest.json"), "{}");
42
- writeFileSync(join(hooksDir, "my-hook", "run.sh"), "#!/bin/sh\n");
43
- writeFileSync(join(hooksDir, "README.md"), "hooks live here");
40
+ writeFileSync(
41
+ join(hooksDir, "user-prompt-submit.ts"),
42
+ "export default () => {};",
43
+ );
44
44
 
45
45
  removeWorkspaceHooksMigration.run(workspaceDir);
46
46
 
47
- expect(existsSync(hooksDir)).toBe(false);
48
- });
49
-
50
- test("removes an empty hooks directory", () => {
51
- mkdirSync(hooksDir, { recursive: true });
52
-
53
- removeWorkspaceHooksMigration.run(workspaceDir);
54
-
55
- expect(existsSync(hooksDir)).toBe(false);
47
+ expect(existsSync(hooksDir)).toBe(true);
48
+ expect(existsSync(join(hooksDir, "user-prompt-submit.ts"))).toBe(true);
56
49
  });
57
50
 
58
51
  test("no-op when the hooks directory does not exist", () => {
@@ -65,35 +58,21 @@ describe("048-remove-workspace-hooks migration", () => {
65
58
  expect(existsSync(workspaceDir)).toBe(true);
66
59
  });
67
60
 
68
- test("idempotent — safe to re-run after the directory is gone", () => {
61
+ test("idempotent — safe to re-run", () => {
69
62
  mkdirSync(hooksDir, { recursive: true });
70
- writeFileSync(join(hooksDir, "stale.json"), "{}");
63
+ writeFileSync(join(hooksDir, "stop.ts"), "export default () => {};");
71
64
 
72
65
  removeWorkspaceHooksMigration.run(workspaceDir);
73
- // Second invocation is a no-op and must not throw.
74
66
  removeWorkspaceHooksMigration.run(workspaceDir);
75
67
 
76
- expect(existsSync(hooksDir)).toBe(false);
77
- });
78
-
79
- test("does not touch unrelated workspace entries", () => {
80
- mkdirSync(hooksDir, { recursive: true });
81
- writeFileSync(join(hooksDir, "stale.json"), "{}");
82
- const skillsDir = join(workspaceDir, "skills");
83
- mkdirSync(skillsDir, { recursive: true });
84
- writeFileSync(join(skillsDir, "keep.md"), "keep me");
85
-
86
- removeWorkspaceHooksMigration.run(workspaceDir);
87
-
88
- expect(existsSync(hooksDir)).toBe(false);
89
- expect(existsSync(skillsDir)).toBe(true);
90
- expect(existsSync(join(skillsDir, "keep.md"))).toBe(true);
68
+ expect(existsSync(join(hooksDir, "stop.ts"))).toBe(true);
91
69
  });
92
70
 
93
71
  describe("down()", () => {
94
72
  test("is a no-op", () => {
73
+ mkdirSync(hooksDir, { recursive: true });
95
74
  removeWorkspaceHooksMigration.down(workspaceDir);
96
- expect(existsSync(hooksDir)).toBe(false);
75
+ expect(existsSync(hooksDir)).toBe(true);
97
76
  });
98
77
  });
99
78
  });
@@ -583,12 +583,20 @@ function normalizeCardShowData(
583
583
  (typeof input[k] === "string" &&
584
584
  (input[k] as string).trim().length > 0),
585
585
  );
586
- Sentry.addBreadcrumb({
587
- category: "card-normalization",
588
- message: `alias recovery: ${usedAliases.join(", ")} → body`,
589
- level: "info",
590
- data: { usedAliases, candidateCount: candidates.length },
591
- });
586
+ try {
587
+ Sentry.withScope((scope) => {
588
+ scope.setLevel("info");
589
+ scope.setTag("card_normalization", "alias_recovery");
590
+ scope.setTag("target_field", "body");
591
+ scope.setContext("card_normalization", {
592
+ used_aliases: usedAliases,
593
+ candidate_count: candidates.length,
594
+ });
595
+ Sentry.captureMessage("card_normalization:alias_recovery:body");
596
+ });
597
+ } catch {
598
+ // Never let telemetry break card rendering.
599
+ }
592
600
  }
593
601
  }
594
602
  for (const key of bodyAliasKeys) {
@@ -605,11 +613,16 @@ function normalizeCardShowData(
605
613
  ]);
606
614
  if (aliased !== undefined) {
607
615
  normalized.title = aliased;
608
- Sentry.addBreadcrumb({
609
- category: "card-normalization",
610
- message: `alias recovery: title`,
611
- level: "info",
612
- });
616
+ try {
617
+ Sentry.withScope((scope) => {
618
+ scope.setLevel("info");
619
+ scope.setTag("card_normalization", "alias_recovery");
620
+ scope.setTag("target_field", "title");
621
+ Sentry.captureMessage("card_normalization:alias_recovery:title");
622
+ });
623
+ } catch {
624
+ // Never let telemetry break card rendering.
625
+ }
613
626
  }
614
627
  }
615
628
  for (const key of titleAliasKeys) {
@@ -634,11 +647,16 @@ function normalizeCardShowData(
634
647
  ]);
635
648
  if (aliased !== undefined) {
636
649
  normalized.subtitle = aliased;
637
- Sentry.addBreadcrumb({
638
- category: "card-normalization",
639
- message: `alias recovery: subtitle`,
640
- level: "info",
641
- });
650
+ try {
651
+ Sentry.withScope((scope) => {
652
+ scope.setLevel("info");
653
+ scope.setTag("card_normalization", "alias_recovery");
654
+ scope.setTag("target_field", "subtitle");
655
+ Sentry.captureMessage("card_normalization:alias_recovery:subtitle");
656
+ });
657
+ } catch {
658
+ // Never let telemetry break card rendering.
659
+ }
642
660
  }
643
661
  }
644
662
  for (const key of subtitleAliasKeys) {
@@ -688,12 +706,21 @@ function normalizeCardShowData(
688
706
  { droppedKeys },
689
707
  "ui_show card data carried keys the card contract does not model; their content will not render",
690
708
  );
691
- Sentry.addBreadcrumb({
692
- category: "card-normalization",
693
- message: `dropped keys: ${droppedKeys.join(", ")}`,
694
- level: "warning",
695
- data: { droppedKeys },
696
- });
709
+ try {
710
+ Sentry.withScope((scope) => {
711
+ scope.setLevel("warning");
712
+ scope.setTag("card_normalization", "dropped_keys");
713
+ scope.setContext("card_normalization", {
714
+ dropped_count: droppedKeys.length,
715
+ });
716
+ // Key names are model-controlled, so they ride in `extra`, which
717
+ // beforeSend redacts (it does not scrub `contexts`) — see instrument.ts.
718
+ scope.setExtra("dropped_keys", droppedKeys);
719
+ Sentry.captureMessage("card_normalization:dropped_keys");
720
+ });
721
+ } catch {
722
+ // Never let telemetry break card rendering.
723
+ }
697
724
  }
698
725
  const parsed = CardSurfaceDataSchema.safeParse(normalized);
699
726
  if (parsed.success) {
@@ -2912,18 +2939,27 @@ export async function surfaceProxyResolver(
2912
2939
  if (result.success) {
2913
2940
  valid.push(result.data);
2914
2941
  } else {
2915
- Sentry.addBreadcrumb({
2916
- category: "card-normalization",
2917
- message: "action parse failure (individual)",
2918
- level: "warning",
2919
- data: {
2920
- issuePaths: result.error.issues.map((i) => i.path.join(".")),
2921
- keys:
2942
+ try {
2943
+ Sentry.withScope((scope) => {
2944
+ scope.setLevel("warning");
2945
+ scope.setTag("card_normalization", "action_parse_failure");
2946
+ scope.setContext("card_normalization", {
2947
+ issue_paths: result.error.issues.map((i) => i.path.join(".")),
2948
+ });
2949
+ // raw object keys are model-controlled, so they ride in `extra`,
2950
+ // which beforeSend redacts (it does not scrub `contexts`) — see
2951
+ // instrument.ts.
2952
+ scope.setExtra(
2953
+ "raw_keys",
2922
2954
  typeof raw === "object" && raw !== null
2923
2955
  ? Object.keys(raw)
2924
2956
  : [typeof raw],
2925
- },
2926
- });
2957
+ );
2958
+ Sentry.captureMessage("card_normalization:action_parse_failure");
2959
+ });
2960
+ } catch {
2961
+ // Never let telemetry break card rendering.
2962
+ }
2927
2963
  }
2928
2964
  }
2929
2965
  inputActions = valid.length > 0 ? valid : undefined;
@@ -2961,11 +2997,24 @@ export async function surfaceProxyResolver(
2961
2997
  !hasTemplate &&
2962
2998
  !hasActions
2963
2999
  ) {
2964
- Sentry.addBreadcrumb({
2965
- category: "card-normalization",
2966
- message: "empty card rejected",
2967
- level: "warning",
2968
- });
3000
+ try {
3001
+ Sentry.withScope((scope) => {
3002
+ scope.setLevel("warning");
3003
+ scope.setTag("card_normalization", "empty_card_rejected");
3004
+ scope.setContext("card_normalization", {
3005
+ surface_type: surfaceType,
3006
+ has_title: hasTitle,
3007
+ has_body: hasBody,
3008
+ has_subtitle: hasSubtitle,
3009
+ has_metadata: hasMetadata,
3010
+ has_template: hasTemplate,
3011
+ has_actions: hasActions,
3012
+ });
3013
+ Sentry.captureMessage("card_normalization:empty_card_rejected");
3014
+ });
3015
+ } catch {
3016
+ // Never let telemetry break card rendering.
3017
+ }
2969
3018
  return {
2970
3019
  content:
2971
3020
  "Error: ui_show card requires content — provide `data.body`, a `template` (e.g. task_progress with steps), `data.metadata`, `data.subtitle`, a `title`, or `actions`. The surface was not displayed because it carried no renderable content. Resend ui_show with populated card content.",