@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.
- package/ARCHITECTURE.md +15 -17
- package/README.md +0 -6
- package/bun.lock +6 -122
- package/node_modules/@vellumai/gateway-client/bun.lock +1 -0
- package/node_modules/@vellumai/gateway-client/package.json +3 -1
- package/node_modules/@vellumai/gateway-client/src/__tests__/gateway-client.test.ts +1 -1
- package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +87 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +3 -5
- package/openapi.yaml +633 -4
- package/package.json +1 -3
- package/src/__tests__/adaptive-thinking-repair.test.ts +185 -0
- package/src/__tests__/agent-loop-compaction-events.test.ts +7 -6
- package/src/__tests__/anthropic-provider.test.ts +129 -0
- package/src/__tests__/background-workers-disk-pressure.test.ts +4 -1
- package/src/__tests__/btw-routes.test.ts +7 -34
- package/src/__tests__/checker.test.ts +6 -12
- package/src/__tests__/config-loader-backfill.test.ts +4 -2
- package/src/__tests__/config-loader-quarantine-notice.test.ts +167 -0
- package/src/__tests__/config-watcher.test.ts +2 -2
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -1
- package/src/__tests__/conversation-error.test.ts +2 -6
- package/src/__tests__/conversation-history-web-search.test.ts +8 -0
- package/src/__tests__/conversation-title-service.test.ts +2 -1
- package/src/__tests__/credential-security-invariants.test.ts +1 -1
- package/src/__tests__/disk-pressure-tools.test.ts +1 -1
- package/src/__tests__/exploration-drift-hook.test.ts +692 -0
- package/src/__tests__/filing-service.test.ts +8 -3
- package/src/__tests__/guardian-action-store.test.ts +0 -167
- package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +1 -1
- package/src/__tests__/heartbeat-disk-pressure.test.ts +4 -1
- package/src/__tests__/heartbeat-service.test.ts +5 -2
- package/src/__tests__/identity-intro-cache.test.ts +12 -5
- package/src/__tests__/identity-routes.test.ts +16 -57
- package/src/__tests__/injector-chain.test.ts +8 -3
- package/src/__tests__/injector-config-quarantine-notice.test.ts +115 -0
- package/src/__tests__/llm-catalog-parity.test.ts +16 -0
- package/src/__tests__/llm-usage-store.test.ts +11 -0
- package/src/__tests__/log-export-workspace.test.ts +468 -3
- package/src/__tests__/memory-v2-static-injector.test.ts +22 -0
- package/src/__tests__/model-intents.test.ts +1 -1
- package/src/__tests__/oauth-cli.test.ts +19 -8
- package/src/__tests__/openai-provider.test.ts +34 -0
- package/src/__tests__/prechat-onboarding-contract.test.ts +0 -1
- package/src/__tests__/recurrence-engine.test.ts +45 -0
- package/src/__tests__/schedule-routes.test.ts +34 -0
- package/src/__tests__/scheduler-disk-pressure.test.ts +1 -1
- package/src/__tests__/script-proxy-conversation-manager.test.ts +10 -5
- package/src/__tests__/secret-fixtures.ts +20 -0
- package/src/__tests__/skill-tool-factory.test.ts +49 -0
- package/src/__tests__/subagent-role-registry.test.ts +24 -1
- package/src/__tests__/subagent-tools.test.ts +1 -0
- package/src/__tests__/system-prompt.test.ts +109 -11
- package/src/__tests__/tool-approval-handler.test.ts +85 -0
- package/src/__tests__/tool-audit-listener.test.ts +86 -0
- package/src/__tests__/tool-error-hook.test.ts +1 -0
- package/src/__tests__/tool-result-spool.test.ts +337 -0
- package/src/__tests__/tool-result-truncate-hook.test.ts +1 -0
- package/src/__tests__/validate-input.test.ts +95 -1
- package/src/__tests__/workspace-migration-098-remove-stale-updates-bulletin-file.test.ts +65 -0
- package/src/__tests__/workspace-migration-099-disable-cache-one-shot-callsites.test.ts +139 -0
- package/src/__tests__/workspace-migration-100-upgrade-quality-profile-to-fable-5.test.ts +174 -0
- package/src/__tests__/workspace-migration-101-upgrade-balanced-economy-to-minimax-m3.test.ts +162 -0
- package/src/__tests__/workspace-release-notes-feature-flag-guard.test.ts +45 -95
- package/src/acp/__tests__/agent-process.test.ts +315 -2
- package/src/acp/__tests__/prepare-agent-env.test.ts +79 -5
- package/src/acp/agent-process.ts +163 -34
- package/src/acp/prepare-agent-env.ts +55 -15
- package/src/agent/loop.ts +81 -24
- package/src/api/events/usage-progress.ts +28 -0
- package/src/api/index.ts +6 -0
- package/src/background-wake/wake-intent-hooks.test.ts +2 -0
- package/src/bundler/app-bundler.ts +25 -42
- package/src/bundler/app-compiler.ts +8 -0
- package/src/calls/call-controller.ts +1 -1
- package/src/cli/commands/plugins.ts +248 -15
- package/src/cli/lib/__tests__/inspect-plugin.test.ts +318 -0
- package/src/cli/lib/__tests__/install-from-github.test.ts +16 -9
- package/src/cli/lib/__tests__/plugin-artifact.test.ts +183 -0
- package/src/cli/lib/__tests__/plugin-details.test.ts +158 -0
- package/src/cli/lib/__tests__/plugin-fingerprint.test.ts +245 -0
- package/src/cli/lib/__tests__/upgrade-plugin.test.ts +307 -0
- package/src/cli/lib/inspect-plugin.ts +252 -0
- package/src/cli/lib/install-from-github.ts +214 -21
- package/src/cli/lib/list-installed-plugins.ts +17 -6
- package/src/cli/lib/plugin-artifact.ts +103 -0
- package/src/cli/lib/plugin-details.ts +18 -1
- package/src/cli/lib/plugin-fingerprint.ts +197 -0
- package/src/cli/lib/upgrade-plugin.ts +225 -0
- package/src/config/bundled-skills/subagent/SKILL.md +2 -0
- package/src/config/bundled-skills/subagent/TOOLS.json +8 -2
- package/src/config/call-site-defaults.ts +13 -2
- package/src/config/feature-flag-registry.json +8 -16
- package/src/config/loader.ts +52 -59
- package/src/config/schema.ts +0 -2
- package/src/config/schemas/__tests__/memory-v2.test.ts +1 -0
- package/src/config/schemas/__tests__/memory-v3.test.ts +10 -0
- package/src/config/schemas/llm.ts +10 -0
- package/src/config/schemas/memory-v2.ts +13 -0
- package/src/config/schemas/memory-v3.ts +92 -0
- package/src/config/seed-inference-profiles.ts +4 -8
- package/src/context/post-turn-tool-result-truncation.ts +32 -18
- package/src/context/tool-result-spool.ts +104 -0
- package/src/credential-execution/feature-gates.ts +0 -1
- package/src/daemon/conversation-agent-loop-handlers.ts +41 -16
- package/src/daemon/conversation-error.ts +6 -15
- package/src/daemon/conversation.ts +9 -0
- package/src/daemon/disk-pressure-policy.ts +0 -1
- package/src/daemon/lifecycle.ts +1 -20
- package/src/daemon/message-types/conversations.ts +2 -15
- package/src/daemon/trust-context.ts +1 -1
- package/src/events/tool-audit-listener.ts +40 -9
- package/src/heartbeat/__tests__/heartbeat-service.test.ts +1 -1
- package/src/home/__tests__/home-content-refresh.test.ts +114 -0
- package/src/home/__tests__/suggested-prompts.test.ts +86 -5
- package/src/home/home-content-refresh.ts +43 -31
- package/src/home/home-greeting-cache.ts +8 -1
- package/src/home/home-greeting.ts +13 -9
- package/src/home/suggested-prompts.ts +77 -24
- package/src/ipc/routes/trust-rules.test.ts +66 -72
- package/src/media/image-credentials.ts +2 -2
- package/src/memory/__tests__/compaction-log-store-clickhouse.test.ts +432 -0
- package/src/memory/{compaction-log-writer-clickhouse.ts → compaction-log-store-clickhouse.ts} +264 -55
- package/src/memory/conversation-attention-store.ts +1 -0
- package/src/memory/conversation-bootstrap.ts +18 -9
- package/src/memory/conversation-crud.ts +12 -2
- package/src/memory/conversation-title-service.ts +53 -9
- package/src/memory/delivery-channels.ts +0 -69
- package/src/memory/graph/extraction-job.ts +0 -15
- package/src/memory/guardian-action-store.ts +1 -376
- package/src/memory/llm-usage-store.ts +5 -1
- package/src/memory/migrations/181-rename-thread-starters-checkpoints.ts +2 -2
- package/src/memory/v2/__tests__/consolidation-job.test.ts +183 -2
- package/src/memory/v2/__tests__/injection.test.ts +70 -0
- package/src/memory/v2/__tests__/static-context.test.ts +12 -0
- package/src/memory/v2/consolidation-job.ts +93 -9
- package/src/memory/v2/injection.ts +53 -0
- package/src/memory/v2/prompts/consolidation.ts +1 -0
- package/src/memory/v2/static-context.ts +13 -1
- package/src/memory/v2/sweep-job.ts +1 -1
- package/src/memory/v2/types.ts +5 -0
- package/src/plugin-api/types.ts +7 -0
- package/src/plugins/defaults/exploration-drift/hooks/post-tool-use.ts +300 -0
- package/src/plugins/defaults/exploration-drift/package.json +15 -0
- package/src/plugins/defaults/index.ts +25 -0
- package/src/plugins/defaults/memory-retrieval/injectors.ts +132 -4
- package/src/plugins/defaults/memory-v3-shadow/__tests__/card.test.ts +92 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/carry-integration.test.ts +2 -1
- package/src/plugins/defaults/memory-v3-shadow/__tests__/fresh-set.test.ts +52 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/injection.test.ts +1 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +2 -1
- package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +136 -5
- package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +17 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +6 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +5 -1
- package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +68 -4
- package/src/plugins/defaults/memory-v3-shadow/card.ts +49 -5
- package/src/plugins/defaults/memory-v3-shadow/fresh-set.ts +59 -0
- package/src/plugins/defaults/memory-v3-shadow/injector.ts +4 -2
- package/src/plugins/defaults/memory-v3-shadow/learned-edges.test.ts +169 -0
- package/src/plugins/defaults/memory-v3-shadow/learned-edges.ts +178 -0
- package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +115 -26
- package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +13 -9
- package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +144 -22
- package/src/plugins/defaults/memory-v3-shadow/types.ts +24 -6
- package/src/plugins/defaults/title-generate/hooks/stop.ts +13 -0
- package/src/plugins/defaults/title-generate/hooks/user-prompt-submit.ts +16 -0
- package/src/prompts/cache-boundary.ts +17 -0
- package/src/prompts/sections.ts +50 -17
- package/src/prompts/system-prompt.ts +12 -4
- package/src/prompts/templates/system-sections.ts +22 -0
- package/src/providers/__tests__/unparseable-tool-args.test.ts +53 -0
- package/src/providers/anthropic/client.ts +74 -28
- package/src/providers/gemini/client.ts +5 -1
- package/src/providers/minimax/client.ts +9 -0
- package/src/providers/model-catalog.ts +28 -0
- package/src/providers/model-intents.ts +3 -3
- package/src/providers/openai/chat-completions-provider.ts +4 -2
- package/src/providers/openai/responses-provider.ts +7 -2
- package/src/providers/retry.ts +8 -0
- package/src/providers/types.ts +11 -0
- package/src/providers/unparseable-tool-args.ts +56 -0
- package/src/runtime/AGENTS.md +6 -0
- package/src/runtime/__tests__/agent-wake.test.ts +2 -2
- package/src/runtime/agent-wake.ts +5 -5
- package/src/runtime/background-job-runner.ts +2 -2
- package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +150 -3
- package/src/runtime/migrations/vbundle-import-analyzer.ts +29 -6
- package/src/runtime/migrations/vbundle-import-policy.ts +23 -0
- package/src/runtime/migrations/vbundle-importer.ts +9 -4
- package/src/runtime/migrations/vbundle-streaming-importer.ts +8 -3
- package/src/runtime/pre-first-message-gate.ts +1 -1
- package/src/runtime/routes/__tests__/conversation-compaction-routes.test.ts +241 -0
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +132 -0
- package/src/runtime/routes/__tests__/gateway-log-routes.test.ts +97 -185
- package/src/runtime/routes/__tests__/home-feed-routes.test.ts +17 -0
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +348 -0
- package/src/runtime/routes/__tests__/task-routes.test.ts +3 -3
- package/src/runtime/routes/btw-routes.ts +0 -14
- package/src/runtime/routes/conversation-compaction-routes.ts +86 -19
- package/src/runtime/routes/conversation-list-routes.ts +77 -5
- package/src/runtime/routes/conversation-management-routes.ts +54 -0
- package/src/runtime/routes/conversation-query-routes.ts +79 -4
- package/src/runtime/routes/gateway-log-routes.ts +14 -64
- package/src/runtime/routes/home-feed-routes.ts +10 -0
- package/src/runtime/routes/identity-intro-cache.ts +1 -1
- package/src/runtime/routes/identity-routes.ts +76 -20
- package/src/runtime/routes/inbound-message-handler.ts +0 -36
- package/src/runtime/routes/log-export-routes.ts +143 -96
- package/src/runtime/routes/plugins-routes.ts +380 -0
- package/src/runtime/routes/redact-staged-export.ts +259 -0
- package/src/runtime/routes/schedule-routes.ts +19 -2
- package/src/runtime/routes/trust-rules-routes.ts +14 -67
- package/src/schedule/recurrence-engine.ts +34 -0
- package/src/schedule/scheduler.ts +1 -0
- package/src/security/redact-json.ts +61 -0
- package/src/skills/validate-input.ts +41 -1
- package/src/subagent/types.ts +26 -1
- package/src/telemetry/types.ts +15 -1
- package/src/telemetry/usage-telemetry-reporter.test.ts +6 -1
- package/src/telemetry/usage-telemetry-reporter.ts +1 -0
- package/src/tools/apps/executors.ts +1 -1
- package/src/tools/skills/skill-tool-factory.ts +19 -8
- package/src/tools/tool-approval-handler.ts +31 -0
- package/src/usage/types.ts +8 -1
- package/src/util/platform.ts +16 -0
- package/src/watcher/engine.ts +1 -0
- package/src/workspace/adaptive-thinking-repair.ts +113 -0
- package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +70 -67
- package/src/workspace/migrations/098-remove-stale-updates-bulletin-file.ts +31 -0
- package/src/workspace/migrations/099-disable-cache-one-shot-callsites.ts +81 -0
- package/src/workspace/migrations/100-upgrade-quality-profile-to-fable-5.ts +86 -0
- package/src/workspace/migrations/101-upgrade-balanced-economy-to-minimax-m3.ts +70 -0
- package/src/workspace/migrations/registry.ts +8 -0
- package/src/__tests__/config-loader-quarantine-bulletin.test.ts +0 -202
- package/src/__tests__/conversation-starters-cadence.test.ts +0 -161
- package/src/__tests__/guardian-action-followup-executor.test.ts +0 -322
- package/src/__tests__/guardian-action-followup-store.test.ts +0 -373
- package/src/__tests__/guardian-action-late-reply.test.ts +0 -1083
- package/src/__tests__/update-bulletin-job.test.ts +0 -292
- package/src/config/schemas/updates.ts +0 -14
- package/src/memory/__tests__/compaction-log-writer-clickhouse.test.ts +0 -227
- package/src/memory/conversation-starters-cadence.ts +0 -78
- package/src/prompts/update-bulletin-job.ts +0 -180
- 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
|
+
});
|