@vellumai/assistant 0.8.11 → 0.8.12-staging.2

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 (244) hide show
  1. package/ARCHITECTURE.md +15 -17
  2. package/README.md +0 -6
  3. package/bun.lock +6 -122
  4. package/node_modules/@vellumai/gateway-client/bun.lock +1 -0
  5. package/node_modules/@vellumai/gateway-client/package.json +3 -1
  6. package/node_modules/@vellumai/gateway-client/src/__tests__/gateway-client.test.ts +1 -1
  7. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +87 -0
  8. package/node_modules/@vellumai/gateway-client/src/index.ts +3 -5
  9. package/openapi.yaml +633 -4
  10. package/package.json +1 -3
  11. package/src/__tests__/adaptive-thinking-repair.test.ts +185 -0
  12. package/src/__tests__/agent-loop-compaction-events.test.ts +7 -6
  13. package/src/__tests__/anthropic-provider.test.ts +129 -0
  14. package/src/__tests__/background-workers-disk-pressure.test.ts +4 -1
  15. package/src/__tests__/btw-routes.test.ts +7 -34
  16. package/src/__tests__/checker.test.ts +6 -12
  17. package/src/__tests__/config-loader-backfill.test.ts +4 -2
  18. package/src/__tests__/config-loader-quarantine-notice.test.ts +167 -0
  19. package/src/__tests__/config-watcher.test.ts +2 -2
  20. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -1
  21. package/src/__tests__/conversation-error.test.ts +2 -6
  22. package/src/__tests__/conversation-history-web-search.test.ts +8 -0
  23. package/src/__tests__/conversation-title-service.test.ts +2 -1
  24. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  25. package/src/__tests__/disk-pressure-tools.test.ts +1 -1
  26. package/src/__tests__/exploration-drift-hook.test.ts +692 -0
  27. package/src/__tests__/filing-service.test.ts +8 -3
  28. package/src/__tests__/guardian-action-store.test.ts +0 -167
  29. package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +1 -1
  30. package/src/__tests__/heartbeat-disk-pressure.test.ts +4 -1
  31. package/src/__tests__/heartbeat-service.test.ts +5 -2
  32. package/src/__tests__/identity-intro-cache.test.ts +12 -5
  33. package/src/__tests__/identity-routes.test.ts +16 -57
  34. package/src/__tests__/injector-chain.test.ts +8 -3
  35. package/src/__tests__/injector-config-quarantine-notice.test.ts +115 -0
  36. package/src/__tests__/llm-catalog-parity.test.ts +16 -0
  37. package/src/__tests__/llm-usage-store.test.ts +11 -0
  38. package/src/__tests__/log-export-workspace.test.ts +468 -3
  39. package/src/__tests__/memory-v2-static-injector.test.ts +22 -0
  40. package/src/__tests__/model-intents.test.ts +1 -1
  41. package/src/__tests__/oauth-cli.test.ts +19 -8
  42. package/src/__tests__/openai-provider.test.ts +34 -0
  43. package/src/__tests__/prechat-onboarding-contract.test.ts +0 -1
  44. package/src/__tests__/recurrence-engine.test.ts +45 -0
  45. package/src/__tests__/schedule-routes.test.ts +34 -0
  46. package/src/__tests__/scheduler-disk-pressure.test.ts +1 -1
  47. package/src/__tests__/script-proxy-conversation-manager.test.ts +10 -5
  48. package/src/__tests__/secret-fixtures.ts +20 -0
  49. package/src/__tests__/skill-tool-factory.test.ts +49 -0
  50. package/src/__tests__/subagent-role-registry.test.ts +24 -1
  51. package/src/__tests__/subagent-tools.test.ts +1 -0
  52. package/src/__tests__/system-prompt.test.ts +109 -11
  53. package/src/__tests__/tool-approval-handler.test.ts +85 -0
  54. package/src/__tests__/tool-audit-listener.test.ts +86 -0
  55. package/src/__tests__/tool-error-hook.test.ts +1 -0
  56. package/src/__tests__/tool-result-spool.test.ts +337 -0
  57. package/src/__tests__/tool-result-truncate-hook.test.ts +1 -0
  58. package/src/__tests__/validate-input.test.ts +95 -1
  59. package/src/__tests__/workspace-migration-098-remove-stale-updates-bulletin-file.test.ts +65 -0
  60. package/src/__tests__/workspace-migration-099-disable-cache-one-shot-callsites.test.ts +139 -0
  61. package/src/__tests__/workspace-migration-100-upgrade-quality-profile-to-fable-5.test.ts +174 -0
  62. package/src/__tests__/workspace-migration-101-upgrade-balanced-economy-to-minimax-m3.test.ts +162 -0
  63. package/src/__tests__/workspace-release-notes-feature-flag-guard.test.ts +45 -95
  64. package/src/acp/__tests__/agent-process.test.ts +315 -2
  65. package/src/acp/__tests__/prepare-agent-env.test.ts +79 -5
  66. package/src/acp/agent-process.ts +163 -34
  67. package/src/acp/prepare-agent-env.ts +55 -15
  68. package/src/agent/loop.ts +81 -24
  69. package/src/api/events/usage-progress.ts +28 -0
  70. package/src/api/index.ts +6 -0
  71. package/src/background-wake/wake-intent-hooks.test.ts +2 -0
  72. package/src/bundler/app-bundler.ts +25 -42
  73. package/src/bundler/app-compiler.ts +8 -0
  74. package/src/calls/call-controller.ts +1 -1
  75. package/src/cli/commands/plugins.ts +248 -15
  76. package/src/cli/lib/__tests__/inspect-plugin.test.ts +318 -0
  77. package/src/cli/lib/__tests__/install-from-github.test.ts +16 -9
  78. package/src/cli/lib/__tests__/plugin-artifact.test.ts +183 -0
  79. package/src/cli/lib/__tests__/plugin-details.test.ts +158 -0
  80. package/src/cli/lib/__tests__/plugin-fingerprint.test.ts +245 -0
  81. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +307 -0
  82. package/src/cli/lib/inspect-plugin.ts +252 -0
  83. package/src/cli/lib/install-from-github.ts +214 -21
  84. package/src/cli/lib/list-installed-plugins.ts +17 -6
  85. package/src/cli/lib/plugin-artifact.ts +103 -0
  86. package/src/cli/lib/plugin-details.ts +18 -1
  87. package/src/cli/lib/plugin-fingerprint.ts +197 -0
  88. package/src/cli/lib/upgrade-plugin.ts +225 -0
  89. package/src/config/bundled-skills/subagent/SKILL.md +2 -0
  90. package/src/config/bundled-skills/subagent/TOOLS.json +8 -2
  91. package/src/config/call-site-defaults.ts +13 -2
  92. package/src/config/feature-flag-registry.json +8 -16
  93. package/src/config/loader.ts +52 -59
  94. package/src/config/schema.ts +0 -2
  95. package/src/config/schemas/__tests__/memory-v2.test.ts +1 -0
  96. package/src/config/schemas/__tests__/memory-v3.test.ts +10 -0
  97. package/src/config/schemas/llm.ts +10 -0
  98. package/src/config/schemas/memory-v2.ts +13 -0
  99. package/src/config/schemas/memory-v3.ts +92 -0
  100. package/src/config/seed-inference-profiles.ts +4 -8
  101. package/src/context/post-turn-tool-result-truncation.ts +32 -18
  102. package/src/context/tool-result-spool.ts +104 -0
  103. package/src/credential-execution/feature-gates.ts +0 -1
  104. package/src/daemon/conversation-agent-loop-handlers.ts +41 -16
  105. package/src/daemon/conversation-error.ts +6 -15
  106. package/src/daemon/conversation.ts +9 -0
  107. package/src/daemon/disk-pressure-policy.ts +0 -1
  108. package/src/daemon/lifecycle.ts +1 -20
  109. package/src/daemon/message-types/conversations.ts +2 -15
  110. package/src/daemon/trust-context.ts +1 -1
  111. package/src/events/tool-audit-listener.ts +40 -9
  112. package/src/heartbeat/__tests__/heartbeat-service.test.ts +1 -1
  113. package/src/home/__tests__/home-content-refresh.test.ts +114 -0
  114. package/src/home/__tests__/suggested-prompts.test.ts +86 -5
  115. package/src/home/home-content-refresh.ts +43 -31
  116. package/src/home/home-greeting-cache.ts +8 -1
  117. package/src/home/home-greeting.ts +13 -9
  118. package/src/home/suggested-prompts.ts +77 -24
  119. package/src/ipc/routes/trust-rules.test.ts +66 -72
  120. package/src/media/image-credentials.ts +2 -2
  121. package/src/memory/__tests__/compaction-log-store-clickhouse.test.ts +432 -0
  122. package/src/memory/{compaction-log-writer-clickhouse.ts → compaction-log-store-clickhouse.ts} +264 -55
  123. package/src/memory/conversation-attention-store.ts +1 -0
  124. package/src/memory/conversation-bootstrap.ts +18 -9
  125. package/src/memory/conversation-crud.ts +12 -2
  126. package/src/memory/conversation-title-service.ts +53 -9
  127. package/src/memory/delivery-channels.ts +0 -69
  128. package/src/memory/graph/extraction-job.ts +0 -15
  129. package/src/memory/guardian-action-store.ts +1 -376
  130. package/src/memory/llm-usage-store.ts +5 -1
  131. package/src/memory/migrations/181-rename-thread-starters-checkpoints.ts +2 -2
  132. package/src/memory/v2/__tests__/consolidation-job.test.ts +183 -2
  133. package/src/memory/v2/__tests__/injection.test.ts +70 -0
  134. package/src/memory/v2/__tests__/static-context.test.ts +12 -0
  135. package/src/memory/v2/consolidation-job.ts +93 -9
  136. package/src/memory/v2/injection.ts +53 -0
  137. package/src/memory/v2/prompts/consolidation.ts +1 -0
  138. package/src/memory/v2/static-context.ts +13 -1
  139. package/src/memory/v2/sweep-job.ts +1 -1
  140. package/src/memory/v2/types.ts +5 -0
  141. package/src/plugin-api/types.ts +7 -0
  142. package/src/plugins/defaults/exploration-drift/hooks/post-tool-use.ts +300 -0
  143. package/src/plugins/defaults/exploration-drift/package.json +15 -0
  144. package/src/plugins/defaults/index.ts +25 -0
  145. package/src/plugins/defaults/memory-retrieval/injectors.ts +132 -4
  146. package/src/plugins/defaults/memory-v3-shadow/__tests__/card.test.ts +92 -0
  147. package/src/plugins/defaults/memory-v3-shadow/__tests__/carry-integration.test.ts +2 -1
  148. package/src/plugins/defaults/memory-v3-shadow/__tests__/fresh-set.test.ts +52 -0
  149. package/src/plugins/defaults/memory-v3-shadow/__tests__/injection.test.ts +1 -0
  150. package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +2 -1
  151. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +136 -5
  152. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +17 -0
  153. package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +6 -0
  154. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +5 -1
  155. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +68 -4
  156. package/src/plugins/defaults/memory-v3-shadow/card.ts +49 -5
  157. package/src/plugins/defaults/memory-v3-shadow/fresh-set.ts +59 -0
  158. package/src/plugins/defaults/memory-v3-shadow/injector.ts +4 -2
  159. package/src/plugins/defaults/memory-v3-shadow/learned-edges.test.ts +169 -0
  160. package/src/plugins/defaults/memory-v3-shadow/learned-edges.ts +178 -0
  161. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +115 -26
  162. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +13 -9
  163. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +144 -22
  164. package/src/plugins/defaults/memory-v3-shadow/types.ts +24 -6
  165. package/src/plugins/defaults/title-generate/hooks/stop.ts +13 -0
  166. package/src/plugins/defaults/title-generate/hooks/user-prompt-submit.ts +16 -0
  167. package/src/prompts/cache-boundary.ts +17 -0
  168. package/src/prompts/sections.ts +50 -17
  169. package/src/prompts/system-prompt.ts +12 -4
  170. package/src/prompts/templates/system-sections.ts +22 -0
  171. package/src/providers/__tests__/unparseable-tool-args.test.ts +53 -0
  172. package/src/providers/anthropic/client.ts +74 -28
  173. package/src/providers/gemini/client.ts +5 -1
  174. package/src/providers/minimax/client.ts +9 -0
  175. package/src/providers/model-catalog.ts +28 -0
  176. package/src/providers/model-intents.ts +3 -3
  177. package/src/providers/openai/chat-completions-provider.ts +4 -2
  178. package/src/providers/openai/responses-provider.ts +7 -2
  179. package/src/providers/retry.ts +8 -0
  180. package/src/providers/types.ts +11 -0
  181. package/src/providers/unparseable-tool-args.ts +56 -0
  182. package/src/runtime/AGENTS.md +6 -0
  183. package/src/runtime/__tests__/agent-wake.test.ts +2 -2
  184. package/src/runtime/agent-wake.ts +5 -5
  185. package/src/runtime/background-job-runner.ts +2 -2
  186. package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +150 -3
  187. package/src/runtime/migrations/vbundle-import-analyzer.ts +29 -6
  188. package/src/runtime/migrations/vbundle-import-policy.ts +23 -0
  189. package/src/runtime/migrations/vbundle-importer.ts +9 -4
  190. package/src/runtime/migrations/vbundle-streaming-importer.ts +8 -3
  191. package/src/runtime/pre-first-message-gate.ts +1 -1
  192. package/src/runtime/routes/__tests__/conversation-compaction-routes.test.ts +241 -0
  193. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +132 -0
  194. package/src/runtime/routes/__tests__/gateway-log-routes.test.ts +97 -185
  195. package/src/runtime/routes/__tests__/home-feed-routes.test.ts +17 -0
  196. package/src/runtime/routes/__tests__/plugins-routes.test.ts +348 -0
  197. package/src/runtime/routes/__tests__/task-routes.test.ts +3 -3
  198. package/src/runtime/routes/btw-routes.ts +0 -14
  199. package/src/runtime/routes/conversation-compaction-routes.ts +86 -19
  200. package/src/runtime/routes/conversation-list-routes.ts +77 -5
  201. package/src/runtime/routes/conversation-management-routes.ts +54 -0
  202. package/src/runtime/routes/conversation-query-routes.ts +79 -4
  203. package/src/runtime/routes/gateway-log-routes.ts +14 -64
  204. package/src/runtime/routes/home-feed-routes.ts +10 -0
  205. package/src/runtime/routes/identity-intro-cache.ts +1 -1
  206. package/src/runtime/routes/identity-routes.ts +76 -20
  207. package/src/runtime/routes/inbound-message-handler.ts +0 -36
  208. package/src/runtime/routes/log-export-routes.ts +143 -96
  209. package/src/runtime/routes/plugins-routes.ts +380 -0
  210. package/src/runtime/routes/redact-staged-export.ts +259 -0
  211. package/src/runtime/routes/schedule-routes.ts +19 -2
  212. package/src/runtime/routes/trust-rules-routes.ts +14 -67
  213. package/src/schedule/recurrence-engine.ts +34 -0
  214. package/src/schedule/scheduler.ts +1 -0
  215. package/src/security/redact-json.ts +61 -0
  216. package/src/skills/validate-input.ts +41 -1
  217. package/src/subagent/types.ts +26 -1
  218. package/src/telemetry/types.ts +15 -1
  219. package/src/telemetry/usage-telemetry-reporter.test.ts +6 -1
  220. package/src/telemetry/usage-telemetry-reporter.ts +1 -0
  221. package/src/tools/apps/executors.ts +1 -1
  222. package/src/tools/skills/skill-tool-factory.ts +19 -8
  223. package/src/tools/tool-approval-handler.ts +31 -0
  224. package/src/usage/types.ts +8 -1
  225. package/src/util/platform.ts +16 -0
  226. package/src/watcher/engine.ts +1 -0
  227. package/src/workspace/adaptive-thinking-repair.ts +113 -0
  228. package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +70 -67
  229. package/src/workspace/migrations/098-remove-stale-updates-bulletin-file.ts +31 -0
  230. package/src/workspace/migrations/099-disable-cache-one-shot-callsites.ts +81 -0
  231. package/src/workspace/migrations/100-upgrade-quality-profile-to-fable-5.ts +86 -0
  232. package/src/workspace/migrations/101-upgrade-balanced-economy-to-minimax-m3.ts +70 -0
  233. package/src/workspace/migrations/registry.ts +8 -0
  234. package/src/__tests__/config-loader-quarantine-bulletin.test.ts +0 -202
  235. package/src/__tests__/conversation-starters-cadence.test.ts +0 -161
  236. package/src/__tests__/guardian-action-followup-executor.test.ts +0 -322
  237. package/src/__tests__/guardian-action-followup-store.test.ts +0 -373
  238. package/src/__tests__/guardian-action-late-reply.test.ts +0 -1083
  239. package/src/__tests__/update-bulletin-job.test.ts +0 -292
  240. package/src/config/schemas/updates.ts +0 -14
  241. package/src/memory/__tests__/compaction-log-writer-clickhouse.test.ts +0 -227
  242. package/src/memory/conversation-starters-cadence.ts +0 -78
  243. package/src/prompts/update-bulletin-job.ts +0 -180
  244. package/src/runtime/guardian-action-followup-executor.ts +0 -306
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Tests for the plugin content-fingerprint helpers.
3
+ *
4
+ * Each test materializes a real tree under a temp directory, fingerprints it,
5
+ * then mutates the tree and asserts the comparison reports the expected
6
+ * modified / added / removed paths. The sidecar is exercised through the
7
+ * `exclude` argument so it never counts as a local addition.
8
+ */
9
+
10
+ import {
11
+ mkdirSync,
12
+ mkdtempSync,
13
+ rmSync,
14
+ symlinkSync,
15
+ writeFileSync,
16
+ } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
20
+
21
+ import {
22
+ compareFingerprint,
23
+ computeContentHash,
24
+ computeFingerprint,
25
+ parseFingerprint,
26
+ } from "../plugin-fingerprint.js";
27
+
28
+ let root: string;
29
+
30
+ beforeEach(() => {
31
+ root = mkdtempSync(join(tmpdir(), "plugin-fingerprint-"));
32
+ });
33
+
34
+ afterEach(() => {
35
+ rmSync(root, { recursive: true, force: true });
36
+ });
37
+
38
+ function write(rel: string, contents: string): void {
39
+ const abs = join(root, rel);
40
+ mkdirSync(join(abs, ".."), { recursive: true });
41
+ writeFileSync(abs, contents);
42
+ }
43
+
44
+ describe("computeFingerprint", () => {
45
+ test("digests every regular file with POSIX-relative keys", () => {
46
+ // GIVEN a tree with a nested file
47
+ write("package.json", "{}");
48
+ write("src/index.ts", "export const x = 1;");
49
+
50
+ // WHEN it is fingerprinted
51
+ const fp = computeFingerprint(root);
52
+
53
+ // THEN every file is keyed by its forward-slash relative path
54
+ expect(Object.keys(fp.files).sort()).toEqual([
55
+ "package.json",
56
+ "src/index.ts",
57
+ ]);
58
+ expect(fp.algorithm).toBe("sha256");
59
+ expect(fp.files["src/index.ts"]).toMatch(/^[0-9a-f]{64}$/);
60
+ });
61
+
62
+ test("excludes named top-level entries (the sidecar)", () => {
63
+ // GIVEN a tree containing the provenance sidecar
64
+ write("package.json", "{}");
65
+ write("install-meta.json", '{"name":"x"}');
66
+
67
+ // WHEN it is fingerprinted excluding the sidecar
68
+ const fp = computeFingerprint(root, ["install-meta.json"]);
69
+
70
+ // THEN the sidecar is not part of the digest map
71
+ expect(Object.keys(fp.files)).toEqual(["package.json"]);
72
+ });
73
+
74
+ test("skips symlinks", () => {
75
+ // GIVEN a tree with a real file and a symlink to it
76
+ write("real.txt", "hello");
77
+ symlinkSync(join(root, "real.txt"), join(root, "link.txt"));
78
+
79
+ // WHEN it is fingerprinted
80
+ const fp = computeFingerprint(root);
81
+
82
+ // THEN only the regular file is digested
83
+ expect(Object.keys(fp.files)).toEqual(["real.txt"]);
84
+ });
85
+ });
86
+
87
+ describe("compareFingerprint", () => {
88
+ test("reports clean when the tree is unchanged", () => {
89
+ // GIVEN a fingerprinted tree
90
+ write("a.txt", "one");
91
+ write("b.txt", "two");
92
+ const baseline = computeFingerprint(root);
93
+
94
+ // WHEN it is compared against itself untouched
95
+ const diff = compareFingerprint(root, baseline);
96
+
97
+ // THEN nothing is reported as changed
98
+ expect(diff.clean).toBe(true);
99
+ expect(diff.modified).toEqual([]);
100
+ expect(diff.added).toEqual([]);
101
+ expect(diff.removed).toEqual([]);
102
+ });
103
+
104
+ test("classifies modified, added, and removed files", () => {
105
+ // GIVEN a baseline of two files
106
+ write("keep.txt", "keep");
107
+ write("change.txt", "before");
108
+ const baseline = computeFingerprint(root);
109
+
110
+ // AND the tree is edited: one file changed, one added, one removed
111
+ write("change.txt", "after");
112
+ write("new.txt", "fresh");
113
+ rmSync(join(root, "keep.txt"));
114
+
115
+ // WHEN the tree is compared against the baseline
116
+ const diff = compareFingerprint(root, baseline);
117
+
118
+ // THEN each change is bucketed correctly
119
+ expect(diff.clean).toBe(false);
120
+ expect(diff.modified).toEqual(["change.txt"]);
121
+ expect(diff.added).toEqual(["new.txt"]);
122
+ expect(diff.removed).toEqual(["keep.txt"]);
123
+ });
124
+
125
+ test("does not count an excluded sidecar as an addition", () => {
126
+ // GIVEN a baseline computed with the sidecar excluded
127
+ write("package.json", "{}");
128
+ const baseline = computeFingerprint(root, ["install-meta.json"]);
129
+
130
+ // AND the sidecar exists on disk (as it would post-install)
131
+ write("install-meta.json", '{"name":"x"}');
132
+
133
+ // WHEN compared with the same exclusion
134
+ const diff = compareFingerprint(root, baseline, ["install-meta.json"]);
135
+
136
+ // THEN the tree is still clean
137
+ expect(diff.clean).toBe(true);
138
+ });
139
+ });
140
+
141
+ describe("parseFingerprint", () => {
142
+ test("round-trips a computed fingerprint through JSON", () => {
143
+ // GIVEN a computed fingerprint serialized to JSON
144
+ write("a.txt", "one");
145
+ const fp = computeFingerprint(root);
146
+
147
+ // WHEN it is parsed back
148
+ const parsed = parseFingerprint(JSON.parse(JSON.stringify(fp)));
149
+
150
+ // THEN it matches the original
151
+ expect(parsed).toEqual(fp);
152
+ });
153
+
154
+ test.each([
155
+ ["a non-object", 42],
156
+ ["a wrong algorithm", { algorithm: "md5", files: {} }],
157
+ ["a missing files map", { algorithm: "sha256" }],
158
+ ["a non-string digest", { algorithm: "sha256", files: { a: 1 } }],
159
+ ])("returns null for %s", (_label, value) => {
160
+ // WHEN a malformed value is parsed
161
+ const parsed = parseFingerprint(value);
162
+
163
+ // THEN it is rejected leniently
164
+ expect(parsed).toBeNull();
165
+ });
166
+ });
167
+
168
+ describe("computeContentHash", () => {
169
+ test("returns a v2-prefixed sha256 digest over the tree", () => {
170
+ // GIVEN a tree with a nested file
171
+ write("package.json", "{}");
172
+ write("src/index.ts", "export const x = 1;");
173
+
174
+ // WHEN the aggregate content hash is computed
175
+ const hash = computeContentHash(root);
176
+
177
+ // THEN it is the versioned sha256 hex format skills also use
178
+ expect(hash).toMatch(/^v2:[0-9a-f]{64}$/);
179
+ });
180
+
181
+ test("is independent of filesystem creation order", () => {
182
+ // GIVEN two trees with identical contents written in different orders
183
+ write("b.txt", "two");
184
+ write("a.txt", "one");
185
+ const first = computeContentHash(root);
186
+
187
+ rmSync(root, { recursive: true, force: true });
188
+ mkdirSync(root, { recursive: true });
189
+ write("a.txt", "one");
190
+ write("b.txt", "two");
191
+
192
+ // WHEN both are hashed
193
+ const second = computeContentHash(root);
194
+
195
+ // THEN the digests match — the walk visits files in path order
196
+ expect(second).toBe(first);
197
+ });
198
+
199
+ test("changes when any file content changes", () => {
200
+ // GIVEN a baseline hash
201
+ write("a.txt", "one");
202
+ const baseline = computeContentHash(root);
203
+
204
+ // AND a file is edited
205
+ write("a.txt", "two");
206
+
207
+ // WHEN the hash is recomputed
208
+ const updated = computeContentHash(root);
209
+
210
+ // THEN it differs from the baseline
211
+ expect(updated).not.toBe(baseline);
212
+ });
213
+
214
+ test("excludes named top-level entries (the sidecar)", () => {
215
+ // GIVEN a tree and a baseline computed with the sidecar excluded
216
+ write("package.json", "{}");
217
+ const baseline = computeContentHash(root, ["install-meta.json"]);
218
+
219
+ // AND the sidecar later exists on disk (as it would post-install)
220
+ write("install-meta.json", '{"name":"x"}');
221
+
222
+ // WHEN the hash is recomputed with the same exclusion
223
+ const after = computeContentHash(root, ["install-meta.json"]);
224
+
225
+ // THEN the sidecar does not perturb the digest
226
+ expect(after).toBe(baseline);
227
+ });
228
+
229
+ test("is not collision-prone across path/content boundaries", () => {
230
+ // GIVEN two trees whose concatenated path+content bytes would collide
231
+ // without length-prefixing ("ab" + "c" vs "a" + "bc")
232
+ write("ab", "c");
233
+ const first = computeContentHash(root);
234
+
235
+ rmSync(root, { recursive: true, force: true });
236
+ mkdirSync(root, { recursive: true });
237
+ write("a", "bc");
238
+
239
+ // WHEN both are hashed
240
+ const second = computeContentHash(root);
241
+
242
+ // THEN the length-prefixed scheme keeps them distinct
243
+ expect(second).not.toBe(first);
244
+ });
245
+ });
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Tests for {@link upgradePlugin}.
3
+ *
4
+ * An upgrade is drift detection (the same exact SHA comparison
5
+ * {@link inspectPlugin} performs) followed by a forced re-install at the
6
+ * marketplace pin. The marketplace + GitHub Contents API are replaced with an
7
+ * in-memory fixture passed via `fetch`, the clone is replaced with a fake
8
+ * {@link GitRunner} that materializes a tree, and the install target is a real
9
+ * temp directory passed via `workspacePluginsDir` — no globals are patched.
10
+ */
11
+
12
+ import {
13
+ existsSync,
14
+ mkdirSync,
15
+ mkdtempSync,
16
+ readFileSync,
17
+ rmSync,
18
+ writeFileSync,
19
+ } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { join } from "node:path";
22
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
23
+
24
+ import {
25
+ type FetchLike,
26
+ type GitRunner,
27
+ PluginSourceUnavailableError,
28
+ } from "../install-from-github.js";
29
+ import { PluginNotInstalledError } from "../uninstall-plugin.js";
30
+ import { PluginNotUpgradableError, upgradePlugin } from "../upgrade-plugin.js";
31
+
32
+ const SHA_A = "a".repeat(40);
33
+ const SHA_B = "b".repeat(40);
34
+ const CANON_REPO = "vellum-ai/vellum-assistant";
35
+ const MANIFEST_URL = `https://api.github.com/repos/${CANON_REPO}/contents/plugins/marketplace.json`;
36
+ const CONTENTS = `https://api.github.com/repos/${CANON_REPO}/contents/`;
37
+
38
+ /** A marketplace manifest pinning `name` to `ref`. */
39
+ function manifestWith(name: string, ref: string): unknown {
40
+ return {
41
+ name: "vellum",
42
+ plugins: [
43
+ {
44
+ name,
45
+ source: { source: "github", repo: `example-org/${name}`, ref },
46
+ description: "A test plugin.",
47
+ category: "developer",
48
+ license: "MIT",
49
+ },
50
+ ],
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Build a `fetch` that serves the marketplace manifest and answers the GitHub
56
+ * Contents API listing (used by the adapter-stub lookup) with a 404, so the
57
+ * clone is treated as a raw external tree. `manifest: undefined` answers the
58
+ * manifest with 404; `manifestStatus` overrides the manifest status to
59
+ * simulate a transient marketplace failure.
60
+ */
61
+ function makeFetch(opts: {
62
+ manifest?: unknown;
63
+ manifestStatus?: number;
64
+ }): FetchLike {
65
+ return (async (input: RequestInfo | URL) => {
66
+ const url = typeof input === "string" ? input : input.toString();
67
+ if (url.startsWith(MANIFEST_URL)) {
68
+ if (opts.manifestStatus !== undefined && opts.manifestStatus !== 200) {
69
+ return new Response("manifest unavailable", {
70
+ status: opts.manifestStatus,
71
+ });
72
+ }
73
+ if (opts.manifest === undefined) {
74
+ return new Response("not found", { status: 404 });
75
+ }
76
+ return new Response(JSON.stringify(opts.manifest), { status: 200 });
77
+ }
78
+ // No adapter stub: the Contents API listing for plugins/<name> is empty.
79
+ if (url.startsWith(CONTENTS)) {
80
+ return new Response("not found", { status: 404 });
81
+ }
82
+ return new Response(`unexpected url: ${url}`, { status: 500 });
83
+ }) as FetchLike;
84
+ }
85
+
86
+ /** A fake clone that materializes one file and reports `commit` at HEAD. */
87
+ function fakeGitRunner(commit: string, calls?: string[][]): GitRunner {
88
+ return async (args, { cwd }) => {
89
+ calls?.push([...args]);
90
+ switch (args[0]) {
91
+ case "fetch": {
92
+ mkdirSync(join(cwd, ".git"), { recursive: true });
93
+ writeFileSync(join(cwd, ".git", "config"), "[core]\n");
94
+ writeFileSync(join(cwd, "package.json"), '{"name":"level-up"}');
95
+ return { stdout: "" };
96
+ }
97
+ case "rev-parse":
98
+ return { stdout: `${commit}\n` };
99
+ default:
100
+ return { stdout: "" };
101
+ }
102
+ };
103
+ }
104
+
105
+ /** A git runner that fails the test if any git command runs. */
106
+ const unusedGitRunner: GitRunner = async (args) => {
107
+ throw new Error(`git should not run for this upgrade: ${args.join(" ")}`);
108
+ };
109
+
110
+ /** Materialize an installed plugin copy with an optional provenance sidecar. */
111
+ function installCopy(
112
+ pluginsDir: string,
113
+ name: string,
114
+ sidecar: { commit: string } | null,
115
+ ): void {
116
+ const dir = join(pluginsDir, name);
117
+ mkdirSync(dir, { recursive: true });
118
+ writeFileSync(
119
+ join(dir, "package.json"),
120
+ JSON.stringify({ name, version: "0.1.0", description: "Installed copy." }),
121
+ );
122
+ if (sidecar !== null) {
123
+ writeFileSync(
124
+ join(dir, "install-meta.json"),
125
+ JSON.stringify({
126
+ origin: "vellum",
127
+ name,
128
+ source: {
129
+ kind: "github",
130
+ owner: "example-org",
131
+ repo: name,
132
+ ref: sidecar.commit,
133
+ },
134
+ commit: sidecar.commit,
135
+ installedAt: "2026-06-10T12:00:00.000Z",
136
+ }),
137
+ );
138
+ }
139
+ }
140
+
141
+ /** Read the commit recorded in a copy's provenance sidecar, if present. */
142
+ function sidecarCommit(pluginsDir: string, name: string): string | null {
143
+ const path = join(pluginsDir, name, "install-meta.json");
144
+ if (!existsSync(path)) return null;
145
+ return JSON.parse(readFileSync(path, "utf-8")).commit ?? null;
146
+ }
147
+
148
+ let ws: string;
149
+ let pluginsDir: string;
150
+
151
+ beforeEach(() => {
152
+ ws = mkdtempSync(join(tmpdir(), "upgrade-plugin-"));
153
+ pluginsDir = join(ws, "plugins");
154
+ mkdirSync(pluginsDir, { recursive: true });
155
+ });
156
+
157
+ afterEach(() => {
158
+ rmSync(ws, { recursive: true, force: true });
159
+ });
160
+
161
+ describe("upgradePlugin", () => {
162
+ test("upgrades to the marketplace pin when it has advanced", async () => {
163
+ // GIVEN an installed copy pinned to SHA_A
164
+ installCopy(pluginsDir, "level-up", { commit: SHA_A });
165
+ // AND the marketplace now pins SHA_B
166
+ const fetch = makeFetch({ manifest: manifestWith("level-up", SHA_B) });
167
+ const runGit = fakeGitRunner(SHA_B);
168
+
169
+ // WHEN the plugin is upgraded
170
+ const result = await upgradePlugin(
171
+ { name: "level-up" },
172
+ { fetch, runGit, workspacePluginsDir: pluginsDir },
173
+ );
174
+
175
+ // THEN it reports the move from the old commit to the pin
176
+ expect(result.outcome).toBe("upgraded");
177
+ expect(result.fromCommit).toBe(SHA_A);
178
+ expect(result.toCommit).toBe(SHA_B);
179
+ expect(result.fileCount).toBeGreaterThan(0);
180
+ // AND the new pin is recorded in the provenance sidecar on disk
181
+ expect(sidecarCommit(pluginsDir, "level-up")).toBe(SHA_B);
182
+ });
183
+
184
+ test("is a no-op when the installed commit already equals the pin", async () => {
185
+ // GIVEN an installed copy already pinned to SHA_A
186
+ installCopy(pluginsDir, "level-up", { commit: SHA_A });
187
+ // AND the marketplace pins the same SHA_A
188
+ const fetch = makeFetch({ manifest: manifestWith("level-up", SHA_A) });
189
+
190
+ // WHEN the plugin is upgraded (git must never run)
191
+ const result = await upgradePlugin(
192
+ { name: "level-up" },
193
+ { fetch, runGit: unusedGitRunner, workspacePluginsDir: pluginsDir },
194
+ );
195
+
196
+ // THEN it reports already-up-to-date and makes no changes
197
+ expect(result.outcome).toBe("already-up-to-date");
198
+ expect(result.fileCount).toBeNull();
199
+ expect(result.toCommit).toBe(SHA_A);
200
+ });
201
+
202
+ test("a dry run reports the move without modifying the install", async () => {
203
+ // GIVEN an installed copy pinned to SHA_A and a marketplace pin of SHA_B
204
+ installCopy(pluginsDir, "level-up", { commit: SHA_A });
205
+ const fetch = makeFetch({ manifest: manifestWith("level-up", SHA_B) });
206
+
207
+ // WHEN the plugin is upgraded with dryRun (git must never run)
208
+ const result = await upgradePlugin(
209
+ { name: "level-up", dryRun: true },
210
+ { fetch, runGit: unusedGitRunner, workspacePluginsDir: pluginsDir },
211
+ );
212
+
213
+ // THEN it reports what would change but leaves the install untouched
214
+ expect(result.outcome).toBe("would-upgrade");
215
+ expect(result.dryRun).toBe(true);
216
+ expect(result.fileCount).toBeNull();
217
+ expect(sidecarCommit(pluginsDir, "level-up")).toBe(SHA_A);
218
+ });
219
+
220
+ test("re-pins and records provenance for an install with none", async () => {
221
+ // GIVEN an installed copy with no provenance sidecar
222
+ installCopy(pluginsDir, "level-up", null);
223
+ // AND the marketplace pins SHA_B
224
+ const fetch = makeFetch({ manifest: manifestWith("level-up", SHA_B) });
225
+ const runGit = fakeGitRunner(SHA_B);
226
+
227
+ // WHEN the plugin is upgraded
228
+ const result = await upgradePlugin(
229
+ { name: "level-up" },
230
+ { fetch, runGit, workspacePluginsDir: pluginsDir },
231
+ );
232
+
233
+ // THEN it upgrades, flags the missing provenance, and records the new pin
234
+ expect(result.outcome).toBe("upgraded");
235
+ expect(result.fromCommit).toBeNull();
236
+ expect(result.provenanceWasUnknown).toBe(true);
237
+ expect(sidecarCommit(pluginsDir, "level-up")).toBe(SHA_B);
238
+ });
239
+
240
+ test("throws PluginNotInstalledError when nothing is installed", async () => {
241
+ // GIVEN no installed copy, though the marketplace has an entry
242
+ const fetch = makeFetch({ manifest: manifestWith("level-up", SHA_B) });
243
+
244
+ // WHEN an upgrade is attempted
245
+ // THEN it refuses because there is no install to advance
246
+ await expect(
247
+ upgradePlugin(
248
+ { name: "level-up" },
249
+ { fetch, runGit: unusedGitRunner, workspacePluginsDir: pluginsDir },
250
+ ),
251
+ ).rejects.toBeInstanceOf(PluginNotInstalledError);
252
+ });
253
+
254
+ test("throws PluginNotUpgradableError when not in the marketplace", async () => {
255
+ // GIVEN an installed copy but an empty marketplace catalog
256
+ installCopy(pluginsDir, "level-up", { commit: SHA_A });
257
+ const fetch = makeFetch({ manifest: undefined });
258
+
259
+ // WHEN an upgrade is attempted
260
+ // THEN there is no pin to advance to
261
+ await expect(
262
+ upgradePlugin(
263
+ { name: "level-up" },
264
+ { fetch, runGit: unusedGitRunner, workspacePluginsDir: pluginsDir },
265
+ ),
266
+ ).rejects.toBeInstanceOf(PluginNotUpgradableError);
267
+ });
268
+
269
+ test("throws PluginSourceUnavailableError when the marketplace is unreachable", async () => {
270
+ // GIVEN an installed copy and a marketplace fetch that fails transiently
271
+ installCopy(pluginsDir, "level-up", { commit: SHA_A });
272
+ const fetch = makeFetch({ manifestStatus: 500 });
273
+
274
+ // WHEN an upgrade is attempted
275
+ // THEN the outage is surfaced as a retryable source-unavailable error
276
+ // (distinct from the permanent no-marketplace-entry conflict), since the
277
+ // same request can succeed once the catalog recovers
278
+ await expect(
279
+ upgradePlugin(
280
+ { name: "level-up" },
281
+ { fetch, runGit: unusedGitRunner, workspacePluginsDir: pluginsDir },
282
+ ),
283
+ ).rejects.toBeInstanceOf(PluginSourceUnavailableError);
284
+ });
285
+
286
+ test("preserves the existing install when the re-install clone fails", async () => {
287
+ // GIVEN an installed copy pinned to SHA_A and an advanced marketplace pin
288
+ installCopy(pluginsDir, "level-up", { commit: SHA_A });
289
+ const fetch = makeFetch({ manifest: manifestWith("level-up", SHA_B) });
290
+ // AND a clone that fails mid-fetch
291
+ const failingGit: GitRunner = async (args) => {
292
+ if (args[0] === "fetch") throw new Error("network down");
293
+ return { stdout: "" };
294
+ };
295
+
296
+ // WHEN the upgrade is attempted
297
+ // THEN it surfaces the clone failure
298
+ await expect(
299
+ upgradePlugin(
300
+ { name: "level-up" },
301
+ { fetch, runGit: failingGit, workspacePluginsDir: pluginsDir },
302
+ ),
303
+ ).rejects.toThrow("network down");
304
+ // AND the previously installed copy is left intact at its old pin
305
+ expect(sidecarCommit(pluginsDir, "level-up")).toBe(SHA_A);
306
+ });
307
+ });