Files
pi-config/extensions/rpiv-pi/extensions/rpiv-core/session-hooks.test.ts

217 lines
8.7 KiB
TypeScript

import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createMockCtx, createMockPi, stubGitExec } from "@juicesharp/rpiv-test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./package-checks.js", () => ({ findMissingSiblings: vi.fn(() => []) }));
vi.mock("./agents.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./agents.js")>();
return {
...actual,
syncBundledAgents: vi.fn(() => ({
added: [],
updated: [],
unchanged: [],
removed: [],
pendingUpdate: [],
pendingRemove: [],
errors: [],
})),
};
});
import type { SyncResult } from "./agents.js";
import { syncBundledAgents } from "./agents.js";
import { clearGitContextCache, getGitContext, resetInjectedMarker, takeGitContextIfChanged } from "./git-context.js";
import { clearInjectionState } from "./guidance.js";
import { findMissingSiblings } from "./package-checks.js";
import { registerSessionHooks } from "./session-hooks.js";
const emptySync: SyncResult = {
added: [],
updated: [],
unchanged: [],
removed: [],
pendingUpdate: [],
pendingRemove: [],
errors: [],
};
let projectDir: string;
beforeEach(() => {
projectDir = mkdtempSync(join(tmpdir(), "rpiv-session-"));
clearInjectionState();
clearGitContextCache();
resetInjectedMarker();
});
afterEach(() => {
rmSync(projectDir, { recursive: true, force: true });
});
describe("registerSessionHooks — event wiring", () => {
it("registers 5 events", () => {
const { pi, captured } = createMockPi();
registerSessionHooks(pi);
for (const ev of ["session_start", "session_compact", "session_shutdown", "tool_call", "before_agent_start"]) {
expect(captured.events.has(ev)).toBe(true);
}
});
});
describe("session_start hook", () => {
it("scaffolds thoughts dirs under ctx.cwd", async () => {
const { pi, captured } = createMockPi({ exec: stubGitExec({}) as never });
registerSessionHooks(pi);
const handler = captured.events.get("session_start")?.[0];
const ctx = createMockCtx({ cwd: projectDir, hasUI: true });
await handler?.({ reason: "startup" } as never, ctx as never);
for (const d of [
"thoughts/shared/discover",
"thoughts/shared/research",
"thoughts/shared/designs",
"thoughts/shared/plans",
"thoughts/shared/handoffs",
"thoughts/shared/reviews",
]) {
expect(existsSync(join(projectDir, d))).toBe(true);
}
});
});
describe("session_start hook — notifications", () => {
it("emits 'Copied N agents' info when added > 0", async () => {
vi.mocked(syncBundledAgents).mockReturnValueOnce({ ...emptySync, added: ["a.md", "b.md"] });
vi.mocked(findMissingSiblings).mockReturnValueOnce([]);
const { pi, captured } = createMockPi({ exec: stubGitExec({}) as never });
registerSessionHooks(pi);
const ctx = createMockCtx({ cwd: projectDir, hasUI: true });
await captured.events.get("session_start")?.[0]({ reason: "startup" } as never, ctx as never);
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/Copied 2 rpiv-pi agent/), "info");
});
it("emits a single drift line combining pendingUpdate + pendingRemove", async () => {
vi.mocked(syncBundledAgents).mockReturnValueOnce({
...emptySync,
pendingUpdate: ["a.md"],
pendingRemove: ["b.md", "c.md"],
});
vi.mocked(findMissingSiblings).mockReturnValueOnce([]);
const { pi, captured } = createMockPi({ exec: stubGitExec({}) as never });
registerSessionHooks(pi);
const ctx = createMockCtx({ cwd: projectDir, hasUI: true });
await captured.events.get("session_start")?.[0]({ reason: "startup" } as never, ctx as never);
const driftCall = (ctx.ui.notify as ReturnType<typeof vi.fn>).mock.calls.find(
(c) => typeof c[0] === "string" && c[0].includes("outdated"),
);
expect(driftCall).toBeDefined();
expect(driftCall?.[0]).toContain("1 outdated");
expect(driftCall?.[0]).toContain("2 removed from bundle");
expect(driftCall?.[1]).toBe("info");
});
it("warns about missing siblings with npm: prefix stripped", async () => {
vi.mocked(syncBundledAgents).mockReturnValueOnce(emptySync);
vi.mocked(findMissingSiblings).mockReturnValueOnce([
{ pkg: "npm:@juicesharp/rpiv-advisor", matches: /./, provides: "x" },
{ pkg: "npm:@juicesharp/rpiv-args", matches: /./, provides: "y" },
] as never);
const { pi, captured } = createMockPi({ exec: stubGitExec({}) as never });
registerSessionHooks(pi);
const ctx = createMockCtx({ cwd: projectDir, hasUI: true });
await captured.events.get("session_start")?.[0]({ reason: "startup" } as never, ctx as never);
const warnCall = (ctx.ui.notify as ReturnType<typeof vi.fn>).mock.calls.find((c) => c[1] === "warning");
expect(warnCall).toBeDefined();
expect(warnCall?.[0]).toContain("rpiv-pi requires 2 sibling");
expect(warnCall?.[0]).toContain("@juicesharp/rpiv-advisor");
expect(warnCall?.[0]).toContain("@juicesharp/rpiv-args");
expect(warnCall?.[0]).not.toContain("npm:");
});
it("skips notifications when !hasUI", async () => {
vi.mocked(syncBundledAgents).mockReturnValueOnce({ ...emptySync, added: ["a.md"] });
vi.mocked(findMissingSiblings).mockReturnValueOnce([
{ pkg: "npm:@juicesharp/rpiv-todo", matches: /./, provides: "t" },
] as never);
const { pi, captured } = createMockPi({ exec: stubGitExec({}) as never });
registerSessionHooks(pi);
const ctx = createMockCtx({ cwd: projectDir, hasUI: false });
await captured.events.get("session_start")?.[0]({ reason: "startup" } as never, ctx as never);
expect(ctx.ui.notify).not.toHaveBeenCalled();
});
});
describe("session_compact hook", () => {
it("re-injects guidance + git-context after compaction (clears caches first)", async () => {
const exec = stubGitExec({ branch: "main", commit: "abc", user: "alice" });
const { pi, captured } = createMockPi({ exec: exec as never });
registerSessionHooks(pi);
// Prime the git-context cache first via session_start so compact's clear has work to do.
await captured.events.get("session_start")?.[0](
{ reason: "startup" } as never,
createMockCtx({ cwd: projectDir, hasUI: false }) as never,
);
const sendBefore = (pi.sendMessage as ReturnType<typeof vi.fn>).mock.calls.length;
await captured.events.get("session_compact")?.[0]({} as never, createMockCtx({ cwd: projectDir }) as never);
// After compact, the next pi.sendMessage call (from injectGitContext) should fire because
// resetInjectedMarker + clearGitContextCache make takeGitContextIfChanged re-emit.
const sendAfter = (pi.sendMessage as ReturnType<typeof vi.fn>).mock.calls.length;
expect(sendAfter).toBeGreaterThan(sendBefore);
});
});
describe("session_shutdown hook", () => {
it("clears git-context cache and allows takeGitContextIfChanged to re-emit", async () => {
const exec = stubGitExec({ branch: "main", commit: "abc", user: "alice" });
const { pi, captured } = createMockPi({ exec: exec as never });
registerSessionHooks(pi);
await takeGitContextIfChanged(pi);
const callsBefore = exec.mock.calls.length;
await captured.events.get("session_shutdown")?.[0]({} as never, createMockCtx() as never);
const reemit = await takeGitContextIfChanged(pi);
expect(reemit).not.toBeNull();
expect(exec.mock.calls.length).toBeGreaterThan(callsBefore);
});
});
describe("tool_call hook", () => {
it("clears git-context cache on mutating bash command", async () => {
const exec = stubGitExec({ branch: "main", commit: "a", user: "u" });
const { pi, captured } = createMockPi({ exec: exec as never });
registerSessionHooks(pi);
const handler = captured.events.get("tool_call")?.[0];
const ctx = createMockCtx({ cwd: projectDir });
await getGitContext(pi);
const before = exec.mock.calls.length;
await handler?.({ toolName: "bash", input: { command: "git commit -m x" } } as never, ctx as never);
await getGitContext(pi);
expect(exec.mock.calls.length).toBeGreaterThan(before);
});
});
describe("before_agent_start hook", () => {
it("returns {message} on changed git sig", async () => {
const { pi, captured } = createMockPi({
exec: stubGitExec({ branch: "main", commit: "abc", user: "alice" }) as never,
});
registerSessionHooks(pi);
const handler = captured.events.get("before_agent_start")?.[0];
const ctx = createMockCtx({ cwd: projectDir });
const r = await handler?.({} as never, ctx as never);
expect(r).toHaveProperty("message");
});
it("returns undefined on dedup (signature unchanged)", async () => {
const { pi, captured } = createMockPi({
exec: stubGitExec({ branch: "main", commit: "abc", user: "alice" }) as never,
});
registerSessionHooks(pi);
const handler = captured.events.get("before_agent_start")?.[0];
const ctx = createMockCtx({ cwd: projectDir });
await handler?.({} as never, ctx as never);
const second = await handler?.({} as never, ctx as never);
expect(second).toBeUndefined();
});
});