@vellumai/assistant 0.8.9-staging.2 → 0.8.9-staging.3

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 (111) hide show
  1. package/docs/activation-funnel-telemetry.md +310 -0
  2. package/package.json +1 -1
  3. package/src/__tests__/activation-early-marking.test.ts +120 -0
  4. package/src/__tests__/agent-loop-output-hooks.test.ts +13 -13
  5. package/src/__tests__/approval-cascade.test.ts +1 -1
  6. package/src/__tests__/compaction-direct.test.ts +32 -18
  7. package/src/__tests__/compaction-events.test.ts +2 -2
  8. package/src/__tests__/compaction.benchmark.test.ts +1 -1
  9. package/src/__tests__/context-overflow-reducer.test.ts +5 -5
  10. package/src/__tests__/context-window-manager-compact-retry.test.ts +1 -1
  11. package/src/__tests__/conversation-abort-tool-results.test.ts +1 -1
  12. package/src/__tests__/conversation-confirmation-signals.test.ts +1 -1
  13. package/src/__tests__/conversation-error.test.ts +15 -1
  14. package/src/__tests__/conversation-history-web-search.test.ts +5 -0
  15. package/src/__tests__/conversation-media-retry.test.ts +1 -1
  16. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +40 -0
  17. package/src/__tests__/conversation-process-callsite.test.ts +1 -1
  18. package/src/__tests__/conversation-provider-retry-repair.test.ts +1 -1
  19. package/src/__tests__/conversation-queue.test.ts +1 -1
  20. package/src/__tests__/conversation-runtime-assembly.test.ts +71 -0
  21. package/src/__tests__/conversation-slash-queue.test.ts +1 -1
  22. package/src/__tests__/conversation-slash-unknown.test.ts +1 -1
  23. package/src/__tests__/conversation-speed-override.test.ts +1 -1
  24. package/src/__tests__/conversation-surfaces-activation-emit.test.ts +395 -0
  25. package/src/__tests__/conversation-surfaces-app-control.test.ts +44 -0
  26. package/src/__tests__/conversation-undo.test.ts +2 -2
  27. package/src/__tests__/conversation-workspace-cache-state.test.ts +1 -1
  28. package/src/__tests__/conversation-workspace-injection.test.ts +1 -1
  29. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -1
  30. package/src/__tests__/cu-unified-flow.test.ts +36 -0
  31. package/src/__tests__/history-repair-hook.test.ts +2 -0
  32. package/src/__tests__/memory-retrieval-hook.test.ts +1 -2
  33. package/src/__tests__/persist-unsendable-image-downscale.test.ts +145 -0
  34. package/src/__tests__/persist-unsendable-image.test.ts +97 -1
  35. package/src/__tests__/post-turn-tool-result-truncation.test.ts +69 -0
  36. package/src/__tests__/skill-feature-flags-integration.test.ts +5 -7
  37. package/src/__tests__/title-generate-hook.test.ts +2 -0
  38. package/src/__tests__/web-fetch.test.ts +45 -0
  39. package/src/acp/__tests__/helpers/acp-config-stub.ts +0 -2
  40. package/src/acp/resolve-agent.test.ts +0 -56
  41. package/src/acp/resolve-agent.ts +10 -38
  42. package/src/agent/loop.ts +13 -27
  43. package/src/cli/lib/__tests__/install-from-github.test.ts +232 -29
  44. package/src/cli/lib/__tests__/plugin-details.test.ts +28 -19
  45. package/src/cli/lib/__tests__/plugin-marketplace.test.ts +57 -7
  46. package/src/cli/lib/__tests__/search-plugins.test.ts +17 -10
  47. package/src/cli/lib/install-from-github.ts +258 -41
  48. package/src/cli/lib/plugin-details.ts +20 -13
  49. package/src/cli/lib/plugin-marketplace.ts +23 -5
  50. package/src/cli/lib/search-plugins.ts +14 -8
  51. package/src/config/acp-defaults.ts +3 -3
  52. package/src/config/acp-schema.ts +1 -7
  53. package/src/config/bundled-skills/acp/SKILL.md +4 -17
  54. package/src/config/bundled-skills/acp/TOOLS.json +2 -2
  55. package/src/config/feature-flag-registry.json +3 -18
  56. package/src/context/post-turn-tool-result-truncation.ts +39 -1
  57. package/src/daemon/conversation-agent-loop-handlers.ts +8 -1
  58. package/src/daemon/conversation-agent-loop.ts +78 -78
  59. package/src/daemon/conversation-error.ts +31 -4
  60. package/src/daemon/conversation-history.ts +1 -1
  61. package/src/daemon/conversation-media-retry.ts +19 -6
  62. package/src/daemon/conversation-messaging.ts +17 -0
  63. package/src/daemon/conversation-process.ts +14 -5
  64. package/src/daemon/conversation-queue-manager.ts +8 -0
  65. package/src/daemon/conversation-runtime-assembly.ts +37 -1
  66. package/src/daemon/conversation-surfaces.ts +141 -3
  67. package/src/daemon/conversation.ts +48 -13
  68. package/src/daemon/persist-unsendable-image.ts +62 -25
  69. package/src/daemon/process-message.ts +1 -1
  70. package/src/memory/__tests__/activation-session-store.test.ts +41 -0
  71. package/src/memory/__tests__/onboarding-events-store.test.ts +80 -0
  72. package/src/memory/activation-session-store.ts +43 -0
  73. package/src/memory/db-init.ts +4 -0
  74. package/src/memory/migrations/273-onboarding-events-funnel-columns.ts +46 -0
  75. package/src/memory/migrations/274-create-activation-sessions.ts +15 -0
  76. package/src/memory/migrations/index.ts +2 -0
  77. package/src/memory/onboarding-events-store.ts +66 -18
  78. package/src/memory/schema/infrastructure.ts +13 -0
  79. package/src/messaging/providers/telegram-bot/api.ts +14 -5
  80. package/src/notifications/adapters/telegram.ts +7 -1
  81. package/src/plugin-api/constants.ts +2 -2
  82. package/src/plugin-api/index.ts +2 -2
  83. package/src/plugin-api/types.ts +19 -5
  84. package/src/plugins/defaults/compaction/compact.ts +24 -15
  85. package/src/plugins/defaults/compaction/context-overflow-reducer.ts +4 -4
  86. package/src/plugins/defaults/compaction/manager-store.ts +1 -1
  87. package/src/{context → plugins/defaults/compaction}/window-manager.ts +12 -12
  88. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +12 -18
  89. package/src/prompts/system-prompt.ts +61 -10
  90. package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +37 -2
  91. package/src/providers/openai/__tests__/vision-not-supported.test.ts +75 -0
  92. package/src/providers/openai/chat-completions-provider.ts +25 -0
  93. package/src/runtime/routes/__tests__/stt-routes.test.ts +112 -0
  94. package/src/runtime/routes/acp-routes.test.ts +3 -20
  95. package/src/runtime/routes/conversation-routes.ts +2 -0
  96. package/src/runtime/routes/playground/__tests__/force-compact.test.ts +1 -1
  97. package/src/runtime/routes/stt-routes.ts +45 -12
  98. package/src/runtime/routes/workspace-routes.ts +50 -15
  99. package/src/telemetry/__tests__/activation-funnel.test.ts +95 -0
  100. package/src/telemetry/activation-funnel.ts +167 -0
  101. package/src/telemetry/types.ts +13 -0
  102. package/src/telemetry/usage-telemetry-reporter.test.ts +154 -0
  103. package/src/telemetry/usage-telemetry-reporter.ts +26 -1
  104. package/src/tools/acp/list-agents.test.ts +2 -18
  105. package/src/tools/acp/list-agents.ts +3 -15
  106. package/src/tools/acp/spawn.test.ts +0 -10
  107. package/src/tools/browser/browser-execution.ts +12 -2
  108. package/src/tools/network/web-fetch.ts +65 -24
  109. package/src/tools/ui-surface/definitions.ts +7 -0
  110. package/src/acp/feature-gate.test.ts +0 -48
  111. package/src/acp/feature-gate.ts +0 -34
