Files
my-pi/pi-permission-system/test/handlers/lifecycle.test.ts
T
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 7e6a8faae1 feat(pi-permission-system): AutoReview 改为跟随主模型的对话式工具调用审查
AutoReview:

- 审查器回到 Codex Guardian 风格的对话式审查,默认跟随会话当前主模型
  (每次审查实时读取 ctx.model);provider/model 必须同时设置才固定审查
  模型,只设其一视为配置错误并转人工。
- 结论只通过 approve / reject 两个工具给出,参数仅 reason;不强制
  toolChoice,正文文本(即使是合法 JSON)一律忽略。
- approve 自动放行;reject 转人工并在弹窗显示理由(标注模型生成)。
  超时、Provider 错误、截断、未调用工具、多次调用、参数无效等异常同样
  转人工,并附固定的系统说明;用户按 Esc 中止时直接拒绝。
- 模型调用改为 modelRegistry.streamSimple(...).result():Pi 1.3.0 面向
  扩展的 ModelRegistry 没有 completeSimple,原写法在真实环境中每次都会
  转人工。
- 对话记录保留可信度标注;工具结果在有更短的 pi-compaction 摘要时替换
  为摘要;token 估算改为非 ASCII 字符按 1 token 计;预算可配置。
- 同一条用户消息内完全相同的请求只缓存 approve 结论。
- 默认 reasoning low、总超时 30 秒、最多 2 次尝试、maxOutputTokens 4000。

删除:

- 未发布过的 TypeSafe Jev 分类器实现、DenialCircuitBreaker。
- 整个 annotator 机制(model-explanation、注册表、registerAnnotator、
  provider-timeout);annotators 配置键保留为已弃用并忽略,避免旧配置
  被拒绝。

测试:

- 新增 reviewer / transcript / 弹窗说明 / 默认配置测试;清理重复、恒真
  或只测实现细节的旧测试。
- 使用 deepseek-v4.1-flash 实测 5 个场景各 5 次,25 次结论全部符合预期。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-19 01:54:17 -07:00

