Files
vercel__vercel-plugin/tests/subagent-start-context.test.ts
John Lindquist 8e24d0694a feat: add chainTo skill chaining, prompt signals, validation upgrades, and dedup reset on context clear
Add a chainTo frontmatter field to skills that triggers follow-up skill injection
when PostToolUse file contents match regex patterns. Add upgradeToSkill/upgradeWhy
fields to validation rules so validate errors can recommend loading a specific skill.
Register posttooluse-bash-chain.mjs in hooks.json. Add ChainToRule interface to
patterns.mts and skill-map-frontmatter.mts, with parseChainToRules() parser. Add
duplicate-key detection to the YAML parser. Reset dedup claim dir and session file
on clear/compact events in session-start-seen-skills so skills re-inject into fresh
context windows.

Add chainTo rules to: agent-browser-verify, agent-browser, ai-elements, ai-gateway,
ai-generation-persistence, ai-sdk, auth, bootstrap, chat-sdk, cms, cron-jobs,
deployments-cicd, email, env-vars, geist, investigation-mode, json-render,
marketplace, micro, ncc, next-forge, nextjs, observability, payments,
react-best-practices, routing-middleware, runtime-cache, satori, shadcn,
sign-in-with-vercel, swr, turbopack, turborepo, v0-dev, vercel-agent, vercel-api,
vercel-cli, vercel-firewall, vercel-flags, vercel-functions, vercel-queues,
vercel-sandbox, vercel-storage, verification, workflow. Add upgradeToSkill to
ai-elements and ai-sdk validate rules. Expand ai-sdk validate messages with
Run Skill() hints. Update nextjs, vercel-storage, runtime-cache, workflow, turborepo
skill bodies.

Add new skills: geistdocs (Geist design system docs), zzz-test-meta-name-mask
(test fixture). Add skills/_chain-audit.md chain coverage audit doc.

Delete .claude-plugin/marketplace.json, .claude-plugin/plugin.json (deprecated),
skills/edge-runtime/SKILL.md (consolidated into vercel-functions).

Add tests: posttooluse-chain.test.ts (4699 lines, chain injection e2e),
ai-sdk-companion.test.ts (181 lines). Expand build-skill-map.test.ts (+335 lines),
validate-rules.test.ts (+936 lines), session-start-seen-skills.test.ts (+74 lines),
skill-map-frontmatter.test.ts (+50 lines), verification-skill.test.ts (+20 lines).