package/src/agent/loop.ts CHANGED
@@ -10,27 +10,22 @@ import {
10
10
  estimateToolsTokens,
11
11
  getCalibrationProviderKey,
12
12
  } from "../context/token-estimator.js";
13
- import type { ContextWindowResult } from "../context/window-manager.js";
14
13
  import type { InboundActorContext } from "../daemon/conversation-runtime-assembly.js";
15
14
  import type { ToolActivityMetadata } from "../daemon/message-types/web-activity.js";
16
15
  import type { TrustContext } from "../daemon/trust-context.js";
17
16
  import { stripHistoricalWebSearchResults } from "../daemon/web-search-history.js";
18
17
  import { HOOKS } from "../plugin-api/constants.js";
19
18
  import type {
20
- AssistantMessageContext,
19
+ PostModelCallContext,
21
20
  PostToolUseContext,
22
21
  PreModelCallContext,
23
22
  StopContext,
24
23
  } from "../plugin-api/types.js";
25
- import {
26
- DEFAULT_COMPACTION_PLUGIN_NAME,
27
- defaultCompact,
28
- } from "../plugins/defaults/compaction/compact.js";
29
- import { getContextWindowManager } from "../plugins/defaults/compaction/manager-store.js";
24
+ import { defaultCompact } from "../plugins/defaults/compaction/compact.js";
25
+ import type { ContextWindowResult } from "../plugins/defaults/compaction/window-manager.js";
30
26
  import postCompact from "../plugins/defaults/memory-retrieval/hooks/post-compact.js";
31
27
  import { runHook } from "../plugins/pipeline.js";
32
28
  import type { CompactionCircuitEvent } from "../plugins/types.js";
