@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.
- package/docs/activation-funnel-telemetry.md +310 -0
- package/package.json +1 -1
- package/src/__tests__/activation-early-marking.test.ts +120 -0
- package/src/__tests__/agent-loop-output-hooks.test.ts +13 -13
- package/src/__tests__/approval-cascade.test.ts +1 -1
- package/src/__tests__/compaction-direct.test.ts +32 -18
- package/src/__tests__/compaction-events.test.ts +2 -2
- package/src/__tests__/compaction.benchmark.test.ts +1 -1
- package/src/__tests__/context-overflow-reducer.test.ts +5 -5
- package/src/__tests__/context-window-manager-compact-retry.test.ts +1 -1
- package/src/__tests__/conversation-abort-tool-results.test.ts +1 -1
- package/src/__tests__/conversation-confirmation-signals.test.ts +1 -1
- package/src/__tests__/conversation-error.test.ts +15 -1
- package/src/__tests__/conversation-history-web-search.test.ts +5 -0
- package/src/__tests__/conversation-media-retry.test.ts +1 -1
- package/src/__tests__/conversation-process-app-control-preactivation.test.ts +40 -0
- package/src/__tests__/conversation-process-callsite.test.ts +1 -1
- package/src/__tests__/conversation-provider-retry-repair.test.ts +1 -1
- package/src/__tests__/conversation-queue.test.ts +1 -1
- package/src/__tests__/conversation-runtime-assembly.test.ts +71 -0
- package/src/__tests__/conversation-slash-queue.test.ts +1 -1
- package/src/__tests__/conversation-slash-unknown.test.ts +1 -1
- package/src/__tests__/conversation-speed-override.test.ts +1 -1
- package/src/__tests__/conversation-surfaces-activation-emit.test.ts +395 -0
- package/src/__tests__/conversation-surfaces-app-control.test.ts +44 -0
- package/src/__tests__/conversation-undo.test.ts +2 -2
- package/src/__tests__/conversation-workspace-cache-state.test.ts +1 -1
- package/src/__tests__/conversation-workspace-injection.test.ts +1 -1
- package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -1
- package/src/__tests__/cu-unified-flow.test.ts +36 -0
- package/src/__tests__/history-repair-hook.test.ts +2 -0
- package/src/__tests__/memory-retrieval-hook.test.ts +1 -2
- package/src/__tests__/persist-unsendable-image-downscale.test.ts +145 -0
- package/src/__tests__/persist-unsendable-image.test.ts +97 -1
- package/src/__tests__/post-turn-tool-result-truncation.test.ts +69 -0
- package/src/__tests__/skill-feature-flags-integration.test.ts +5 -7
- package/src/__tests__/title-generate-hook.test.ts +2 -0
- package/src/__tests__/web-fetch.test.ts +45 -0
- package/src/acp/__tests__/helpers/acp-config-stub.ts +0 -2
- package/src/acp/resolve-agent.test.ts +0 -56
- package/src/acp/resolve-agent.ts +10 -38
- package/src/agent/loop.ts +13 -27
- package/src/cli/lib/__tests__/install-from-github.test.ts +232 -29
- package/src/cli/lib/__tests__/plugin-details.test.ts +28 -19
- package/src/cli/lib/__tests__/plugin-marketplace.test.ts +57 -7
- package/src/cli/lib/__tests__/search-plugins.test.ts +17 -10
- package/src/cli/lib/install-from-github.ts +258 -41
- package/src/cli/lib/plugin-details.ts +20 -13
- package/src/cli/lib/plugin-marketplace.ts +23 -5
- package/src/cli/lib/search-plugins.ts +14 -8
- package/src/config/acp-defaults.ts +3 -3
- package/src/config/acp-schema.ts +1 -7
- package/src/config/bundled-skills/acp/SKILL.md +4 -17
- package/src/config/bundled-skills/acp/TOOLS.json +2 -2
- package/src/config/feature-flag-registry.json +3 -18
- package/src/context/post-turn-tool-result-truncation.ts +39 -1
- package/src/daemon/conversation-agent-loop-handlers.ts +8 -1
- package/src/daemon/conversation-agent-loop.ts +78 -78
- package/src/daemon/conversation-error.ts +31 -4
- package/src/daemon/conversation-history.ts +1 -1
- package/src/daemon/conversation-media-retry.ts +19 -6
- package/src/daemon/conversation-messaging.ts +17 -0
- package/src/daemon/conversation-process.ts +14 -5
- package/src/daemon/conversation-queue-manager.ts +8 -0
- package/src/daemon/conversation-runtime-assembly.ts +37 -1
- package/src/daemon/conversation-surfaces.ts +141 -3
- package/src/daemon/conversation.ts +48 -13
- package/src/daemon/persist-unsendable-image.ts +62 -25
- package/src/daemon/process-message.ts +1 -1
- package/src/memory/__tests__/activation-session-store.test.ts +41 -0
- package/src/memory/__tests__/onboarding-events-store.test.ts +80 -0
- package/src/memory/activation-session-store.ts +43 -0
- package/src/memory/db-init.ts +4 -0
- package/src/memory/migrations/273-onboarding-events-funnel-columns.ts +46 -0
- package/src/memory/migrations/274-create-activation-sessions.ts +15 -0
- package/src/memory/migrations/index.ts +2 -0
- package/src/memory/onboarding-events-store.ts +66 -18
- package/src/memory/schema/infrastructure.ts +13 -0
- package/src/messaging/providers/telegram-bot/api.ts +14 -5
- package/src/notifications/adapters/telegram.ts +7 -1
- package/src/plugin-api/constants.ts +2 -2
- package/src/plugin-api/index.ts +2 -2
- package/src/plugin-api/types.ts +19 -5
- package/src/plugins/defaults/compaction/compact.ts +24 -15
- package/src/plugins/defaults/compaction/context-overflow-reducer.ts +4 -4
- package/src/plugins/defaults/compaction/manager-store.ts +1 -1
- package/src/{context → plugins/defaults/compaction}/window-manager.ts +12 -12
- package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +12 -18
- package/src/prompts/system-prompt.ts +61 -10
- package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +37 -2
- package/src/providers/openai/__tests__/vision-not-supported.test.ts +75 -0
- package/src/providers/openai/chat-completions-provider.ts +25 -0
- package/src/runtime/routes/__tests__/stt-routes.test.ts +112 -0
- package/src/runtime/routes/acp-routes.test.ts +3 -20
- package/src/runtime/routes/conversation-routes.ts +2 -0
- package/src/runtime/routes/playground/__tests__/force-compact.test.ts +1 -1
- package/src/runtime/routes/stt-routes.ts +45 -12
- package/src/runtime/routes/workspace-routes.ts +50 -15
- package/src/telemetry/__tests__/activation-funnel.test.ts +95 -0
- package/src/telemetry/activation-funnel.ts +167 -0
- package/src/telemetry/types.ts +13 -0
- package/src/telemetry/usage-telemetry-reporter.test.ts +154 -0
- package/src/telemetry/usage-telemetry-reporter.ts +26 -1
- package/src/tools/acp/list-agents.test.ts +2 -18
- package/src/tools/acp/list-agents.ts +3 -15
- package/src/tools/acp/spawn.test.ts +0 -10
- package/src/tools/browser/browser-execution.ts +12 -2
- package/src/tools/network/web-fetch.ts +65 -24
- package/src/tools/ui-surface/definitions.ts +7 -0
- package/src/acp/feature-gate.test.ts +0 -48
- 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
|
-
|
|
19
|
+
PostModelCallContext,
|
|
21
20
|
PostToolUseContext,
|
|
22
21
|
PreModelCallContext,
|
|
23
22
|
StopContext,
|
|
24
23
|
} from "../plugin-api/types.js";
|
|
25
|
-
import {
|
|
26
|
-
|
|
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;
|
|
734
|
-
//
|
|
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
|
-
|
|
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
|
-
// `
|
|
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 `
|
|
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 `
|
|
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:
|
|
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.
|
|
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
|
-
"
|
|
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 `
|
|
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: "
|
|
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
|
|
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: "
|
|
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("
|
|
532
|
-
expect(result.commit).toBe("
|
|
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("
|
|
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(
|
|
557
|
-
|
|
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:
|
|
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("
|
|
622
|
-
// GIVEN
|
|
623
|
-
// AND
|
|
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
|
-
"
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
|
|
708
|
+
commit: "63a91ecadbf4c4719a4602a5abb00883f9966034",
|
|
632
709
|
});
|
|
633
710
|
|
|
634
|
-
// WHEN we install
|
|
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
|
|
714
|
+
{ fetch, runGit, workspacePluginsDir: pluginsDir },
|
|
639
715
|
);
|
|
640
716
|
|
|
641
|
-
// THEN the
|
|
642
|
-
//
|
|
643
|
-
//
|
|
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.
|
|
646
|
-
|
|
647
|
-
expect(
|
|
648
|
-
|
|
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
|
-
|
|
651
|
-
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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(
|
|
219
|
-
|
|
220
|
-
|
|
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("
|
|
226
|
-
// GIVEN
|
|
227
|
-
//
|
|
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: "
|
|
241
|
+
ref: "9999999999999999999999999999999999999999",
|
|
238
242
|
},
|
|
239
243
|
},
|
|
240
244
|
],
|
|
241
245
|
},
|
|
242
246
|
listings: {
|
|
243
|
-
"
|
|
244
|
-
fileEntry("README.md", "raw://
|
|
247
|
+
"example-org/simple-memory": [
|
|
248
|
+
fileEntry("README.md", "raw://ext/readme"),
|
|
245
249
|
],
|
|
246
250
|
},
|
|
247
|
-
raw: { "raw://
|
|
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
|
|
257
|
-
|
|
258
|
-
expect(details.
|
|
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: "
|
|
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: "
|
|
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: "
|
|
365
|
+
ref: "1111111111111111111111111111111111111111",
|
|
357
366
|
});
|
|
358
367
|
});
|
|
359
368
|
|