@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.
- package/ARCHITECTURE.md +18 -34
- package/bun.lock +7 -8
- package/docs/activation-funnel-telemetry.md +4 -4
- package/docs/architecture/security.md +29 -28
- package/docs/stt-provider-onboarding.md +3 -5
- package/docs/workflows-testing.md +13 -44
- package/docs/workflows.md +3 -5
- package/node_modules/@vellumai/ces-client/src/__tests__/ces-client.test.ts +47 -0
- package/node_modules/@vellumai/ces-client/src/rpc-client.ts +28 -5
- package/node_modules/@vellumai/environments/src/seeds.ts +2 -5
- package/node_modules/@vellumai/gateway-client/src/index.ts +17 -6
- package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +119 -0
- package/node_modules/@vellumai/gateway-client/src/types.ts +15 -84
- package/openapi.yaml +135 -59
- package/package.json +2 -1
- package/scripts/sync-llm-catalog.ts +6 -15
- package/scripts/sync-web-search-catalog.ts +3 -11
- package/src/__tests__/actor-trust-resolver-address-fallback.test.ts +14 -26
- package/src/__tests__/agent-loop-compaction-strip.test.ts +240 -0
- package/src/__tests__/agent-loop-output-hooks.test.ts +69 -0
- package/src/__tests__/agent-loop-override-profile.test.ts +25 -0
- package/src/__tests__/always-loaded-tools-guard.test.ts +2 -3
- package/src/__tests__/app-dir-path-guard.test.ts +0 -1
- package/src/__tests__/assistant-feature-flag-guard.test.ts +1 -4
- package/src/__tests__/assistant-feature-flag-guardrails.test.ts +0 -2
- package/src/__tests__/avatar-identity-sync.test.ts +2 -27
- package/src/__tests__/btw-routes.test.ts +6 -8
- package/src/__tests__/checker.test.ts +0 -3
- package/src/__tests__/config-loader-backfill.test.ts +103 -6
- package/src/__tests__/config-watcher.test.ts +0 -18
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +22 -0
- package/src/__tests__/credential-broker.test.ts +449 -1
- package/src/__tests__/credential-execution-tools.test.ts +0 -1
- package/src/__tests__/credential-prompt-route.test.ts +3 -4
- package/src/__tests__/credential-routes.test.ts +360 -0
- package/src/__tests__/credential-security-invariants.test.ts +2 -13
- package/src/__tests__/dynamic-page-surface.test.ts +101 -1
- package/src/__tests__/fixtures/credential-security-fixtures.ts +2 -33
- package/src/__tests__/gateway-only-guard.test.ts +3 -7
- package/src/__tests__/identity-routes.test.ts +0 -189
- package/src/__tests__/inbound-invite-redemption.test.ts +4 -4
- package/src/__tests__/invite-redemption-service.test.ts +4 -4
- package/src/__tests__/llm-callsite-catalog.test.ts +5 -6
- package/src/__tests__/llm-catalog-parity.test.ts +0 -22
- package/src/__tests__/llm-resolver.test.ts +49 -24
- package/src/__tests__/oauth-provider-seed-logos.test.ts +4 -6
- package/src/__tests__/onboarding-persona-write.test.ts +1 -1
- package/src/__tests__/persona-resolver.test.ts +11 -14
- package/src/__tests__/plugin-api-model-profiles.test.ts +178 -0
- package/src/__tests__/registry.test.ts +2 -7
- package/src/__tests__/schedule-routes-workflow-validation.test.ts +1 -10
- package/src/__tests__/schedule-routes.test.ts +0 -30
- package/src/__tests__/schedule-tools.test.ts +2 -18
- package/src/__tests__/skill-execute-input.test.ts +46 -1
- package/src/__tests__/skill-runtime-path.test.ts +2 -3
- package/src/__tests__/subagent-tools.test.ts +116 -0
- package/src/__tests__/surface-completion-nudge-hook.test.ts +367 -0
- package/src/__tests__/token-estimator-accuracy.benchmark.test.ts +1 -29
- package/src/__tests__/token-manager.test.ts +519 -0
- package/src/__tests__/tool-executor.test.ts +0 -79
- package/src/__tests__/trusted-contact-multichannel.test.ts +3 -3
- package/src/__tests__/trusted-contact-verification.test.ts +6 -6
- package/src/__tests__/voice-invite-redemption.test.ts +2 -2
- package/src/__tests__/web-search-catalog-parity.test.ts +6 -25
- package/src/__tests__/workspace-greetings.test.ts +152 -0
- package/src/agent/loop.ts +25 -5
- package/src/api/README.md +6 -6
- package/src/api/responses/conversation-message.ts +2 -4
- package/src/api/responses/home.ts +0 -4
- package/src/approvals/guardian-request-resolvers.ts +2 -2
- package/src/calls/relay-access-wait.ts +1 -1
- package/src/calls/voice-session-bridge.ts +2 -2
- package/src/cli/commands/plugins.ts +143 -2
- package/src/cli/lib/__tests__/diff-plugin.test.ts +443 -0
- package/src/cli/lib/__tests__/merge-plugin-tree.test.ts +313 -0
- package/src/cli/lib/__tests__/upgrade-plugin.test.ts +253 -2
- package/src/cli/lib/diff-plugin.ts +346 -0
- package/src/cli/lib/install-from-github.ts +105 -17
- package/src/cli/lib/merge-plugin-tree.ts +228 -0
- package/src/cli/lib/plugin-fingerprint.ts +14 -0
- package/src/cli/lib/upgrade-plugin.ts +270 -10
- package/src/cli/program.ts +0 -2
- package/src/config/bundled-skills/subagent/SKILL.md +4 -0
- package/src/config/bundled-skills/subagent/TOOLS.json +4 -0
- package/src/config/bundled-skills/workflows/SKILL.md +0 -1
- package/src/config/bundled-tool-registry.ts +2 -7
- package/src/config/call-site-defaults.ts +12 -2
- package/src/config/feature-flag-registry.json +1 -17
- package/src/config/inference-profile-validation.ts +26 -0
- package/src/config/loader.ts +4 -0
- package/src/config/profile-order.ts +28 -0
- package/src/config/schemas/elevenlabs.ts +0 -1
- package/src/config/schemas/platform.ts +0 -8
- package/src/config/seed-inference-profiles.ts +25 -20
- package/src/contacts/contact-store.ts +87 -96
- package/src/contacts/contacts-write.ts +5 -21
- package/src/context/compactor.ts +2 -2
- package/src/credential-execution/process-manager.ts +55 -14
- package/src/credential-execution/prompted-credential.ts +2 -3
- package/src/daemon/config-watcher.ts +0 -4
- package/src/daemon/conversation-agent-loop.ts +15 -4
- package/src/daemon/conversation-slash.ts +2 -23
- package/src/daemon/conversation-tool-setup.ts +11 -3
- package/src/daemon/conversation.ts +2 -0
- package/src/daemon/handlers/config-channels.ts +20 -16
- package/src/daemon/handlers/config-slack-channel.ts +2 -3
- package/src/daemon/lifecycle.ts +0 -7
- package/src/daemon/message-types/conversations.ts +3 -3
- package/src/daemon/message-types/sync.ts +0 -1
- package/src/daemon/orphan-reaper.test.ts +0 -19
- package/src/daemon/orphan-reaper.ts +2 -24
- package/src/daemon/server.ts +0 -10
- package/src/home/relationship-state.ts +2 -4
- package/src/memory/__tests__/memory-retrospective-job.test.ts +195 -401
- package/src/memory/bookmark-crud.ts +1 -2
- package/src/memory/db-init.ts +7 -17
- package/src/memory/embedding-backend.ts +23 -0
- package/src/memory/embedding-billing-breaker.ts +96 -0
- package/src/memory/jobs-store.ts +25 -13
- package/src/memory/jobs-worker.ts +52 -0
- package/src/memory/memory-retrospective-constants.ts +4 -4
- package/src/memory/memory-retrospective-job.ts +19 -227
- package/src/memory/migrations/291-contact-channels-renormalize-addresses.ts +62 -0
- package/src/memory/migrations/__tests__/291-contact-channels-renormalize-addresses.test.ts +311 -0
- package/src/memory/migrations/__tests__/run-migrations.test.ts +52 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/migrations/run-migrations.ts +41 -0
- package/src/memory/migrations/validate-migration-state.ts +1 -1
- package/src/memory/schema/contacts.ts +0 -4
- package/src/messaging/providers/slack/adapter.ts +1 -1
- package/src/notifications/adapters/shared.ts +29 -0
- package/src/notifications/adapters/slack.ts +5 -32
- package/src/notifications/adapters/telegram.ts +2 -20
- package/src/notifications/broadcaster.ts +10 -1
- package/src/notifications/home-feed-side-effect.ts +4 -3
- package/src/notifications/notification-utils.ts +17 -19
- package/src/notifications/types.ts +7 -0
- package/src/oauth/AGENTS.md +5 -24
- package/src/plugin-api/constants.ts +1 -1
- package/src/plugin-api/index.ts +6 -1
- package/src/plugin-api/model-profiles.ts +33 -0
- package/src/plugin-api/types.ts +50 -2
- package/src/plugins/defaults/index.ts +25 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +54 -2
- package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +107 -7
- package/src/plugins/defaults/surface-completion-nudge/hooks/post-model-call.ts +276 -0
- package/src/plugins/defaults/surface-completion-nudge/hooks/stop.ts +22 -0
- package/src/plugins/defaults/surface-completion-nudge/nudge-state-store.ts +46 -0
- package/src/plugins/defaults/surface-completion-nudge/package.json +14 -0
- package/src/plugins/defaults/task-progress-nudge/hooks/post-tool-use.ts +1 -1
- package/src/prompts/persona-resolver.ts +2 -2
- package/src/runtime/AGENTS.md +0 -1
- package/src/runtime/actor-trust-resolver.ts +12 -44
- package/src/runtime/btw-sidechain.ts +3 -6
- package/src/runtime/channel-approval-types.ts +18 -45
- package/src/runtime/channel-invite-transports/telegram.ts +4 -4
- package/src/runtime/channel-verification-service.ts +4 -3
- package/src/runtime/invite-redemption-service.ts +3 -3
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +218 -1
- package/src/runtime/routes/app-routes.ts +1 -1
- package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +2 -2
- package/src/runtime/routes/assets/vellum-design-system.css +1959 -0
- package/src/runtime/routes/btw-routes.ts +1 -27
- package/src/runtime/routes/conversation-compaction-routes.ts +1 -1
- package/src/runtime/routes/conversation-routes.ts +2 -2
- package/src/runtime/routes/credential-routes.ts +40 -16
- package/src/runtime/routes/empty-state-greeting-cache.ts +1 -2
- package/src/runtime/routes/identity-routes.ts +1 -296
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +1 -1
- package/src/runtime/routes/plugins-routes.ts +171 -5
- package/src/runtime/routes/schedule-routes.ts +0 -22
- package/src/runtime/routes/workflow-routes.test.ts +4 -43
- package/src/runtime/routes/workflow-routes.ts +0 -28
- package/src/runtime/routes/workspace-greetings.ts +55 -0
- package/src/runtime/sync/resource-sync-events.ts +1 -11
- package/src/schedule/inference-profile.ts +2 -14
- package/src/subagent/manager.ts +6 -0
- package/src/subagent/types.ts +6 -0
- package/src/tools/AGENTS.md +3 -3
- package/src/tools/browser/browser-execution.ts +1 -1
- package/src/tools/network/web-search-error.ts +1 -1
- package/src/tools/permission-checker.ts +1 -1
- package/src/tools/schedule/create.ts +3 -9
- package/src/tools/schedule/update.ts +2 -10
- package/src/tools/side-effects.ts +2 -17
- package/src/tools/skills/execute.ts +34 -0
- package/src/tools/subagent/spawn.ts +34 -9
- package/src/tools/tool-approval-handler.ts +1 -3
- package/src/tools/tool-manifest.ts +0 -2
- package/src/tools/ui-surface/definitions.ts +39 -1
- package/src/tools/workflows/run-workflow.test.ts +8 -18
- package/src/util/platform.ts +2 -2
- package/src/workflows/capabilities.ts +2 -3
- package/src/workflows/run-manager.test.ts +0 -25
- package/src/workflows/run-manager.ts +2 -24
- package/src/__tests__/app-control-no-global-cgevent.test.ts +0 -98
- package/src/__tests__/credential-security-e2e.test.ts +0 -362
- package/src/__tests__/credential-vault-unit.test.ts +0 -1528
- package/src/__tests__/credential-vault.test.ts +0 -1706
- package/src/__tests__/identity-intro-cache.test.ts +0 -315
- package/src/__tests__/secret-onetime-send.test.ts +0 -182
- package/src/cli/commands/__tests__/task.test.ts +0 -914
- package/src/cli/commands/task.ts +0 -771
- package/src/config/bundled-skills/personal-page/SKILL.md +0 -57
- package/src/config/bundled-skills/personal-page/TOOLS.json +0 -27
- package/src/config/bundled-skills/personal-page/tools/app-refresh.ts +0 -17
- package/src/config/preloaded-apps/personal-page/src/components/About.tsx +0 -22
- package/src/config/preloaded-apps/personal-page/src/components/App.tsx +0 -16
- package/src/config/preloaded-apps/personal-page/src/components/Features.tsx +0 -77
- package/src/config/preloaded-apps/personal-page/src/components/Hero.tsx +0 -57
- package/src/config/preloaded-apps/personal-page/src/components/Pending.tsx +0 -28
- package/src/config/preloaded-apps/personal-page/src/components/animations.tsx +0 -234
- package/src/config/preloaded-apps/personal-page/src/components/icons.tsx +0 -48
- package/src/config/preloaded-apps/personal-page/src/components/media.ts +0 -16
- package/src/config/preloaded-apps/personal-page/src/index.html +0 -20
- package/src/config/preloaded-apps/personal-page/src/main.tsx +0 -7
- package/src/config/preloaded-apps/personal-page/src/profile-data.ts +0 -82
- package/src/config/preloaded-apps/personal-page/src/styles.css +0 -759
- package/src/memory/__tests__/preloaded-apps.test.ts +0 -85
- package/src/memory/preloaded-apps.ts +0 -116
- package/src/runtime/routes/identity-intro-cache.ts +0 -172
- 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 {
|
|
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
|
+
});
|