250 lines
9.3 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import {
SessionLifecycleHandler,
UNTRUSTED_PROJECT_MESSAGE,
} from "#src/handlers/lifecycle";
import type { ServiceLifecycle } from "#src/service-lifecycle";
import { makeCtx } from "#test/helpers/handler-fixtures";
import {
makeLogger,
makeRealResolver,
makeRealSession,
} from "#test/helpers/session-fixtures";
// ── status stub ────────────────────────────────────────────────────────────
vi.mock("../../src/status", () => ({
PERMISSION_SYSTEM_STATUS_KEY: "permission-system",
syncPermissionSystemStatus: vi.fn(),
getPermissionSystemStatus: vi.fn(),
}));
// ── helpers ────────────────────────────────────────────────────────────────
function makeSetup(opts?: { configIssues?: string[] }) {
const { session, permissionManager, sessionRules, forwarding, configStore } =
makeRealSession();
const { resolver } = makeRealResolver(permissionManager, sessionRules);
if (opts?.configIssues) {
vi.mocked(permissionManager.getConfigIssues).mockReturnValue(
opts.configIssues,
);
}
const serviceLifecycle: ServiceLifecycle = {
activate: vi.fn<ServiceLifecycle["activate"]>(),
teardown: vi.fn<ServiceLifecycle["teardown"]>(),
};
// Use a session-independent logger so assertions verify direct injection,
// not reach-through to session.logger.
const logger = makeLogger();
const audit = { writeSummary: vi.fn<(logger: unknown) => void>() };
const handler = new SessionLifecycleHandler(
session,
resolver,
serviceLifecycle,
logger,
audit,
);
return {
handler,
session,
resolver,
permissionManager,
logger,
forwarding,
configStore,
serviceLifecycle,
audit,
};
}
// ── handleSessionStart ─────────────────────────────────────────────────────
describe("handleSessionStart", () => {
it("refreshes config with ctx, trusted", async () => {
const ctx = makeCtx();
const { handler, configStore } = makeSetup();
await handler.handleSessionStart({ reason: "startup" }, ctx);
expect(configStore.refresh).toHaveBeenCalledWith(ctx, true);
});
it("calls resetForNewSession with ctx, trusted", async () => {
const ctx = makeCtx();
const { handler, session } = makeSetup();
const spy = vi.spyOn(session, "resetForNewSession");
await handler.handleSessionStart({ reason: "startup" }, ctx);
expect(spy).toHaveBeenCalledWith(ctx, true);
});
describe("project untrusted", () => {
function untrustedCtx(): ReturnType<typeof makeCtx> {
return makeCtx({
isProjectTrusted: vi.fn<() => boolean>().mockReturnValue(false),
});
}
it("withholds the project scope from refreshConfig and resetForNewSession", async () => {
const ctx = untrustedCtx();
const { handler, configStore, session } = makeSetup();
const spy = vi.spyOn(session, "resetForNewSession");
await handler.handleSessionStart({ reason: "startup" }, ctx);
expect(configStore.refresh).toHaveBeenCalledWith(ctx, false);
expect(spy).toHaveBeenCalledWith(ctx, false);
});
it("loudly warns and records a review entry when untrusted", async () => {
const ctx = untrustedCtx();
const { handler, logger } = makeSetup();
await handler.handleSessionStart({ reason: "startup" }, ctx);
expect(logger.warn).toHaveBeenCalledWith(UNTRUSTED_PROJECT_MESSAGE);
expect(logger.review).toHaveBeenCalledWith("project_trust.skipped", {
cwd: ctx.cwd,
phase: "session_start",
});
});
it("does not warn when the project is trusted", async () => {
const { handler, logger } = makeSetup();
await handler.handleSessionStart({ reason: "startup" }, makeCtx());
expect(logger.warn).not.toHaveBeenCalledWith(UNTRUSTED_PROJECT_MESSAGE);
});
});
it("logs resolved config paths", async () => {
const { handler, configStore } = makeSetup();
await handler.handleSessionStart({ reason: "startup" }, makeCtx());
expect(configStore.logResolvedPaths).toHaveBeenCalledOnce();
});
it("notifies each policy issue", async () => {
const { handler, logger } = makeSetup({
configIssues: ["issue A", "issue B"],
});
await handler.handleSessionStart({ reason: "startup" }, makeCtx());
expect(logger.warn).toHaveBeenCalledWith("issue A");
expect(logger.warn).toHaveBeenCalledWith("issue B");
});
it("writes lifecycle.reload debug log when reason is reload", async () => {
const ctx = makeCtx({ cwd: "/proj" });
const { handler, logger } = makeSetup();
await handler.handleSessionStart({ reason: "reload" }, ctx);
expect(logger.debug).toHaveBeenCalledWith("lifecycle.reload", {
triggeredBy: "session_start",
reason: "reload",
cwd: "/proj",
});
});
it("activates the service for the session with ctx", async () => {
const ctx = makeCtx();
const { handler, serviceLifecycle } = makeSetup();
await handler.handleSessionStart({ reason: "startup" }, ctx);
expect(serviceLifecycle.activate).toHaveBeenCalledWith(ctx);
});
it("calls refreshConfig before resetForNewSession", async () => {
const callOrder: string[] = [];
const { handler, session, configStore } = makeSetup();
vi.spyOn(configStore, "refresh").mockImplementation(() => {
callOrder.push("refreshConfig");
});
vi.spyOn(session, "resetForNewSession").mockImplementation(() => {
callOrder.push("resetForNewSession");
});
await handler.handleSessionStart({ reason: "startup" }, makeCtx());
expect(callOrder).toEqual(["refreshConfig", "resetForNewSession"]);
});
});
// ── handleResourcesDiscover ────────────────────────────────────────────────
describe("handleResourcesDiscover", () => {
it("does nothing when reason is not reload", async () => {
const { handler, session } = makeSetup();
const spy = vi.spyOn(session, "reload");
await handler.handleResourcesDiscover({ reason: "startup" }, makeCtx());
expect(spy).not.toHaveBeenCalled();
});
it("reloads the session with the trust flag on reload", async () => {
const { handler, session } = makeSetup();
const spy = vi.spyOn(session, "reload");
await handler.handleResourcesDiscover({ reason: "reload" }, makeCtx());
expect(spy).toHaveBeenCalledWith(true);
});
it("withholds the project scope and warns on an untrusted reload", async () => {
const ctx = makeCtx({
cwd: "/proj",
isProjectTrusted: vi.fn<() => boolean>().mockReturnValue(false),
});
const { handler, session, logger } = makeSetup();
const spy = vi.spyOn(session, "reload");
await handler.handleResourcesDiscover({ reason: "reload" }, ctx);
expect(spy).toHaveBeenCalledWith(false);
expect(logger.warn).toHaveBeenCalledWith(UNTRUSTED_PROJECT_MESSAGE);
expect(logger.review).toHaveBeenCalledWith("project_trust.skipped", {
cwd: "/proj",
phase: "resources_discover",
});
});
it("writes lifecycle.reload debug log on reload", async () => {
const ctx = makeCtx({ cwd: "/proj" });
const { handler, session, logger } = makeSetup();
session.activate(ctx);
await handler.handleResourcesDiscover({ reason: "reload" }, ctx);
expect(logger.debug).toHaveBeenCalledWith("lifecycle.reload", {
triggeredBy: "resources_discover",
reason: "reload",
cwd: "/proj",
});
});
it("logs cwd as null when runtimeContext is null on reload", async () => {
const { handler, logger } = makeSetup();
await handler.handleResourcesDiscover({ reason: "reload" }, makeCtx());
expect(logger.debug).toHaveBeenCalledWith("lifecycle.reload", {
triggeredBy: "resources_discover",
reason: "reload",
cwd: null,
});
});
});
// ── handleSessionShutdown ──────────────────────────────────────────────────
describe("handleSessionShutdown", () => {
it("clears UI status when runtime context is present", async () => {
const ctx = makeCtx();
const { handler, session } = makeSetup();
session.activate(ctx);
await handler.handleSessionShutdown();
expect(ctx.ui.setStatus).toHaveBeenCalledWith(
"permission-system",
undefined,
);
});
it("calls shutdown on the session", async () => {
const { handler, session } = makeSetup();
const spy = vi.spyOn(session, "shutdown");
await handler.handleSessionShutdown();
expect(spy).toHaveBeenCalledOnce();
});
it("calls serviceLifecycle.teardown", async () => {
const { handler, serviceLifecycle } = makeSetup();
await handler.handleSessionShutdown();
expect(serviceLifecycle.teardown).toHaveBeenCalledOnce();
});
it("writes the decision-audit summary to the logger", async () => {
const { handler, audit, logger } = makeSetup();
await handler.handleSessionShutdown();
expect(audit.writeSummary).toHaveBeenCalledWith(logger);
});
});