mirror of
https://github.com/vercel/vercel-plugin.git
synced 2026-09-14 15:39:47 +08:00
7566bed4d4
Claude session-start no longer appends VERCEL_PLUGIN_SEEN_SKILLS to CLAUDE_ENV_FILE now that claim-dir/session initialization handles dedup. Cursor still emits the empty env var payload on stdout. Verified: bun run build:hooks Verified: bun test tests/session-start-seen-skills.test.ts Verified: bun test tests/session-timeline-subagent.test.ts Verified: bun test hooks/session-hooks-platform-compat.test.ts Swarm-Agent: codex-seen-skills-init-cleanup
210 lines
7.8 KiB
TypeScript
210 lines
7.8 KiB
TypeScript
import { describe, test, expect, beforeEach } from "bun:test";
|
|
import { mkdtempSync, readFileSync, writeFileSync, 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 SESSION_START_SCRIPT = join(ROOT, "hooks", "session-start-seen-skills.mjs");
|
|
const UNLIMITED_BUDGET = "999999";
|
|
let testSession: string;
|
|
|
|
beforeEach(() => {
|
|
testSession = `timeline-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
});
|
|
|
|
async function runSessionStart(envFilePath: string): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
const proc = Bun.spawn(["node", SESSION_START_SCRIPT], {
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: { ...process.env, CLAUDE_ENV_FILE: envFilePath },
|
|
});
|
|
|
|
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 };
|
|
}
|
|
|
|
async function runHookEnv(
|
|
input: { tool_name: string; tool_input: Record<string, string> },
|
|
env: Record<string, string | undefined>,
|
|
opts?: { sessionId?: string },
|
|
): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
const payload = JSON.stringify({ ...input, session_id: opts?.sessionId ?? testSession });
|
|
const proc = Bun.spawn(["node", HOOK_SCRIPT], {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: {
|
|
...process.env,
|
|
VERCEL_PLUGIN_INJECTION_BUDGET: UNLIMITED_BUDGET,
|
|
...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 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;
|
|
|
|
if (!Array.isArray(injectedSkills)) {
|
|
return [];
|
|
}
|
|
|
|
return injectedSkills.filter((skill): skill is string => typeof skill === "string");
|
|
}
|
|
|
|
function parseDebugLines(stderr: string): Array<Record<string, unknown>> {
|
|
return stderr
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.length > 0)
|
|
.map((line) => {
|
|
try {
|
|
return JSON.parse(line) as Record<string, unknown>;
|
|
} catch {
|
|
return { line };
|
|
}
|
|
});
|
|
}
|
|
|
|
describe("session timeline subagent integration", () => {
|
|
test("session-start-seen-skills.mjs preserves CLAUDE_ENV_FILE contents", async () => {
|
|
const tempDir = mkdtempSync(join(tmpdir(), "session-timeline-lead-"));
|
|
const leadEnvPath = join(tempDir, "lead.env");
|
|
|
|
try {
|
|
writeFileSync(leadEnvPath, "export SEEDED=1\n", "utf-8");
|
|
|
|
const result = await runSessionStart(leadEnvPath);
|
|
expect(result.code).toBe(0);
|
|
expect(result.stdout).toBe("");
|
|
expect(result.stderr).toBe("");
|
|
|
|
const content = readFileSync(leadEnvPath, "utf-8");
|
|
expect(content).toBe("export SEEDED=1\n");
|
|
} finally {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("lead scaffold timeline dedups in the lead session but a fresh subagent gets its own nextjs injection", async () => {
|
|
const tempDir = mkdtempSync(join(tmpdir(), "session-timeline-subagent-"));
|
|
const leadEnvPath = join(tempDir, "lead.env");
|
|
const subagentEnvPath = join(tempDir, "subagent.env");
|
|
|
|
try {
|
|
writeFileSync(leadEnvPath, "", "utf-8");
|
|
writeFileSync(subagentEnvPath, "", "utf-8");
|
|
|
|
const leadSessionStart = await runSessionStart(leadEnvPath);
|
|
expect(leadSessionStart.code).toBe(0);
|
|
|
|
const leadScaffold = await runHookEnv(
|
|
{ tool_name: "Bash", tool_input: { command: "npx create-next-app@latest notion-clone --ts --app" } },
|
|
{ VERCEL_PLUGIN_SEEN_SKILLS: "", VERCEL_PLUGIN_HOOK_DEBUG: "1" },
|
|
);
|
|
|
|
expect(leadScaffold.code).toBe(0);
|
|
expect(parseInjectedSkills(leadScaffold.stdout)).toContain("nextjs");
|
|
|
|
const leadRead = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: "/Users/me/notion-clone/app/page.tsx" } },
|
|
{ VERCEL_PLUGIN_SEEN_SKILLS: "nextjs" },
|
|
);
|
|
|
|
expect(leadRead.code).toBe(0);
|
|
expect(JSON.parse(leadRead.stdout)).toEqual({});
|
|
|
|
const subagentSessionStart = await runSessionStart(subagentEnvPath);
|
|
expect(subagentSessionStart.code).toBe(0);
|
|
|
|
const subagentSession = `timeline-subagent-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
const subagentRead = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: "/Users/me/notion-clone/app/page.tsx" } },
|
|
{ VERCEL_PLUGIN_SEEN_SKILLS: "", VERCEL_PLUGIN_HOOK_DEBUG: "1" },
|
|
{ sessionId: subagentSession },
|
|
);
|
|
|
|
expect(subagentRead.code).toBe(0);
|
|
expect(parseInjectedSkills(subagentRead.stdout)).toContain("nextjs");
|
|
|
|
const debugLines = parseDebugLines(subagentRead.stderr);
|
|
const dedupStrategyLine = debugLines.find((line) => line.event === "dedup-strategy");
|
|
expect(dedupStrategyLine).toBeDefined();
|
|
expect(dedupStrategyLine?.strategy).toBe("file");
|
|
} finally {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("subagent with isolated session re-injects skills that the lead already deduped via file claims", async () => {
|
|
const tempDir2 = mkdtempSync(join(tmpdir(), "session-timeline-scope-"));
|
|
const leadEnvPath = join(tempDir2, "lead.env");
|
|
const subagentEnvPath = join(tempDir2, "subagent.env");
|
|
|
|
try {
|
|
writeFileSync(leadEnvPath, "", "utf-8");
|
|
writeFileSync(subagentEnvPath, "", "utf-8");
|
|
|
|
// Lead session start
|
|
const leadSessionStart = await runSessionStart(leadEnvPath);
|
|
expect(leadSessionStart.code).toBe(0);
|
|
|
|
// Lead injects nextjs via page.tsx read
|
|
const leadRead = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: "/Users/me/notion-clone/app/page.tsx" } },
|
|
{ VERCEL_PLUGIN_SEEN_SKILLS: "", VERCEL_PLUGIN_HOOK_DEBUG: "1" },
|
|
);
|
|
expect(leadRead.code).toBe(0);
|
|
const leadInjected = parseInjectedSkills(leadRead.stdout);
|
|
expect(leadInjected).toContain("nextjs");
|
|
|
|
// Lead second read — deduped
|
|
const leadSecond = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: "/Users/me/notion-clone/app/page.tsx" } },
|
|
{ VERCEL_PLUGIN_SEEN_SKILLS: leadInjected.join(",") },
|
|
);
|
|
expect(leadSecond.code).toBe(0);
|
|
expect(JSON.parse(leadSecond.stdout)).toEqual({});
|
|
|
|
// Subagent session start
|
|
const subagentSessionStart = await runSessionStart(subagentEnvPath);
|
|
expect(subagentSessionStart.code).toBe(0);
|
|
|
|
// Subagent with different session ID — scope isolation means nextjs re-injects
|
|
const subagentSession = `timeline-scope-sub-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
const subagentRead = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: "/Users/me/notion-clone/app/page.tsx" } },
|
|
{ VERCEL_PLUGIN_SEEN_SKILLS: "", VERCEL_PLUGIN_HOOK_DEBUG: "1" },
|
|
{ sessionId: subagentSession },
|
|
);
|
|
expect(subagentRead.code).toBe(0);
|
|
const subInjected = parseInjectedSkills(subagentRead.stdout);
|
|
expect(subInjected).toContain("nextjs");
|
|
|
|
// Verify it's a fresh file-based dedup scope
|
|
const subDebug = parseDebugLines(subagentRead.stderr);
|
|
const subDedup = subDebug.find((line) => line.event === "dedup-strategy");
|
|
expect(subDedup).toBeDefined();
|
|
expect(subDedup?.strategy).toBe("file");
|
|
} finally {
|
|
rmSync(tempDir2, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|