mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
434 lines
14 KiB
TypeScript
434 lines
14 KiB
TypeScript
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
// ── Module mocks (hoisted) ─────────────────────────────────────────────────
|
|
|
|
const { mockGetActiveAgentName, mockGetActiveAgentNameFromSystemPrompt } =
|
|
vi.hoisted(() => ({
|
|
mockGetActiveAgentName: vi.fn<(ctx: ExtensionContext) => string | null>(),
|
|
mockGetActiveAgentNameFromSystemPrompt:
|
|
vi.fn<(systemPrompt?: string) => string | null>(),
|
|
}));
|
|
|
|
vi.mock("../src/active-agent", () => ({
|
|
getActiveAgentName: mockGetActiveAgentName,
|
|
getActiveAgentNameFromSystemPrompt: mockGetActiveAgentNameFromSystemPrompt,
|
|
}));
|
|
|
|
// ── Test helpers ───────────────────────────────────────────────────────────
|
|
|
|
import type { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
|
|
import { win32PathFlavor } from "#src/path/path-flavor";
|
|
import { SessionApproval } from "#src/session-approval";
|
|
import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
|
|
import { resolveToolPreviewLimits } from "#src/tool-preview-formatter";
|
|
import { makeCtx } from "#test/helpers/handler-fixtures";
|
|
import {
|
|
makeConfigStore,
|
|
makeFakePermissionManager,
|
|
makeRealSession,
|
|
} from "#test/helpers/session-fixtures";
|
|
|
|
// Alias so the existing tests read naturally.
|
|
const createSession = makeRealSession;
|
|
const makePermissionManager = makeFakePermissionManager;
|
|
|
|
function makeSkillEntry(
|
|
name: string,
|
|
overrides: Partial<SkillPromptEntry> = {},
|
|
): SkillPromptEntry {
|
|
return {
|
|
name,
|
|
description: `${name} skill`,
|
|
location: `/${name}/SKILL.md`,
|
|
state: "allow",
|
|
normalizedLocation: `/${name}/SKILL.md`,
|
|
normalizedBaseDir: `/${name}`,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// ── Tests ──────────────────────────────────────────────────────────────────
|
|
|
|
beforeEach(() => {
|
|
mockGetActiveAgentName.mockReset();
|
|
mockGetActiveAgentNameFromSystemPrompt.mockReset();
|
|
mockGetActiveAgentName.mockReturnValue(null);
|
|
mockGetActiveAgentNameFromSystemPrompt.mockReturnValue(null);
|
|
});
|
|
|
|
describe("PermissionSession", () => {
|
|
describe("activate and deactivate", () => {
|
|
it("stores the context on activate", () => {
|
|
const { session, forwarding } = createSession();
|
|
const ctx = makeCtx();
|
|
|
|
session.activate(ctx);
|
|
|
|
expect(forwarding.start).toHaveBeenCalledWith(ctx);
|
|
});
|
|
|
|
it("clears context on deactivate", () => {
|
|
const { session, forwarding } = createSession();
|
|
session.activate(makeCtx());
|
|
session.deactivate();
|
|
|
|
expect(forwarding.stop).toHaveBeenCalled();
|
|
});
|
|
|
|
it("forwards activate to the authorizer selection", () => {
|
|
const { session, authorizerSelection } = createSession();
|
|
const ctx = makeCtx();
|
|
|
|
session.activate(ctx);
|
|
|
|
expect(authorizerSelection.activate).toHaveBeenCalledWith(ctx);
|
|
});
|
|
|
|
it("forwards deactivate to the authorizer selection", () => {
|
|
const { session, authorizerSelection } = createSession();
|
|
session.activate(makeCtx());
|
|
session.deactivate();
|
|
|
|
expect(authorizerSelection.deactivate).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("resetForNewSession", () => {
|
|
it("configures the injected PermissionManager for the context cwd when trusted", () => {
|
|
const pm = makePermissionManager();
|
|
const { session } = createSession({ permissionManager: pm });
|
|
const ctx = makeCtx({ cwd: "/new/project" });
|
|
|
|
session.resetForNewSession(ctx, true);
|
|
|
|
expect(pm.configureForCwd).toHaveBeenCalledWith("/new/project");
|
|
});
|
|
|
|
it("withholds the project cwd (global-only) when the project is untrusted", () => {
|
|
const pm = makePermissionManager();
|
|
const { session } = createSession({ permissionManager: pm });
|
|
const ctx = makeCtx({ cwd: "/new/project" });
|
|
|
|
session.resetForNewSession(ctx, false);
|
|
|
|
expect(pm.configureForCwd).toHaveBeenCalledWith(undefined);
|
|
});
|
|
|
|
it("clears skill entries", () => {
|
|
const { session } = createSession();
|
|
session.setActiveSkillEntries([makeSkillEntry("test")]);
|
|
expect(session.getActiveSkillEntries()).toHaveLength(1);
|
|
|
|
session.resetForNewSession(makeCtx(), true);
|
|
|
|
expect(session.getActiveSkillEntries()).toEqual([]);
|
|
});
|
|
|
|
it("starts forwarding with the new context", () => {
|
|
const { session, forwarding } = createSession();
|
|
const ctx = makeCtx();
|
|
|
|
session.resetForNewSession(ctx, true);
|
|
|
|
expect(forwarding.start).toHaveBeenCalledWith(ctx);
|
|
});
|
|
|
|
it("activates the new context", () => {
|
|
const { session } = createSession();
|
|
const ctx = makeCtx();
|
|
|
|
session.resetForNewSession(ctx, true);
|
|
|
|
// Verify context is stored by calling resolveAgentName which needs it
|
|
mockGetActiveAgentName.mockReturnValue("test-agent");
|
|
const name = session.resolveAgentName(ctx);
|
|
expect(name).toBe("test-agent");
|
|
});
|
|
});
|
|
|
|
describe("getPathNormalizer", () => {
|
|
it("returns a normalizer bound to the reset session cwd", () => {
|
|
const { session } = createSession();
|
|
session.resetForNewSession(makeCtx({ cwd: "/projects/app" }), true);
|
|
|
|
const ap = session.getPathNormalizer().forPath("src/foo.ts");
|
|
|
|
expect(ap.value()).toBe("/projects/app/src/foo.ts");
|
|
});
|
|
|
|
it("rebinds the normalizer cwd on a subsequent reset", () => {
|
|
const { session } = createSession();
|
|
session.resetForNewSession(makeCtx({ cwd: "/projects/app" }), true);
|
|
session.resetForNewSession(makeCtx({ cwd: "/projects/other" }), true);
|
|
|
|
expect(session.getPathNormalizer().forPath("a.ts").value()).toBe(
|
|
"/projects/other/a.ts",
|
|
);
|
|
});
|
|
|
|
it("binds the normalizer on activate, before any reset (no fail-open)", () => {
|
|
const { session } = createSession();
|
|
// A tool call can activate the session before session_start resets it;
|
|
// the normalizer must still track the active ctx cwd.
|
|
session.activate(makeCtx({ cwd: "/projects/activated" }));
|
|
|
|
expect(session.getPathNormalizer().forPath("a.ts").value()).toBe(
|
|
"/projects/activated/a.ts",
|
|
);
|
|
});
|
|
|
|
it("builds a win32 normalizer when constructed with the win32 flavor", () => {
|
|
const { session } = createSession({ flavor: win32PathFlavor });
|
|
session.resetForNewSession(makeCtx({ cwd: "C:\\Projects\\App" }), true);
|
|
|
|
expect(session.getPathNormalizer().forPath("src\\foo.ts").value()).toBe(
|
|
"c:\\projects\\app\\src\\foo.ts",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("shutdown", () => {
|
|
it("clears session rules", () => {
|
|
const { session, sessionRules } = createSession();
|
|
sessionRules.recordSessionApproval(SessionApproval.single("bash", "*"));
|
|
expect(sessionRules.getRuleset()).toHaveLength(1);
|
|
|
|
session.shutdown();
|
|
|
|
expect(sessionRules.getRuleset()).toEqual([]);
|
|
});
|
|
|
|
it("clears skill entries", () => {
|
|
const { session } = createSession();
|
|
session.setActiveSkillEntries([makeSkillEntry("s")]);
|
|
|
|
session.shutdown();
|
|
|
|
expect(session.getActiveSkillEntries()).toEqual([]);
|
|
});
|
|
|
|
it("stops forwarding and deactivates context", () => {
|
|
const { session, forwarding } = createSession();
|
|
session.activate(makeCtx());
|
|
|
|
session.shutdown();
|
|
|
|
expect(forwarding.stop).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("skill entries", () => {
|
|
it("get/set skill entries", () => {
|
|
const { session } = createSession();
|
|
const entries = [makeSkillEntry("a"), makeSkillEntry("b")];
|
|
session.setActiveSkillEntries(entries);
|
|
expect(session.getActiveSkillEntries()).toEqual(entries);
|
|
});
|
|
});
|
|
|
|
describe("resolveAgentName", () => {
|
|
it("returns name from session context", () => {
|
|
mockGetActiveAgentName.mockReturnValue("ctx-agent");
|
|
const { session } = createSession();
|
|
const ctx = makeCtx();
|
|
|
|
expect(session.resolveAgentName(ctx)).toBe("ctx-agent");
|
|
});
|
|
|
|
it("falls back to system prompt", () => {
|
|
mockGetActiveAgentName.mockReturnValue(null);
|
|
mockGetActiveAgentNameFromSystemPrompt.mockReturnValue("prompt-agent");
|
|
const { session } = createSession();
|
|
const ctx = makeCtx();
|
|
|
|
expect(session.resolveAgentName(ctx, "system prompt")).toBe(
|
|
"prompt-agent",
|
|
);
|
|
});
|
|
|
|
it("falls back to last known name", () => {
|
|
const { session } = createSession();
|
|
const ctx = makeCtx();
|
|
|
|
// First call sets name
|
|
mockGetActiveAgentName.mockReturnValue("first-agent");
|
|
session.resolveAgentName(ctx);
|
|
|
|
// Second call with no name resolves to last known
|
|
mockGetActiveAgentName.mockReturnValue(null);
|
|
mockGetActiveAgentNameFromSystemPrompt.mockReturnValue(null);
|
|
expect(session.resolveAgentName(ctx)).toBe("first-agent");
|
|
});
|
|
|
|
it("exposes lastKnownActiveAgentName", () => {
|
|
const { session } = createSession();
|
|
expect(session.lastKnownActiveAgentName).toBeNull();
|
|
|
|
mockGetActiveAgentName.mockReturnValue("named");
|
|
session.resolveAgentName(makeCtx());
|
|
expect(session.lastKnownActiveAgentName).toBe("named");
|
|
});
|
|
});
|
|
|
|
describe("infrastructure paths", () => {
|
|
it("getInfrastructureReadDirs combines piInfrastructureDirs and piInfrastructureReadPaths", () => {
|
|
const configStore = makeConfigStore({
|
|
current: vi.fn().mockReturnValue({
|
|
piInfrastructureReadPaths: ["/extra/path"],
|
|
}),
|
|
});
|
|
const { session } = createSession({ configStore });
|
|
expect(session.getInfrastructureReadDirs()).toEqual([
|
|
"/test/agent",
|
|
"/test/agent/git",
|
|
"/extra/path",
|
|
]);
|
|
});
|
|
|
|
it("getInfrastructureReadDirs returns only piInfrastructureDirs when config omits the field", () => {
|
|
const { session } = createSession();
|
|
expect(session.getInfrastructureReadDirs()).toEqual([
|
|
"/test/agent",
|
|
"/test/agent/git",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("config delegation", () => {
|
|
it("refreshConfig delegates to configStore.refresh with the trust flag", () => {
|
|
const { session, configStore } = createSession();
|
|
const ctx = makeCtx();
|
|
session.refreshConfig(ctx, true);
|
|
expect(configStore.refresh).toHaveBeenCalledWith(ctx, true);
|
|
});
|
|
|
|
it("refreshConfig forwards projectTrusted=false when untrusted", () => {
|
|
const { session, configStore } = createSession();
|
|
const ctx = makeCtx();
|
|
session.refreshConfig(ctx, false);
|
|
expect(configStore.refresh).toHaveBeenCalledWith(ctx, false);
|
|
});
|
|
|
|
it("logResolvedConfigPaths delegates to configStore.logResolvedPaths", () => {
|
|
const { session, configStore } = createSession();
|
|
session.logResolvedConfigPaths();
|
|
expect(configStore.logResolvedPaths).toHaveBeenCalled();
|
|
});
|
|
|
|
it("config getter delegates to configStore.current()", () => {
|
|
const fakeConfig = { debugLog: true } as typeof DEFAULT_EXTENSION_CONFIG;
|
|
const configStore = makeConfigStore({
|
|
current: vi.fn().mockReturnValue(fakeConfig),
|
|
});
|
|
const { session } = createSession({ configStore });
|
|
expect(session.config).toBe(fakeConfig);
|
|
});
|
|
|
|
it("getToolPreviewLimits returns the built-in limits regardless of config", () => {
|
|
const configStore = makeConfigStore({
|
|
current: vi.fn().mockReturnValue({
|
|
toolInputPreviewMaxLength: 400,
|
|
toolTextSummaryMaxLength: 120,
|
|
}),
|
|
});
|
|
const { session } = createSession({ configStore });
|
|
expect(session.getToolPreviewLimits()).toEqual(
|
|
resolveToolPreviewLimits(),
|
|
);
|
|
});
|
|
|
|
it("getToolPreviewLimits returns positive built-in defaults", () => {
|
|
const { session } = createSession();
|
|
const limits = session.getToolPreviewLimits();
|
|
expect(limits.toolInputPreviewMaxLength).toBeGreaterThan(0);
|
|
expect(limits.toolTextSummaryMaxLength).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe("reload", () => {
|
|
it("configures PermissionManager for current context cwd when trusted", () => {
|
|
const pm = makePermissionManager();
|
|
const { session } = createSession({ permissionManager: pm });
|
|
const ctx = makeCtx({ cwd: "/project" });
|
|
session.activate(ctx);
|
|
|
|
session.reload(true);
|
|
|
|
expect(pm.configureForCwd).toHaveBeenCalledWith("/project");
|
|
});
|
|
|
|
it("withholds the project cwd (global-only) when the project is untrusted", () => {
|
|
const pm = makePermissionManager();
|
|
const { session } = createSession({ permissionManager: pm });
|
|
const ctx = makeCtx({ cwd: "/project" });
|
|
session.activate(ctx);
|
|
|
|
session.reload(false);
|
|
|
|
expect(pm.configureForCwd).toHaveBeenCalledWith(undefined);
|
|
});
|
|
|
|
it("clears skill entries", () => {
|
|
const { session } = createSession();
|
|
session.setActiveSkillEntries([makeSkillEntry("s")]);
|
|
|
|
session.reload(true);
|
|
|
|
expect(session.getActiveSkillEntries()).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("getRuntimeContext", () => {
|
|
it("returns null before activation", () => {
|
|
const { session } = createSession();
|
|
expect(session.getRuntimeContext()).toBeNull();
|
|
});
|
|
|
|
it("returns context after activation", () => {
|
|
const { session } = createSession();
|
|
const ctx = makeCtx();
|
|
session.activate(ctx);
|
|
expect(session.getRuntimeContext()).toBe(ctx);
|
|
});
|
|
|
|
it("returns null after deactivation", () => {
|
|
const { session } = createSession();
|
|
session.activate(makeCtx());
|
|
session.deactivate();
|
|
expect(session.getRuntimeContext()).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("notify", () => {
|
|
it("forwards the message to ctx.ui.notify with 'warning' severity after activation", () => {
|
|
const { session } = createSession();
|
|
const ctx = makeCtx();
|
|
session.activate(ctx);
|
|
|
|
session.notify("something went wrong");
|
|
|
|
expect(ctx.ui.notify).toHaveBeenCalledOnce();
|
|
expect(ctx.ui.notify).toHaveBeenCalledWith(
|
|
"something went wrong",
|
|
"warning",
|
|
);
|
|
});
|
|
|
|
it("is a no-op and does not throw before activation", () => {
|
|
const { session } = createSession();
|
|
|
|
expect(() => session.notify("msg")).not.toThrow();
|
|
});
|
|
|
|
it("is a no-op and does not throw after deactivation", () => {
|
|
const { session } = createSession();
|
|
const ctx = makeCtx();
|
|
session.activate(ctx);
|
|
session.deactivate();
|
|
|
|
expect(() => session.notify("msg")).not.toThrow();
|
|
});
|
|
});
|
|
});
|