mirror of
https://github.com/vercel/vercel-plugin.git
synced 2026-09-14 15:39:47 +08:00
186 lines
6.8 KiB
TypeScript
186 lines
6.8 KiB
TypeScript
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
import { writeFileSync, readdirSync, rmSync } from "node:fs";
|
|
import { join, resolve } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
|
|
const ROOT = resolve(import.meta.dirname, "..");
|
|
const HOOK_SCRIPT = join(ROOT, "hooks", "pretooluse-skill-inject.mjs");
|
|
const UNLIMITED_BUDGET = "999999";
|
|
let testSession: string;
|
|
|
|
const EXPECTED_SLACK_ROUTE_SKILLS = ["chat-sdk", "vercel-functions", "nextjs"] as const;
|
|
|
|
function seedSeenSkills(skills: string[], session?: string): void {
|
|
const sid = session ?? testSession;
|
|
writeFileSync(join(tmpdir(), `vercel-plugin-${sid}-seen-skills.txt`), skills.join(","), "utf-8");
|
|
}
|
|
|
|
function cleanupSessionDedup(session?: string): void {
|
|
const prefix = `vercel-plugin-${session ?? testSession}-`;
|
|
try {
|
|
for (const entry of readdirSync(tmpdir())) {
|
|
if (entry.startsWith(prefix)) {
|
|
rmSync(join(tmpdir(), entry), { recursive: true, force: true });
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
testSession = `subagent-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanupSessionDedup();
|
|
});
|
|
|
|
async function runHookEnv(
|
|
input: { tool_name: string; tool_input: Record<string, string> },
|
|
env: Record<string, string | undefined>,
|
|
opts?: { omitSessionId?: boolean; omitSeenSkillsEnv?: boolean },
|
|
): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
const payload = opts?.omitSessionId
|
|
? JSON.stringify(input)
|
|
: JSON.stringify({ ...input, session_id: testSession });
|
|
const childEnv: Record<string, string | undefined> = {
|
|
...process.env,
|
|
VERCEL_PLUGIN_INJECTION_BUDGET: UNLIMITED_BUDGET,
|
|
...env,
|
|
};
|
|
delete childEnv.VERCEL_PLUGIN_SEEN_SKILLS;
|
|
const proc = Bun.spawn(["node", HOOK_SCRIPT], {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: childEnv,
|
|
});
|
|
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 parseInjectedSkills(stdout: string): string[] {
|
|
const parsed = JSON.parse(stdout);
|
|
const ctx = parsed?.hookSpecificOutput?.additionalContext || "";
|
|
const match = ctx.match(/<!-- skillInjection: (\{.*?\}) -->/);
|
|
const si = match ? JSON.parse(match[1]) : {};
|
|
const injectedSkills = si.injectedSkills;
|
|
return Array.isArray(injectedSkills) ? injectedSkills : [];
|
|
}
|
|
|
|
function parseDebugLines(stderr: string): Array<Record<string, unknown>> {
|
|
return stderr
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.length > 0)
|
|
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
|
}
|
|
|
|
describe("subagent fresh env dedup behavior", () => {
|
|
const slackRoutePath = "/Users/me/slack-clone/app/api/slack/route.ts";
|
|
|
|
test("fresh subagent without inherited seen-skills re-injects the same skills as the lead agent", async () => {
|
|
// Lead agent injects skills
|
|
const { code: leadCode, stdout: leadStdout } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: slackRoutePath } },
|
|
{ VERCEL_PLUGIN_HOOK_DEBUG: "1" },
|
|
);
|
|
|
|
expect(leadCode).toBe(0);
|
|
const leadInjected = parseInjectedSkills(leadStdout);
|
|
expect(leadInjected).toEqual(EXPECTED_SLACK_ROUTE_SKILLS);
|
|
|
|
// Second call with file-based dedup — should be deduped
|
|
seedSeenSkills([...leadInjected]);
|
|
const { code: leadSecondCode, stdout: leadSecondStdout } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: slackRoutePath } },
|
|
{},
|
|
);
|
|
|
|
expect(leadSecondCode).toBe(0);
|
|
expect(JSON.parse(leadSecondStdout)).toEqual({});
|
|
|
|
// Fresh subagent with a new session — no dedup state — re-injects
|
|
const leadSession = testSession;
|
|
const subagentSession = `subagent-child-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
testSession = subagentSession;
|
|
let subagentResult: { code: number; stdout: string; stderr: string };
|
|
try {
|
|
subagentResult = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: slackRoutePath } },
|
|
{ VERCEL_PLUGIN_HOOK_DEBUG: "1" },
|
|
);
|
|
} finally {
|
|
cleanupSessionDedup(subagentSession);
|
|
testSession = leadSession;
|
|
}
|
|
|
|
expect(subagentResult.code).toBe(0);
|
|
expect(parseInjectedSkills(subagentResult.stdout)).toEqual(EXPECTED_SLACK_ROUTE_SKILLS);
|
|
|
|
const subagentDebugLines = parseDebugLines(subagentResult.stderr);
|
|
const dedupStrategy = subagentDebugLines.find((line) => line.event === "dedup-strategy");
|
|
expect(dedupStrategy).toBeDefined();
|
|
expect(dedupStrategy?.strategy).toBe("file");
|
|
});
|
|
|
|
test("fresh subagent uses file strategy and injects skills", async () => {
|
|
const { code, stdout, stderr } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: slackRoutePath } },
|
|
{ VERCEL_PLUGIN_HOOK_DEBUG: "1" },
|
|
);
|
|
|
|
expect(code).toBe(0);
|
|
expect(parseInjectedSkills(stdout)).toEqual(EXPECTED_SLACK_ROUTE_SKILLS);
|
|
|
|
const debugLines = parseDebugLines(stderr);
|
|
const dedupStrategy = debugLines.find((line) => line.event === "dedup-strategy");
|
|
expect(dedupStrategy).toBeDefined();
|
|
expect(dedupStrategy?.strategy).toBe("file");
|
|
});
|
|
|
|
test("subagent that inherits the lead seen-skills via file dedup is deduped", async () => {
|
|
seedSeenSkills(["chat-sdk", "vercel-functions", "nextjs"]);
|
|
const { code, stdout } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: slackRoutePath } },
|
|
{},
|
|
);
|
|
|
|
expect(code).toBe(0);
|
|
expect(JSON.parse(stdout)).toEqual({});
|
|
});
|
|
|
|
test("subagent with agent_id uses isolated scope so parent dedup does not suppress it", async () => {
|
|
// Lead agent injects skills first
|
|
const { code: leadCode, stdout: leadStdout } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: slackRoutePath } },
|
|
{},
|
|
);
|
|
expect(leadCode).toBe(0);
|
|
const leadInjected = parseInjectedSkills(leadStdout);
|
|
expect(leadInjected).toEqual(EXPECTED_SLACK_ROUTE_SKILLS);
|
|
|
|
// Subagent with distinct session and no inherited seen-skills
|
|
// simulates scope isolation — skills re-inject
|
|
const subSession = `subagent-scope-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
const origSession = testSession;
|
|
testSession = subSession;
|
|
try {
|
|
const { code, stdout } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: slackRoutePath } },
|
|
{ VERCEL_PLUGIN_HOOK_DEBUG: "1" },
|
|
{ omitSeenSkillsEnv: true },
|
|
);
|
|
expect(code).toBe(0);
|
|
const subInjected = parseInjectedSkills(stdout);
|
|
// Subagent gets the same skills despite parent having injected them
|
|
expect(subInjected).toEqual(EXPECTED_SLACK_ROUTE_SKILLS);
|
|
} finally {
|
|
testSession = origSession;
|
|
}
|
|
});
|
|
});
|