217 lines
8.7 KiB
TypeScript
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();
|
|
});
|
|
});
|