33
- import { PluginExecutionError } from "../plugins/types.js";
34
29
  import { normalizeThinkingConfigForWire } from "../providers/thinking-config.js";
35
30
  import type {
36
31
  ContentBlock,
@@ -730,17 +725,8 @@ export class AgentLoop {
730
725
  // Record the history-stripped marker right after stripping, before the
731
726
  // pipeline runs.
732
727
  await onEvent({ type: "history_stripped" });
733
- // The compaction module owns the per-conversation manager; resolve it from
734
- // the store rather than holding a handle on the loop. Absent for callers
735
- // without a compaction path (agent wakes, standalone unit tests), which
736
- // never reach this gate.
737
- const manager = getContextWindowManager(this.conversationId);
738
- if (manager == null) {
739
- throw new PluginExecutionError(
740
- `default-compaction: no ContextWindowManager registered for conversation ${this.conversationId} — the compaction store must construct one before compaction runs`,
741
- DEFAULT_COMPACTION_PLUGIN_NAME,
742
- );
743
- }
728
+ // The compaction module owns the per-conversation manager; pass the
729
+ // conversation id and let `defaultCompact` resolve it from the store.
744
730
  // The mid-loop budget gate is reached only when this turn decides to
745
731
  // compact in place, so `force` past the auto-threshold check.
746
732
  // `actorTrustClass` comes from the turn's trust snapshot (the actor whose
@@ -748,7 +734,7 @@ export class AgentLoop {
748
734
  // guardian-only attachments for untrusted actors. `overrideProfile` is the
749
735
  // turn's resolved inference-profile override for the summary call.
750
736
  const compactResult = await defaultCompact({
751
- manager,
737
+ conversationId: this.conversationId,
752
738
  messages: rawHistory,
753
739
  signal,
754
740
  force: true,
@@ -1114,7 +1100,7 @@ export class AgentLoop {
1114
1100
 
1115
1101
  // A `pre-model-call` hook (below) can defer this turn's assistant
1116
1102
  // output; when set, the live text stream is held so an
1117
- // `assistant-message` hook can emit the finalized (transformed) text
1103
+ // `post-model-call` hook can emit the finalized (transformed) text
1118
1104
  // instead. Reset per model call.
1119
1105
  let deferAssistantOutput = false;
1120
1106
 
@@ -1128,7 +1114,7 @@ export class AgentLoop {
1128
1114
  onEvent: (event) => {
1129
1115
  if (event.type === "text_delta") {
1130
1116
  // Held when the turn's output is deferred — the final text is
1131
- // emitted once, after the `assistant-message` hook runs.
1117
+ // emitted once, after the `post-model-call` hook runs.
1132
1118
  if (deferAssistantOutput) return;
1133
1119
  // Apply sensitive-output placeholder substitution (chunk-safe)
1134
1120
  if (substitutionMap.size > 0) {
@@ -1298,7 +1284,7 @@ export class AgentLoop {
1298
1284
  streamingPending = "";
1299
1285
  }
1300
1286
 
1301
- // Run the `assistant-message` hook on a finalized message and, when
1287
+ // Run the `post-model-call` hook on a finalized message and, when
1302
1288
  // output was deferred, emit the finalized text once (with sensitive-output
1303
1289
  // substitution applied, matching the live stream). Fail-open: the hook
1304
1290
  // receives a clone, so a throw — even mid in-place mutation — leaves the
@@ -1308,19 +1294,19 @@ export class AgentLoop {
1308
1294
  ): Promise<Message> => {
1309
1295
  let finalized = message;
1310
1296
  try {
1311
- const ctx: AssistantMessageContext = {
1297
+ const ctx: PostModelCallContext = {
1312
1298
  conversationId: this.conversationId,
1313
1299
  callSite,
1314
1300
  content: structuredClone(message.content),
1315
1301
  stopReason: response.stopReason,
1316
1302
  logger: rlog,
1317
1303
  };
1318
- const result = await runHook(HOOKS.ASSISTANT_MESSAGE, ctx);
1304
+ const result = await runHook(HOOKS.POST_MODEL_CALL, ctx);
1319
1305
  finalized = { role: "assistant", content: result.content };
1320
1306
  } catch (assistantMessageError) {
1321
1307
  rlog.error(
1322
1308
  { err: assistantMessageError },
1323
- "assistant-message hook failed — keeping the original content",
1309
+ "post-model-call hook failed — keeping the original content",
1324
1310
  );
1325
1311
  finalized = message;
1326
1312
  }
@@ -1460,7 +1446,7 @@ export class AgentLoop {
1460
1446
  }
1461
1447
  }
1462
1448
 
1463
- // Run the `assistant-message` hook + emit any deferred final text.
1449
+ // Run the `post-model-call` hook + emit any deferred final text.
1464
1450
  // On a no-tool turn this point is reached only after the `stop` hook
1465
1451
  // resolves to "stop" (a `continue` already re-queried above), so a
1466
1452
  // re-queried reply is never transformed-then-discarded.
@@ -31,7 +31,9 @@ import {
31
31
  InvalidPluginNameError,
32
32
  PluginAlreadyInstalledError,
33
33
  PluginNotFoundError,
34
+ PluginPostinstallError,
34
35
  PluginSourceUnavailableError,
36
+ type PostinstallRunner,
35
37
  sanitizePluginName,
36
38
  } from "../install-from-github.js";
37
39
 
@@ -54,6 +56,13 @@ const DOWNLOAD_HOST = "https://files.test/";
54
56
  function makeContentsFetch(opts: {
55
57
  tree: Record<string, Uint8Array | string | null>;
56
58
  manifest?: unknown;
59
+ /**
60
+ * HTTP status to answer the manifest fetch with. Defaults to 200 (when
61
+ * `manifest` is provided) or 404 (when omitted). Set to a transient status
62
+ * (e.g. 500) to simulate a marketplace lookup that fails rather than being
63
+ * absent — exercising the degrade-to-first-party path.
64
+ */
65
+ manifestStatus?: number;
57
66
  }): FetchLike {
58
67
  const MANIFEST_URL = `https://api.github.com/repos/${CANON_REPO}/contents/experimental/plugins/marketplace.json`;
59
68
  const CONTENTS = `https://api.github.com/repos/${CANON_REPO}/contents/`;
@@ -90,6 +99,11 @@ function makeContentsFetch(opts: {
90
99
  const url = typeof input === "string" ? input : input.toString();
91
100
 
92
101
  if (url.startsWith(MANIFEST_URL)) {
102
+ if (opts.manifestStatus !== undefined && opts.manifestStatus !== 200) {
103
+ return new Response("manifest unavailable", {
104
+ status: opts.manifestStatus,
105
+ });
106
+ }
93
107
  if (opts.manifest === undefined) {
94
108
  return new Response("not found", { status: 404 });
95
109
  }
@@ -182,6 +196,33 @@ const unusedGitRunner: GitRunner = async (args) => {
182
196
  );
183
197
  };
184
198
 
199
+ /**
200
+ * Read the real, committed caveman adapter stub from the repo so the
201
+ * integration test exercises the adapter that ships rather than a fixture
202
+ * copy that could drift from it. Returns every stub file keyed by its
203
+ * Contents-API path (`experimental/plugins/caveman/<rel>`) so the fetch fake
204
+ * serves the whole stub — package.json, the adapter, and its templates — to
205
+ * the real postinstall runner. Resolves the stub relative to this test file.
206
+ */
207
+ function readRealCavemanStub(): Record<string, string> {
208
+ const repoRel = "experimental/plugins/caveman";
209
+ const stubDir = join(import.meta.dir, "../../../../../", repoRel);
210
+ const tree: Record<string, string> = {};
211
+ const walk = (relDir: string): void => {
212
+ const absDir = relDir ? join(stubDir, relDir) : stubDir;
213
+ for (const entry of readdirSync(absDir, { withFileTypes: true })) {
214
+ const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
215
+ if (entry.isDirectory()) {
216
+ walk(rel);
217
+ continue;
218
+ }
219
+ tree[`${repoRel}/${rel}`] = readFileSync(join(stubDir, rel), "utf-8");
220
+ }
221
+ };
222
+ walk("");
223
+ return tree;
224
+ }
225
+
185
226
  describe("installPlugin — first-party", () => {
186
227
  let ws: string;
187
228
  let pluginsDir: string;
@@ -495,7 +536,7 @@ describe("installPlugin — marketplace resolution", () => {
495
536
  source: {
496
537
  source: "github",
497
538
  repo: "JuliusBrussee/caveman",
498
- ref: "v1.8.2",
539
+ ref: "63a91ecadbf4c4719a4602a5abb00883f9966034",
499
540
  },
500
541
  description: "Ultra-compressed communication mode.",
501
542
  },
@@ -503,7 +544,7 @@ describe("installPlugin — marketplace resolution", () => {
503
544
  };
504
545
 
505
546
  test("installs a whitelisted plugin by shallow-cloning its pinned repo + ref", async () => {
506
- // GIVEN a marketplace whitelisting caveman at its repo root, pinned to a tag
547
+ // GIVEN a marketplace whitelisting caveman at its repo root, pinned to a commit SHA
507
548
  const fetch = makeContentsFetch({ tree: {}, manifest: CAVEMAN_MANIFEST });
508
549
  const calls: string[][] = [];
509
550
  const runGit = fakeGitRunner({
@@ -513,7 +554,7 @@ describe("installPlugin — marketplace resolution", () => {
513
554
  ".claude-plugin": null,
514
555
  ".claude-plugin/plugin.json": "{}",
515
556
  },
516
- commit: "1111111222222233333334444444555555666666",
557
+ commit: "63a91ecadbf4c4719a4602a5abb00883f9966034",
517
558
  calls,
518
559
  });
519
560
 
@@ -528,8 +569,8 @@ describe("installPlugin — marketplace resolution", () => {
528
569
  // result reports the pinned ref and resolved commit, and `.git` is dropped
529
570
  const target = join(pluginsDir, "caveman");
530
571
  expect(result.target).toBe(target);
531
- expect(result.ref).toBe("v1.8.2");
532
- expect(result.commit).toBe("1111111222222233333334444444555555666666");
572
+ expect(result.ref).toBe("63a91ecadbf4c4719a4602a5abb00883f9966034");
573
+ expect(result.commit).toBe("63a91ecadbf4c4719a4602a5abb00883f9966034");
533
574
  expect(result.fileCount).toBe(3);
534
575
  expect(readFileSync(join(target, "package.json"), "utf-8")).toBe(
535
576
  '{"name":"caveman"}',
@@ -541,7 +582,7 @@ describe("installPlugin — marketplace resolution", () => {
541
582
 
542
583
  // AND the clone fetched the pinned ref from the pinned repo URL.
543
584
  const fetchCall = calls.find((c) => c[0] === "fetch");
544
- expect(fetchCall).toContain("v1.8.2");
585
+ expect(fetchCall).toContain("63a91ecadbf4c4719a4602a5abb00883f9966034");
545
586
  const remoteCall = calls.find((c) => c[0] === "remote");
546
587
  expect(remoteCall?.at(-1)).toBe(
547
588
  "https://github.com/JuliusBrussee/caveman.git",
@@ -553,8 +594,31 @@ describe("installPlugin — marketplace resolution", () => {
553
594
  );
554
595
  expect(manifest.source.kind).toBe("external");
555
596
  expect(manifest.source.owner).toBe("JuliusBrussee");
556
- expect(manifest.source.ref).toBe("v1.8.2");
557
- expect(manifest.commit).toBe("1111111222222233333334444444555555666666");
597
+ expect(manifest.source.ref).toBe(
598
+ "63a91ecadbf4c4719a4602a5abb00883f9966034",
599
+ );
600
+ expect(manifest.commit).toBe("63a91ecadbf4c4719a4602a5abb00883f9966034");
601
+ });
602
+
603
+ test("refuses to install when the checked-out commit differs from the pinned SHA", async () => {
604
+ // GIVEN a clone whose resolved HEAD does not match the manifest's pinned
605
+ // commit SHA — i.e. the upstream object served something unexpected
606
+ const fetch = makeContentsFetch({ tree: {}, manifest: CAVEMAN_MANIFEST });
607
+ const runGit = fakeGitRunner({
608
+ tree: { "package.json": '{"name":"caveman"}' },
609
+ commit: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
610
+ });
611
+
612
+ // WHEN we install
613
+ // THEN the integrity check fails closed rather than materializing and
614
+ // later `import()`-ing code from an unexpected revision, and nothing lands
615
+ await expect(
616
+ installPlugin(
617
+ { name: "caveman", ref: "main" },
618
+ { fetch, runGit, workspacePluginsDir: pluginsDir },
619
+ ),
620
+ ).rejects.toBeInstanceOf(PluginSourceUnavailableError);
621
+ expect(readdirSync(pluginsDir)).toEqual([]);
558
622
  });
559
623
 
560
624
  test("a missing remote ref surfaces a clean not-found", async () => {
@@ -563,7 +627,8 @@ describe("installPlugin — marketplace resolution", () => {
563
627
  const runGit = fakeGitRunner({
564
628
  tree: {},
565
629
  fetchError: Object.assign(new Error("git fetch failed"), {
566
- stderr: "fatal: couldn't find remote ref v1.8.2",
630
+ stderr:
631
+ "fatal: couldn't find remote ref 63a91ecadbf4c4719a4602a5abb00883f9966034",
567
632
  }),
568
633
  });
569
634
 
@@ -618,39 +683,177 @@ describe("installPlugin — marketplace resolution", () => {
618
683
  });
619
684
  });
620
685
 
621
- test("first-party plugin wins a name collision with the marketplace", async () => {
622
- // GIVEN a manifest whitelisting "caveman" externally
623
- // AND an in-repo first-party plugin that also claims the name "caveman"
686
+ test("overlays a curated adapter stub and runs its postinstall transform", async () => {
687
+ // GIVEN caveman is whitelisted externally
688
+ // AND we curate an adapter stub for it at experimental/plugins/caveman —
689
+ // the real, committed stub (package.json + postinstall.ts + templates), so
690
+ // this test exercises the adapter that ships, not a fixture copy of it
624
691
  const fetch = makeContentsFetch({
692
+ tree: readRealCavemanStub(),
693
+ manifest: CAVEMAN_MANIFEST,
694
+ });
695
+ // AND the upstream clone is a Claude Code plugin: a name-mismatched
696
+ // package.json, the `.claude-plugin/plugin.json` manifest, and the
697
+ // terse-mode ruleset in skills/caveman/SKILL.md
698
+ const runGit = fakeGitRunner({
625
699
  tree: {
626
- "experimental/plugins/caveman/package.json":
627
- '{"name":"@vellumai/caveman"}',
628
- "experimental/plugins/caveman/hooks": null,
629
- "experimental/plugins/caveman/hooks/init.ts": "// first-party",
700
+ "package.json": '{"name":"caveman-installer"}',
701
+ ".claude-plugin/plugin.json": JSON.stringify({
702
+ name: "caveman",
703
+ description: "Ultra-compressed communication mode.",
704
+ }),
705
+ "skills/caveman/SKILL.md":
706
+ "---\nname: caveman\ndescription: terse mode\n---\n\nCAVEMAN MODE. Drop filler words. Keep technical substance.",
630
707
  },
631
- manifest: CAVEMAN_MANIFEST,
708
+ commit: "63a91ecadbf4c4719a4602a5abb00883f9966034",
632
709
  });
633
710
 
634
- // WHEN we install by the colliding name (git must NOT run — the in-repo
635
- // plugin is served entirely from the Contents API)
711
+ // WHEN we install (real postinstall runner — no injected stub)
636
712
  const result = await installPlugin(
637
713
  { name: "caveman", ref: "main" },
638
- { fetch, runGit: unusedGitRunner, workspacePluginsDir: pluginsDir },
714
+ { fetch, runGit, workspacePluginsDir: pluginsDir },
639
715
  );
640
716
 
641
- // THEN the in-repo plugin is installed (matching what the search catalog
642
- // advertises for the name), not the external repo — the marketplace is
643
- // additive and never overrides a first-party plugin
717
+ // THEN the materialized tree is a valid Vellum plugin: package.json `name`
718
+ // matches the directory and declares the @vellumai/plugin-api peer dep
719
+ // (fixing both loader warnings), and the upstream installer name is gone
644
720
  const target = join(pluginsDir, "caveman");
645
- expect(result.ref).toBe("main");
646
- expect(result.commit).toBeNull();
647
- expect(readFileSync(join(target, "package.json"), "utf-8")).toBe(
648
- '{"name":"@vellumai/caveman"}',
721
+ expect(result.commit).toBe("63a91ecadbf4c4719a4602a5abb00883f9966034");
722
+ const pkg = JSON.parse(readFileSync(join(target, "package.json"), "utf-8"));
723
+ expect(pkg.name).toBe("caveman");
724
+ expect(pkg.peerDependencies["@vellumai/plugin-api"]).toBeString();
725
+ expect(pkg.scripts?.postinstall).toBeUndefined();
726
+
727
+ // AND a pre-model-call hook is synthesized carrying the upstream ruleset,
728
+ // so caveman actually runs (terse mode injected on the user-facing call)
729
+ const hook = readFileSync(
730
+ join(target, "hooks", "pre-model-call.ts"),
731
+ "utf-8",
732
+ );
733
+ expect(hook).toContain("PreModelCallContext");
734
+ expect(hook).toContain('ctx.callSite !== "mainAgent"');
735
+ expect(hook).toContain("CAVEMAN MODE. Drop filler words.");
736
+ // AND the ruleset is sourced verbatim — the YAML frontmatter is stripped
737
+ expect(hook).not.toContain("description: terse mode");
738
+ });
739
+
740
+ test("does not run a postinstall for a raw clone without an adapter stub", async () => {
741
+ // GIVEN caveman is whitelisted but we curate NO adapter stub for it
742
+ // (the Contents API has no experimental/plugins/caveman directory)
743
+ const fetch = makeContentsFetch({ tree: {}, manifest: CAVEMAN_MANIFEST });
744
+ const runGit = fakeGitRunner({
745
+ tree: { "package.json": '{"name":"caveman"}', "hooks/init.ts": "//" },
746
+ commit: "63a91ecadbf4c4719a4602a5abb00883f9966034",
747
+ });
748
+ // AND a postinstall runner that fails the test if it is ever invoked
749
+ const runPostinstall: PostinstallRunner = async () => {
750
+ throw new Error("postinstall must not run for a stubless raw clone");
751
+ };
752
+
753
+ // WHEN we install
754
+ await installPlugin(
755
+ { name: "caveman", ref: "main" },
756
+ { fetch, runGit, runPostinstall, workspacePluginsDir: pluginsDir },
649
757
  );
650
- expect(readFileSync(join(target, "hooks", "init.ts"), "utf-8")).toBe(
651
- "// first-party",
758
+
759
+ // THEN the clone is installed verbatim — no transform ran
760
+ const target = join(pluginsDir, "caveman");
761
+ expect(readFileSync(join(target, "package.json"), "utf-8")).toBe(
762
+ '{"name":"caveman"}',
652
763
  );
653
764
  });
765
+
766
+ test("aborts and rolls back when the adapter's postinstall fails", async () => {
767
+ // GIVEN a whitelisted plugin with a curated stub declaring a postinstall
768
+ const fetch = makeContentsFetch({
769
+ tree: {
770
+ "experimental/plugins/caveman/package.json": JSON.stringify({
771
+ name: "caveman",
772
+ scripts: { postinstall: "bun ./postinstall.ts" },
773
+ }),
774
+ "experimental/plugins/caveman/postinstall.ts": "// adapter",
775
+ },
776
+ manifest: CAVEMAN_MANIFEST,
777
+ });
778
+ const runGit = fakeGitRunner({
779
+ tree: { "package.json": '{"name":"caveman-installer"}' },
780
+ commit: "63a91ecadbf4c4719a4602a5abb00883f9966034",
781
+ });
782
+ // AND its adapter exits non-zero
783
+ const runPostinstall: PostinstallRunner = async () => {
784
+ throw new Error("adapter blew up");
785
+ };
786
+
787
+ // WHEN we install
788
+ // THEN it surfaces a PluginPostinstallError and nothing is materialized —
789
+ // better to fail loudly than ship a half-transformed plugin
790
+ await expect(
791
+ installPlugin(
792
+ { name: "caveman", ref: "main" },
793
+ { fetch, runGit, runPostinstall, workspacePluginsDir: pluginsDir },
794
+ ),
795
+ ).rejects.toBeInstanceOf(PluginPostinstallError);
796
+ expect(readdirSync(pluginsDir)).toEqual([]);
797
+ });
798
+
799
+ test("rejects a stub whose postinstall is not a single bun invocation", async () => {
800
+ // GIVEN a curated stub whose postinstall is an arbitrary shell command
801
+ // rather than the supported `bun <script>` adapter convention
802
+ const fetch = makeContentsFetch({
803
+ tree: {
804
+ "experimental/plugins/caveman/package.json": JSON.stringify({
805
+ name: "caveman",
806
+ scripts: { postinstall: "rm -rf /" },
807
+ }),
808
+ },
809
+ manifest: CAVEMAN_MANIFEST,
810
+ });
811
+ const runGit = fakeGitRunner({
812
+ tree: { "package.json": '{"name":"caveman-installer"}' },
813
+ commit: "63a91ecadbf4c4719a4602a5abb00883f9966034",
814
+ });
815
+
816
+ // WHEN we install (the default runner is never reached — resolution rejects
817
+ // the command before anything executes)
818
+ // THEN it surfaces a PluginPostinstallError and nothing is materialized
819
+ await expect(
820
+ installPlugin(
821
+ { name: "caveman", ref: "main" },
822
+ { fetch, runGit, workspacePluginsDir: pluginsDir },
823
+ ),
824
+ ).rejects.toBeInstanceOf(PluginPostinstallError);
825
+ expect(readdirSync(pluginsDir)).toEqual([]);
826
+ });
827
+
828
+ test("refuses to install an adapter stub as first-party when the marketplace lookup fails", async () => {
829
+ // GIVEN the marketplace lookup fails transiently (e.g. rate-limit / 5xx)
830
+ // rather than being absent, so resolution degrades to the first-party path
831
+ // AND the same-named in-repo directory is an adapter stub (a package.json
832
+ // with a postinstall + adapter script, but no hooks/tools of its own)
833
+ const fetch = makeContentsFetch({
834
+ tree: {
835
+ "experimental/plugins/caveman/package.json": JSON.stringify({
836
+ name: "caveman",
837
+ scripts: { postinstall: "bun ./postinstall.ts" },
838
+ }),
839
+ "experimental/plugins/caveman/postinstall.ts": "// adapter",
840
+ },
841
+ manifestStatus: 500,
842
+ });
843
+
844
+ // WHEN we install the name (git never runs — there is no external source to
845
+ // clone because the marketplace that would have named it was unreadable)
846
+ // THEN it fails loudly with a PluginPostinstallError instead of
847
+ // materializing the bare stub as a non-functional standalone plugin, and
848
+ // nothing is left behind on disk
849
+ await expect(
850
+ installPlugin(
851
+ { name: "caveman", ref: "main" },
852
+ { fetch, runGit: unusedGitRunner, workspacePluginsDir: pluginsDir },
853
+ ),
854
+ ).rejects.toBeInstanceOf(PluginPostinstallError);
855
+ expect(readdirSync(pluginsDir)).toEqual([]);
856
+ });
654
857
  });
655
858
 
656
859
  describe("sanitizePluginName", () => {
@@ -116,7 +116,7 @@ describe("getPluginDetails", () => {
116
116
  source: {
117
117
  source: "github",
118
118
  repo: "example-org/caveman",
119
- ref: "v1",
119
+ ref: "1111111111111111111111111111111111111111",
120
120
  },
121
121
  description: "manifest description",
122
122
  homepage: "https://example.com/caveman",
@@ -152,7 +152,7 @@ describe("getPluginDetails", () => {
152
152
  expect(details.source).toEqual({
153
153
  kind: "github",
154
154
  repo: "example-org/caveman",
155
- ref: "v1",
155
+ ref: "1111111111111111111111111111111111111111",
156
156
  });
157
157
  expect(details.installed).toBe(false);
158
158
  expect(details.readme).toContain("Grug brain plugin");
@@ -176,7 +176,7 @@ describe("getPluginDetails", () => {
176
176
  source: {
177
177
  source: "github",
178
178
  repo: "example-org/caveman",
179
- ref: "v1.8.2",
179
+ ref: "2222222222222222222222222222222222222222",
180
180
  },
181
181
  },
182
182
  ],
@@ -215,16 +215,20 @@ describe("getPluginDetails", () => {
215
215
  );
216
216
 
217
217
  // THEN the external repo is read at the plugin's pinned ref, not `main`
218
- expect(contentsRefs.get("external")).toBe("v1.8.2");
219
- // AND the first-party collision probe still uses the catalog ref
220
- expect(contentsRefs.get("first-party")).toBe("main");
218
+ expect(contentsRefs.get("external")).toBe(
219
+ "2222222222222222222222222222222222222222",
220
+ );
221
+ // AND the marketplace entry owns the name, so the first-party directory is
222
+ // never probed (its same-named in-repo dir would be the adapter stub)
223
+ expect(contentsRefs.has("first-party")).toBe(false);
221
224
  // AND the README from the pinned ref is surfaced
222
225
  expect(details.readme).toContain("Caveman");
223
226
  });
224
227
 
225
- test("first-party directory wins a name also claimed by the marketplace", async () => {
226
- // GIVEN both a marketplace entry AND a first-party in-repo directory for
227
- // the same name
228
+ test("a marketplace entry owns a name shared by an in-repo stub dir", async () => {
229
+ // GIVEN a marketplace entry for "simple-memory"
230
+ // AND a same-named in-repo directory — caveman-style, this is the plugin's
231
+ // adapter stub, not a standalone first-party plugin
228
232
  const fetch = makeFetch({
229
233
  marketplace: {
230
234
  name: "vellum",
@@ -234,17 +238,17 @@ describe("getPluginDetails", () => {
234
238
  source: {
235
239
  source: "github",
236
240
  repo: "example-org/simple-memory",
237
- ref: "v9",
241
+ ref: "9999999999999999999999999999999999999999",
238
242
  },
239
243
  },
240
244
  ],
241
245
  },
242
246
  listings: {
243
- "vellum-ai/vellum-assistant/experimental/plugins/simple-memory": [
244
- fileEntry("README.md", "raw://fp/readme"),
247
+ "example-org/simple-memory": [
248
+ fileEntry("README.md", "raw://ext/readme"),
245
249
  ],
246
250
  },
247
- raw: { "raw://fp/readme": "# Simple Memory (first-party)" },
251
+ raw: { "raw://ext/readme": "# Simple Memory (external)" },
248
252
  });
249
253
 
250
254
  // WHEN we resolve the detail view
@@ -253,9 +257,14 @@ describe("getPluginDetails", () => {
253
257
  { fetch, workspacePluginsDir: workspace },
254
258
  );
255
259
 
256
- // THEN the first-party source is used, not the external one
257
- expect(details.source).toEqual({ kind: "first-party" });
258
- expect(details.readme).toContain("first-party");
260
+ // THEN the external marketplace source is used — the stub dir is an
261
+ // adapter overlay for the clone, so it does not override the claim
262
+ expect(details.source).toEqual({
263
+ kind: "github",
264
+ repo: "example-org/simple-memory",
265
+ ref: "9999999999999999999999999999999999999999",
266
+ });
267
+ expect(details.readme).toContain("external");
259
268
  });
260
269
 
261
270
  test("prefers an installed copy's README and package.json over the repo", async () => {
@@ -278,7 +287,7 @@ describe("getPluginDetails", () => {
278
287
  source: {
279
288
  source: "github",
280
289
  repo: "example-org/caveman",
281
- ref: "v1",
290
+ ref: "1111111111111111111111111111111111111111",
282
291
  },
283
292
  description: "manifest description",
284
293
  },
@@ -330,7 +339,7 @@ describe("getPluginDetails", () => {
330
339
  source: {
331
340
  source: "github",
332
341
  repo: "example-org/caveman",
333
- ref: "v1",
342
+ ref: "1111111111111111111111111111111111111111",
334
343
  },
335
344
  description: "manifest description",
336
345
  license: "MIT",
@@ -353,7 +362,7 @@ describe("getPluginDetails", () => {
353
362
  expect(details.source).toEqual({
354
363
  kind: "github",
355
364
  repo: "example-org/caveman",
356
- ref: "v1",
365
+ ref: "1111111111111111111111111111111111111111",
357
366
  });
358
367
  });
359
368