Files
vercel__vercel-plugin/tests/pretooluse-policy-recall.test.ts
John Lindquist bb7f86156a feat: diagnose companion recall routing
Expose companion recall in session diagnostics so operators can verify that verified-companion routing remains causal, synthetic, and subordinate to stronger direct or policy-driven matches.

Locking these expectations into regression coverage reduces the risk of attribution drift, hidden policy contamination, and debugging blind spots as routing logic evolves.

Ploop-Iter: 2
2026-03-28 07:55:21 -07:00

649 lines
21 KiB
TypeScript

import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { writeFileSync, mkdirSync, rmSync, unlinkSync } from "node:fs";
import { join, resolve } from "node:path";
import {
createEmptyRoutingPolicy,
type RoutingPolicyFile,
} from "../hooks/src/routing-policy.mts";
import {
projectPolicyPath,
saveProjectRoutingPolicy,
} from "../hooks/src/routing-policy-ledger.mts";
import {
statePath as verificationStatePath,
} from "../hooks/src/verification-ledger.mts";
import {
readRoutingDecisionTrace,
traceDir,
} from "../hooks/src/routing-decision-trace.mts";
import {
tryClaimSessionKey,
dedupClaimDirPath,
} from "../hooks/src/hook-env.mts";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const ROOT = resolve(import.meta.dirname, "..");
const HOOK_SCRIPT = join(ROOT, "hooks", "pretooluse-skill-inject.mjs");
const TEST_PROJECT = "/tmp/test-pretooluse-policy-recall-" + Date.now();
const T0 = "2026-03-27T19:00:00.000Z";
const T1 = "2026-03-27T19:01:00.000Z";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Write a mock verification plan so loadCachedPlanResult returns a story. */
function writeMockPlanState(sessionId: string, opts?: {
storyKind?: string;
route?: string | null;
targetBoundary?: string;
}): void {
const sp = verificationStatePath(sessionId);
mkdirSync(join(sp, ".."), { recursive: true });
writeFileSync(sp, JSON.stringify({
version: 1,
stories: [{
id: "recall-story-1",
kind: opts?.storyKind ?? "flow-verification",
route: opts?.route ?? "/settings",
promptExcerpt: "test policy recall",
createdAt: T0,
updatedAt: T1,
requestedSkills: [],
}],
observationIds: [],
satisfiedBoundaries: [],
missingBoundaries: [opts?.targetBoundary ?? "clientRequest"],
recentRoutes: [],
primaryNextAction: {
targetBoundary: opts?.targetBoundary ?? "clientRequest",
suggestedAction: "curl http://localhost:3000/settings",
},
blockedReasons: [],
}));
}
function cleanupPlanState(sessionId: string): void {
const sp = verificationStatePath(sessionId);
try { rmSync(join(sp, ".."), { recursive: true, force: true }); } catch {}
}
function cleanupPolicyFile(): void {
try { unlinkSync(projectPolicyPath(TEST_PROJECT)); } catch {}
}
/** Build a policy with a strong verified winner for a given scenario key. */
function buildStrongPolicy(
skill: string,
scenarioKey: string,
overrides?: Partial<{ exposures: number; wins: number; directiveWins: number; staleMisses: number }>,
): RoutingPolicyFile {
const policy = createEmptyRoutingPolicy();
policy.scenarios[scenarioKey] = {
[skill]: {
exposures: overrides?.exposures ?? 5,
wins: overrides?.wins ?? 5,
directiveWins: overrides?.directiveWins ?? 2,
staleMisses: overrides?.staleMisses ?? 0,
lastUpdatedAt: T0,
},
};
return policy;
}
/** Run PreToolUse hook as subprocess with cwd pointing to TEST_PROJECT. */
async function runHook(
toolName: string,
toolInput: Record<string, unknown>,
sessionId: string,
extraEnv?: Record<string, string>,
): Promise<{ code: number; stdout: string; stderr: string; parsed: Record<string, unknown> | null }> {
const payload = JSON.stringify({
tool_name: toolName,
tool_input: toolInput,
session_id: sessionId,
cwd: TEST_PROJECT,
});
const proc = Bun.spawn(["node", HOOK_SCRIPT], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
VERCEL_PLUGIN_SEEN_SKILLS: "",
VERCEL_PLUGIN_LOG_LEVEL: "debug",
...extraEnv,
},
});
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();
let parsed: Record<string, unknown> | null = null;
try { parsed = JSON.parse(stdout); } catch {}
return { code, stdout, stderr, parsed };
}
/** Extract skillInjection metadata from hook output. */
function extractInjectionMeta(stdout: string): {
matchedSkills: string[];
injectedSkills: string[];
reasons: Record<string, { trigger: string; reasonCode: string }>;
} | null {
try {
const output = JSON.parse(stdout);
const ctx = output.hookSpecificOutput?.additionalContext || "";
const match = ctx.match(/<!-- skillInjection: (\{.*?\}) -->/);
if (!match) return null;
return JSON.parse(match[1]);
} catch {
return null;
}
}
/** Parse structured stderr log lines. */
function parseLogLines(stderr: string): Array<Record<string, unknown>> {
return stderr
.split("\n")
.filter((l) => l.trim())
.map((l) => { try { return JSON.parse(l); } catch { return null; } })
.filter((o): o is Record<string, unknown> => o !== null);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("pretooluse policy recall integration", () => {
let sessionId: string;
beforeEach(() => {
sessionId = `recall-test-${Date.now()}-${Math.random().toString(36).slice(2)}`;
mkdirSync(TEST_PROJECT, { recursive: true });
});
afterEach(() => {
cleanupPlanState(sessionId);
cleanupPolicyFile();
try { rmSync(traceDir(sessionId), { recursive: true, force: true }); } catch {}
// Clean up file-based dedup claims
try { rmSync(dedupClaimDirPath(sessionId, "seen-skills"), { recursive: true, force: true }); } catch {}
});
test("recalls a verified winner when pattern matching misses it", async () => {
// Seed plan state with story route /settings and target boundary clientRequest
writeMockPlanState(sessionId, {
storyKind: "flow-verification",
route: "/settings",
targetBoundary: "clientRequest",
});
// Seed policy with strong exact-route bucket for "verification" skill
const policy = buildStrongPolicy(
"verification",
"PreToolUse|flow-verification|clientRequest|Bash|/settings",
);
saveProjectRoutingPolicy(TEST_PROJECT, policy);
// Run PreToolUse with a command that won't pattern-match "verification"
const result = await runHook(
"Bash",
{ command: "echo hello" },
sessionId,
);
expect(result.code).toBe(0);
// Check debug logs for policy-recall-injected
const logs = parseLogLines(result.stderr);
const recallLog = logs.find((l) => l.event === "policy-recall-injected");
expect(recallLog).toBeDefined();
expect(recallLog!.skill).toBe("verification");
expect(recallLog!.scenario).toBe("PreToolUse|flow-verification|clientRequest|Bash|/settings");
// Check injection metadata
const meta = extractInjectionMeta(result.stdout);
if (meta) {
expect(meta.injectedSkills).toContain("verification");
expect(meta.reasons?.verification?.trigger).toBe("policy-recall");
expect(meta.reasons?.verification?.reasonCode).toBe("route-scoped-verified-policy-recall");
}
});
test("recalled skill is NOT forced into summary-only mode (summary path is identical to full)", async () => {
writeMockPlanState(sessionId, {
storyKind: "flow-verification",
route: "/settings",
targetBoundary: "clientRequest",
});
const policy = buildStrongPolicy(
"verification",
"PreToolUse|flow-verification|clientRequest|Bash|/settings",
);
saveProjectRoutingPolicy(TEST_PROJECT, policy);
const result = await runHook(
"Bash",
{ command: "echo hello" },
sessionId,
);
expect(result.code).toBe(0);
// Recalled skill should NOT be marked summary-only since the summary and
// full payloads are identical (both use skillInvocationMessage).
const traces = readRoutingDecisionTrace(sessionId);
if (traces.length > 0) {
const recallEntry = traces[0].ranked?.find(
(r: { skill: string }) => r.skill === "verification",
);
if (recallEntry) {
expect(recallEntry.summaryOnly).toBe(false);
expect(recallEntry.synthetic).toBe(true);
}
}
});
test("decision trace marks recalled skill as synthetic with policy-recall reason", async () => {
writeMockPlanState(sessionId, {
storyKind: "flow-verification",
route: "/settings",
targetBoundary: "clientRequest",
});
const policy = buildStrongPolicy(
"verification",
"PreToolUse|flow-verification|clientRequest|Bash|/settings",
);
saveProjectRoutingPolicy(TEST_PROJECT, policy);
const result = await runHook(
"Bash",
{ command: "echo hello" },
sessionId,
);
expect(result.code).toBe(0);
const traces = readRoutingDecisionTrace(sessionId);
expect(traces.length).toBeGreaterThanOrEqual(1);
const recallEntry = traces[0].ranked?.find(
(r: { skill: string }) => r.skill === "verification",
);
expect(recallEntry).toBeDefined();
expect(recallEntry!.synthetic).toBe(true);
expect(recallEntry!.pattern?.type).toBe("policy-recall");
expect(recallEntry!.pattern?.value).toBe("route-scoped-verified-policy-recall");
});
test("does not recall when no active verification story exists", async () => {
// No writeMockPlanState — no story
const policy = buildStrongPolicy(
"verification",
"PreToolUse|flow-verification|clientRequest|Bash|/settings",
);
saveProjectRoutingPolicy(TEST_PROJECT, policy);
const result = await runHook(
"Bash",
{ command: "echo hello" },
sessionId,
);
expect(result.code).toBe(0);
const logs = parseLogLines(result.stderr);
const recallLog = logs.find((l) => l.event === "policy-recall-injected");
expect(recallLog).toBeUndefined();
const skipLog = logs.find((l) => l.event === "policy-recall-skipped");
expect(skipLog).toBeDefined();
});
test("does not recall when target boundary is null", async () => {
// Plan state with no target boundary
const sp = verificationStatePath(sessionId);
mkdirSync(join(sp, ".."), { recursive: true });
writeFileSync(sp, JSON.stringify({
version: 1,
stories: [{
id: "no-boundary-story",
kind: "flow-verification",
route: "/settings",
promptExcerpt: "test",
createdAt: T0,
updatedAt: T1,
requestedSkills: [],
}],
observationIds: [],
satisfiedBoundaries: [],
missingBoundaries: [],
recentRoutes: [],
primaryNextAction: { targetBoundary: null, suggestedAction: null },
blockedReasons: [],
}));
const policy = buildStrongPolicy(
"verification",
"PreToolUse|flow-verification|clientRequest|Bash|/settings",
);
saveProjectRoutingPolicy(TEST_PROJECT, policy);
const result = await runHook(
"Bash",
{ command: "echo hello" },
sessionId,
);
expect(result.code).toBe(0);
const logs = parseLogLines(result.stderr);
const recallLog = logs.find((l) => l.event === "policy-recall-injected");
expect(recallLog).toBeUndefined();
});
test("does not recall a skill that is already ranked via pattern matching", async () => {
writeMockPlanState(sessionId, {
storyKind: "flow-verification",
route: "/settings",
targetBoundary: "clientRequest",
});
// Build policy for "nextjs" which will be pattern-matched by next.config.ts
const policy = buildStrongPolicy(
"nextjs",
"PreToolUse|flow-verification|clientRequest|Read|/settings",
);
saveProjectRoutingPolicy(TEST_PROJECT, policy);
// Run with a file read that will pattern-match nextjs
const result = await runHook(
"Read",
{ file_path: "next.config.ts" },
sessionId,
);
expect(result.code).toBe(0);
// Should not appear as policy-recall since it was already matched via patterns
const logs = parseLogLines(result.stderr);
const recallLog = logs.find(
(l) => l.event === "policy-recall-injected" && l.skill === "nextjs",
);
expect(recallLog).toBeUndefined();
});
test("does not recall a skill that is already in injectedSkills (dedup)", async () => {
writeMockPlanState(sessionId, {
storyKind: "flow-verification",
route: "/settings",
targetBoundary: "clientRequest",
});
const policy = buildStrongPolicy(
"verification",
"PreToolUse|flow-verification|clientRequest|Bash|/settings",
);
saveProjectRoutingPolicy(TEST_PROJECT, policy);
// Claim verification via file-based dedup (the authoritative source when sessionId is present)
tryClaimSessionKey(sessionId, "seen-skills", "verification");
const result = await runHook(
"Bash",
{ command: "echo hello" },
sessionId,
);
expect(result.code).toBe(0);
const logs = parseLogLines(result.stderr);
const recallLog = logs.find((l) => l.event === "policy-recall-injected");
expect(recallLog).toBeUndefined();
});
test("respects existing cap and budget behavior", async () => {
writeMockPlanState(sessionId, {
storyKind: "flow-verification",
route: "/settings",
targetBoundary: "clientRequest",
});
const policy = buildStrongPolicy(
"verification",
"PreToolUse|flow-verification|clientRequest|Bash|/settings",
);
saveProjectRoutingPolicy(TEST_PROJECT, policy);
// Use a very small budget to force budget exhaustion
const result = await runHook(
"Bash",
{ command: "echo hello" },
sessionId,
{ VERCEL_PLUGIN_INJECTION_BUDGET: "100" },
);
expect(result.code).toBe(0);
// The recalled skill should be attempted but may be dropped by budget
// Key assertion: the hook does not crash and respects budget
const logs = parseLogLines(result.stderr);
const recallLog = logs.find((l) => l.event === "policy-recall-injected");
// It should still attempt to inject
expect(recallLog).toBeDefined();
});
test("falls back to wildcard route when exact route has no qualified evidence", async () => {
writeMockPlanState(sessionId, {
storyKind: "flow-verification",
route: "/settings",
targetBoundary: "clientRequest",
});
// No exact-route bucket; only wildcard
const policy = buildStrongPolicy(
"verification",
"PreToolUse|flow-verification|clientRequest|Bash|*",
);
saveProjectRoutingPolicy(TEST_PROJECT, policy);
const result = await runHook(
"Bash",
{ command: "echo hello" },
sessionId,
);
expect(result.code).toBe(0);
const logs = parseLogLines(result.stderr);
const recallLog = logs.find((l) => l.event === "policy-recall-injected");
expect(recallLog).toBeDefined();
expect(recallLog!.scenario).toBe("PreToolUse|flow-verification|clientRequest|Bash|*");
});
test("recalled skill is inserted behind direct match, not at slot-1", async () => {
writeMockPlanState(sessionId, {
storyKind: "flow-verification",
route: "/settings",
targetBoundary: "clientRequest",
});
// Seed policy for "verification" which won't pattern-match "next.config.ts"
const policy = buildStrongPolicy(
"verification",
"PreToolUse|flow-verification|clientRequest|Read|/settings",
);
saveProjectRoutingPolicy(TEST_PROJECT, policy);
// Read next.config.ts — will pattern-match "nextjs" as a direct match
const result = await runHook(
"Read",
{ file_path: "next.config.ts" },
sessionId,
);
expect(result.code).toBe(0);
// Check injection metadata: direct match should be first, recalled second
const meta = extractInjectionMeta(result.stdout);
if (meta && meta.injectedSkills.length >= 2) {
// The direct pattern match should remain first
expect(meta.reasons?.[meta.injectedSkills[0]]?.trigger).not.toBe("policy-recall");
// Verification should be present but not first
const verificationIdx = meta.injectedSkills.indexOf("verification");
if (verificationIdx !== -1) {
expect(verificationIdx).toBeGreaterThan(0);
}
}
// Check debug logs confirm insertionIndex > 0
const logs = parseLogLines(result.stderr);
const recallLog = logs.find((l) => l.event === "policy-recall-injected" && l.skill === "verification");
if (recallLog) {
expect(recallLog.insertionIndex).toBeGreaterThan(0);
}
});
test("recalled skill takes slot-1 when no direct matches exist", async () => {
writeMockPlanState(sessionId, {
storyKind: "flow-verification",
route: "/settings",
targetBoundary: "clientRequest",
});
const policy = buildStrongPolicy(
"verification",
"PreToolUse|flow-verification|clientRequest|Bash|/settings",
);
saveProjectRoutingPolicy(TEST_PROJECT, policy);
// echo hello won't pattern-match anything
const result = await runHook(
"Bash",
{ command: "echo hello" },
sessionId,
);
expect(result.code).toBe(0);
const logs = parseLogLines(result.stderr);
const recallLog = logs.find((l) => l.event === "policy-recall-injected" && l.skill === "verification");
expect(recallLog).toBeDefined();
expect(recallLog!.insertionIndex).toBe(0);
});
// ---------------------------------------------------------------------------
// Regression: policy recall parity with companion recall
// ---------------------------------------------------------------------------
test("policy recall still owns attribution when both policy and companion recall are present", async () => {
writeMockPlanState(sessionId, {
storyKind: "flow-verification",
route: "/settings",
targetBoundary: "clientRequest",
});
// Strong policy for "verification"
const policy = buildStrongPolicy(
"verification",
"PreToolUse|flow-verification|clientRequest|Bash|/settings",
);
saveProjectRoutingPolicy(TEST_PROJECT, policy);
const result = await runHook(
"Bash",
{ command: "echo hello" },
sessionId,
);
expect(result.code).toBe(0);
// Policy recall should still be logged
const logs = parseLogLines(result.stderr);
const recallLog = logs.find((l) => l.event === "policy-recall-injected");
expect(recallLog).toBeDefined();
expect(recallLog!.skill).toBe("verification");
// Attribution candidate should be the policy-recalled skill or direct match,
// never a companion-only skill
const attributionLog = logs.find((l) => l.event === "companion-recall-attribution");
if (attributionLog) {
// companionRecalledSkills should NOT include the attribution candidate
const companionRecalled = attributionLog.companionRecalledSkills as string[];
expect(companionRecalled).not.toContain(attributionLog.causalCandidate);
}
});
test("companion recall never suppresses a stronger direct pattern match", async () => {
writeMockPlanState(sessionId, {
storyKind: "flow-verification",
route: "/settings",
targetBoundary: "clientRequest",
});
// Policy for "verification" — strong history
const policy = buildStrongPolicy(
"verification",
"PreToolUse|flow-verification|clientRequest|Read|/settings",
);
saveProjectRoutingPolicy(TEST_PROJECT, policy);
// Read next.config.ts — "nextjs" will be a direct pattern match
const result = await runHook(
"Read",
{ file_path: "next.config.ts" },
sessionId,
);
expect(result.code).toBe(0);
const meta = extractInjectionMeta(result.stdout);
if (meta && meta.injectedSkills.length > 0) {
// The first injected skill must be the direct pattern match, not a companion
const firstSkillReason = meta.reasons?.[meta.injectedSkills[0]];
if (firstSkillReason) {
expect(firstSkillReason.trigger).not.toBe("verified-companion");
}
}
// Decision trace should show direct match before any companion entries
const traces = readRoutingDecisionTrace(sessionId);
if (traces.length > 0 && traces[0].ranked && traces[0].ranked.length > 0) {
const first = traces[0].ranked[0] as { pattern?: { type: string } };
if (first.pattern) {
expect(first.pattern.type).not.toBe("verified-companion");
}
}
});
test("at most one recalled skill in phase 1", async () => {
writeMockPlanState(sessionId, {
storyKind: "flow-verification",
route: "/settings",
targetBoundary: "clientRequest",
});
// Two strong skills in the same bucket
const policy = createEmptyRoutingPolicy();
const key = "PreToolUse|flow-verification|clientRequest|Bash|/settings";
policy.scenarios[key] = {
verification: {
exposures: 5, wins: 5, directiveWins: 2, staleMisses: 0,
lastUpdatedAt: T0,
},
"agent-browser-verify": {
exposures: 5, wins: 5, directiveWins: 3, staleMisses: 0,
lastUpdatedAt: T0,
},
};
saveProjectRoutingPolicy(TEST_PROJECT, policy);
const result = await runHook(
"Bash",
{ command: "echo hello" },
sessionId,
);
expect(result.code).toBe(0);
const logs = parseLogLines(result.stderr);
const recallLogs = logs.filter((l) => l.event === "policy-recall-injected");
expect(recallLogs.length).toBeLessThanOrEqual(1);
});
});