Update build-manifest.ts to emit chainTo rules and upgradeToSkill fields. Rebuild
generated/skill-manifest.json, generated/skill-catalog.md, generated/build-from-skills.manifest.json.
Rebuild all compiled hooks/*.mjs. Update CLAUDE.md lexical prompt default to on.
Update vercel.md ecosystem graph, docs, and cli-reference.
2026-03-11 15:18:35 -06:00

333 lines
9.5 KiB
TypeScript

import { describe, test, expect, beforeEach } from "bun:test";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { appendPendingLaunch, type PendingLaunch } from "../hooks/src/subagent-state.mts";
const ROOT = resolve(import.meta.dirname, "..");
const HOOK_SCRIPT = join(ROOT, "hooks", "subagent-start-bootstrap.mjs");
let testSession: string;
let tempDir: string;
beforeEach(() => {
testSession = `subagent-ctx-${Date.now()}-${Math.random().toString(36).slice(2)}`;
tempDir = mkdtempSync(join(tmpdir(), "subagent-ctx-"));
});
/**
* Run the SubagentStart bootstrap hook by piping JSON on stdin.
*/
async function runSubagentStart(
input: {
session_id?: string;
agent_id?: string;
agent_type?: string;
cwd?: string;
},
env?: Record<string, string | undefined>,
): Promise<{ code: number; stdout: string; stderr: string }> {
const payload = JSON.stringify({
session_id: testSession,
hook_event_name: "SubagentStart",
...input,
});
const proc = Bun.spawn(["node", HOOK_SCRIPT], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
VERCEL_PLUGIN_LOG_LEVEL: "off",
...env,
},
});
proc.stdin.write(payload);
proc.stdin.end();
const code = await proc.exited;
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
return { code, stdout, stderr };
}
function parseContext(stdout: string): string {
if (!stdout.trim()) return "";
const parsed = JSON.parse(stdout);
return parsed?.hookSpecificOutput?.additionalContext || "";
}
/**
* Write a fake profile cache to disk so the hook can read it.
*/
function writeProfileCache(sessionId: string, likelySkills: string[]): void {
// Profile cache path follows: <tmpdir>/vercel-plugin-<sessionId>-profile.json
const cachePath = join(tmpdir(), `vercel-plugin-${sessionId}-profile.json`);
writeFileSync(
cachePath,
JSON.stringify({
projectRoot: "/Users/me/project",
likelySkills,
greenfield: false,
bootstrapHints: [],
resourceHints: [],
setupMode: false,
agentBrowserAvailable: false,
timestamp: new Date().toISOString(),
}),
"utf-8",
);
}
function cleanupProfileCache(sessionId: string): void {
const cachePath = join(tmpdir(), `vercel-plugin-${sessionId}-profile.json`);
try {
rmSync(cachePath, { force: true });
} catch {}
}
function cleanupPendingLaunches(sessionId: string): void {
const pendingLaunchPath = join(tmpdir(), `vercel-plugin-${sessionId}-pending-launches.jsonl`);
try {
rmSync(pendingLaunchPath, { force: true });
rmSync(`${pendingLaunchPath}.lock`, { force: true });
} catch {}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("subagent-start-context: additionalContext per agent type", () => {
test("Explore agent receives minimal budget context", async () => {
writeProfileCache(testSession, ["nextjs", "vercel-storage"]);
try {
const { code, stdout } = await runSubagentStart({
agent_id: "explore-1",
agent_type: "Explore",
});
expect(code).toBe(0);
const ctx = parseContext(stdout);
expect(ctx).toContain('budget="minimal"');
expect(ctx).toContain('agent_type="Explore"');
expect(ctx).toContain("nextjs");
expect(ctx).toContain("vercel-storage");
} finally {
cleanupProfileCache(testSession);
}
});
test("Plan agent receives light budget context with summaries", async () => {
writeProfileCache(testSession, ["nextjs", "vercel-storage"]);
try {
const { code, stdout } = await runSubagentStart({
agent_id: "plan-1",
agent_type: "Plan",
});
expect(code).toBe(0);
const ctx = parseContext(stdout);
expect(ctx).toContain('budget="light"');
expect(ctx).toContain('agent_type="Plan"');
expect(ctx).toContain("nextjs");
} finally {
cleanupProfileCache(testSession);
}
});
test("general-purpose agent receives standard budget context", async () => {
writeProfileCache(testSession, ["nextjs"]);
try {
const { code, stdout } = await runSubagentStart({
agent_id: "gp-1",
agent_type: "general-purpose",
});
expect(code).toBe(0);
const ctx = parseContext(stdout);
expect(ctx).toContain('budget="standard"');
expect(ctx).toContain('agent_type="general-purpose"');
expect(ctx).toContain("nextjs");
} finally {
cleanupProfileCache(testSession);
}
});
test("unknown agent type falls back to standard budget", async () => {
writeProfileCache(testSession, ["nextjs"]);
try {
const { code, stdout } = await runSubagentStart({
agent_id: "custom-1",
agent_type: "my-custom-agent",
});
expect(code).toBe(0);
const ctx = parseContext(stdout);
expect(ctx).toContain('budget="standard"');
expect(ctx).toContain('agent_type="my-custom-agent"');
} finally {
cleanupProfileCache(testSession);
}
});
});
describe("subagent-start-context: budget enforcement", () => {
test("Explore context stays within 1KB budget", async () => {
// Give it many skills to potentially exceed budget
writeProfileCache(testSession, [
"nextjs", "vercel-storage", "ai-sdk", "shadcn", "auth",
"vercel-functions", "turborepo",
]);
try {
const { code, stdout } = await runSubagentStart({
agent_id: "explore-budget",
agent_type: "Explore",
});
expect(code).toBe(0);
const ctx = parseContext(stdout);
expect(Buffer.byteLength(ctx, "utf8")).toBeLessThanOrEqual(1024);
} finally {
cleanupProfileCache(testSession);
}
});
test("Plan context stays within 3KB budget", async () => {
writeProfileCache(testSession, [
"nextjs", "vercel-storage", "ai-sdk", "shadcn", "auth",
"vercel-functions", "turborepo",
]);
try {
const { code, stdout } = await runSubagentStart({
agent_id: "plan-budget",
agent_type: "Plan",
});
expect(code).toBe(0);
const ctx = parseContext(stdout);
expect(Buffer.byteLength(ctx, "utf8")).toBeLessThanOrEqual(3072);
} finally {
cleanupProfileCache(testSession);
}
});
test("general-purpose context stays within 8KB budget", async () => {
writeProfileCache(testSession, [
"nextjs", "vercel-storage", "ai-sdk", "shadcn", "auth",
"vercel-functions", "turborepo",
]);
try {
const { code, stdout } = await runSubagentStart({
agent_id: "gp-budget",
agent_type: "general-purpose",
});
expect(code).toBe(0);
const ctx = parseContext(stdout);
expect(Buffer.byteLength(ctx, "utf8")).toBeLessThanOrEqual(8000);
} finally {
cleanupProfileCache(testSession);
}
});
});
describe("subagent-start-context: profile cache and fallback", () => {
test("falls back to VERCEL_PLUGIN_LIKELY_SKILLS env when no profile cache", async () => {
// No profile cache written — should fall back to env var
const { code, stdout } = await runSubagentStart(
{
agent_id: "fallback-1",
agent_type: "general-purpose",
},
{
VERCEL_PLUGIN_LIKELY_SKILLS: "nextjs,ai-sdk",
},
);
expect(code).toBe(0);
const ctx = parseContext(stdout);
expect(ctx).toContain("nextjs");
expect(ctx).toContain("ai-sdk");
});
test("returns context even with no likely skills", async () => {
const { code, stdout } = await runSubagentStart(
{
agent_id: "empty-1",
agent_type: "Explore",
},
{
VERCEL_PLUGIN_LIKELY_SKILLS: "",
},
);
expect(code).toBe(0);
const ctx = parseContext(stdout);
expect(ctx).toContain("Vercel plugin active");
expect(ctx).toContain("unknown stack");
});
test("merges pending launch prompt matches into likely skills before context assembly", async () => {
writeProfileCache(testSession, ["nextjs"]);
const pendingLaunch: PendingLaunch = {
description: "Plan a durable workflow",
prompt: "Use Workflow DevKit retries and step orchestration for this task",
subagent_type: "Plan",
createdAt: Date.now(),
};
appendPendingLaunch(testSession, pendingLaunch);
try {
const firstRun = await runSubagentStart({
agent_id: "plan-prompt",
agent_type: "Plan",
});
expect(firstRun.code).toBe(0);
const firstContext = parseContext(firstRun.stdout);
expect(firstContext).toContain("Project likely uses: workflow, nextjs.");
const secondRun = await runSubagentStart({
agent_id: "plan-prompt-2",
agent_type: "Plan",
});
expect(secondRun.code).toBe(0);
const secondContext = parseContext(secondRun.stdout);
expect(secondContext).toContain("Project likely uses: nextjs.");
} finally {
cleanupProfileCache(testSession);
cleanupPendingLaunches(testSession);
}
});
test("empty stdin produces empty output (no crash)", async () => {
const proc = Bun.spawn(["node", HOOK_SCRIPT], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
env: { ...process.env, VERCEL_PLUGIN_LOG_LEVEL: "off" },
});
proc.stdin.write("");
proc.stdin.end();
const code = await proc.exited;
const stdout = await new Response(proc.stdout).text();
expect(code).toBe(0);
expect(stdout.trim()).toBe("");
});
});