@vellumai/assistant 0.9.0 → 0.9.1-staging.1

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 (222) hide show
  1. package/ARCHITECTURE.md +18 -34
  2. package/bun.lock +7 -8
  3. package/docs/activation-funnel-telemetry.md +4 -4
  4. package/docs/architecture/security.md +29 -28
  5. package/docs/stt-provider-onboarding.md +3 -5
  6. package/docs/workflows-testing.md +13 -44
  7. package/docs/workflows.md +3 -5
  8. package/node_modules/@vellumai/ces-client/src/__tests__/ces-client.test.ts +47 -0
  9. package/node_modules/@vellumai/ces-client/src/rpc-client.ts +28 -5
  10. package/node_modules/@vellumai/environments/src/seeds.ts +2 -5
  11. package/node_modules/@vellumai/gateway-client/src/index.ts +17 -6
  12. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +119 -0
  13. package/node_modules/@vellumai/gateway-client/src/types.ts +15 -84
  14. package/openapi.yaml +135 -59
  15. package/package.json +2 -1
  16. package/scripts/sync-llm-catalog.ts +6 -15
  17. package/scripts/sync-web-search-catalog.ts +3 -11
  18. package/src/__tests__/actor-trust-resolver-address-fallback.test.ts +14 -26
  19. package/src/__tests__/agent-loop-compaction-strip.test.ts +240 -0
  20. package/src/__tests__/agent-loop-output-hooks.test.ts +69 -0
  21. package/src/__tests__/agent-loop-override-profile.test.ts +25 -0
  22. package/src/__tests__/always-loaded-tools-guard.test.ts +2 -3
  23. package/src/__tests__/app-dir-path-guard.test.ts +0 -1
  24. package/src/__tests__/assistant-feature-flag-guard.test.ts +1 -4
  25. package/src/__tests__/assistant-feature-flag-guardrails.test.ts +0 -2
  26. package/src/__tests__/avatar-identity-sync.test.ts +2 -27
  27. package/src/__tests__/btw-routes.test.ts +6 -8
  28. package/src/__tests__/checker.test.ts +0 -3
  29. package/src/__tests__/config-loader-backfill.test.ts +103 -6
  30. package/src/__tests__/config-watcher.test.ts +0 -18
  31. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +22 -0
  32. package/src/__tests__/credential-broker.test.ts +449 -1
  33. package/src/__tests__/credential-execution-tools.test.ts +0 -1
  34. package/src/__tests__/credential-prompt-route.test.ts +3 -4
  35. package/src/__tests__/credential-routes.test.ts +360 -0
  36. package/src/__tests__/credential-security-invariants.test.ts +2 -13
  37. package/src/__tests__/dynamic-page-surface.test.ts +101 -1
  38. package/src/__tests__/fixtures/credential-security-fixtures.ts +2 -33
  39. package/src/__tests__/gateway-only-guard.test.ts +3 -7
  40. package/src/__tests__/identity-routes.test.ts +0 -189
  41. package/src/__tests__/inbound-invite-redemption.test.ts +4 -4
  42. package/src/__tests__/invite-redemption-service.test.ts +4 -4
  43. package/src/__tests__/llm-callsite-catalog.test.ts +5 -6
  44. package/src/__tests__/llm-catalog-parity.test.ts +0 -22
  45. package/src/__tests__/llm-resolver.test.ts +49 -24
  46. package/src/__tests__/oauth-provider-seed-logos.test.ts +4 -6
  47. package/src/__tests__/onboarding-persona-write.test.ts +1 -1
  48. package/src/__tests__/persona-resolver.test.ts +11 -14
  49. package/src/__tests__/plugin-api-model-profiles.test.ts +178 -0
  50. package/src/__tests__/registry.test.ts +2 -7
  51. package/src/__tests__/schedule-routes-workflow-validation.test.ts +1 -10
  52. package/src/__tests__/schedule-routes.test.ts +0 -30
  53. package/src/__tests__/schedule-tools.test.ts +2 -18
  54. package/src/__tests__/skill-execute-input.test.ts +46 -1
  55. package/src/__tests__/skill-runtime-path.test.ts +2 -3
  56. package/src/__tests__/subagent-tools.test.ts +116 -0
  57. package/src/__tests__/surface-completion-nudge-hook.test.ts +367 -0
  58. package/src/__tests__/token-estimator-accuracy.benchmark.test.ts +1 -29
  59. package/src/__tests__/token-manager.test.ts +519 -0
  60. package/src/__tests__/tool-executor.test.ts +0 -79
  61. package/src/__tests__/trusted-contact-multichannel.test.ts +3 -3
  62. package/src/__tests__/trusted-contact-verification.test.ts +6 -6
  63. package/src/__tests__/voice-invite-redemption.test.ts +2 -2
  64. package/src/__tests__/web-search-catalog-parity.test.ts +6 -25
  65. package/src/__tests__/workspace-greetings.test.ts +152 -0
  66. package/src/agent/loop.ts +25 -5
  67. package/src/api/README.md +6 -6
  68. package/src/api/responses/conversation-message.ts +2 -4
  69. package/src/api/responses/home.ts +0 -4
  70. package/src/approvals/guardian-request-resolvers.ts +2 -2
  71. package/src/calls/relay-access-wait.ts +1 -1
  72. package/src/calls/voice-session-bridge.ts +2 -2
  73. package/src/cli/commands/plugins.ts +143 -2
  74. package/src/cli/lib/__tests__/diff-plugin.test.ts +443 -0
  75. package/src/cli/lib/__tests__/merge-plugin-tree.test.ts +313 -0
  76. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +253 -2
  77. package/src/cli/lib/diff-plugin.ts +346 -0
  78. package/src/cli/lib/install-from-github.ts +105 -17
  79. package/src/cli/lib/merge-plugin-tree.ts +228 -0
  80. package/src/cli/lib/plugin-fingerprint.ts +14 -0
  81. package/src/cli/lib/upgrade-plugin.ts +270 -10
  82. package/src/cli/program.ts +0 -2
  83. package/src/config/bundled-skills/subagent/SKILL.md +4 -0
  84. package/src/config/bundled-skills/subagent/TOOLS.json +4 -0
  85. package/src/config/bundled-skills/workflows/SKILL.md +0 -1
  86. package/src/config/bundled-tool-registry.ts +2 -7
  87. package/src/config/call-site-defaults.ts +12 -2
  88. package/src/config/feature-flag-registry.json +1 -17
  89. package/src/config/inference-profile-validation.ts +26 -0
  90. package/src/config/loader.ts +4 -0
  91. package/src/config/profile-order.ts +28 -0
  92. package/src/config/schemas/elevenlabs.ts +0 -1
  93. package/src/config/schemas/platform.ts +0 -8
  94. package/src/config/seed-inference-profiles.ts +25 -20
  95. package/src/contacts/contact-store.ts +87 -96
  96. package/src/contacts/contacts-write.ts +5 -21
  97. package/src/context/compactor.ts +2 -2
  98. package/src/credential-execution/process-manager.ts +55 -14
  99. package/src/credential-execution/prompted-credential.ts +2 -3
  100. package/src/daemon/config-watcher.ts +0 -4
  101. package/src/daemon/conversation-agent-loop.ts +15 -4
  102. package/src/daemon/conversation-slash.ts +2 -23
  103. package/src/daemon/conversation-tool-setup.ts +11 -3
  104. package/src/daemon/conversation.ts +2 -0
  105. package/src/daemon/handlers/config-channels.ts +20 -16
  106. package/src/daemon/handlers/config-slack-channel.ts +2 -3
  107. package/src/daemon/lifecycle.ts +0 -7
  108. package/src/daemon/message-types/conversations.ts +3 -3
  109. package/src/daemon/message-types/sync.ts +0 -1
  110. package/src/daemon/orphan-reaper.test.ts +0 -19
  111. package/src/daemon/orphan-reaper.ts +2 -24
  112. package/src/daemon/server.ts +0 -10
  113. package/src/home/relationship-state.ts +2 -4
  114. package/src/memory/__tests__/memory-retrospective-job.test.ts +195 -401
  115. package/src/memory/bookmark-crud.ts +1 -2
  116. package/src/memory/db-init.ts +7 -17
  117. package/src/memory/embedding-backend.ts +23 -0
  118. package/src/memory/embedding-billing-breaker.ts +96 -0
  119. package/src/memory/jobs-store.ts +25 -13
  120. package/src/memory/jobs-worker.ts +52 -0
  121. package/src/memory/memory-retrospective-constants.ts +4 -4
  122. package/src/memory/memory-retrospective-job.ts +19 -227
  123. package/src/memory/migrations/291-contact-channels-renormalize-addresses.ts +62 -0
  124. package/src/memory/migrations/__tests__/291-contact-channels-renormalize-addresses.test.ts +311 -0
  125. package/src/memory/migrations/__tests__/run-migrations.test.ts +52 -0
  126. package/src/memory/migrations/index.ts +1 -0
  127. package/src/memory/migrations/run-migrations.ts +41 -0
  128. package/src/memory/migrations/validate-migration-state.ts +1 -1
  129. package/src/memory/schema/contacts.ts +0 -4
  130. package/src/messaging/providers/slack/adapter.ts +1 -1
  131. package/src/notifications/adapters/shared.ts +29 -0
  132. package/src/notifications/adapters/slack.ts +5 -32
  133. package/src/notifications/adapters/telegram.ts +2 -20
  134. package/src/notifications/broadcaster.ts +10 -1
  135. package/src/notifications/home-feed-side-effect.ts +4 -3
  136. package/src/notifications/notification-utils.ts +17 -19
  137. package/src/notifications/types.ts +7 -0
  138. package/src/oauth/AGENTS.md +5 -24
  139. package/src/plugin-api/constants.ts +1 -1
  140. package/src/plugin-api/index.ts +6 -1
  141. package/src/plugin-api/model-profiles.ts +33 -0
  142. package/src/plugin-api/types.ts +50 -2
  143. package/src/plugins/defaults/index.ts +25 -0
  144. package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +54 -2
  145. package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +107 -7
  146. package/src/plugins/defaults/surface-completion-nudge/hooks/post-model-call.ts +276 -0
  147. package/src/plugins/defaults/surface-completion-nudge/hooks/stop.ts +22 -0
  148. package/src/plugins/defaults/surface-completion-nudge/nudge-state-store.ts +46 -0
  149. package/src/plugins/defaults/surface-completion-nudge/package.json +14 -0
  150. package/src/plugins/defaults/task-progress-nudge/hooks/post-tool-use.ts +1 -1
  151. package/src/prompts/persona-resolver.ts +2 -2
  152. package/src/runtime/AGENTS.md +0 -1
  153. package/src/runtime/actor-trust-resolver.ts +12 -44
  154. package/src/runtime/btw-sidechain.ts +3 -6
  155. package/src/runtime/channel-approval-types.ts +18 -45
  156. package/src/runtime/channel-invite-transports/telegram.ts +4 -4
  157. package/src/runtime/channel-verification-service.ts +4 -3
  158. package/src/runtime/invite-redemption-service.ts +3 -3
  159. package/src/runtime/routes/__tests__/plugins-routes.test.ts +218 -1
  160. package/src/runtime/routes/app-routes.ts +1 -1
  161. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +2 -2
  162. package/src/runtime/routes/assets/vellum-design-system.css +1959 -0
  163. package/src/runtime/routes/btw-routes.ts +1 -27
  164. package/src/runtime/routes/conversation-compaction-routes.ts +1 -1
  165. package/src/runtime/routes/conversation-routes.ts +2 -2
  166. package/src/runtime/routes/credential-routes.ts +40 -16
  167. package/src/runtime/routes/empty-state-greeting-cache.ts +1 -2
  168. package/src/runtime/routes/identity-routes.ts +1 -296
  169. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +1 -1
  170. package/src/runtime/routes/plugins-routes.ts +171 -5
  171. package/src/runtime/routes/schedule-routes.ts +0 -22
  172. package/src/runtime/routes/workflow-routes.test.ts +4 -43
  173. package/src/runtime/routes/workflow-routes.ts +0 -28
  174. package/src/runtime/routes/workspace-greetings.ts +55 -0
  175. package/src/runtime/sync/resource-sync-events.ts +1 -11
  176. package/src/schedule/inference-profile.ts +2 -14
  177. package/src/subagent/manager.ts +6 -0
  178. package/src/subagent/types.ts +6 -0
  179. package/src/tools/AGENTS.md +3 -3
  180. package/src/tools/browser/browser-execution.ts +1 -1
  181. package/src/tools/network/web-search-error.ts +1 -1
  182. package/src/tools/permission-checker.ts +1 -1
  183. package/src/tools/schedule/create.ts +3 -9
  184. package/src/tools/schedule/update.ts +2 -10
  185. package/src/tools/side-effects.ts +2 -17
  186. package/src/tools/skills/execute.ts +34 -0
  187. package/src/tools/subagent/spawn.ts +34 -9
  188. package/src/tools/tool-approval-handler.ts +1 -3
  189. package/src/tools/tool-manifest.ts +0 -2
  190. package/src/tools/ui-surface/definitions.ts +39 -1
  191. package/src/tools/workflows/run-workflow.test.ts +8 -18
  192. package/src/util/platform.ts +2 -2
  193. package/src/workflows/capabilities.ts +2 -3
  194. package/src/workflows/run-manager.test.ts +0 -25
  195. package/src/workflows/run-manager.ts +2 -24
  196. package/src/__tests__/app-control-no-global-cgevent.test.ts +0 -98
  197. package/src/__tests__/credential-security-e2e.test.ts +0 -362
  198. package/src/__tests__/credential-vault-unit.test.ts +0 -1528
  199. package/src/__tests__/credential-vault.test.ts +0 -1706
  200. package/src/__tests__/identity-intro-cache.test.ts +0 -315
  201. package/src/__tests__/secret-onetime-send.test.ts +0 -182
  202. package/src/cli/commands/__tests__/task.test.ts +0 -914
  203. package/src/cli/commands/task.ts +0 -771
  204. package/src/config/bundled-skills/personal-page/SKILL.md +0 -57
  205. package/src/config/bundled-skills/personal-page/TOOLS.json +0 -27
  206. package/src/config/bundled-skills/personal-page/tools/app-refresh.ts +0 -17
  207. package/src/config/preloaded-apps/personal-page/src/components/About.tsx +0 -22
  208. package/src/config/preloaded-apps/personal-page/src/components/App.tsx +0 -16
  209. package/src/config/preloaded-apps/personal-page/src/components/Features.tsx +0 -77
  210. package/src/config/preloaded-apps/personal-page/src/components/Hero.tsx +0 -57
  211. package/src/config/preloaded-apps/personal-page/src/components/Pending.tsx +0 -28
  212. package/src/config/preloaded-apps/personal-page/src/components/animations.tsx +0 -234
  213. package/src/config/preloaded-apps/personal-page/src/components/icons.tsx +0 -48
  214. package/src/config/preloaded-apps/personal-page/src/components/media.ts +0 -16
  215. package/src/config/preloaded-apps/personal-page/src/index.html +0 -20
  216. package/src/config/preloaded-apps/personal-page/src/main.tsx +0 -7
  217. package/src/config/preloaded-apps/personal-page/src/profile-data.ts +0 -82
  218. package/src/config/preloaded-apps/personal-page/src/styles.css +0 -759
  219. package/src/memory/__tests__/preloaded-apps.test.ts +0 -85
  220. package/src/memory/preloaded-apps.ts +0 -116
  221. package/src/runtime/routes/identity-intro-cache.ts +0 -172
  222. package/src/tools/credentials/vault.ts +0 -712
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Tests for {@link mergePluginTree}.
3
+ *
4
+ * The merge is exercised against three real temp directories (base/ours/theirs)
5
+ * and the merged tree is read back from a fourth (dest). `git merge-file` runs
6
+ * for real — these tests assert the line-level merge behavior (non-conflicting
7
+ * hunks from both sides survive; conflicts resolve toward the strategy), the
8
+ * file-level add/delete resolution, and binary whole-file handling.
9
+ */
10
+
11
+ import {
12
+ mkdirSync,
13
+ mkdtempSync,
14
+ readFileSync,
15
+ rmSync,
16
+ writeFileSync,
17
+ } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { dirname, join } from "node:path";
20
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
21
+
22
+ import { mergePluginTree } from "../merge-plugin-tree.js";
23
+
24
+ /** A file tree keyed by POSIX-relative path; Buffer values are binary files. */
25
+ type Tree = Record<string, string | Buffer>;
26
+
27
+ let scratch: string;
28
+ let baseDir: string;
29
+ let oursDir: string;
30
+ let theirsDir: string;
31
+ let destDir: string;
32
+
33
+ function writeTree(root: string, tree: Tree): void {
34
+ for (const [rel, content] of Object.entries(tree)) {
35
+ const abs = join(root, rel);
36
+ mkdirSync(dirname(abs), { recursive: true });
37
+ writeFileSync(abs, content);
38
+ }
39
+ }
40
+
41
+ /** Read a merged file as UTF-8, or null when absent from the dest tree. */
42
+ function read(rel: string): string | null {
43
+ const abs = join(destDir, rel);
44
+ try {
45
+ return readFileSync(abs, "utf-8");
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ beforeEach(() => {
52
+ scratch = mkdtempSync(join(tmpdir(), "merge-plugin-tree-"));
53
+ baseDir = join(scratch, "base");
54
+ oursDir = join(scratch, "ours");
55
+ theirsDir = join(scratch, "theirs");
56
+ destDir = join(scratch, "dest");
57
+ for (const d of [baseDir, oursDir, theirsDir, destDir]) {
58
+ mkdirSync(d, { recursive: true });
59
+ }
60
+ });
61
+
62
+ afterEach(() => {
63
+ rmSync(scratch, { recursive: true, force: true });
64
+ });
65
+
66
+ describe("mergePluginTree", () => {
67
+ test("keeps non-conflicting edits from both sides and resolves conflicts toward ours", async () => {
68
+ // GIVEN a file edited on disjoint lines by each side, a true conflict, and
69
+ // a one-sided addition on each side
70
+ writeTree(baseDir, {
71
+ "common.txt": "a\nb\nc\n",
72
+ "conflict.txt": "base\n",
73
+ });
74
+ writeTree(oursDir, {
75
+ "common.txt": "A\nb\nc\n",
76
+ "conflict.txt": "ours\n",
77
+ "local-only.txt": "local\n",
78
+ });
79
+ writeTree(theirsDir, {
80
+ "common.txt": "a\nb\nC\n",
81
+ "conflict.txt": "theirs\n",
82
+ "remote-only.txt": "remote\n",
83
+ });
84
+
85
+ // WHEN merged with the `ours` strategy
86
+ const count = await mergePluginTree({
87
+ baseDir,
88
+ oursDir,
89
+ theirsDir,
90
+ destDir,
91
+ strategy: "ours",
92
+ });
93
+
94
+ // THEN both disjoint edits survive, both additions land, and the conflict
95
+ // resolves toward the local edit
96
+ expect(read("common.txt")).toBe("A\nb\nC\n");
97
+ expect(read("local-only.txt")).toBe("local\n");
98
+ expect(read("remote-only.txt")).toBe("remote\n");
99
+ expect(read("conflict.txt")).toBe("ours\n");
100
+ expect(count).toBe(4);
101
+ });
102
+
103
+ test("resolves conflicts toward the pin under the theirs strategy", async () => {
104
+ // GIVEN a file edited differently on both sides
105
+ writeTree(baseDir, { "conflict.txt": "base\n" });
106
+ writeTree(oursDir, { "conflict.txt": "ours\n" });
107
+ writeTree(theirsDir, { "conflict.txt": "theirs\n" });
108
+
109
+ // WHEN merged with the `theirs` strategy
110
+ await mergePluginTree({
111
+ baseDir,
112
+ oursDir,
113
+ theirsDir,
114
+ destDir,
115
+ strategy: "theirs",
116
+ });
117
+
118
+ // THEN the conflicting hunk resolves toward the pin
119
+ expect(read("conflict.txt")).toBe("theirs\n");
120
+ });
121
+
122
+ test("never writes conflict markers", async () => {
123
+ // GIVEN a hard conflict on every line
124
+ writeTree(baseDir, { "f.txt": "1\n2\n3\n" });
125
+ writeTree(oursDir, { "f.txt": "1\nOURS\n3\n" });
126
+ writeTree(theirsDir, { "f.txt": "1\nTHEIRS\n3\n" });
127
+
128
+ // WHEN merged
129
+ await mergePluginTree({
130
+ baseDir,
131
+ oursDir,
132
+ theirsDir,
133
+ destDir,
134
+ strategy: "ours",
135
+ });
136
+
137
+ // THEN the result carries no git conflict markers
138
+ const merged = read("f.txt") ?? "";
139
+ expect(merged).not.toContain("<<<<<<<");
140
+ expect(merged).not.toContain("=======");
141
+ expect(merged).not.toContain(">>>>>>>");
142
+ });
143
+
144
+ test("drops a file deleted upstream when it is unchanged locally", async () => {
145
+ // GIVEN a file present at base and locally but removed at the pin
146
+ writeTree(baseDir, { "gone.txt": "keep\n", "stay.txt": "x\n" });
147
+ writeTree(oursDir, { "gone.txt": "keep\n", "stay.txt": "x\n" });
148
+ writeTree(theirsDir, { "stay.txt": "x\n" });
149
+
150
+ // WHEN merged
151
+ await mergePluginTree({
152
+ baseDir,
153
+ oursDir,
154
+ theirsDir,
155
+ destDir,
156
+ strategy: "theirs",
157
+ });
158
+
159
+ // THEN the upstream deletion is honored
160
+ expect(read("gone.txt")).toBeNull();
161
+ expect(read("stay.txt")).toBe("x\n");
162
+ });
163
+
164
+ test("keeps a locally-edited file the pin deleted under the ours strategy", async () => {
165
+ // GIVEN a modify/delete conflict: edited locally, deleted at the pin
166
+ writeTree(baseDir, { "f.txt": "base\n" });
167
+ writeTree(oursDir, { "f.txt": "local edit\n" });
168
+ writeTree(theirsDir, {});
169
+
170
+ // WHEN merged with `ours`
171
+ await mergePluginTree({
172
+ baseDir,
173
+ oursDir,
174
+ theirsDir,
175
+ destDir,
176
+ strategy: "ours",
177
+ });
178
+
179
+ // THEN the local edit wins over the deletion
180
+ expect(read("f.txt")).toBe("local edit\n");
181
+ });
182
+
183
+ test("honors the pin's deletion of a locally-edited file under the theirs strategy", async () => {
184
+ // GIVEN the same modify/delete conflict
185
+ writeTree(baseDir, { "f.txt": "base\n" });
186
+ writeTree(oursDir, { "f.txt": "local edit\n" });
187
+ writeTree(theirsDir, {});
188
+
189
+ // WHEN merged with `theirs`
190
+ await mergePluginTree({
191
+ baseDir,
192
+ oursDir,
193
+ theirsDir,
194
+ destDir,
195
+ strategy: "theirs",
196
+ });
197
+
198
+ // THEN the deletion wins over the local edit
199
+ expect(read("f.txt")).toBeNull();
200
+ });
201
+
202
+ test("resolves a binary conflict whole-file by strategy rather than line-merging", async () => {
203
+ // GIVEN a binary file (NUL bytes) diverged on both sides
204
+ const base = Buffer.from([0x00, 0x01, 0x02]);
205
+ const ours = Buffer.from([0x00, 0xaa, 0xbb]);
206
+ const theirs = Buffer.from([0x00, 0xcc, 0xdd]);
207
+ writeTree(baseDir, { "img.bin": base });
208
+ writeTree(oursDir, { "img.bin": ours });
209
+ writeTree(theirsDir, { "img.bin": theirs });
210
+
211
+ // WHEN merged with `theirs`
212
+ await mergePluginTree({
213
+ baseDir,
214
+ oursDir,
215
+ theirsDir,
216
+ destDir,
217
+ strategy: "theirs",
218
+ });
219
+
220
+ // THEN the whole pin blob is taken, byte-for-byte (no markers, no corruption)
221
+ expect(readFileSync(join(destDir, "img.bin")).equals(theirs)).toBe(true);
222
+ });
223
+
224
+ test("keeps a one-sided binary change rather than resolving by strategy", async () => {
225
+ // GIVEN a binary file the pin updated but the local install left untouched
226
+ const base = Buffer.from([0x00, 0x01, 0x02]);
227
+ const theirs = Buffer.from([0x00, 0xcc, 0xdd]);
228
+ writeTree(baseDir, { "img.bin": base });
229
+ writeTree(oursDir, { "img.bin": base });
230
+ writeTree(theirsDir, { "img.bin": theirs });
231
+
232
+ // WHEN merged with `ours` (which only governs true conflicts)
233
+ await mergePluginTree({
234
+ baseDir,
235
+ oursDir,
236
+ theirsDir,
237
+ destDir,
238
+ strategy: "ours",
239
+ });
240
+
241
+ // THEN the upstream-only update survives instead of being dropped
242
+ expect(readFileSync(join(destDir, "img.bin")).equals(theirs)).toBe(true);
243
+ });
244
+
245
+ test("keeps a one-sided local binary edit under the theirs strategy", async () => {
246
+ // GIVEN a binary file edited locally but unchanged in the pin
247
+ const base = Buffer.from([0x00, 0x01, 0x02]);
248
+ const ours = Buffer.from([0x00, 0xaa, 0xbb]);
249
+ writeTree(baseDir, { "img.bin": base });
250
+ writeTree(oursDir, { "img.bin": ours });
251
+ writeTree(theirsDir, { "img.bin": base });
252
+
253
+ // WHEN merged with `theirs` (which only governs true conflicts)
254
+ await mergePluginTree({
255
+ baseDir,
256
+ oursDir,
257
+ theirsDir,
258
+ destDir,
259
+ strategy: "theirs",
260
+ });
261
+
262
+ // THEN the local-only edit survives instead of being reverted to the pin
263
+ expect(readFileSync(join(destDir, "img.bin")).equals(ours)).toBe(true);
264
+ });
265
+
266
+ test("excludes the provenance sidecar from the merge", async () => {
267
+ // GIVEN an install-meta sidecar present on every side with differing content
268
+ writeTree(baseDir, {
269
+ "install-meta.json": '{"commit":"base"}',
270
+ "f.txt": "x\n",
271
+ });
272
+ writeTree(oursDir, {
273
+ "install-meta.json": '{"commit":"ours"}',
274
+ "f.txt": "x\n",
275
+ });
276
+ writeTree(theirsDir, {
277
+ "install-meta.json": '{"commit":"theirs"}',
278
+ "f.txt": "x\n",
279
+ });
280
+
281
+ // WHEN merged
282
+ const count = await mergePluginTree({
283
+ baseDir,
284
+ oursDir,
285
+ theirsDir,
286
+ destDir,
287
+ strategy: "ours",
288
+ });
289
+
290
+ // THEN the sidecar is never carried through (the caller rewrites it)
291
+ expect(read("install-meta.json")).toBeNull();
292
+ expect(count).toBe(1);
293
+ });
294
+
295
+ test("merges files in nested directories", async () => {
296
+ // GIVEN a conflict on a deeply-nested file
297
+ writeTree(baseDir, { "src/a/b.txt": "base\n" });
298
+ writeTree(oursDir, { "src/a/b.txt": "ours\n" });
299
+ writeTree(theirsDir, { "src/a/b.txt": "theirs\n" });
300
+
301
+ // WHEN merged
302
+ await mergePluginTree({
303
+ baseDir,
304
+ oursDir,
305
+ theirsDir,
306
+ destDir,
307
+ strategy: "ours",
308
+ });
309
+
310
+ // THEN the nested path is preserved and resolved
311
+ expect(read("src/a/b.txt")).toBe("ours\n");
312
+ });
313
+ });
@@ -18,7 +18,7 @@ import {
18
18
  writeFileSync,
19
19
  } from "node:fs";
20
20
  import { tmpdir } from "node:os";
21
- import { join } from "node:path";
21
+ import { dirname, join } from "node:path";
22
22
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
23
23
 
24
24
  import {
@@ -26,8 +26,14 @@ import {
26
26
  type GitRunner,
27
27
  PluginSourceUnavailableError,
28
28
  } from "../install-from-github.js";
29
+ import { computeFingerprint } from "../plugin-fingerprint.js";
29
30
  import { PluginNotInstalledError } from "../uninstall-plugin.js";
30
- import { PluginNotUpgradableError, upgradePlugin } from "../upgrade-plugin.js";
31
+ import {
32
+ PluginMergeBaselineError,
33
+ PluginNotUpgradableError,
34
+ PluginUpgradeStrategyUnsupportedError,
35
+ upgradePlugin,
36
+ } from "../upgrade-plugin.js";
31
37
 
32
38
  const SHA_A = "a".repeat(40);
33
39
  const SHA_B = "b".repeat(40);
@@ -382,3 +388,248 @@ describe("upgradePlugin", () => {
382
388
  expect(sidecarCommit(pluginsDir, "level-up")).toBe(SHA_A);
383
389
  });
384
390
  });
391
+
392
+ /** A file tree keyed by POSIX-relative path. */
393
+ type Tree = Record<string, string>;
394
+
395
+ /**
396
+ * A fake clone whose materialized tree depends on the fetched ref, so a merge
397
+ * upgrade's base clone (`source.ref` = the recorded install commit) and pin
398
+ * clone (`source.ref` = the marketplace pin) yield different trees. The ref is
399
+ * the last `git fetch` argument; `rev-parse HEAD` echoes it back so the
400
+ * checked-out commit matches the requested ref.
401
+ */
402
+ function treeGitRunner(treesByRef: Record<string, Tree>): GitRunner {
403
+ const refByCwd = new Map<string, string>();
404
+ return async (args, { cwd }) => {
405
+ switch (args[0]) {
406
+ case "fetch": {
407
+ const ref = args[args.length - 1];
408
+ refByCwd.set(cwd, ref);
409
+ const tree = treesByRef[ref] ?? {};
410
+ for (const [rel, content] of Object.entries(tree)) {
411
+ const abs = join(cwd, rel);
412
+ mkdirSync(dirname(abs), { recursive: true });
413
+ writeFileSync(abs, content);
414
+ }
415
+ // `.git` is stripped during materialization; create it so the strip
416
+ // path is exercised, matching a real clone.
417
+ mkdirSync(join(cwd, ".git"), { recursive: true });
418
+ writeFileSync(join(cwd, ".git", "config"), "[core]\n");
419
+ return { stdout: "" };
420
+ }
421
+ case "rev-parse":
422
+ return { stdout: `${refByCwd.get(cwd) ?? ""}\n` };
423
+ default:
424
+ return { stdout: "" };
425
+ }
426
+ };
427
+ }
428
+
429
+ /**
430
+ * Materialize an installed copy of `ours` on disk plus a provenance sidecar
431
+ * recording `commit` and a fingerprint. By default the fingerprint is computed
432
+ * over `base` (what install materialized); pass `fingerprintTree` to record a
433
+ * mismatching baseline.
434
+ */
435
+ function installMergeCopy(
436
+ name: string,
437
+ ours: Tree,
438
+ commit: string,
439
+ fingerprintTree: Tree | null,
440
+ ): void {
441
+ const dir = join(pluginsDir, name);
442
+ mkdirSync(dir, { recursive: true });
443
+ for (const [rel, content] of Object.entries(ours)) {
444
+ const abs = join(dir, rel);
445
+ mkdirSync(dirname(abs), { recursive: true });
446
+ writeFileSync(abs, content);
447
+ }
448
+ const sidecar: Record<string, unknown> = {
449
+ origin: "vellum",
450
+ name,
451
+ source: { kind: "github", owner: "example-org", repo: name, ref: commit },
452
+ commit,
453
+ installedAt: "2026-06-10T12:00:00.000Z",
454
+ };
455
+ if (fingerprintTree !== null) {
456
+ const refDir = mkdtempSync(join(tmpdir(), "merge-fingerprint-"));
457
+ try {
458
+ for (const [rel, content] of Object.entries(fingerprintTree)) {
459
+ const abs = join(refDir, rel);
460
+ mkdirSync(dirname(abs), { recursive: true });
461
+ writeFileSync(abs, content);
462
+ }
463
+ sidecar.fingerprint = computeFingerprint(refDir, ["install-meta.json"]);
464
+ } finally {
465
+ rmSync(refDir, { recursive: true, force: true });
466
+ }
467
+ }
468
+ writeFileSync(join(dir, "install-meta.json"), JSON.stringify(sidecar));
469
+ }
470
+
471
+ /** Read an installed file's contents, or null when absent. */
472
+ function installedFile(name: string, rel: string): string | null {
473
+ const path = join(pluginsDir, name, rel);
474
+ return existsSync(path) ? readFileSync(path, "utf-8") : null;
475
+ }
476
+
477
+ describe("upgradePlugin --strategy", () => {
478
+ // base → ours / theirs trees shared by the three-way merge tests:
479
+ // - common.txt: edited on disjoint lines by each side (a clean auto-merge)
480
+ // - conflict.txt: edited differently on both sides (a true conflict)
481
+ // - local-only.txt / remote-only.txt: added on one side only
482
+ const BASE: Tree = {
483
+ "common.txt": "a\nb\nc\n",
484
+ "conflict.txt": "base\n",
485
+ };
486
+ const OURS: Tree = {
487
+ "common.txt": "A\nb\nc\n",
488
+ "conflict.txt": "ours\n",
489
+ "local-only.txt": "added locally\n",
490
+ };
491
+ const THEIRS: Tree = {
492
+ "common.txt": "a\nb\nC\n",
493
+ "conflict.txt": "theirs\n",
494
+ "remote-only.txt": "added upstream\n",
495
+ };
496
+
497
+ test("--strategy ours carries both sides' edits and resolves conflicts toward local", async () => {
498
+ // GIVEN an install at SHA_A with local edits, and a pin at SHA_B
499
+ installMergeCopy("level-up", OURS, SHA_A, BASE);
500
+ const fetch = makeFetch({ manifest: manifestWith("level-up", SHA_B) });
501
+ const runGit = treeGitRunner({ [SHA_A]: BASE, [SHA_B]: THEIRS });
502
+
503
+ // WHEN the plugin is upgraded with the `ours` strategy
504
+ const result = await upgradePlugin(
505
+ { name: "level-up", strategy: "ours" },
506
+ { fetch, runGit, workspacePluginsDir: pluginsDir },
507
+ );
508
+
509
+ // THEN it moves to the pin and records the strategy
510
+ expect(result.outcome).toBe("upgraded");
511
+ expect(result.toCommit).toBe(SHA_B);
512
+ expect(result.strategy).toBe("ours");
513
+ // AND non-conflicting edits from both sides survive
514
+ expect(installedFile("level-up", "common.txt")).toBe("A\nb\nC\n");
515
+ expect(installedFile("level-up", "local-only.txt")).toBe("added locally\n");
516
+ expect(installedFile("level-up", "remote-only.txt")).toBe(
517
+ "added upstream\n",
518
+ );
519
+ // AND the conflicting file resolves toward the local edit
520
+ expect(installedFile("level-up", "conflict.txt")).toBe("ours\n");
521
+ });
522
+
523
+ test("--strategy theirs carries both sides' edits and resolves conflicts toward the pin", async () => {
524
+ // GIVEN an install at SHA_A with local edits, and a pin at SHA_B
525
+ installMergeCopy("level-up", OURS, SHA_A, BASE);
526
+ const fetch = makeFetch({ manifest: manifestWith("level-up", SHA_B) });
527
+ const runGit = treeGitRunner({ [SHA_A]: BASE, [SHA_B]: THEIRS });
528
+
529
+ // WHEN the plugin is upgraded with the `theirs` strategy
530
+ const result = await upgradePlugin(
531
+ { name: "level-up", strategy: "theirs" },
532
+ { fetch, runGit, workspacePluginsDir: pluginsDir },
533
+ );
534
+
535
+ // THEN non-conflicting edits from both sides still survive
536
+ expect(result.strategy).toBe("theirs");
537
+ expect(installedFile("level-up", "common.txt")).toBe("A\nb\nC\n");
538
+ expect(installedFile("level-up", "local-only.txt")).toBe("added locally\n");
539
+ expect(installedFile("level-up", "remote-only.txt")).toBe(
540
+ "added upstream\n",
541
+ );
542
+ // AND the conflicting file resolves toward the pin
543
+ expect(installedFile("level-up", "conflict.txt")).toBe("theirs\n");
544
+ });
545
+
546
+ test("--strategy overwrite discards local edits and re-installs the pin wholesale", async () => {
547
+ // GIVEN an install at SHA_A with a local-only file, and a pin at SHA_B
548
+ installMergeCopy("level-up", OURS, SHA_A, BASE);
549
+ const fetch = makeFetch({ manifest: manifestWith("level-up", SHA_B) });
550
+ const runGit = treeGitRunner({ [SHA_A]: BASE, [SHA_B]: THEIRS });
551
+
552
+ // WHEN the plugin is upgraded with the `overwrite` strategy
553
+ const result = await upgradePlugin(
554
+ { name: "level-up", strategy: "overwrite" },
555
+ { fetch, runGit, workspacePluginsDir: pluginsDir },
556
+ );
557
+
558
+ // THEN the on-disk tree is exactly the pin — local edits are gone
559
+ expect(result.strategy).toBe("overwrite");
560
+ expect(installedFile("level-up", "common.txt")).toBe("a\nb\nC\n");
561
+ expect(installedFile("level-up", "conflict.txt")).toBe("theirs\n");
562
+ expect(installedFile("level-up", "remote-only.txt")).toBe(
563
+ "added upstream\n",
564
+ );
565
+ expect(installedFile("level-up", "local-only.txt")).toBeNull();
566
+ });
567
+
568
+ test("defaults to overwrite when no strategy is given", async () => {
569
+ // GIVEN an install with a local-only file and an advanced pin
570
+ installMergeCopy("level-up", OURS, SHA_A, BASE);
571
+ const fetch = makeFetch({ manifest: manifestWith("level-up", SHA_B) });
572
+ const runGit = treeGitRunner({ [SHA_A]: BASE, [SHA_B]: THEIRS });
573
+
574
+ // WHEN the plugin is upgraded without a strategy
575
+ const result = await upgradePlugin(
576
+ { name: "level-up" },
577
+ { fetch, runGit, workspacePluginsDir: pluginsDir },
578
+ );
579
+
580
+ // THEN it overwrites: the local-only file is dropped
581
+ expect(result.strategy).toBe("overwrite");
582
+ expect(installedFile("level-up", "local-only.txt")).toBeNull();
583
+ });
584
+
585
+ test("throws PluginUpgradeStrategyUnsupportedError for the assistant strategy", async () => {
586
+ // GIVEN an installed copy and an advanced pin
587
+ installMergeCopy("level-up", OURS, SHA_A, BASE);
588
+ const fetch = makeFetch({ manifest: manifestWith("level-up", SHA_B) });
589
+
590
+ // WHEN an upgrade requests the not-yet-implemented `assistant` strategy
591
+ // THEN it is rejected before any clone runs
592
+ await expect(
593
+ upgradePlugin(
594
+ { name: "level-up", strategy: "assistant" },
595
+ { fetch, runGit: unusedGitRunner, workspacePluginsDir: pluginsDir },
596
+ ),
597
+ ).rejects.toBeInstanceOf(PluginUpgradeStrategyUnsupportedError);
598
+ });
599
+
600
+ test("throws PluginMergeBaselineError when no fingerprint was recorded", async () => {
601
+ // GIVEN an install whose sidecar records no fingerprint (older install)
602
+ installMergeCopy("level-up", OURS, SHA_A, null);
603
+ const fetch = makeFetch({ manifest: manifestWith("level-up", SHA_B) });
604
+ const runGit = treeGitRunner({ [SHA_A]: BASE, [SHA_B]: THEIRS });
605
+
606
+ // WHEN a merge strategy is requested
607
+ // THEN the baseline cannot be trusted, so the merge is refused
608
+ await expect(
609
+ upgradePlugin(
610
+ { name: "level-up", strategy: "ours" },
611
+ { fetch, runGit, workspacePluginsDir: pluginsDir },
612
+ ),
613
+ ).rejects.toBeInstanceOf(PluginMergeBaselineError);
614
+ });
615
+
616
+ test("throws PluginMergeBaselineError when the re-materialized base drifts from the recorded fingerprint", async () => {
617
+ // GIVEN an install whose recorded fingerprint describes a tree that differs
618
+ // from what re-materializing the install commit produces (an adapter
619
+ // overlay that moved since install)
620
+ installMergeCopy("level-up", OURS, SHA_A, {
621
+ "common.txt": "totally different baseline\n",
622
+ });
623
+ const fetch = makeFetch({ manifest: manifestWith("level-up", SHA_B) });
624
+ const runGit = treeGitRunner({ [SHA_A]: BASE, [SHA_B]: THEIRS });
625
+
626
+ // WHEN a merge strategy is requested
627
+ // THEN the baseline is rejected rather than producing a corrupt merge
628
+ await expect(
629
+ upgradePlugin(
630
+ { name: "level-up", strategy: "theirs" },
631
+ { fetch, runGit, workspacePluginsDir: pluginsDir },
632
+ ),
633
+ ).rejects.toBeInstanceOf(PluginMergeBaselineError);
634
+ });
635
+ });