Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions hooks/session-start-seen-skills.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
removeAllSessionDedupArtifacts
} from "./hook-env.mjs";
import { createLogger } from "./logger.mjs";
var CONTEXT_CLEARING_EVENTS = /* @__PURE__ */ new Set(["clear", "compact"]);
var CONTEXT_CLEARING_SOURCES = /* @__PURE__ */ new Set(["clear", "compact"]);
function parseSessionStartSeenSkillsInput(raw) {
try {
if (!raw.trim()) return null;
Expand Down Expand Up @@ -44,9 +44,9 @@ function main() {
process.stdout.write(formatSessionStartSeenSkillsCursorOutput());
return;
}
const hookEvent = input?.hook_event_name ?? "";
const source = input?.source ?? "";
const sessionId = input?.session_id ?? "";
const resetTriggered = CONTEXT_CLEARING_EVENTS.has(hookEvent) && !!sessionId;
const resetTriggered = CONTEXT_CLEARING_SOURCES.has(source) && !!sessionId;
let removedFiles = 0;
let removedDirs = 0;
if (resetTriggered) {
Expand All @@ -55,7 +55,7 @@ function main() {
removedDirs = result.removedDirs;
}
log.debug("session-start-seen-skills:decision", {
event: hookEvent || "unknown",
source: source || "unknown",
sessionId: sessionId || "none",
resetTriggered,
removedFiles,
Expand Down
11 changes: 6 additions & 5 deletions hooks/src/session-start-seen-skills.mts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,12 @@ interface SessionStartSeenSkillsInput {
conversation_id?: string;
cursor_version?: string;
hook_event_name?: string;
source?: string;
[key: string]: unknown;
}

/** Events where previously-injected skills are no longer in the context window. */
const CONTEXT_CLEARING_EVENTS = new Set(["clear", "compact"]);
/** Sources where previously-injected skills are no longer in the context window. */
const CONTEXT_CLEARING_SOURCES = new Set(["clear", "compact"]);

export function parseSessionStartSeenSkillsInput(raw: string): SessionStartSeenSkillsInput | null {
try {
Expand Down Expand Up @@ -84,9 +85,9 @@ function main(): void {
}

// Claude Code: reset dedup state on clear/compact so skills get re-injected.
const hookEvent = input?.hook_event_name ?? "";
const source = input?.source ?? "";
const sessionId = input?.session_id ?? "";
const resetTriggered = CONTEXT_CLEARING_EVENTS.has(hookEvent) && !!sessionId;
const resetTriggered = CONTEXT_CLEARING_SOURCES.has(source) && !!sessionId;

let removedFiles = 0;
let removedDirs = 0;
Expand All @@ -98,7 +99,7 @@ function main(): void {
}

log.debug("session-start-seen-skills:decision", {
event: hookEvent || "unknown",
source: source || "unknown",
sessionId: sessionId || "none",
resetTriggered,
removedFiles,
Expand Down
68 changes: 62 additions & 6 deletions tests/session-start-seen-skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ describe("session-start-seen-skills hook", () => {
// Fire the hook with a "clear" event
const result = await runSessionStart(
{ CLAUDE_ENV_FILE: undefined },
JSON.stringify({ session_id: sessionId, hook_event_name: "clear" }),
JSON.stringify({ session_id: sessionId, hook_event_name: "SessionStart", source: "clear" }),
);

expect(result.code).toBe(0);
Expand Down Expand Up @@ -161,7 +161,7 @@ describe("session-start-seen-skills hook", () => {

const result = await runSessionStart(
{ CLAUDE_ENV_FILE: undefined },
JSON.stringify({ session_id: sessionId, hook_event_name: "compact" }),
JSON.stringify({ session_id: sessionId, hook_event_name: "SessionStart", source: "compact" }),
);

expect(result.code).toBe(0);
Expand All @@ -187,7 +187,7 @@ describe("session-start-seen-skills hook", () => {
// 3. Fire "clear" event via session-start hook
const result = await runSessionStart(
{ CLAUDE_ENV_FILE: undefined },
JSON.stringify({ session_id: sessionId, hook_event_name: "clear" }),
JSON.stringify({ session_id: sessionId, hook_event_name: "SessionStart", source: "clear" }),
);
expect(result.code).toBe(0);

Expand All @@ -213,7 +213,7 @@ describe("session-start-seen-skills hook", () => {
// 3. Fire "compact" event via session-start hook
const result = await runSessionStart(
{ CLAUDE_ENV_FILE: undefined },
JSON.stringify({ session_id: sessionId, hook_event_name: "compact" }),
JSON.stringify({ session_id: sessionId, hook_event_name: "SessionStart", source: "compact" }),
);
expect(result.code).toBe(0);

Expand Down Expand Up @@ -253,7 +253,7 @@ describe("session-start-seen-skills hook", () => {
// Fire "clear" event
const result = await runSessionStart(
{ CLAUDE_ENV_FILE: undefined },
JSON.stringify({ session_id: sessionId, hook_event_name: "clear" }),
JSON.stringify({ session_id: sessionId, hook_event_name: "SessionStart", source: "clear" }),
);
expect(result.code).toBe(0);

Expand Down Expand Up @@ -291,7 +291,7 @@ describe("session-start-seen-skills hook", () => {

const result = await runSessionStart(
{ CLAUDE_ENV_FILE: undefined },
JSON.stringify({ session_id: sessionId, hook_event_name: "startup" }),
JSON.stringify({ session_id: sessionId, hook_event_name: "SessionStart", source: "startup" }),
);

expect(result.code).toBe(0);
Expand All @@ -304,6 +304,62 @@ describe("session-start-seen-skills hook", () => {
try { rmSync(dedupFilePath(sessionId, "seen-skills")); } catch {}
}
});

test("test_clear_event_preserves_other_session_artifacts", async () => {
const sessionId = crypto.randomUUID();
const otherSessionId = crypto.randomUUID();

try {
expect(tryClaimSessionKey(sessionId, "seen-skills", "nextjs")).toBe(true);
expect(tryClaimSessionKey(otherSessionId, "seen-skills", "nextjs")).toBe(true);
expect(tryClaimSessionKey(otherSessionId, "seen-skills", "ai-sdk", "subagent-abc")).toBe(true);
writeFileSync(dedupFilePath(otherSessionId, "seen-skills"), "nextjs", "utf-8");

const result = await runSessionStart(
{ CLAUDE_ENV_FILE: undefined },
JSON.stringify({ session_id: sessionId, hook_event_name: "SessionStart", source: "clear" }),
);

expect(result.code).toBe(0);
expect(existsSync(dedupClaimDirPath(sessionId, "seen-skills"))).toBe(false);
expect(existsSync(dedupClaimDirPath(otherSessionId, "seen-skills"))).toBe(true);
expect(existsSync(dedupClaimDirPath(otherSessionId, "seen-skills", "subagent-abc"))).toBe(true);
expect(readFileSync(dedupFilePath(otherSessionId, "seen-skills"), "utf-8")).toBe("nextjs");
} finally {
removeAllSessionDedupArtifacts(sessionId);
removeAllSessionDedupArtifacts(otherSessionId);
}
});

const preservingPayloads: Array<[string, (sessionId: string) => Record<string, unknown>]> = [
["resume", (sessionId) => ({ session_id: sessionId, hook_event_name: "SessionStart", source: "resume" })],
["unknown source", (sessionId) => ({ session_id: sessionId, hook_event_name: "SessionStart", source: "fork" })],
["missing source", (sessionId) => ({ session_id: sessionId, hook_event_name: "SessionStart" })],
["legacy hook_event_name clear", (sessionId) => ({ session_id: sessionId, hook_event_name: "clear" })],
["legacy hook_event_name compact", (sessionId) => ({ session_id: sessionId, hook_event_name: "compact" })],
["missing session_id", () => ({ hook_event_name: "SessionStart", source: "clear" })],
["empty session_id", () => ({ session_id: "", hook_event_name: "SessionStart", source: "compact" })],
["cursor payload", (sessionId) => ({ session_id: sessionId, conversation_id: "cursor-conversation", source: "clear" })],
];

for (const [name, payload] of preservingPayloads) {
test(`test_${name.replaceAll(" ", "_")}_preserves_claim_dir`, async () => {
const sessionId = crypto.randomUUID();

try {
expect(tryClaimSessionKey(sessionId, "seen-skills", "nextjs")).toBe(true);
writeFileSync(dedupFilePath(sessionId, "seen-skills"), "nextjs", "utf-8");

const result = await runSessionStart({ CLAUDE_ENV_FILE: undefined }, JSON.stringify(payload(sessionId)));

expect(result.code).toBe(0);
expect(existsSync(dedupClaimDirPath(sessionId, "seen-skills"))).toBe(true);
expect(readFileSync(dedupFilePath(sessionId, "seen-skills"), "utf-8")).toBe("nextjs");
} finally {
removeAllSessionDedupArtifacts(sessionId);
}
});
}
});

describe("hook-env session temp path guards", () => {
Expand Down