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>
This commit is contained in:
云服务部-叶林立
2026-10-19 01:54:17 -07:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent dec48dab6d
commit 7e6a8faae1
87 changed files with 2831 additions and 2658 deletions
@@ -25,13 +25,6 @@ async function visitContexts(
}
describe("NESTED_EXECUTION_CONTEXTS", () => {
it("maps the substitution node types to their execution context", () => {
expect([...NESTED_EXECUTION_CONTEXTS]).toEqual([
["command_substitution", "command_substitution"],
["process_substitution", "process_substitution"],
]);
});
it("omits subshell, which the command enumerator emits whole", () => {
expect(NESTED_EXECUTION_CONTEXTS.has("subshell")).toBe(false);
});
@@ -7,12 +7,6 @@ import {
import { makeTSNode } from "#test/helpers/fake-ts-node";
describe("SKIP_SUBTREE_TYPES", () => {
it("contains the three node types that must not be descended", () => {
expect(SKIP_SUBTREE_TYPES.has("heredoc_body")).toBe(true);
expect(SKIP_SUBTREE_TYPES.has("heredoc_end")).toBe(true);
expect(SKIP_SUBTREE_TYPES.has("comment")).toBe(true);
});
it("does not contain common argument node types", () => {
expect(SKIP_SUBTREE_TYPES.has("word")).toBe(false);
expect(SKIP_SUBTREE_TYPES.has("string")).toBe(false);
@@ -49,15 +49,6 @@ describe("classifyTokenAsPathCandidate", () => {
expect(classifyTokenAsPathCandidate("@scope/pkg")).toBeNull();
});
test("@/ prefix is NOT rejected (it looks like an absolute-rooted scoped path)", () => {
// @/ passes the @ guard; then for path candidate it doesn't start with /
// or ~/, and doesn't contain .., so it returns null anyway from the
// acceptance gate — but the rejection is not due to the @ guard.
// This test documents that @/ is not rejected by the shared rejection.
// The path classifier then rejects it for not matching any acceptance shape.
expect(classifyTokenAsPathCandidate("@/foo/bar")).toBeNull();
});
test("regex metacharacters → null", () => {
// REGEX_METACHAR_PATTERN: .*, .+, \|, \(, \), [...], ^/
expect(classifyTokenAsPathCandidate("foo.*")).toBeNull();
@@ -350,50 +341,12 @@ describe("classifyTokenAsRuleCandidate", () => {
});
describe("rule-vs-path divergence", () => {
const dotFiles = [".env", ".gitignore", ".eslintrc"];
const relPaths = ["src/index.ts", "lib/utils.js", "config/settings.json"];
for (const tok of dotFiles) {
test(`dot-file "${tok}": rule accepts, path rejects`, () => {
test("dot-files and bare relative paths: rule accepts, path rejects", () => {
for (const tok of [".env", ".gitignore", "src/index.ts"]) {
expect(classifyTokenAsRuleCandidate(tok, posixPathFlavor)).toBe(tok);
expect(classifyTokenAsPathCandidate(tok)).toBeNull();
});
}
for (const tok of relPaths) {
test(`relative path "${tok}": rule accepts, path rejects`, () => {
expect(classifyTokenAsRuleCandidate(tok, posixPathFlavor)).toBe(tok);
expect(classifyTokenAsPathCandidate(tok)).toBeNull();
});
}
const sharedAccepted = ["/etc/hosts", "~/docs", "../sibling"];
for (const tok of sharedAccepted) {
test(`"${tok}": both classifiers accept`, () => {
expect(classifyTokenAsRuleCandidate(tok, posixPathFlavor)).toBe(tok);
expect(classifyTokenAsPathCandidate(tok)).toBe(tok);
});
}
const winDrivePaths = [
"C:/Windows/win.ini",
"D:\\secrets\\password.txt",
"c:/foo",
];
for (const tok of winDrivePaths) {
test(`Windows drive path "${tok}": both classifiers accept`, () => {
expect(classifyTokenAsRuleCandidate(tok, posixPathFlavor)).toBe(tok);
expect(classifyTokenAsPathCandidate(tok)).toBe(tok);
});
}
const sharedRejected = ["hello", "--flag", "FOO=/bar", "https://x.com"];
for (const tok of sharedRejected) {
test(`"${tok}": both classifiers reject`, () => {
expect(classifyTokenAsRuleCandidate(tok, posixPathFlavor)).toBeNull();
expect(classifyTokenAsPathCandidate(tok)).toBeNull();
});
}
}
});
});
});
@@ -413,10 +366,6 @@ describe("classifyBareTokenCandidate", () => {
expect(classifyBareTokenCandidate("build")).toBe("build");
});
test("consults no policy — identical result for every token of the same shape", () => {
expect(classifyBareTokenCandidate("anything")).toBe("anything");
});
describe("shared rejection prelude", () => {
test("flag (leading dash) → null", () => {
expect(classifyBareTokenCandidate("-r")).toBeNull();
@@ -10,8 +10,9 @@ import {
* separated, but a quoted span is one word carrying its quotes — tree-sitter
* emits a `string`/`raw_string` argument as a single named child.
*
* `program.test.ts` pins the real node adapter end to end; this stands in for it
* so the extraction rules can be exercised without a parse.
* `sync-commands.test.ts` pins the real node adapter end to end (wrapperKind
* and executedUnit over a warm tree-sitter parse); this stands in for it so
* the extraction rules can be exercised without a parse.
*/
function words(unitText: string): CommandWord[] {
const out: CommandWord[] = [];
@@ -39,20 +39,15 @@ describe("normalizeInput — non-MCP surfaces", () => {
// every path-bearing or special surface to the catch-all ["*"] exactly as it
// does for any unrecognised extension tool.
describe("path-bearing and special surfaces collapse to '*'", () => {
it("path surface ignores input.path and returns ['*']", () => {
// After #504 removal: path no longer has a special branch.
const result = normalizeInput("path", { path: ".env" }, []);
expect(result.surface).toBe("path");
expect(result.values).toEqual(["*"]);
expect(result.resultExtras).toEqual({});
});
it("read surface ignores input.path and returns ['*']", () => {
const result = normalizeInput("read", { path: ".env" }, []);
expect(result.surface).toBe("read");
expect(result.values).toEqual(["*"]);
expect(result.resultExtras).toEqual({});
it("path-bearing surfaces ignore input.path and return ['*']", () => {
// After #504 removal: path-bearing surfaces no longer have a special
// branch in normalizeInput — the access-path gate handles them (#502).
for (const surface of ["path", "read"]) {
const result = normalizeInput(surface, { path: ".env" }, []);
expect(result.surface).toBe(surface);
expect(result.values).toEqual(["*"]);
expect(result.resultExtras).toEqual({});
}
});
it("missing path also returns ['*'] (unchanged fallback)", () => {
@@ -79,14 +74,9 @@ describe("normalizeInput — non-MCP surfaces", () => {
expect(result.resultExtras).toEqual({});
});
it("falls back to '*' when name is missing", () => {
const result = normalizeInput("skill", {}, []);
expect(result.values).toEqual(["*"]);
});
it("falls back to '*' when name is not a string", () => {
const result = normalizeInput("skill", { name: 99 }, []);
expect(result.values).toEqual(["*"]);
it("falls back to '*' when name is missing or not a string", () => {
expect(normalizeInput("skill", {}, []).values).toEqual(["*"]);
expect(normalizeInput("skill", { name: 99 }, []).values).toEqual(["*"]);
});
});
@@ -98,16 +88,12 @@ describe("normalizeInput — non-MCP surfaces", () => {
expect(result.resultExtras).toEqual({ command: "git status" });
});
it("uses empty string when command is missing", () => {
const result = normalizeInput("bash", {}, []);
expect(result.values).toEqual([""]);
expect(result.resultExtras).toEqual({ command: "" });
});
it("uses empty string when command is not a string", () => {
const result = normalizeInput("bash", { command: 42 }, []);
expect(result.values).toEqual([""]);
expect(result.resultExtras).toEqual({ command: "" });
it("uses empty string when command is missing or not a string", () => {
for (const input of [{}, { command: 42 }]) {
const result = normalizeInput("bash", input, []);
expect(result.values).toEqual([""]);
expect(result.resultExtras).toEqual({ command: "" });
}
});
it("strips leading comment lines from values but keeps original in resultExtras", () => {
@@ -159,18 +145,9 @@ describe("normalizeInput — non-MCP surfaces", () => {
});
describe("normalizeInput — MCP surface", () => {
it("surface is 'mcp'", () => {
const result = normalizeInput("mcp", { tool: "exa:search" }, []);
expect(result.surface).toBe("mcp");
});
it("values end with the catch-all 'mcp' target", () => {
const result = normalizeInput("mcp", { tool: "exa:search" }, []);
expect(result.values.at(-1)).toBe("mcp");
});
it("values include specific targets before the catch-all for a qualified tool call", () => {
const result = normalizeInput("mcp", { tool: "exa:search" }, []);
expect(result.surface).toBe("mcp");
expect(result.values).toContain("exa_search");
expect(result.values).toContain("exa:search");
expect(result.values).toContain("exa");
@@ -239,20 +216,6 @@ describe("buildAccessIntentForSurface", () => {
});
it("emits an access-path intent for a path-bearing tool surface (read)", () => {
const intent = buildAccessIntentForSurface(
"read",
"/test/project/.env",
normalizer,
undefined,
);
expect(intent.kind).toBe("access-path");
if (intent.kind === "access-path") {
expect(intent.surface).toBe("read");
expect(intent.path.value()).toBe("/test/project/.env");
}
});
it("emits a tool intent for a non-path surface (bash)", () => {
const intent = buildAccessIntentForSurface(
"bash",
@@ -343,9 +306,4 @@ describe("buildResolvedIntentFromMatchValues", () => {
agentName: "Explore",
});
});
it("threads an empty agentName through for agent-neutral resolution", () => {
const intent = buildResolvedIntentFromMatchValues("bash", ["ls"], "");
expect(intent.agentName).toBe("");
});
});
@@ -20,11 +20,8 @@ describe("parseQualifiedMcpToolName", () => {
});
});
it("returns null for empty string", () => {
it("returns null for empty or whitespace-only string", () => {
expect(parseQualifiedMcpToolName("")).toBeNull();
});
it("returns null for whitespace-only string", () => {
expect(parseQualifiedMcpToolName(" ")).toBeNull();
});
@@ -40,11 +37,8 @@ describe("parseQualifiedMcpToolName", () => {
expect(parseQualifiedMcpToolName("exa_search")).toBeNull();
});
it("returns null when server part is empty after trimming", () => {
it("returns null when either part is empty after trimming", () => {
expect(parseQualifiedMcpToolName(" :search")).toBeNull();
});
it("returns null when tool part is empty after trimming", () => {
expect(parseQualifiedMcpToolName("exa: ")).toBeNull();
});
});
@@ -77,20 +71,21 @@ describe("createMcpPermissionTargets", () => {
});
it("derives server targets from configured server names when tool name ends with _<server>", () => {
const targets = createMcpPermissionTargets({ tool: "search_exa" }, [
"exa",
]);
expect(targets).toContain("exa_search_exa");
expect(targets).toContain("exa:search_exa");
expect(targets).toContain("exa");
});
it("does not derive server targets for names not ending in _<server>", () => {
const targets = createMcpPermissionTargets({ tool: "exa_search" }, [
"exa",
]);
// exa_search ends with _exa? No — it ends with _search. This tool name
// does NOT trigger server derivation because it does not end with _exa.
expect(targets).toContain("exa_search");
});
it("does not include duplicate entries", () => {
const targets = createMcpPermissionTargets({ tool: "exa:search" }, [
"exa",
]);
const unique = [...new Set(targets)];
expect(targets).toEqual(unique);
expect(targets).not.toContain("exa");
expect(targets).not.toContain("exa:exa_search");
});
});
@@ -154,16 +149,11 @@ describe("createMcpPermissionTargets", () => {
expect(targets).toContain("mcp_status");
});
it("produces mcp_status for null input", () => {
const targets = createMcpPermissionTargets(null, []);
expect(targets).toContain("mcp_status");
});
it("produces mcp_status when no server/tool/connect/describe/search present", () => {
const targets = createMcpPermissionTargets({ unrelated: "value" }, [
"exa",
]);
expect(targets).toContain("mcp_status");
it("produces mcp_status for null or unrecognized input", () => {
expect(createMcpPermissionTargets(null, [])).toContain("mcp_status");
expect(
createMcpPermissionTargets({ unrelated: "value" }, ["exa"]),
).toContain("mcp_status");
});
});
@@ -1,41 +0,0 @@
import { describe, expect, test } from "vitest";
import {
PATH_BEARING_TOOLS,
PATH_SURFACES,
} from "#src/access-intent/path-surfaces";
describe("PATH_BEARING_TOOLS", () => {
test("contains the expected tool names", () => {
for (const tool of ["read", "write", "edit", "find", "grep", "ls"]) {
expect(PATH_BEARING_TOOLS.has(tool)).toBe(true);
}
});
test("does not contain bash or mcp", () => {
expect(PATH_BEARING_TOOLS.has("bash")).toBe(false);
expect(PATH_BEARING_TOOLS.has("mcp")).toBe(false);
});
});
describe("PATH_SURFACES", () => {
test("contains the path-bearing tools plus the path gate", () => {
for (const surface of [
"read",
"write",
"edit",
"find",
"grep",
"ls",
"path",
]) {
expect(PATH_SURFACES.has(surface)).toBe(true);
}
});
test("does not contain bash or mcp", () => {
expect(PATH_SURFACES.has("bash")).toBe(false);
expect(PATH_SURFACES.has("mcp")).toBe(false);
});
});
@@ -1,5 +1,4 @@
import { describe, expect, test } from "vitest";
import { PATH_BEARING_TOOLS } from "#src/access-intent/path-surfaces";
import {
classifyToolKind,
isMcpCheck,
@@ -20,8 +19,8 @@ describe("classifyToolKind", () => {
expect(classifyToolKind("skill")).toBe("skill");
});
test("classifies every path-bearing built-in tool as path", () => {
for (const tool of PATH_BEARING_TOOLS) {
test("classifies path-bearing built-in tools as path", () => {
for (const tool of ["read", "write", "edit", "grep", "find", "ls"]) {
expect(classifyToolKind(tool)).toBe("path");
}
});
@@ -221,4 +221,73 @@ describe("composeAuthorizerChain", () => {
});
expect(terminal.authorize).toHaveBeenCalledOnce();
});
it("collects defer notes and forwards them to the terminal on the details", async () => {
const terminal = makeTerminal();
const details = makeDetails();
const first = makeLink(
{
kind: "defer",
note: { origin: "system", text: "fidelity uncertain" },
},
"first",
);
const second = makeLink(
{
kind: "defer",
note: { origin: "model", text: "user forbade it" },
},
"second",
);
const composed = composeAuthorizerChain(
[first, second],
terminal,
makeQuery(),
log,
);
await composed.authorize(details);
expect(terminal.authorize).toHaveBeenCalledWith({
...details,
deferNotes: [
{ origin: "system", text: "fidelity uncertain" },
{ origin: "model", text: "user forbade it" },
],
});
});
it("passes the original details object when no link attached a note", async () => {
const terminal = makeTerminal();
const details = makeDetails();
const link = makeLink({ kind: "defer" });
const composed = composeAuthorizerChain([link], terminal, makeQuery(), log);
await composed.authorize(details);
// Identity, not just equality: the details are not re-wrapped when there
// is nothing to attach.
expect(terminal.authorize).toHaveBeenCalledWith(details);
});
it("discards a deferring link's note when a later link decides", async () => {
const terminal = makeTerminal();
const first = makeLink(
{ kind: "defer", note: { origin: "system", text: "not sure" } },
"first",
);
const second = makeLink({ kind: "allow" }, "second");
const composed = composeAuthorizerChain(
[first, second],
terminal,
makeQuery(),
log,
);
await composed.authorize(makeDetails());
// The note only exists to inform a *deferred* prompt; a decided ask never
// reaches the terminal, so there is nothing to forward.
expect(terminal.authorize).not.toHaveBeenCalled();
});
});
@@ -128,16 +128,7 @@ describe("AuthorizerSelection", () => {
});
describe("lifecycle", () => {
it("activate then deactivate rejects a subsequent escalate", async () => {
const selection = new AuthorizerSelection(makeDeps());
selection.activate(makeCtx());
selection.deactivate();
await expect(selection.escalate(makeDetails())).rejects.toThrow(
"escalate called before the session was activated",
);
});
it("multiple activate calls escalate against the most recent context", async () => {
it("multiple activate calls still escalate successfully", async () => {
const prompter = makePrompterApi();
const selection = new AuthorizerSelection(makeDeps({ prompter }));
selection.activate(makeCtx({ cwd: "/old" }));
@@ -21,26 +21,4 @@ describe("DenyingAuthorizer", () => {
},
});
});
it("denies the same way for a skill-sourced request", async () => {
const authorizer: TerminalAuthorizer = new DenyingAuthorizer();
const decision = await authorizer.authorize(
makePromptDetails({
requestId: "req-2",
source: "skill_input",
skillName: "deploy-helper",
}),
);
expect(decision).toEqual({
approved: false,
state: "denied",
confirmationUnavailable: true,
decidedBy: {
kind: "unavailable",
reason: "No live authority was reachable for this session",
},
});
});
});
@@ -111,14 +111,6 @@ describe("logPermissionForwardingWarning", () => {
it("does not throw when logger is null", () => {
expect(() => logPermissionForwardingWarning(null, "ignored")).not.toThrow();
});
it("does not call anything when logger is null", () => {
// Verify the null-logger path is a true no-op — cannot easily spy on null,
// but we can verify the call succeeds silently.
expect(() =>
logPermissionForwardingWarning(null, "msg", new Error("err")),
).not.toThrow();
});
});
// ── logPermissionForwardingError ───────────────────────────────────────────
@@ -20,10 +20,7 @@ import {
servingHeartbeatDir,
servingHeartbeatPath,
} from "#src/authority/forwarding-liveness";
import {
PERMISSION_FORWARDING_POLL_INTERVAL_MS,
type PermissionForwardingTarget,
} from "#src/authority/permission-forwarding";
import type { PermissionForwardingTarget } from "#src/authority/permission-forwarding";
let root: string;
let forwardingDir: string;
@@ -61,20 +58,6 @@ function readRecord(sessionId: string): ServingHeartbeat {
) as ServingHeartbeat;
}
describe("timing constants", () => {
it("refreshes less often than the inbox is polled, so a per-tick call is cheap", () => {
expect(SERVING_HEARTBEAT_REFRESH_MS).toBeGreaterThan(
PERMISSION_FORWARDING_POLL_INTERVAL_MS,
);
});
it("tolerates several missed refreshes before calling a record stale", () => {
expect(SERVING_HEARTBEAT_STALE_MS).toBeGreaterThan(
SERVING_HEARTBEAT_REFRESH_MS * 2,
);
});
});
describe("servingHeartbeatPath", () => {
it("places the record beside the sessions tree, not inside it", () => {
expect(servingHeartbeatDir(forwardingDir)).toBe(
@@ -283,6 +283,26 @@ describe("LocalUserAuthorizer", () => {
});
});
describe("defer notes", () => {
it("forwards chain-supplied deferNotes into the request options", async () => {
const { deps, decisionFn } = makeDeps();
const authorizer = new LocalUserAuthorizer(deps);
const deferNotes = [
{ origin: "system" as const, text: "fidelity uncertain" },
{ origin: "model" as const, text: "user forbade it" },
];
await authorizer.authorize(makeDetails({ deferNotes }));
expect(decisionFn).toHaveBeenCalledWith(
expect.anything(),
expect.any(String),
expect.anything(),
{ deferNotes },
);
});
});
it("returns the decision from requestPermissionDecision", async () => {
const decision: PermissionPromptDecision = {
approved: false,
@@ -5,8 +5,6 @@ import {
createPermissionForwardingLocation,
isForwardedPermissionRequestForSession,
resolvePermissionForwardingTarget,
SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
SUBAGENT_PARENT_SESSION_ENV_KEY,
} from "#src/authority/permission-forwarding";
import { makeSubagentRegistry } from "#test/helpers/forwarding-fixtures";
@@ -14,28 +12,6 @@ afterEach(() => {
vi.unstubAllEnvs();
});
describe("SUBAGENT_PARENT_SESSION_ENV_CANDIDATES", () => {
test("is an array containing PI_AGENT_ROUTER_PARENT_SESSION_ID", () => {
expect(Array.isArray(SUBAGENT_PARENT_SESSION_ENV_CANDIDATES)).toBe(true);
expect(SUBAGENT_PARENT_SESSION_ENV_CANDIDATES).toContain(
"PI_AGENT_ROUTER_PARENT_SESSION_ID",
);
});
test("contains PI_SUBAGENT_PARENT_SESSION for CLI-based subagent extensions", () => {
expect(SUBAGENT_PARENT_SESSION_ENV_CANDIDATES).toContain(
"PI_SUBAGENT_PARENT_SESSION",
);
});
test("deprecated SUBAGENT_PARENT_SESSION_ENV_KEY equals the first candidate", () => {
// eslint-disable-next-line @typescript-eslint/no-deprecated -- test verifying the deprecated alias
expect(SUBAGENT_PARENT_SESSION_ENV_KEY).toBe(
SUBAGENT_PARENT_SESSION_ENV_CANDIDATES[0],
);
});
});
describe("resolvePermissionForwardingTarget", () => {
test("hasUI=true returns the current session ID as its own target", () => {
expect(
@@ -245,30 +221,6 @@ describe("resolvePermissionForwardingTarget — registry resolution", () => {
// Moved from permission-system.test.ts catch-all (#342)
// ---------------------------------------------------------------------------
test("Permission forwarding resolves the parent interactive session from subagent runtime env", () => {
const target = resolvePermissionForwardingTarget({
hasUI: false,
isSubagent: true,
currentSessionId: "child-session",
env: {
PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-session",
},
});
expect(target).toEqual({ sessionId: "parent-session", source: "env" });
});
test("Permission forwarding does not guess a target session when subagent runtime env is missing", () => {
const target = resolvePermissionForwardingTarget({
hasUI: false,
isSubagent: true,
currentSessionId: "child-session",
env: {},
});
expect(target).toBe(null);
});
test("Permission forwarding uses session-scoped directories per interactive session", () => {
const forwardingRoot = join(tmpdir(), "pi-permission-system-forwarding-root");
const sessionA = createPermissionForwardingLocation(
@@ -79,16 +79,6 @@ describe("PermissionPrompter", () => {
);
});
it("calls authorizer.authorize with the details", async () => {
const authorizer = makeAuthorizer();
const prompter = new PermissionPrompter(makeDeps());
const details = makeDetails();
await prompter.prompt(authorizer, details);
expect(authorizer.authorize).toHaveBeenCalledWith(details);
});
it("logs permission_request.approved when the authorizer approves", async () => {
const logger = { review: vi.fn() };
const prompter = new PermissionPrompter(makeDeps({ logger }));
@@ -59,18 +59,6 @@ describe("ServingSessionRegistry", () => {
registry.clearServing("sess-1");
expect(registry.isServing("sess-1")).toBe(false);
});
it("is a no-op for an unmarked session", () => {
const registry = new ServingSessionRegistry();
registry.clearServing("sess-1");
expect(registry.servingIds()).toEqual([]);
});
});
describe("servingIds", () => {
it("is empty for a fresh registry", () => {
expect(new ServingSessionRegistry().servingIds()).toEqual([]);
});
});
});
@@ -98,12 +86,6 @@ describe("composeServingAnnouncers", () => {
expect(first.clearServing).toHaveBeenCalledExactlyOnceWith("sess-1");
expect(second.clearServing).toHaveBeenCalledExactlyOnceWith("sess-1");
});
it("is a no-op with no channels", () => {
expect(() => {
composeServingAnnouncers().markServing("sess-1");
}).not.toThrow();
});
});
describe("getServingSessionRegistry", () => {
@@ -1,5 +1,4 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { SUBAGENT_ENV_HINT_KEYS } from "#src/authority/permission-forwarding";
import {
isRegisteredSubagentChild,
isSubagentExecutionContext,
@@ -258,23 +257,6 @@ describe("isSubagentExecutionContext — env hint detection", () => {
).toBe(true);
});
test("covers all declared SUBAGENT_ENV_HINT_KEYS", () => {
// Verify the keys we test match what the module declares.
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_IS_SUBAGENT");
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_SESSION_ID");
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_AGENT_ROUTER_SUBAGENT");
// nicobailon/pi-subagents
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_CHILD");
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_RUN_ID");
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_CHILD_AGENT");
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_DEPTH");
// HazAT/pi-interactive-subagents
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_NAME");
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_ID");
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_SESSION");
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_ACTIVITY_FILE");
});
test("returns false when env hint value is empty string", () => {
vi.stubEnv("PI_IS_SUBAGENT", "");
expect(
@@ -471,19 +453,6 @@ describe("isSubagentExecutionContext — registry detection", () => {
).toBe(true);
});
test("returns true when registered session has a parentSessionId", () => {
const registry = new SubagentSessionRegistry();
registry.register(childSessionId, { parentSessionId: "parent-123" });
expect(
isSubagentExecutionContext(
makeCtx(outsideDir, childSessionId),
subagentRoot,
posixPathFlavor,
registry,
),
).toBe(true);
});
test("returns false when registry is provided but session id is not registered", () => {
const registry = new SubagentSessionRegistry();
expect(
@@ -508,22 +477,6 @@ describe("isSubagentExecutionContext — registry detection", () => {
).toBe(false);
});
test("registry check takes priority over env var detection", () => {
// Registry says registered; env var not set — should still return true.
const registry = new SubagentSessionRegistry();
registry.register(childSessionId, {});
// Confirm no env var is set
expect(process.env.PI_IS_SUBAGENT).toBeUndefined();
expect(
isSubagentExecutionContext(
makeCtx(outsideDir, childSessionId),
subagentRoot,
posixPathFlavor,
registry,
),
).toBe(true);
});
test("unregistered session falls through to env var detection", () => {
vi.stubEnv("PI_IS_SUBAGENT", "true");
const registry = new SubagentSessionRegistry(); // empty — childSessionId not registered
@@ -537,11 +490,4 @@ describe("isSubagentExecutionContext — registry detection", () => {
),
).toBe(true);
});
test("no registry passed — existing behaviour unchanged", () => {
// Ensure the parameter is truly optional (no registry arg)
expect(
isSubagentExecutionContext(makeCtx(null), subagentRoot, posixPathFlavor),
).toBe(false);
});
});
@@ -1,100 +1,41 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { describe, expect, test } from "vitest";
import type { SubagentDetectionContext } from "#src/authority/subagent-context";
import { SubagentDetection } from "#src/authority/subagent-detection";
import { SubagentSessionRegistry } from "#src/authority/subagent-registry";
import { posixPathFlavor } from "#src/path/path-flavor";
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
function makeCtx(
sessionDir: string | null,
sessionId: string = "",
): SubagentDetectionContext {
function makeCtx(sessionId: string): SubagentDetectionContext {
return {
sessionManager: {
getSessionDir: vi.fn(() => sessionDir ?? ""),
getSessionId: vi.fn(() => sessionId),
getSessionDir: () => "",
getSessionId: () => sessionId,
},
};
}
const subagentSessionsDir = "/agent/subagent-sessions";
describe("SubagentDetection", () => {
describe("isSubagent", () => {
test("returns true for a registered in-process child (registry source)", () => {
const registry = new SubagentSessionRegistry();
registry.register("child-1", {});
const detection = new SubagentDetection({
subagentSessionsDir,
flavor: posixPathFlavor,
registry,
});
expect(detection.isSubagent(makeCtx(null, "child-1"))).toBe(true);
});
test("returns true when a subagent env hint is set (env source)", () => {
vi.stubEnv("PI_IS_SUBAGENT", "1");
const detection = new SubagentDetection({
subagentSessionsDir,
flavor: posixPathFlavor,
registry: new SubagentSessionRegistry(),
});
expect(detection.isSubagent(makeCtx("/somewhere/else"))).toBe(true);
});
test("returns true when the session dir is nested under subagentSessionsDir (filesystem source)", () => {
const detection = new SubagentDetection({
subagentSessionsDir,
flavor: posixPathFlavor,
registry: new SubagentSessionRegistry(),
});
expect(
detection.isSubagent(makeCtx(`${subagentSessionsDir}/child-1`)),
).toBe(true);
});
test("returns false when no source matches", () => {
const detection = new SubagentDetection({
subagentSessionsDir,
flavor: posixPathFlavor,
registry: new SubagentSessionRegistry(),
});
expect(detection.isSubagent(makeCtx("/projects/my-app"))).toBe(false);
// The detection sources themselves are covered exhaustively in
// subagent-context.test.ts; these two tests pin the only branches this
// wrapper owns: registry consult and registry-absent fallback.
describe("SubagentDetection.isRegisteredChild", () => {
test("returns true when the session id is registered", () => {
const registry = new SubagentSessionRegistry();
registry.register("child-1", {});
const detection = new SubagentDetection({
subagentSessionsDir,
flavor: posixPathFlavor,
registry,
});
expect(detection.isRegisteredChild(makeCtx("child-1"))).toBe(true);
});
describe("isRegisteredChild", () => {
test("returns true when the session id is registered", () => {
const registry = new SubagentSessionRegistry();
registry.register("child-1", {});
const detection = new SubagentDetection({
subagentSessionsDir,
flavor: posixPathFlavor,
registry,
});
expect(detection.isRegisteredChild(makeCtx(null, "child-1"))).toBe(true);
});
test("returns false when the session id is not registered", () => {
const detection = new SubagentDetection({
subagentSessionsDir,
flavor: posixPathFlavor,
registry: new SubagentSessionRegistry(),
});
expect(detection.isRegisteredChild(makeCtx(null, "child-1"))).toBe(false);
});
test("returns false when constructed without a registry", () => {
const detection = new SubagentDetection({
subagentSessionsDir,
flavor: posixPathFlavor,
});
expect(detection.isRegisteredChild(makeCtx(null, "child-1"))).toBe(false);
test("returns false when constructed without a registry", () => {
const detection = new SubagentDetection({
subagentSessionsDir,
flavor: posixPathFlavor,
});
expect(detection.isRegisteredChild(makeCtx("child-1"))).toBe(false);
});
});
@@ -98,13 +98,6 @@ describe("subscribeSubagentLifecycle", () => {
expect(handlers.has("subagents:child:disposed")).toBe(true);
});
it("exposes the canonical channel-name strings", () => {
expect(SUBAGENT_CHILD_SESSION_CREATED).toBe(
"subagents:child:session-created",
);
expect(SUBAGENT_CHILD_DISPOSED).toBe("subagents:child:disposed");
});
// ── #298 regression: concurrent siblings must be independent ──────────────
it("disposing one sibling does not evict the other (collision regression)", () => {
@@ -16,54 +16,16 @@ function makeInfo(
}
describe("SubagentSessionRegistry", () => {
test("has() returns false for an unregistered key", () => {
const registry = new SubagentSessionRegistry();
expect(registry.has("session-abc")).toBe(false);
});
test("get() returns undefined for an unregistered key", () => {
const registry = new SubagentSessionRegistry();
expect(registry.get("session-abc")).toBeUndefined();
});
test("has() returns true after register()", () => {
const registry = new SubagentSessionRegistry();
registry.register("session-abc", makeInfo());
expect(registry.has("session-abc")).toBe(true);
});
test("get() returns the registered info after register()", () => {
test("register() stores and unregister() removes the registered info", () => {
const registry = new SubagentSessionRegistry();
const info = makeInfo({ parentSessionId: "parent-123" });
registry.register("session-abc", info);
expect(registry.get("session-abc")).toEqual(info);
});
test("register() stores entry without parentSessionId", () => {
const registry = new SubagentSessionRegistry();
registry.register("session-abc", makeInfo());
expect(registry.get("session-abc")).toEqual({});
});
test("has() returns false after unregister()", () => {
const registry = new SubagentSessionRegistry();
registry.register("session-abc", makeInfo());
registry.unregister("session-abc");
expect(registry.has("session-abc")).toBe(false);
});
test("get() returns undefined after unregister()", () => {
const registry = new SubagentSessionRegistry();
registry.register("session-abc", makeInfo());
registry.unregister("session-abc");
expect(registry.get("session-abc")).toBeUndefined();
});
test("unregister() is a no-op for an unknown key", () => {
const registry = new SubagentSessionRegistry();
expect(() => registry.unregister("session-nonexistent")).not.toThrow();
});
test("register() overwrites a previous entry for the same key", () => {
const registry = new SubagentSessionRegistry();
registry.register("session-abc", makeInfo({ parentSessionId: "parent-1" }));
@@ -116,11 +78,6 @@ describe("getSubagentSessionRegistry (process-global accessor)", () => {
delete (globalThis as Record<symbol, unknown>)[REGISTRY_KEY];
});
test("returns a SubagentSessionRegistry instance", () => {
const registry = getSubagentSessionRegistry();
expect(registry).toBeInstanceOf(SubagentSessionRegistry);
});
test("returns the same instance on repeated calls", () => {
const first = getSubagentSessionRegistry();
const second = getSubagentSessionRegistry();
@@ -137,9 +94,4 @@ describe("getSubagentSessionRegistry (process-global accessor)", () => {
expect(reader.has("child-session-xyz")).toBe(true);
expect(reader.get("child-session-xyz")?.parentSessionId).toBe("parent-abc");
});
test("starts empty on first call", () => {
const registry = getSubagentSessionRegistry();
expect(registry.has("any-session-id")).toBe(false);
});
});
@@ -1,94 +0,0 @@
import { describe, expect, it } from "vitest";
import type { Api, Model, Provider } from "@earendil-works/pi-ai";
import { resolveReviewModel, type ReviewModelRegistry } from "#src/auto-review/model";
import { DEFAULT_AUTO_REVIEW_CONFIG, type AutoReviewConfig } from "#src/auto-review/config";
const model: Model<Api> = {
id: "reviewer-model",
name: "Reviewer Model",
api: "openai-completions",
provider: "vendor",
baseUrl: "https://example.invalid",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
maxTokens: 100,
};
function registryWith(providerId: string | undefined): ReviewModelRegistry {
const provider = { id: providerId ?? "vendor", getModels: () => [model] } as unknown as Provider<Api>;
return {
find: (candidateProvider, modelId) =>
candidateProvider === providerId && modelId === model.id ? model : undefined,
getAll: () => [model],
getProvider: (candidate) => (candidate === providerId ? provider : undefined),
getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "key" }),
};
}
function config(overrides: Partial<AutoReviewConfig>): AutoReviewConfig {
return { ...DEFAULT_AUTO_REVIEW_CONFIG, ...overrides };
}
describe("resolveReviewModel", () => {
it("resolves the operator-named model from the registry", () => {
const result = resolveReviewModel(
registryWith("vendor"),
config({ provider: "vendor", model: "reviewer-model" }),
);
expect(result.ok).toBe(true);
if (!result.ok) throw new Error("expected success");
expect(result.value.model.id).toBe("reviewer-model");
expect(result.value.provider.id).toBe("vendor");
});
it("reports model-unconfigured when no provider is named", () => {
const result = resolveReviewModel(registryWith("vendor"), config({ model: "reviewer-model" }));
expect(result).toEqual({ ok: false, category: "model-unconfigured" });
});
it("reports model-unconfigured when no model is named", () => {
const result = resolveReviewModel(registryWith("vendor"), config({ provider: "vendor" }));
expect(result).toEqual({ ok: false, category: "model-unconfigured" });
});
it("reports model-unconfigured for the default config with no named pair", () => {
const result = resolveReviewModel(registryWith("vendor"), DEFAULT_AUTO_REVIEW_CONFIG);
expect(result).toEqual({ ok: false, category: "model-unconfigured" });
});
it("reports provider-unresolved for an unknown provider", () => {
const result = resolveReviewModel(
registryWith("vendor"),
config({ provider: "missing", model: "reviewer-model" }),
);
expect(result).toEqual({ ok: false, category: "provider-unresolved" });
});
it("reports model-unresolved for a model the provider does not publish", () => {
const result = resolveReviewModel(
registryWith("vendor"),
config({ provider: "vendor", model: "missing-model" }),
);
expect(result).toEqual({ ok: false, category: "model-unresolved" });
});
it("never synthesizes a model the provider does not publish", () => {
// The reviewer used to fall back to a synthesized Codex stand-in. It must
// now refuse instead, so the ask defers to the human prompt.
const result = resolveReviewModel(
registryWith("vendor"),
config({ provider: "vendor", model: "codex-auto-review" }),
);
expect(result.ok).toBe(false);
});
});
@@ -0,0 +1,583 @@
import { describe, expect, it, vi } from "vitest";
import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai";
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
import {
DEFAULT_AUTO_REVIEW_CONFIG,
type AutoReviewConfig,
} from "#src/auto-review/config";
import { REVIEW_SYSTEM_PROMPT, REVIEW_TOOLS } from "#src/auto-review/policy";
import {
createApprovalCache,
createPermissionReviewer,
type ReviewContext,
} from "#src/auto-review/reviewer";
import type { PermissionQuery } from "#src/service";
import { makeAuthorizerLog } from "#test/helpers/authorizer-log-fixtures";
import { makePromptDetails } from "#test/helpers/prompt-details-fixtures";
// ── Fixtures ──────────────────────────────────────────────────────────────
function userEntry(id: string, text: string): SessionEntry {
return {
type: "message",
id,
message: { role: "user", content: text },
} as unknown as SessionEntry;
}
/** A branch holding one trusted user instruction. */
const TRUSTED_BRANCH: SessionEntry[] = [userEntry("e1", "please read the readme")];
function makeModel(
id: string,
overrides: { provider?: string; reasoning?: boolean } = {},
): Model<Api> {
return {
id,
provider: overrides.provider ?? "test-provider",
reasoning: overrides.reasoning ?? false,
api: "openai-completions",
name: id,
baseUrl: "https://example.invalid/v1/",
input: ["text"],
contextWindow: 128_000,
maxTokens: 8_000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
} as unknown as Model<Api>;
}
function verdictMessage(
name: "approve" | "reject" | string,
reason = "looks fine",
): AssistantMessage {
return {
role: "assistant",
content: [
{
type: "toolCall",
id: "call-1",
name,
arguments: { reason },
},
],
stopReason: "toolUse",
usage: { input: 11, output: 3 },
} as unknown as AssistantMessage;
}
/**
* Which registry completion surface the fake exposes — the real Pi 1.3.0
* extension-facing registry has `streamSimple` only, so that is the default
* path under test.
*/
type CompletionPath = "stream" | "complete" | "none";
interface HarnessOptions {
readonly config?: Partial<AutoReviewConfig>;
readonly branch?: SessionEntry[];
readonly produce?: ReturnType<typeof vi.fn>;
readonly sessionModel?: Model<Api> | undefined;
readonly configuredModel?: Model<Api> | undefined;
readonly completion?: CompletionPath;
readonly sessionSignal?: AbortSignal;
readonly ctxSignal?: AbortSignal;
readonly registryFind?: ReturnType<typeof vi.fn>;
}
function harness(options: HarnessOptions = {}) {
const {
branch = TRUSTED_BRANCH,
configuredModel = makeModel("configured-model", { provider: "cfg" }),
completion = "stream",
sessionSignal,
ctxSignal,
} = options;
const sessionModel = "sessionModel" in options
? options.sessionModel
: makeModel("session-model");
const config: AutoReviewConfig = {
...DEFAULT_AUTO_REVIEW_CONFIG,
...options.config,
};
const produce =
options.produce ?? vi.fn(async () => verdictMessage("approve"));
// Mirror `streamSimple(model, context, options)` returning a stream whose
// `result()` resolves the final AssistantMessage — the surface Pi 1.3.0's
// extension-facing ModelRegistry actually publishes.
const streamSimple = vi.fn(function (
this: unknown,
model: unknown,
context: unknown,
streamOptions?: unknown,
) {
return {
result: () =>
(produce as (this: unknown, ...args: unknown[]) => Promise<unknown>).call(
this,
model,
context,
streamOptions,
),
};
});
const find =
options.registryFind ??
vi.fn((provider: string, modelId: string) =>
provider === "cfg" && modelId === configuredModel.id
? configuredModel
: undefined,
);
const registry = {
find,
...(completion === "stream" ? { streamSimple } : {}),
...(completion === "complete" ? { completeSimple: produce } : {}),
};
const ctx = {
model: sessionModel,
modelRegistry: registry,
sessionManager: {
getBranch: () => branch,
getSessionId: () => "session-1",
},
signal: ctxSignal,
};
const approvalCache = createApprovalCache();
const log = makeAuthorizerLog();
const authorize = createPermissionReviewer({
config,
context: ctx as unknown as ReviewContext,
approvalCache,
...(sessionSignal !== undefined ? { sessionSignal } : {}),
});
return { authorize, produce, streamSimple, registry, ctx, log };
}
const query = {
checkPermission: vi.fn(),
getToolPermission: vi.fn(),
} as unknown as PermissionQuery;
function failureCategory(log: ReturnType<typeof makeAuthorizerLog>): unknown {
const call = log.debug.mock.calls.find(
([event]) => event === "auto_review.failure",
);
return call?.[1]?.errorCategory;
}
function decisionFields(log: ReturnType<typeof makeAuthorizerLog>) {
const call = log.review.mock.calls.find(
([event]) => event === "auto_review.decision",
);
return call?.[1] as Record<string, unknown> | undefined;
}
// ── Decision path ─────────────────────────────────────────────────────────
describe("createPermissionReviewer", () => {
it("follows the live session model in session-model mode", async () => {
const first = makeModel("model-a");
const second = makeModel("model-b");
const { authorize, produce, ctx } = harness({
sessionModel: first,
config: { provider: undefined, model: undefined },
});
await authorize(makePromptDetails({ requestId: "r1", command: "ls a" }), query, makeAuthorizerLog());
ctx.model = second;
await authorize(makePromptDetails({ requestId: "r2", command: "ls b" }), query, makeAuthorizerLog());
expect(produce).toHaveBeenCalledTimes(2);
expect(produce.mock.calls[0]?.[0]).toBe(first);
expect(produce.mock.calls[1]?.[0]).toBe(second);
});
it("uses the configured provider/model pair when both are set", async () => {
const configured = makeModel("pinned", { provider: "cfg" });
const { authorize, produce, registry } = harness({
config: { provider: "cfg", model: "pinned" },
configuredModel: configured,
});
const verdict = await authorize(makePromptDetails(), query, makeAuthorizerLog());
expect(verdict).toEqual({ kind: "allow" });
expect(registry.find).toHaveBeenCalledWith("cfg", "pinned");
expect(produce.mock.calls[0]?.[0]).toBe(configured);
});
it("defers with the pairing note when exactly one of provider/model is set", async () => {
const { authorize, produce, log } = harness({
config: { provider: "cfg", model: undefined },
});
const verdict = await authorize(makePromptDetails(), query, log);
expect(verdict.kind).toBe("defer");
if (verdict.kind === "defer") {
expect(verdict.note?.origin).toBe("system");
expect(verdict.note?.text).toContain("provider 与 autoReview.model 需同时设置或同时留空");
}
expect(produce).not.toHaveBeenCalled();
expect(failureCategory(log)).toBe("model-unconfigured");
});
it("defers with the missing-model note when the named model does not resolve", async () => {
const { authorize, produce, log } = harness({
config: { provider: "cfg", model: "nonexistent" },
});
const verdict = await authorize(makePromptDetails(), query, log);
expect(verdict.kind).toBe("defer");
if (verdict.kind === "defer") {
expect(verdict.note?.text).toContain("cfg/nonexistent");
}
expect(produce).not.toHaveBeenCalled();
expect(failureCategory(log)).toBe("model-unresolved");
});
it("defers with the no-session-model note in follow mode when ctx.model is unset", async () => {
const { authorize, produce, log } = harness({ sessionModel: undefined });
const verdict = await authorize(makePromptDetails(), query, log);
expect(verdict.kind).toBe("defer");
if (verdict.kind === "defer") {
expect(verdict.note?.text).toContain("当前会话没有选中模型");
}
expect(produce).not.toHaveBeenCalled();
expect(failureCategory(log)).toBe("no-session-model");
});
it("defers with the unsupported note when the registry cannot complete", async () => {
const { authorize, log } = harness({ completion: "none" });
const verdict = await authorize(makePromptDetails(), query, log);
expect(verdict.kind).toBe("defer");
if (verdict.kind === "defer") {
expect(verdict.note?.text).toContain("不支持独立模型调用");
}
expect(failureCategory(log)).toBe("completion-unsupported");
});
it("returns allow when the model calls approve", async () => {
const { authorize } = harness();
const verdict = await authorize(makePromptDetails(), query, makeAuthorizerLog());
expect(verdict).toEqual({ kind: "allow" });
});
it("defers with a model-origin note when the model calls reject", async () => {
const { authorize } = harness({
produce: vi.fn(async () => verdictMessage("reject", "too risky")),
});
const verdict = await authorize(makePromptDetails(), query, makeAuthorizerLog());
expect(verdict.kind).toBe("defer");
if (verdict.kind === "defer") {
expect(verdict.note).toEqual({ origin: "model", text: "too risky" });
}
});
it("treats a reply with no tool call as no-verdict, even valid JSON text", async () => {
const message = {
role: "assistant",
content: [{ type: "text", text: '{"outcome":"allow"}' }],
stopReason: "stop",
} as unknown as AssistantMessage;
const { authorize, produce, log } = harness({
produce: vi.fn(async () => message),
});
const verdict = await authorize(makePromptDetails(), query, log);
expect(verdict.kind).toBe("defer");
if (verdict.kind === "defer") {
expect(verdict.note?.origin).toBe("system");
expect(verdict.note?.text).toContain("未调用 approve/reject");
}
expect(failureCategory(log)).toBe("no-verdict");
expect(produce).toHaveBeenCalledTimes(1);
});
it("treats two tool calls as an invalid response", async () => {
const message = {
role: "assistant",
content: [
{ type: "toolCall", id: "1", name: "approve", arguments: { reason: "a" } },
{ type: "toolCall", id: "2", name: "reject", arguments: { reason: "b" } },
],
stopReason: "toolUse",
} as unknown as AssistantMessage;
const { authorize, produce, log } = harness({
produce: vi.fn(async () => message),
});
const verdict = await authorize(makePromptDetails(), query, log);
expect(verdict.kind).toBe("defer");
if (verdict.kind === "defer") {
expect(verdict.note?.text).toContain("结论格式无效");
}
expect(failureCategory(log)).toBe("invalid-response");
expect(produce).toHaveBeenCalledTimes(1);
});
it("treats an unknown verdict tool as an invalid response", async () => {
const { authorize, log } = harness({
produce: vi.fn(async () => verdictMessage("maybe")),
});
const verdict = await authorize(makePromptDetails(), query, log);
expect(verdict.kind).toBe("defer");
expect(failureCategory(log)).toBe("invalid-response");
});
it("treats a missing or empty reason as an invalid response", async () => {
const message = {
role: "assistant",
content: [{ type: "toolCall", id: "1", name: "approve", arguments: {} }],
stopReason: "toolUse",
} as unknown as AssistantMessage;
const { authorize, log } = harness({
produce: vi.fn(async () => message),
});
const verdict = await authorize(makePromptDetails(), query, log);
expect(verdict.kind).toBe("defer");
expect(failureCategory(log)).toBe("invalid-response");
});
it("does not retry a truncated response", async () => {
const message = {
role: "assistant",
content: [{ type: "text", text: "partial" }],
stopReason: "length",
} as unknown as AssistantMessage;
const { authorize, produce, log } = harness({
produce: vi.fn(async () => message),
});
const verdict = await authorize(makePromptDetails(), query, log);
expect(verdict.kind).toBe("defer");
if (verdict.kind === "defer") {
expect(verdict.note?.text).toContain("输出被截断");
}
expect(failureCategory(log)).toBe("truncated");
expect(produce).toHaveBeenCalledTimes(1);
});
it("retries a provider error and allows on the second attempt", async () => {
const errorMessage = {
role: "assistant",
content: [],
stopReason: "error",
errorMessage: "upstream 500",
} as unknown as AssistantMessage;
const produce = vi
.fn()
.mockResolvedValueOnce(errorMessage)
.mockResolvedValue(verdictMessage("approve"));
const { authorize, log } = harness({ produce, config: { maxAttempts: 2 } });
const verdict = await authorize(makePromptDetails(), query, log);
expect(verdict).toEqual({ kind: "allow" });
expect(produce).toHaveBeenCalledTimes(2);
expect(decisionFields(log)?.attempts).toBe(2);
});
it("defers with a timeout note when the call outlasts the budget", async () => {
const produce = vi.fn(
(_model: unknown, _context: unknown, options?: { signal?: AbortSignal }) =>
new Promise<AssistantMessage>((resolve) => {
options?.signal?.addEventListener(
"abort",
() =>
resolve({
role: "assistant",
content: [],
stopReason: "aborted",
} as unknown as AssistantMessage),
{ once: true },
);
}),
);
const { authorize, log } = harness({
produce,
config: { timeoutMs: 20 },
});
const verdict = await authorize(makePromptDetails(), query, log);
expect(verdict.kind).toBe("defer");
if (verdict.kind === "defer") {
expect(verdict.note?.text).toContain("超时");
}
expect(failureCategory(log)).toBe("timeout");
});
it("denies outright when the user aborts via ctx.signal", async () => {
const controller = new AbortController();
const produce = vi.fn(
(_model: unknown, _context: unknown, options?: { signal?: AbortSignal }) =>
new Promise<AssistantMessage>((resolve) => {
options?.signal?.addEventListener(
"abort",
() =>
resolve({
role: "assistant",
content: [],
stopReason: "aborted",
} as unknown as AssistantMessage),
{ once: true },
);
}),
);
const { authorize, log } = harness({
produce,
ctxSignal: controller.signal,
config: { timeoutMs: 60_000 },
});
const pending = authorize(makePromptDetails(), query, log);
controller.abort();
const verdict = await pending;
expect(verdict).toEqual({
kind: "deny",
reason: "Operation aborted by the user.",
});
});
it("defers with the cancelled note when the session shuts down mid-call", async () => {
const controller = new AbortController();
const produce = vi.fn(
(_model: unknown, _context: unknown, options?: { signal?: AbortSignal }) =>
new Promise<AssistantMessage>((resolve) => {
options?.signal?.addEventListener(
"abort",
() =>
resolve({
role: "assistant",
content: [],
stopReason: "aborted",
} as unknown as AssistantMessage),
{ once: true },
);
}),
);
const { authorize, log } = harness({
produce,
sessionSignal: controller.signal,
config: { timeoutMs: 60_000 },
});
const pending = authorize(makePromptDetails(), query, log);
controller.abort();
const verdict = await pending;
expect(verdict.kind).toBe("defer");
if (verdict.kind === "defer") {
expect(verdict.note?.text).toBe("自动审查已取消,已转人工确认。");
}
expect(failureCategory(log)).toBe("cancelled");
});
it("passes reasoning only for reasoning-capable models, and never 'off'", async () => {
const reasoningModel = makeModel("thinker", { reasoning: true });
const { authorize, produce } = harness({
sessionModel: reasoningModel,
config: { reasoning: "low" },
});
await authorize(makePromptDetails({ command: "a" }), query, makeAuthorizerLog());
expect(produce.mock.calls[0]?.[2]?.reasoning).toBe("low");
const plain = makeModel("plain", { reasoning: false });
const second = harness({ sessionModel: plain, config: { reasoning: "low" } });
await second.authorize(makePromptDetails({ command: "b" }), query, makeAuthorizerLog());
expect(second.produce.mock.calls[0]?.[2]).not.toHaveProperty("reasoning");
const off = harness({ sessionModel: reasoningModel, config: { reasoning: "off" } });
await off.authorize(makePromptDetails({ command: "c" }), query, makeAuthorizerLog());
expect(off.produce.mock.calls[0]?.[2]).not.toHaveProperty("reasoning");
});
it("sends the review tools and no toolChoice", async () => {
const { authorize, streamSimple, registry } = harness();
await authorize(makePromptDetails(), query, makeAuthorizerLog());
// The reviewer reaches the model through streamSimple(...).result() on
// Pi 1.3.0's extension-facing registry — assert the call sees the same
// model/context/options and `this` bound to the registry itself.
expect(streamSimple).toHaveBeenCalledTimes(1);
expect(streamSimple.mock.contexts[0]).toBe(registry);
const [model, context, options] = streamSimple.mock.calls[0]! as [
unknown,
{ systemPrompt: string; tools: unknown },
Record<string, unknown>,
];
expect(model).toBeDefined();
expect(context.systemPrompt).toBe(REVIEW_SYSTEM_PROMPT);
expect(context.tools).toBe(REVIEW_TOOLS);
expect(options).not.toHaveProperty("toolChoice");
expect(options.maxRetries).toBe(0);
expect(options.maxTokens).toBe(4_000);
});
it("uses completeSimple directly when the registry exposes it", async () => {
const { authorize, produce, streamSimple } = harness({
completion: "complete",
});
const verdict = await authorize(makePromptDetails(), query, makeAuthorizerLog());
expect(verdict).toEqual({ kind: "allow" });
expect(produce).toHaveBeenCalledTimes(1);
expect(streamSimple).not.toHaveBeenCalled();
});
it("caches approvals keyed to the action and latest trusted entry", async () => {
const { authorize, produce, log } = harness();
const details = makePromptDetails();
await authorize(details, query, makeAuthorizerLog());
await authorize(details, query, log);
expect(produce).toHaveBeenCalledTimes(1);
expect(decisionFields(log)?.cached).toBe(true);
});
it("invalidates the cache when a new trusted entry arrives", async () => {
const branch = [...TRUSTED_BRANCH];
const { authorize, produce } = harness({ branch });
const details = makePromptDetails();
await authorize(details, query, makeAuthorizerLog());
branch.push(userEntry("e2", "now do something else"));
await authorize(details, query, makeAuthorizerLog());
expect(produce).toHaveBeenCalledTimes(2);
});
it("never caches a reject", async () => {
const { authorize, produce } = harness({
produce: vi.fn(async () => verdictMessage("reject", "no")),
});
const details = makePromptDetails();
await authorize(details, query, makeAuthorizerLog());
await authorize(details, query, makeAuthorizerLog());
expect(produce).toHaveBeenCalledTimes(2);
});
});
@@ -1,12 +1,42 @@
import { describe, expect, it, vi } from "vitest";
import type { ExtensionContext, ModelRegistry, SessionManager } from "@earendil-works/pi-coding-agent";
import { DEFAULT_AUTO_REVIEW_CONFIG, type AutoReviewConfig } from "#src/auto-review/config";
import type { Api, Model } from "@earendil-works/pi-ai";
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import {
DEFAULT_AUTO_REVIEW_CONFIG,
type AutoReviewConfig,
} from "#src/auto-review/config";
import { installBuiltinAutoReview } from "#src/auto-review/runtime";
import type { ConfigReader } from "#src/config-store";
import type { PermissionsService } from "#src/service";
function harness(autoReview: AutoReviewConfig, hasUI = true) {
function makeModel(id: string, provider = "cfg"): Model<Api> {
return { id, provider } as unknown as Model<Api>;
}
/**
* A registry exposing `streamSimple`, as a real Pi 1.3.0 runtime does — the
* reviewer completes through `streamSimple(...).result()`.
*
* The pinned development types predate this surface, so the registry is built
* as a plain object — the same structural approach the reviewer takes.
*/
function chatRegistry(provider: string, modelId: string) {
return {
find: (p: string, m: string) =>
p === provider && m === modelId ? makeModel(m, p) : undefined,
streamSimple: vi.fn(),
};
}
interface HarnessOptions {
readonly autoReview: AutoReviewConfig;
readonly hasUI?: boolean;
readonly registry?: object;
}
function harness(options: HarnessOptions) {
const { autoReview, hasUI = true } = options;
const notify = vi.fn();
const registerAuthorizer = vi.fn(() => () => {});
const controller = installBuiltinAutoReview({
@@ -25,8 +55,10 @@ function harness(autoReview: AutoReviewConfig, hasUI = true) {
const ctx = {
hasUI,
ui: { notify },
modelRegistry: {} as ModelRegistry,
sessionManager: { getBranch: () => [] } as unknown as Pick<SessionManager, "getBranch">,
modelRegistry: options.registry ?? {},
sessionManager: { getBranch: () => [], getSessionId: () => "s" },
model: makeModel("session-model"),
signal: undefined,
} as unknown as ExtensionContext;
controller.handleSessionStart(ctx);
@@ -35,33 +67,77 @@ function harness(autoReview: AutoReviewConfig, hasUI = true) {
describe("built-in AutoReview lifecycle", () => {
it("registers the auto-review authorizer", () => {
const { registerAuthorizer } = harness({ ...DEFAULT_AUTO_REVIEW_CONFIG });
const { registerAuthorizer } = harness({
autoReview: { ...DEFAULT_AUTO_REVIEW_CONFIG },
});
expect(registerAuthorizer).toHaveBeenCalledWith("auto-review", expect.any(Function));
expect(registerAuthorizer).toHaveBeenCalledWith(
"auto-review",
expect.any(Function),
);
});
it("warns once at session start when provider and model are unset", () => {
const { notify } = harness({ ...DEFAULT_AUTO_REVIEW_CONFIG });
it("warns when exactly one of provider/model is set", () => {
const { notify } = harness({
autoReview: { ...DEFAULT_AUTO_REVIEW_CONFIG, provider: "cfg" },
});
expect(notify).toHaveBeenCalledTimes(1);
expect(notify.mock.calls[0]?.[1]).toBe("warning");
expect(notify.mock.calls[0]?.[0]).toMatch(/provider.*model/iu);
expect(notify.mock.calls[0]?.[0]).toMatch(/only one of/iu);
});
it("stays silent when both provider and model are configured", () => {
const { notify } = harness({ ...DEFAULT_AUTO_REVIEW_CONFIG, provider: "vendor", model: "m" });
it("warns when the named model cannot be resolved", () => {
const { notify } = harness({
autoReview: { ...DEFAULT_AUTO_REVIEW_CONFIG, provider: "cfg", model: "missing" },
registry: chatRegistry("cfg", "present"),
});
expect(notify).toHaveBeenCalledTimes(1);
expect(notify.mock.calls[0]?.[0]).toMatch(/cfg\/missing.*not available/iu);
});
it("warns when the runtime exposes no completion capability", () => {
const { notify } = harness({
autoReview: { ...DEFAULT_AUTO_REVIEW_CONFIG, provider: "cfg", model: "m" },
registry: {},
});
expect(notify).toHaveBeenCalledTimes(1);
expect(notify.mock.calls[0]?.[0]).toMatch(/does not support an independent model call/iu);
});
it("stays silent when the named model resolves", () => {
const { notify } = harness({
autoReview: { ...DEFAULT_AUTO_REVIEW_CONFIG, provider: "cfg", model: "m" },
registry: chatRegistry("cfg", "m"),
});
expect(notify).not.toHaveBeenCalled();
});
it("stays silent in follow-the-session-model mode", () => {
const { notify } = harness({
autoReview: { ...DEFAULT_AUTO_REVIEW_CONFIG },
registry: {},
});
expect(notify).not.toHaveBeenCalled();
});
it("stays silent when AutoReview is disabled", () => {
const { notify } = harness({ ...DEFAULT_AUTO_REVIEW_CONFIG, enabled: false });
const { notify } = harness({
autoReview: { ...DEFAULT_AUTO_REVIEW_CONFIG, enabled: false, provider: "cfg" },
});
expect(notify).not.toHaveBeenCalled();
});
it("does not notify without a UI", () => {
const { notify } = harness({ ...DEFAULT_AUTO_REVIEW_CONFIG }, false);
const { notify } = harness({
autoReview: { ...DEFAULT_AUTO_REVIEW_CONFIG, provider: "cfg" },
hasUI: false,
});
expect(notify).not.toHaveBeenCalled();
});
@@ -77,7 +153,7 @@ describe("built-in AutoReview lifecycle", () => {
yoloMode: false,
doublePressToConfirm: true,
authorizerChain: [],
autoReview: { ...DEFAULT_AUTO_REVIEW_CONFIG },
autoReview: { ...DEFAULT_AUTO_REVIEW_CONFIG, provider: "cfg" },
}),
} as ConfigReader,
});
@@ -0,0 +1,264 @@
import { describe, expect, it, vi } from "vitest";
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
import {
collectTranscriptEntries,
estimateTokens,
renderTranscript,
truncateToApproximateTokens,
} from "#src/auto-review/transcript";
function entry(
id: string,
message: Record<string, unknown>,
): SessionEntry {
return { type: "message", id, message } as unknown as SessionEntry;
}
function user(id: string, text: string): SessionEntry {
return entry(id, { role: "user", content: text });
}
function toolResult(
id: string,
toolCallId: string,
text: string,
extras: Record<string, unknown> = {},
): SessionEntry {
return entry(id, {
role: "toolResult",
toolName: "read",
toolCallId,
isError: false,
content: [{ type: "text", text }],
...extras,
});
}
const renderedContent = (line: string) => JSON.parse(line).content as string;
const renderedLabel = (line: string) => JSON.parse(line).label as string;
describe("estimateTokens", () => {
it("counts ASCII at a quarter token per character", () => {
expect(estimateTokens("abcd")).toBe(1);
expect(estimateTokens("abcde")).toBe(2);
});
it("counts every non-ASCII code point as a full token", () => {
expect(estimateTokens("你好")).toBe(2);
expect(estimateTokens("ab你好cd")).toBe(1 + 2); // ceil(4/4) + 2
});
});
describe("truncateToApproximateTokens", () => {
it("bounds the estimated tokens of the rendered result", () => {
const text = "汉".repeat(5_000);
const truncated = truncateToApproximateTokens(text, 100);
expect(estimateTokens(truncated)).toBeLessThanOrEqual(100);
expect(truncated).toContain("...[truncated]...");
});
});
describe("renderTranscript summary substitution", () => {
it("replaces a tool result with a strictly shorter summary", () => {
const original = "x".repeat(500);
const branch = [
user("e1", "do it"),
toolResult("e2", "call-1", original),
];
const querySummaries = vi.fn(() => [
{ toolCallId: "call-1", deterministicSummary: "short summary" },
]);
const result = renderTranscript(branch, { querySummaries });
expect(querySummaries).toHaveBeenCalledTimes(1);
expect(querySummaries).toHaveBeenCalledWith(["call-1"]);
const toolLine = result.entries.find((line) =>
renderedLabel(line).includes("read"),
);
expect(toolLine).toBeDefined();
expect(renderedLabel(toolLine!)).toContain("(summarized)");
expect(renderedContent(toolLine!)).toBe("short summary\n[summarized]");
expect(result.stats.toolResultsSummarized).toBe(1);
});
it("prefers the semantic summary over the deterministic one", () => {
const branch = [user("e1", "go"), toolResult("e2", "call-1", "y".repeat(300))];
const result = renderTranscript(branch, {
querySummaries: () => [
{
toolCallId: "call-1",
semanticSummary: "semantic",
deterministicSummary: "deterministic",
},
],
});
const toolLine = result.entries.find((line) =>
renderedLabel(line).includes("summarized"),
);
expect(renderedContent(toolLine!)).toBe("semantic\n[summarized]");
});
it("keeps the original when the summary is not shorter", () => {
const branch = [user("e1", "go"), toolResult("e2", "call-1", "tiny")];
const result = renderTranscript(branch, {
querySummaries: () => [
{ toolCallId: "call-1", deterministicSummary: "a much longer summary that exceeds the original text" },
],
});
const toolLine = result.entries.find((line) =>
renderedLabel(line).includes("read"),
);
expect(renderedLabel(toolLine!)).not.toContain("summarized");
expect(renderedContent(toolLine!)).toBe("tiny");
expect(result.stats.toolResultsSummarized).toBe(0);
});
it("matches a record by resultTimestamp when both timestamps exist", () => {
const branch = [
user("e1", "go"),
toolResult("e2", "call-1", "z".repeat(300), { timestamp: 111 }),
];
const result = renderTranscript(branch, {
querySummaries: () => [
{ toolCallId: "call-1", resultTimestamp: 999, deterministicSummary: "wrong" },
{ toolCallId: "call-1", resultTimestamp: 111, deterministicSummary: "right" },
],
});
const toolLine = result.entries.find((line) =>
renderedLabel(line).includes("summarized"),
);
expect(renderedContent(toolLine!)).toBe("right\n[summarized]");
});
it("never summarizes a user_interaction result or an assistant toolCall argument", () => {
const branch = [
user("e1", "go"),
entry("e2", {
role: "assistant",
content: [
{
type: "toolCall",
id: "ask-1",
name: "ask_user_question",
arguments: { question: "proceed?" },
},
],
}),
entry("e3", {
role: "toolResult",
toolName: "ask_user_question",
toolCallId: "ask-1",
isError: false,
details: {
cancelled: false,
answers: [{ question: "proceed?", answer: "yes" }],
},
content: [{ type: "text", text: "answered" }],
}),
];
const querySummaries = vi.fn(() => [
{ toolCallId: "ask-1", deterministicSummary: "s" },
]);
const result = renderTranscript(branch, { querySummaries });
// The user_interaction entry carries no toolCallId, so the lookup is not
// even consulted for it; no (summarized) label may appear.
expect(result.entries.some((line) => renderedLabel(line).includes("summarized"))).toBe(false);
expect(result.stats.toolResultsSummarized).toBe(0);
});
it("queries once for all tool results together", () => {
const branch = [
user("e1", "go"),
toolResult("e2", "call-1", "a".repeat(300)),
toolResult("e3", "call-2", "b".repeat(300)),
];
const querySummaries = vi.fn(() => []);
renderTranscript(branch, { querySummaries });
expect(querySummaries).toHaveBeenCalledTimes(1);
expect(querySummaries).toHaveBeenCalledWith(["call-1", "call-2"]);
});
it("keeps originals when the lookup throws", () => {
const branch = [user("e1", "go"), toolResult("e2", "call-1", "c".repeat(300))];
const result = renderTranscript(branch, {
querySummaries: () => {
throw new Error("service down");
},
});
const toolLine = result.entries.find((line) =>
renderedLabel(line).includes("read"),
);
expect(renderedContent(toolLine!)).toBe("c".repeat(300));
expect(result.stats.toolResultsSummarized).toBe(0);
});
it("works without a lookup", () => {
const branch = [user("e1", "go"), toolResult("e2", "call-1", "d".repeat(300))];
const result = renderTranscript(branch);
const toolLine = result.entries.find((line) =>
renderedLabel(line).includes("read"),
);
expect(renderedContent(toolLine!)).toBe("d".repeat(300));
expect(result.stats.toolResultsSummarized).toBe(0);
});
});
describe("renderTranscript budgets", () => {
it("honours maxUntrustedEntries", () => {
const branch = [
user("e1", "go"),
toolResult("e2", "call-1", "one"),
toolResult("e3", "call-2", "two"),
toolResult("e4", "call-3", "three"),
];
const result = renderTranscript(branch, {
budgets: { messageTokens: 10_000, toolTokens: 10_000, maxUntrustedEntries: 2 },
});
const toolLines = result.entries.filter((line) =>
renderedLabel(line).includes("read"),
);
expect(toolLines).toHaveLength(2);
// Newest first: the two most recent results are retained.
expect(result.entries.some((line) => renderedContent(line) === "three")).toBe(true);
expect(result.entries.some((line) => renderedContent(line) === "one")).toBe(false);
});
it("always keeps the latest trusted user entry", () => {
const branch = [
user("e1", "first instruction " + "w".repeat(400)),
toolResult("e2", "call-1", "x".repeat(2_000)),
user("e3", "latest instruction"),
];
const result = renderTranscript(branch, {
budgets: { messageTokens: 50, toolTokens: 50, maxUntrustedEntries: 40 },
});
expect(result.entries.some((line) => renderedContent(line) === "latest instruction")).toBe(true);
expect(result.stats.latestTrustedEntryRetained).toBe(true);
expect(result.latestTrustedEntryId).toBe("e3");
});
});
describe("collectTranscriptEntries", () => {
it("emits user entries with their SessionEntry id", () => {
const entries = collectTranscriptEntries([user("e1", "hello")]);
expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({ kind: "user", entryId: "e1" });
});
});
+1 -27
View File
@@ -1,31 +1,5 @@
import { describe, expect, it } from "vitest";
import { ARITY, prefix, stripBashCommentLines } from "#src/bash-arity";
describe("ARITY dictionary", () => {
it("is exported as a plain object", () => {
expect(typeof ARITY).toBe("object");
});
it("maps 'git' to arity 2", () => {
expect(ARITY.git).toBe(2);
});
it("maps 'npm run' to arity 3", () => {
expect(ARITY["npm run"]).toBe(3);
});
it("maps 'npm' to arity 2 (fallback when 'npm run' does not match)", () => {
expect(ARITY.npm).toBe(2);
});
it("maps 'docker compose' to arity 3", () => {
expect(ARITY["docker compose"]).toBe(3);
});
it("maps 'docker' to arity 2 (fallback)", () => {
expect(ARITY.docker).toBe(2);
});
});
import { prefix, stripBashCommentLines } from "#src/bash-arity";
describe("prefix", () => {
it("returns empty array for empty input", () => {
@@ -51,7 +51,6 @@ const EXPECTED_HANDLERS = [
"session_shutdown",
"session_start",
"tool_call",
"turn_start",
];
let agentDir: string;
+14 -33
View File
@@ -345,11 +345,20 @@ describe("loadUnifiedConfig", () => {
expect(result.issues.length).toBeGreaterThan(0);
});
it("parses forwardingTimeoutMs when a valid positive integer is present", () => {
it("parses the positive-integer scalar knobs", () => {
const configPath = join(tempDir, "config.json");
writeFileSync(configPath, JSON.stringify({ forwardingTimeoutMs: 120_000 }));
writeFileSync(
configPath,
JSON.stringify({
forwardingTimeoutMs: 120_000,
toolInputPreviewMaxLength: 1000,
toolTextSummaryMaxLength: 120,
}),
);
const result = loadUnifiedConfig(configPath);
expect(result.config.forwardingTimeoutMs).toBe(120_000);
expect(result.config.toolInputPreviewMaxLength).toBe(1000);
expect(result.config.toolTextSummaryMaxLength).toBe(120);
});
it("rejects a non-positive forwardingTimeoutMs", () => {
@@ -359,38 +368,13 @@ describe("loadUnifiedConfig", () => {
expect(result.issues.length).toBeGreaterThan(0);
});
it("parses toolInputPreviewMaxLength when a valid positive integer is present", () => {
const configPath = join(tempDir, "config.json");
writeFileSync(
configPath,
JSON.stringify({ toolInputPreviewMaxLength: 1000 }),
);
const result = loadUnifiedConfig(configPath);
expect(result.config.toolInputPreviewMaxLength).toBe(1000);
});
it("parses toolTextSummaryMaxLength when a valid positive integer is present", () => {
const configPath = join(tempDir, "config.json");
writeFileSync(
configPath,
JSON.stringify({ toolTextSummaryMaxLength: 120 }),
);
const result = loadUnifiedConfig(configPath);
expect(result.config.toolTextSummaryMaxLength).toBe(120);
});
it("omits toolInputPreviewMaxLength when absent", () => {
it("omits the scalar knobs when absent", () => {
const configPath = join(tempDir, "config.json");
writeFileSync(configPath, JSON.stringify({ debugLog: false }));
const result = loadUnifiedConfig(configPath);
expect(result.config).not.toHaveProperty("toolInputPreviewMaxLength");
});
it("omits toolTextSummaryMaxLength when absent", () => {
const configPath = join(tempDir, "config.json");
writeFileSync(configPath, JSON.stringify({ debugLog: false }));
const result = loadUnifiedConfig(configPath);
expect(result.config).not.toHaveProperty("toolTextSummaryMaxLength");
expect(result.config).not.toHaveProperty("forwardingTimeoutMs");
});
it.each([
@@ -523,16 +507,13 @@ describe("mergeUnifiedConfigs", () => {
expect(merged.promptFieldMaxWidth).toBe(400);
});
it("replaces the review-log field width with a later value", () => {
it("replaces the review-log field width with a later value and keeps the base's otherwise", () => {
expect(
mergeUnifiedConfigs(
{ reviewLogFieldMaxWidth: 1000 },
{ reviewLogFieldMaxWidth: 200 },
).reviewLogFieldMaxWidth,
).toBe(200);
});
it("carries the review-log field width from the base when unoverridden", () => {
expect(
mergeUnifiedConfigs({ reviewLogFieldMaxWidth: 200 }, {})
.reviewLogFieldMaxWidth,
+12 -53
View File
@@ -2,70 +2,29 @@ import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
DEBUG_LOG_FILENAME,
getGlobalConfigDir,
getGlobalConfigPath,
getGlobalLogsDir,
getLegacyExtensionConfigPath,
getLegacyGlobalPolicyPath,
getProjectAgentsDir,
REVIEW_LOG_FILENAME,
} from "#src/config-paths";
describe("config-paths", () => {
const agentDir = "/home/user/.pi/agent";
const cwd = "/projects/my-app";
const extensionRoot = "/opt/extensions/pi-permission-system";
describe("new layout paths", () => {
it("getGlobalConfigDir returns extensions/pi-permission-system under agentDir", () => {
expect(getGlobalConfigDir(agentDir)).toBe(
join(agentDir, "extensions", "pi-permission-system"),
);
});
it("getGlobalConfigPath returns policy.jsonc under the global config dir", () => {
expect(getGlobalConfigPath(agentDir)).toBe(
join(agentDir, "extensions", "pi-permission-system", "policy.jsonc"),
);
});
it("getGlobalLogsDir returns logs under the global config dir", () => {
expect(getGlobalLogsDir(agentDir)).toBe(
join(agentDir, "extensions", "pi-permission-system", "logs"),
);
});
it("getProjectAgentsDir returns .pi/agents under cwd", () => {
expect(getProjectAgentsDir(cwd)).toBe(join(cwd, ".pi", "agents"));
});
it("places the global policy at extensions/pi-permission-system/policy.jsonc under the agent dir", () => {
expect(getGlobalConfigPath(agentDir)).toBe(
join(agentDir, "extensions", "pi-permission-system", "policy.jsonc"),
);
});
describe("legacy paths", () => {
it("getLegacyGlobalPolicyPath returns pi-permissions.jsonc under agentDir", () => {
expect(getLegacyGlobalPolicyPath(agentDir)).toBe(
join(agentDir, "pi-permissions.jsonc"),
);
});
it("getLegacyExtensionConfigPath returns config.json under extensionRoot", () => {
expect(getLegacyExtensionConfigPath(extensionRoot)).toBe(
join(extensionRoot, "config.json"),
);
});
it("places logs next to the global config dir", () => {
expect(getGlobalLogsDir(agentDir)).toBe(
join(agentDir, "extensions", "pi-permission-system", "logs"),
);
});
describe("log filenames", () => {
it("DEBUG_LOG_FILENAME is a .jsonl file", () => {
expect(DEBUG_LOG_FILENAME).toBe("pi-permission-system-debug.jsonl");
});
it("REVIEW_LOG_FILENAME is a .jsonl file", () => {
expect(REVIEW_LOG_FILENAME).toBe(
"pi-permission-system-permission-review.jsonl",
);
});
it("keeps the legacy global policy path for migration detection", () => {
expect(getLegacyGlobalPolicyPath(agentDir)).toBe(
join(agentDir, "pi-permissions.jsonc"),
);
});
});
@@ -1,9 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import {
type DecisionReporter,
GateDecisionReporter,
} from "#src/decision-reporter";
import { GateDecisionReporter } from "#src/decision-reporter";
import {
PERMISSIONS_DECISION_CHANNEL,
type PermissionDecisionEvent,
@@ -46,14 +43,6 @@ function makeDecisionEvent(
// ── tests ──────────────────────────────────────────────────────────────────
describe("GateDecisionReporter", () => {
it("satisfies the DecisionReporter interface", () => {
const reporter: DecisionReporter = new GateDecisionReporter(
makeLogger(),
makeEvents(),
);
expect(reporter).toBeDefined();
});
describe("writeReviewLog", () => {
it("delegates to logger.review with event and details", () => {
const logger = makeLogger();
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { renderPromptDialog } from "#src/presentation/dialog-renderer";
import type { PromptPayload } from "#src/presentation/prompt-payload";
const BUDGET = { maxRows: 40, fieldMaxWidth: 400, width: 120 };
function payload(overrides: Partial<PromptPayload> = {}): PromptPayload {
return {
kind: "bash",
request: {
requester: { agentName: null, forwarded: false, sessionId: null },
surface: "bash",
toolName: "bash",
invokedToolName: null,
value: "rm -rf ./dist",
matchedPattern: "rm*",
commandContext: null,
executedUnit: null,
},
evidence: [],
annotations: [],
...overrides,
};
}
describe("renderPromptDialog: decision notes", () => {
it("renders a model-origin note with the model-generated suffix", () => {
const view = renderPromptDialog(payload(), BUDGET, undefined, [
{ origin: "model", text: "该操作的最坏后果可能不可逆。" },
]);
const joined = view.lines.join("\n");
expect(joined).toContain("review");
expect(joined).toContain("该操作的最坏后果可能不可逆。");
expect(joined).toContain("模型生成,仅供参考");
});
it("renders a system-origin note plain, without the suffix", () => {
const view = renderPromptDialog(payload(), BUDGET, undefined, [
{ origin: "system", text: "自动审查超时,已转人工确认。" },
]);
const joined = view.lines.join("\n");
expect(joined).toContain("review");
expect(joined).toContain("自动审查超时,已转人工确认。");
expect(joined).not.toContain("模型生成");
});
it("leads the ask facts, so a row-budget elision cannot drop the refusal reason", () => {
const view = renderPromptDialog(
payload({
evidence: Array.from({ length: 40 }, (_, index) => ({
label: `evidence-${index}`,
text: `value-${index}`,
detail: null,
})),
}),
{ maxRows: 6, fieldMaxWidth: 400, width: 120 },
undefined,
[{ origin: "system", text: "risk too high" }],
);
expect(view.lines.join("\n")).toContain("risk too high");
expect(view.elided).toBe(true);
});
it("renders no note line when there are no notes", () => {
const view = renderPromptDialog(payload(), BUDGET);
const joined = view.lines.join("\n");
expect(joined).not.toContain("review");
});
});
@@ -99,24 +99,14 @@ describe("normalizePermissionSystemConfig", () => {
});
});
it("defaults debugLog to false when missing", () => {
it("applies the documented defaults for missing runtime knobs", () => {
const result = normalizePermissionSystemConfig({});
expect(result.debugLog).toBe(false);
});
it("defaults permissionReviewLog to true when missing", () => {
const result = normalizePermissionSystemConfig({});
expect(result.permissionReviewLog).toBe(true);
});
it("defaults yoloMode to false when missing", () => {
const result = normalizePermissionSystemConfig({});
expect(result.yoloMode).toBe(false);
});
it("defaults doublePressToConfirm to true when missing", () => {
const result = normalizePermissionSystemConfig({});
expect(result.doublePressToConfirm).toBe(true);
expect(result).toMatchObject({
debugLog: false,
permissionReviewLog: true,
yoloMode: false,
doublePressToConfirm: true,
});
});
it("sets doublePressToConfirm false when explicitly disabled", () => {
@@ -126,44 +116,29 @@ describe("normalizePermissionSystemConfig", () => {
expect(result.doublePressToConfirm).toBe(false);
});
it("includes forwardingTimeoutMs when a valid positive integer is provided", () => {
it("includes the optional scalar knobs when provided", () => {
const result = normalizePermissionSystemConfig({
forwardingTimeoutMs: 120_000,
});
expect(result.forwardingTimeoutMs).toBe(120_000);
});
it("omits forwardingTimeoutMs when absent", () => {
const result = normalizePermissionSystemConfig({});
expect("forwardingTimeoutMs" in result).toBe(false);
});
it("includes the prompt-budget knobs when provided", () => {
const result = normalizePermissionSystemConfig({
promptMaxRows: 12,
promptFieldMaxWidth: 80,
reviewLogFieldMaxWidth: 200,
});
expect(result.forwardingTimeoutMs).toBe(120_000);
expect(result.promptMaxRows).toBe(12);
expect(result.promptFieldMaxWidth).toBe(80);
expect(result.reviewLogFieldMaxWidth).toBe(200);
});
it("omits the prompt-budget knobs when absent, leaving the renderer's defaults", () => {
it("omits the optional scalar knobs when absent, leaving consumer defaults", () => {
const result = normalizePermissionSystemConfig({});
expect("promptMaxRows" in result).toBe(false);
expect("promptFieldMaxWidth" in result).toBe(false);
});
it("includes the review-log field width when provided", () => {
expect(
normalizePermissionSystemConfig({ reviewLogFieldMaxWidth: 200 })
.reviewLogFieldMaxWidth,
).toBe(200);
});
it("omits the review-log field width when absent, leaving the writer's default", () => {
expect(
"reviewLogFieldMaxWidth" in normalizePermissionSystemConfig({}),
).toBe(false);
for (const key of [
"forwardingTimeoutMs",
"promptMaxRows",
"promptFieldMaxWidth",
"reviewLogFieldMaxWidth",
]) {
expect(key in result).toBe(false);
}
});
// Deliberately dropped rather than carried: a declared config field no
@@ -187,11 +162,7 @@ describe("normalizePermissionSystemConfig", () => {
expect(result.shellTools).toEqual({
exec_command: { commandArgument: "cmd", workdirArgument: "workdir" },
});
});
it("omits shellTools when absent", () => {
const result = normalizePermissionSystemConfig({});
expect("shellTools" in result).toBe(false);
expect("shellTools" in normalizePermissionSystemConfig({})).toBe(false);
});
it("includes authorizerChain when provided", () => {
@@ -199,11 +170,7 @@ describe("normalizePermissionSystemConfig", () => {
authorizerChain: ["model-judge", "typo-reviewer"],
});
expect(result.authorizerChain).toEqual(["model-judge", "typo-reviewer"]);
});
it("omits authorizerChain when absent", () => {
const result = normalizePermissionSystemConfig({});
expect("authorizerChain" in result).toBe(false);
expect("authorizerChain" in normalizePermissionSystemConfig({})).toBe(false);
});
});
@@ -107,17 +107,6 @@ describe("describeBashPathGate", () => {
expect(desc.preCheck?.state).toBe("deny");
});
it("returns GateDescriptor when a token evaluates to ask", async () => {
const result = await describeGate(
makeTcc(),
makeResolver(makeCheckResult({ state: "ask", matchedPattern: "*" })),
);
expect(result).not.toBeNull();
expect(isGateDescriptor(result)).toBe(true);
const desc = result as GateDescriptor;
expect(desc.preCheck?.state).toBe("ask");
});
it("descriptor includes triggering token in prompt message", async () => {
const result = (await describeGate(
makeTcc(),
@@ -129,14 +118,6 @@ describe("describeBashPathGate", () => {
expect(result.payload.request.value).toBe(".env");
});
it("descriptor decision uses surface 'path'", async () => {
const result = (await describeGate(
makeTcc(),
makeResolver(makeCheckResult({ state: "deny", matchedPattern: "*.env" })),
)) as GateDescriptor;
expect(result.decision.surface).toBe("path");
});
it("carries the deciding token's access facts on promptDetails (bash path surface)", async () => {
const resolver = makeResolver(
makeCheckResult({ state: "deny", matchedPattern: "*.env" }),
@@ -9,11 +9,6 @@ describe("pickMostRestrictive", () => {
expect(pickMostRestrictive([])).toBeUndefined();
});
it("returns the single result for a one-element list", () => {
const only = makeGateCheckResult({ state: "allow" });
expect(pickMostRestrictive([only])).toBe(only);
});
it("prefers deny over ask and allow regardless of position", () => {
const allow = makeGateCheckResult({ state: "allow", matchedPattern: "a" });
const ask = makeGateCheckResult({ state: "ask", matchedPattern: "b" });
@@ -40,13 +35,4 @@ describe("pickMostRestrictive", () => {
expect(pickMostRestrictive([deny1, deny2])).toBe(deny1);
});
it("keeps the first ask on ties when no deny is present", () => {
const allow = makeGateCheckResult({ state: "allow" });
const ask1 = makeGateCheckResult({ state: "ask", matchedPattern: "first" });
const ask2 = makeGateCheckResult({
state: "ask",
matchedPattern: "second",
});
expect(pickMostRestrictive([allow, ask1, ask2])).toBe(ask1);
});
});
@@ -9,7 +9,6 @@ import {
deriveResolution,
resolveYoloGrant,
} from "#src/handlers/gates/helpers";
import { posixPathFlavor } from "#src/path/path-flavor";
import type { PermissionCheckResult } from "#src/types";
import { makeCheckResult } from "#test/helpers/handler-fixtures";
@@ -130,50 +129,18 @@ describe("buildDecisionEvent", () => {
});
});
it("normalises undefined origin to null", () => {
it("normalises absent optional fields to null", () => {
const event = buildDecisionEvent(
{ surface: "bash", value: "git status" },
makeCheck({ origin: undefined }),
{ surface: "read", value: "read" },
makeCheck({ origin: undefined, matchedPattern: undefined }),
null,
"allow",
"user_approved",
"deny",
"policy_deny",
);
expect(event.origin).toBeNull();
});
it("normalises null agentName to null", () => {
const event = buildDecisionEvent(
{ surface: "read", value: "read" },
makeCheck(),
null,
"deny",
"policy_deny",
);
expect(event.agentName).toBeNull();
});
it("normalises undefined matchedPattern to null", () => {
const event = buildDecisionEvent(
{ surface: "read", value: "read" },
makeCheck({ matchedPattern: undefined }),
null,
"deny",
"policy_deny",
);
expect(event.matchedPattern).toBeNull();
});
it("passes result and resolution through", () => {
const event = buildDecisionEvent(
{ surface: "bash", value: "rm -rf /" },
makeCheck(),
null,
"deny",
"user_denied",
);
expect(event.result).toBe("deny");
expect(event.resolution).toBe("user_denied");
});
});
describe("resolveYoloGrant", () => {
@@ -227,18 +194,6 @@ describe("resolveYoloGrant", () => {
});
describe("accessFactsFromPath", () => {
it("projects the AccessPath's match set and boundary as strings", () => {
const path = AccessPath.forPath("/outside/x.ts", {
cwd: "/repo",
flavor: posixPathFlavor,
});
expect(accessFactsFromPath("external_directory", path)).toEqual({
surface: "external_directory",
matchValues: path.matchValues(),
boundaryValue: path.boundaryValue(),
});
});
it("collapses an empty boundary (literal-only path) to null", () => {
const path = AccessPath.forLiteral("relative-token");
expect(path.boundaryValue()).toBe("");
@@ -118,32 +118,6 @@ describe("describePathGate", () => {
expect(desc.preCheck?.state).toBe("deny");
});
it("returns GateDescriptor when path check result is ask", () => {
const resolver = makeResolver(
makeCheckResult({ state: "ask", matchedPattern: "*.env" }),
);
const result = describePathGate(makeTcc(), resolver, normalizer);
expect(result).not.toBeNull();
expect(isGateDescriptor(result)).toBe(true);
const desc = result as GateDescriptor;
expect(desc.surface).toBe("path");
expect(desc.preCheck?.state).toBe("ask");
});
it("descriptor has correct session approval surface and pattern", () => {
const resolver = makeResolver(
makeCheckResult({ state: "ask", matchedPattern: "*" }),
);
const result = describePathGate(
makeTcc({ input: { path: "/test/project/src/.env" } }),
resolver,
normalizer,
) as GateDescriptor;
expect(result.sessionApproval).toBeDefined();
expect(result.sessionApproval?.surface).toBe("path");
expect(result.sessionApproval?.representativePattern).toBeDefined();
});
it("binds a current-directory file's session approval to the cwd subtree", () => {
const resolver = makeResolver(
makeCheckResult({ state: "ask", matchedPattern: "*" }),
@@ -171,6 +145,7 @@ describe("describePathGate", () => {
expect(result.payload.kind).toBe("path");
expect(result.payload.request.toolName).toBe("read");
expect(result.payload.request.value).toBe(".env");
expect(result.payload.request.matchedPattern).toBe("*.env");
expect(result.payload.request.requester.agentName).toBeNull();
});
@@ -194,21 +169,6 @@ describe("describePathGate", () => {
});
});
it("emits a path payload naming the matched rule", () => {
const resolver = makeResolver(
makeCheckResult({ state: "ask", matchedPattern: "*.env" }),
);
const result = describePathGate(
makeTcc(),
resolver,
normalizer,
) as GateDescriptor;
expect(result.payload.kind).toBe("path");
expect(result.payload.request.value).toBe(".env");
expect(result.payload.request.matchedPattern).toBe("*.env");
});
it("descriptor decision uses surface 'path' and the file path as value", () => {
const resolver = makeResolver(
makeCheckResult({ state: "deny", matchedPattern: "*.env" }),
@@ -685,20 +685,6 @@ describe("GateRunner.run — null and bypass dispatch", () => {
});
expect(deps.reporter.writeReviewLog).not.toHaveBeenCalled();
});
it("routes a descriptor to the gate check logic and returns allow", async () => {
const { runner } = makeGateRunner();
const result = await runner.run(makeDescriptor(), null);
expect(result).toEqual({ action: "allow" });
});
it("routes a descriptor to the gate check logic and returns block", async () => {
const { runner } = makeGateRunner({
resolveResult: makeCheckResult({ state: "deny", matchedPattern: "*" }),
});
const result = await runner.run(makeDescriptor(), null);
expect(result).toMatchObject({ action: "block" });
});
});
// ── GateRunner — request identity ────────────────────────────────────
@@ -61,34 +61,6 @@ describe("SkillInputGatePipeline.evaluate", () => {
expect(notifier.warn).not.toHaveBeenCalled();
});
it("does not call notifier.warn when the skill requires approval (ask)", async () => {
const inputs = makeSkillInputInputs({
checkPermission: () => makeCheckResult({ state: "ask" }),
});
const notifier = makeNotifier();
const { runner } = makeGateRunner();
const pipeline = new SkillInputGatePipeline(inputs);
await pipeline.evaluate("librarian", null, notifier, runner);
expect(notifier.warn).not.toHaveBeenCalled();
});
it("includes agent name in the deny notice when agent is present", async () => {
const inputs = makeSkillInputInputs({
checkPermission: () => makeCheckResult({ state: "deny" }),
});
const notifier = makeNotifier();
const { runner } = makeGateRunner();
const pipeline = new SkillInputGatePipeline(inputs);
await pipeline.evaluate("librarian", "code-agent", notifier, runner);
expect(notifier.warn).toHaveBeenCalledWith(
expect.stringContaining("code-agent"),
);
});
// ── outcome mapping ─────────────────────────────────────────────────────
it("returns allow when the gate passes", async () => {
@@ -144,18 +116,4 @@ describe("SkillInputGatePipeline.evaluate", () => {
"code-agent",
);
});
it("calls checkPermission with undefined agentName when agentName is null", async () => {
const inputs = makeSkillInputInputs();
const { runner } = makeGateRunner();
const pipeline = new SkillInputGatePipeline(inputs);
await pipeline.evaluate("explorer", null, makeNotifier(), runner);
expect(inputs.checkPermission).toHaveBeenCalledWith(
"skill",
{ name: "explorer" },
undefined,
);
});
});
@@ -18,36 +18,14 @@ function makeSkillCheck(state: "allow" | "deny" | "ask") {
// ── describeSkillInputGate ─────────────────────────────────────────────────
describe("describeSkillInputGate", () => {
it("sets surface to 'skill'", () => {
const descriptor = describeSkillInputGate(
"librarian",
null,
makeSkillCheck("allow"),
);
expect(descriptor.surface).toBe("skill");
});
it("sets input.name to the skill name", () => {
const descriptor = describeSkillInputGate(
"librarian",
null,
makeSkillCheck("allow"),
);
expect(descriptor.input).toEqual({ name: "librarian" });
});
it("passes preCheck through verbatim", () => {
const check = makeSkillCheck("deny");
const descriptor = describeSkillInputGate("librarian", null, check);
expect(descriptor.preCheck).toBe(check);
});
it("makes the skill the payload's decision-relevant value", () => {
const descriptor = describeSkillInputGate(
"librarian",
null,
makeSkillCheck("allow"),
);
expect(descriptor.surface).toBe("skill");
expect(descriptor.input).toEqual({ name: "librarian" });
expect(descriptor.payload.kind).toBe("skill");
expect(descriptor.payload.request.surface).toBe("skill");
expect(descriptor.payload.request.value).toBe("librarian");
@@ -76,26 +54,6 @@ describe("describeSkillInputGate", () => {
});
});
it("emits a skill payload naming the skill as the decision value", () => {
const descriptor = describeSkillInputGate(
"librarian",
"code-agent",
makeSkillCheck("ask"),
);
expect(descriptor.payload.kind).toBe("skill");
expect(descriptor.payload.request.value).toBe("librarian");
});
it("names the skill in promptDetails so the prompt can identify it", () => {
const descriptor = describeSkillInputGate(
"librarian",
null,
makeSkillCheck("ask"),
);
expect(descriptor.promptDetails.skillName).toBe("librarian");
});
it("sets logContext source to 'skill_input' with skill name and agent", () => {
const descriptor = describeSkillInputGate(
"librarian",
@@ -133,14 +91,4 @@ describe("describeSkillInputGate", () => {
boundaryValue: null,
});
});
it("does not set preResolved or sessionApproval", () => {
const descriptor = describeSkillInputGate(
"librarian",
null,
makeSkillCheck("allow"),
);
expect(descriptor.preResolved).toBeUndefined();
expect(descriptor.sessionApproval).toBeUndefined();
});
});
@@ -77,32 +77,17 @@ describe("describeSkillReadGate", () => {
expect(result).toBeNull();
});
it("returns GateDescriptor with preResolved.state matching skill entry state (ask)", () => {
const result = describeSkillReadGate(makeTcc(), normalizer, () => [
makeSkillEntry({ state: "ask" }),
]);
expect(result).not.toBeNull();
const desc = result!;
expect(desc.preResolved).toEqual({ state: "ask" });
});
it("returns GateDescriptor with preResolved.state matching skill entry state (allow)", () => {
const result = describeSkillReadGate(makeTcc(), normalizer, () => [
makeSkillEntry({ state: "allow" }),
]);
expect(result).not.toBeNull();
const desc = result!;
expect(desc.preResolved).toEqual({ state: "allow" });
});
it("returns GateDescriptor with preResolved.state matching skill entry state (deny)", () => {
const result = describeSkillReadGate(makeTcc(), normalizer, () => [
makeSkillEntry({ state: "deny" }),
]);
expect(result).not.toBeNull();
const desc = result!;
expect(desc.preResolved).toEqual({ state: "deny" });
});
it.each(["ask", "allow", "deny"] as const)(
"returns GateDescriptor with preResolved.state matching skill entry state (%s)",
(state) => {
const result = describeSkillReadGate(makeTcc(), normalizer, () => [
makeSkillEntry({ state }),
]);
expect(result).not.toBeNull();
const desc = result!;
expect(desc.preResolved).toEqual({ state });
},
);
it("decision surface is 'skill' and decision value is the skill name", () => {
const result = describeSkillReadGate(makeTcc(), normalizer, () => [
@@ -123,15 +108,6 @@ describe("describeSkillReadGate", () => {
});
});
it("emits a skill_read payload keeping the skill as the decision value", () => {
const result = describeSkillReadGate(makeTcc(), normalizer, () => [
makeSkillEntry({ name: "my-skill" }),
])!;
expect(result.payload.kind).toBe("skill_read");
expect(result.payload.request.value).toBe("my-skill");
});
it("payload contains the skill name and the path it was reached through", () => {
const result = describeSkillReadGate(makeTcc(), normalizer, () => [
makeSkillEntry({ name: "librarian" }),
@@ -175,11 +151,4 @@ describe("describeSkillReadGate", () => {
agentName: "agent-1",
});
});
it("surface is 'skill' on the descriptor", () => {
const result = describeSkillReadGate(makeTcc(), normalizer, () => [
makeSkillEntry(),
])!;
expect(result.surface).toBe("skill");
});
});
@@ -103,34 +103,6 @@ describe("ToolCallGatePipeline", () => {
expect(runSpy).toHaveBeenCalledTimes(1);
});
it("calls getToolPreviewLimits() during evaluate", async () => {
const getToolPreviewLimits = vi.fn(() => ({
toolInputPreviewMaxLength: 500,
toolTextSummaryMaxLength: 100,
}));
const resolver = makeResolver(makeCheckResult());
const inputs = makeGateInputs({ getToolPreviewLimits });
const { runner } = makeGateRunner();
const pipeline = new ToolCallGatePipeline(resolver, inputs);
await pipeline.evaluate(makeTcc({ toolName: "read", input: {} }), runner);
expect(getToolPreviewLimits).toHaveBeenCalled();
});
it("calls getActiveSkillEntries() during evaluate", async () => {
const getActiveSkillEntries = vi.fn<() => []>(() => []);
const resolver = makeResolver(makeCheckResult());
const inputs = makeGateInputs({ getActiveSkillEntries });
const { runner } = makeGateRunner();
const pipeline = new ToolCallGatePipeline(resolver, inputs);
await pipeline.evaluate(makeTcc({ toolName: "read", input: {} }), runner);
expect(getActiveSkillEntries).toHaveBeenCalled();
});
it("does not call BashProgram.parse for non-bash tools", async () => {
const resolver = makeResolver(makeCheckResult());
const inputs = makeGateInputs();
@@ -53,23 +53,14 @@ const normalizer = new PathNormalizer(posixPathFlavor, "/test/project");
// ── tests ──────────────────────────────────────────────────────────────────
describe("describeToolGate", () => {
it("returns descriptor with tool name as surface for standard tools", () => {
const desc = describeToolGate(
makeTcc({ toolName: "read" }),
makeCheckResult("ask"),
makeFormatter(),
);
expect(desc.surface).toBe("read");
expect(desc.decision.surface).toBe("read");
});
it("returns descriptor with tool name as decision value for standard tools", () => {
it("gates a standard tool on its own surface and name", () => {
const desc = describeToolGate(
makeTcc({ toolName: "write" }),
makeCheckResult("ask"),
makeFormatter(),
);
expect(desc.decision.value).toBe("write");
expect(desc.surface).toBe("write");
expect(desc.decision).toEqual({ surface: "write", value: "write" });
});
it("returns bash surface with command in decision.value for bash tools", () => {
@@ -85,6 +76,7 @@ describe("describeToolGate", () => {
expect(desc.surface).toBe("bash");
expect(desc.decision.surface).toBe("bash");
expect(desc.decision.value).toBe("git status");
expect(desc.payload.request.value).toBe("git status");
});
it("gates an aliased shell tool on the bash surface while keeping its tool name in logs (#574)", () => {
@@ -177,42 +169,6 @@ describe("describeToolGate", () => {
expect(desc.payload.request.requester.agentName).toBeNull();
});
it("names the requesting agent on the payload when provided", () => {
const check = makeCheckResult("ask", { toolName: "read" });
const desc = describeToolGate(
makeTcc({ agentName: "my-agent" }),
check,
makeFormatter(),
);
expect(desc.payload.request.requester.agentName).toBe("my-agent");
});
it("carries the command as the decision-relevant value for a bash ask", () => {
const check = makeCheckResult("ask", { toolName: "bash", command: "ls" });
const desc = describeToolGate(
makeTcc({ toolName: "bash", input: { command: "ls" } }),
check,
makeFormatter(),
);
expect(desc.payload.kind).toBe("bash");
expect(desc.payload.request.value).toBe("ls");
});
it("populates sessionApproval via suggestSessionPattern", () => {
const check = makeCheckResult("ask", {
toolName: "bash",
command: "git status",
});
const desc = describeToolGate(
makeTcc({ toolName: "bash", input: { command: "git status" } }),
check,
makeFormatter(),
);
expect(desc.sessionApproval).toBeDefined();
expect(desc.sessionApproval?.surface).toBe("bash");
expect(desc.sessionApproval?.representativePattern).toBeDefined();
});
it("binds a current-directory file's session approval to the cwd subtree", () => {
const check = makeCheckResult("ask", { toolName: "edit" });
const desc = describeToolGate(
@@ -275,7 +231,6 @@ describe("describeToolGate", () => {
toolCallId: "tc-42",
toolName: "read",
});
expect(desc.promptDetails.sessionLabel).toBeDefined();
});
it("carries the AccessPath's facts on promptDetails for a path-bearing tool", () => {
@@ -328,15 +283,4 @@ describe("describeToolGate", () => {
});
expect(desc.logContext.command).toBe("ls");
});
it("uses toolName as input for checkPermission surface", () => {
const desc = describeToolGate(
makeTcc({ toolName: "edit", input: { path: "/a.ts" } }),
makeCheckResult("ask", { toolName: "edit" }),
makeFormatter(),
normalizer.forPath("/a.ts"),
);
expect(desc.surface).toBe("edit");
expect(desc.input).toEqual({ path: "/a.ts" });
});
});
@@ -68,7 +68,7 @@ describe("handleInput decision events — skill gate", () => {
});
});
it("emits allow with user_approved when state=ask and user approves", async () => {
it("emits a decision event on the escalate path", async () => {
const { handler, events } = makeHandler({
session: {
checkPermission: makeSkillCheckPermission("ask"),
@@ -92,84 +92,4 @@ describe("handleInput decision events — skill gate", () => {
resolution: "user_approved",
});
});
it("emits deny with user_denied when state=ask and user denies", async () => {
const { handler, events } = makeHandler({
session: {
checkPermission: makeSkillCheckPermission("ask"),
},
prompter: {
escalate: vi.fn<AskEscalator["escalate"]>().mockResolvedValue({
approved: false,
state: "denied",
decidedBy: DECIDED_BY_HUMAN,
}),
},
});
await handler.handleInput({ text: "/skill:explorer" }, makeCtx());
const decisions = getDecisionEvents(events);
expect(decisions).toHaveLength(1);
expect(decisions[0]).toMatchObject({
surface: "skill",
value: "explorer",
result: "deny",
resolution: "user_denied",
});
});
it("emits deny with confirmation_unavailable when state=ask but no UI", async () => {
const { handler, events } = makeHandler({
session: {
checkPermission: makeSkillCheckPermission("ask"),
},
prompter: {
escalate: vi.fn<AskEscalator["escalate"]>().mockResolvedValue({
approved: false,
state: "denied",
confirmationUnavailable: true,
decidedBy: DECIDED_BY_HUMAN,
}),
},
});
await handler.handleInput(
{ text: "/skill:explorer" },
makeCtx({ hasUI: false }),
);
const decisions = getDecisionEvents(events);
expect(decisions).toHaveLength(1);
expect(decisions[0]).toMatchObject({
surface: "skill",
value: "explorer",
result: "deny",
resolution: "confirmation_unavailable",
});
});
it("emits allow with auto_approved when prompt returns autoApproved:true", async () => {
const { handler, events } = makeHandler({
session: {
checkPermission: makeSkillCheckPermission("ask"),
},
prompter: {
escalate: vi.fn<AskEscalator["escalate"]>().mockResolvedValue({
approved: true,
state: "approved",
autoApproved: true,
decidedBy: DECIDED_BY_HUMAN,
}),
},
});
await handler.handleInput({ text: "/skill:explorer" }, makeCtx());
const decisions = getDecisionEvents(events);
expect(decisions).toHaveLength(1);
expect(decisions[0]).toMatchObject({
surface: "skill",
value: "explorer",
result: "allow",
resolution: "auto_approved",
});
});
});
@@ -64,12 +64,6 @@ describe("handleInput", () => {
expect(result).toEqual({ action: "continue" });
});
it("does not check permissions for non-skill input", async () => {
const { handler, permissionManager } = makeHandler();
await handler.handleInput(makeInputEvent("just a message"), makeCtx());
expect(permissionManager.check).not.toHaveBeenCalled();
});
it("returns continue when skill is allowed", async () => {
const { handler } = makeHandler();
const result = await handler.handleInput(
@@ -117,14 +117,6 @@ describe("handleSessionStart", () => {
expect(configStore.logResolvedPaths).toHaveBeenCalledOnce();
});
it("resolves agent name from ctx", async () => {
const ctx = makeCtx();
const { handler, session } = makeSetup();
const spy = vi.spyOn(session, "resolveAgentName");
await handler.handleSessionStart({ reason: "startup" }, ctx);
expect(spy).toHaveBeenCalledWith(ctx);
});
it("notifies each policy issue", async () => {
const { handler, logger } = makeSetup({
configIssues: ["issue A", "issue B"],
@@ -134,12 +126,6 @@ describe("handleSessionStart", () => {
expect(logger.warn).toHaveBeenCalledWith("issue B");
});
it("does not warn when there are no policy issues", async () => {
const { handler, logger } = makeSetup();
await handler.handleSessionStart({ reason: "startup" }, makeCtx());
expect(logger.warn).not.toHaveBeenCalled();
});
it("writes lifecycle.reload debug log when reason is reload", async () => {
const ctx = makeCtx({ cwd: "/proj" });
const { handler, logger } = makeSetup();
@@ -151,12 +137,6 @@ describe("handleSessionStart", () => {
});
});
it("does not write lifecycle.reload debug log for non-reload reasons", async () => {
const { handler, logger } = makeSetup();
await handler.handleSessionStart({ reason: "startup" }, makeCtx());
expect(logger.debug).not.toHaveBeenCalled();
});
it("activates the service for the session with ctx", async () => {
const ctx = makeCtx();
const { handler, serviceLifecycle } = makeSetup();
@@ -248,11 +228,6 @@ describe("handleSessionShutdown", () => {
);
});
it("does not throw when runtime context is null", async () => {
const { handler } = makeSetup();
await expect(handler.handleSessionShutdown()).resolves.not.toThrow();
});
it("calls shutdown on the session", async () => {
const { handler, session } = makeSetup();
const spy = vi.spyOn(session, "shutdown");
@@ -102,155 +102,3 @@ describe("handleToolCall decision events — session_approved", () => {
});
});
});
// ── user_approved path ─────────────────────────────────────────────────────
describe("handleToolCall decision events — user_approved", () => {
it("emits allow with user_approved when state=ask and user approves once", async () => {
const { handler, events } = makeHandler({
session: {
checkPermission: vi
.fn()
.mockReturnValue(makeCheckResult({ state: "ask" })),
},
prompter: {
escalate: vi.fn<AskEscalator["escalate"]>().mockResolvedValue({
approved: true,
state: "approved",
decidedBy: DECIDED_BY_HUMAN,
}),
},
});
await handler.handleToolCall(makeToolCallEvent("read"), makeCtx());
const decisions = getDecisionEvents(events);
expect(decisions).toHaveLength(1);
expect(decisions[0]).toMatchObject({
result: "allow",
resolution: "user_approved",
});
});
it("emits allow with user_approved_for_session when user approves for session", async () => {
const { handler, events } = makeHandler({
session: {
checkPermission: vi
.fn()
.mockReturnValue(makeCheckResult({ state: "ask" })),
},
prompter: {
escalate: vi.fn<AskEscalator["escalate"]>().mockResolvedValue({
approved: true,
state: "approved_for_session",
decidedBy: DECIDED_BY_HUMAN,
}),
},
});
await handler.handleToolCall(makeToolCallEvent("read"), makeCtx());
const decisions = getDecisionEvents(events);
expect(decisions).toHaveLength(1);
expect(decisions[0]).toMatchObject({
result: "allow",
resolution: "user_approved_for_session",
});
});
});
// ── user_denied path ───────────────────────────────────────────────────────
describe("handleToolCall decision events — user_denied", () => {
it("emits deny with user_denied when state=ask and user denies", async () => {
const { handler, events } = makeHandler({
session: {
checkPermission: vi
.fn()
.mockReturnValue(makeCheckResult({ state: "ask" })),
},
prompter: {
escalate: vi.fn<AskEscalator["escalate"]>().mockResolvedValue({
approved: false,
state: "denied",
decidedBy: DECIDED_BY_HUMAN,
}),
},
});
await handler.handleToolCall(makeToolCallEvent("read"), makeCtx());
const decisions = getDecisionEvents(events);
expect(decisions).toHaveLength(1);
expect(decisions[0]).toMatchObject({
result: "deny",
resolution: "user_denied",
});
});
});
// ── confirmation_unavailable path ──────────────────────────────────────────
describe("handleToolCall decision events — confirmation_unavailable", () => {
it("emits deny with confirmation_unavailable when state=ask but no UI", async () => {
const { handler, events } = makeHandler({
session: {
checkPermission: vi
.fn()
.mockReturnValue(makeCheckResult({ state: "ask" })),
},
prompter: {
escalate: vi.fn<AskEscalator["escalate"]>().mockResolvedValue({
approved: false,
state: "denied",
confirmationUnavailable: true,
decidedBy: DECIDED_BY_HUMAN,
}),
},
});
await handler.handleToolCall(
makeToolCallEvent("read"),
makeCtx({ hasUI: false }),
);
const decisions = getDecisionEvents(events);
expect(decisions).toHaveLength(1);
expect(decisions[0]).toMatchObject({
result: "deny",
resolution: "confirmation_unavailable",
});
});
});
// ── auto_approved path (yolo mode) ───────────────────────────────────
describe("handleToolCall decision events — auto_approved", () => {
it("emits allow with auto_approved when prompt returns autoApproved:true", async () => {
const { handler, events } = makeHandler({
session: {
checkPermission: vi
.fn()
.mockReturnValue(makeCheckResult({ state: "ask" })),
},
prompter: {
escalate: vi.fn<AskEscalator["escalate"]>().mockResolvedValue({
approved: true,
state: "approved",
autoApproved: true,
decidedBy: DECIDED_BY_HUMAN,
}),
},
});
await handler.handleToolCall(makeToolCallEvent("read"), makeCtx());
const decisions = getDecisionEvents(events);
expect(decisions).toHaveLength(1);
expect(decisions[0]).toMatchObject({
result: "allow",
resolution: "auto_approved",
});
});
});
@@ -48,26 +48,6 @@ describe("getEventInput", () => {
// ── handleToolCall ─────────────────────────────────────────────────────────
describe("handleToolCall", () => {
it("activates session with ctx", async () => {
const ctx = makeCtx();
const { handler, forwarding } = makeHandler();
await handler.handleToolCall(makeToolCallEvent("read"), ctx);
// session.activate(ctx) calls forwarding.start(ctx) on the real session
expect(forwarding.start).toHaveBeenCalledWith(ctx);
});
it("blocks when tool name cannot be resolved", async () => {
const { handler } = makeHandler();
const result = await handler.handleToolCall(
{ type: "tool_call" },
makeCtx(),
);
expect(result).toEqual({
action: "block",
reason: expect.stringContaining("tool"),
});
});
it("blocks when tool is not registered", async () => {
const { handler } = makeHandler({ tools: ["read"] });
const result = await handler.handleToolCall(
@@ -26,20 +26,6 @@ describe("validateRequestedTool", () => {
).toBeTruthy();
});
it("blocks when name field is an empty string", () => {
const result = validateRequestedTool({ name: "" }, TOOLS);
expect(result.status).toBe("block");
});
it("blocks when name field is null", () => {
const result = validateRequestedTool({ name: null }, TOOLS);
expect(result.status).toBe("block");
});
it("blocks when event is a primitive", () => {
const result = validateRequestedTool("not-an-object", TOOLS);
expect(result.status).toBe("block");
});
});
describe("unregistered tool", () => {
@@ -48,22 +34,6 @@ describe("validateRequestedTool", () => {
expect(result.status).toBe("block");
});
it("includes available tool names in the block reason", () => {
const result = validateRequestedTool({ name: "unknown-tool" }, TOOLS);
expect(result.status).toBe("block");
const { reason } = result as Extract<
RequestedToolValidation,
{ status: "block" }
>;
expect(reason).toContain("read");
expect(reason).toContain("bash");
expect(reason).toContain("edit");
});
it("blocks with empty available list when no tools are registered", () => {
const result = validateRequestedTool({ name: "anything" }, []);
expect(result.status).toBe("block");
});
});
describe("registered tool (ok path)", () => {
@@ -81,12 +51,5 @@ describe("validateRequestedTool", () => {
const result = validateRequestedTool({ name: "bash" }, TOOLS);
expect(result).toEqual({ status: "ok", toolName: "bash" });
});
it("resolves tool name via the `arguments` field naming convention", () => {
// getToolNameFromValue reads `.name` then falls back to other fields;
// a plain `{ name: "edit" }` event is sufficient here.
const result = validateRequestedTool({ name: "edit" }, TOOLS);
expect(result).toEqual({ status: "ok", toolName: "edit" });
});
});
});
@@ -49,4 +49,81 @@ describe("HardGuardPolicyLoader", () => {
expect(loader.loadGlobalConfig().permission).toEqual({ "*": "deny" });
expect(loader.getConfigIssues().join("\n")).toContain("allow or deny");
});
it("rejects an unsupported version instead of interpreting it", () => {
const root = mkdtempSync(join(tmpdir(), "hard-guard-loader-"));
roots.push(root);
const userPath = join(root, "hard-guards.jsonc");
writeFileSync(
userPath,
`{ "version": 2, "permission": { "*": "allow" } }`,
);
const loader = new HardGuardPolicyLoader(
userPath,
join(root, "missing-fallback.jsonc"),
);
expect(loader.loadGlobalConfig().permission).toEqual({ "*": "deny" });
expect(loader.getConfigIssues().join("\n")).toContain(
"unsupported hard guard version",
);
});
it("keeps the last good snapshot when the file turns invalid", () => {
const root = mkdtempSync(join(tmpdir(), "hard-guard-loader-"));
roots.push(root);
const userPath = join(root, "hard-guards.jsonc");
writeFileSync(
userPath,
`{ "version": 1, "permission": { "*": "allow", "~/.ssh/**": "deny" } }`,
);
const loader = new HardGuardPolicyLoader(
userPath,
join(root, "missing-fallback.jsonc"),
);
const first = loader.loadGlobalConfig();
expect(first.permission).toEqual({
"*": "allow",
"~/.ssh/**": "deny",
});
// A corrupt edit must not weaken the boundary: the last valid snapshot
// keeps applying while the file stays broken.
writeFileSync(userPath, "{ not json");
const second = loader.loadGlobalConfig();
expect(second).toEqual(first);
expect(loader.getConfigIssues().join("\n")).toContain("Failed to load");
});
it("restores the file's own rules once it becomes valid again", () => {
const root = mkdtempSync(join(tmpdir(), "hard-guard-loader-"));
roots.push(root);
const userPath = join(root, "hard-guards.jsonc");
writeFileSync(
userPath,
`{ "version": 1, "permission": { "*": "allow", "~/.ssh/**": "deny" } }`,
);
const loader = new HardGuardPolicyLoader(
userPath,
join(root, "missing-fallback.jsonc"),
);
loader.loadGlobalConfig();
writeFileSync(userPath, "{ not json");
loader.loadGlobalConfig();
writeFileSync(
userPath,
`{ "version": 1, "permission": { "*": "allow", "~/.aws/**": "deny" } }`,
);
const restored = loader.loadGlobalConfig();
expect(restored.permission).toEqual({
"*": "allow",
"~/.aws/**": "deny",
});
});
});
@@ -359,34 +359,6 @@ describe("checkPermission — source derivation and matchedPattern", () => {
});
});
describe("matchedPattern for session rules across surfaces", () => {
it("matchedPattern is the session rule pattern for a session match (bash)", () => {
const manager = createMissingConfigManager();
const sessionRules: Ruleset = [sessionRule("bash", "git *")];
const result = checkTool(
manager,
"bash",
{ command: "git status" },
undefined,
sessionRules,
);
expect(result.matchedPattern).toBe("git *");
expect(result.source).toBe("session");
});
it("matchedPattern is the session rule pattern for a session match (skill)", () => {
const manager = createMissingConfigManager();
const sessionRules: Ruleset = [sessionRule("skill", "librarian")];
const result = checkTool(
manager,
"skill",
{ name: "librarian" },
undefined,
sessionRules,
);
expect(result.matchedPattern).toBe("librarian");
});
});
});
@@ -1972,146 +1944,6 @@ test("PermissionManager.getConfigIssues returns empty array for empty config", (
}
});
// ---------------------------------------------------------------------------
// Session-aware checkPermission() — moved from catch-all (#342)
// ---------------------------------------------------------------------------
test("checkPermission returns source 'session' for bash when session rules match", () => {
const { manager, cleanup } = createManager({ permission: {} });
try {
const sessionRules = [sessionRule("bash", "git *")];
const result = checkTool(
manager,
"bash",
{ command: "git status --short" },
undefined,
sessionRules,
);
expect(result.state).toBe("allow");
expect(result.source).toBe("session");
expect(result.matchedPattern).toBe("git *");
} finally {
cleanup();
}
});
test("checkPermission returns source 'session' for bash when session rule is exact match", () => {
const { manager, cleanup } = createManager({ permission: {} });
try {
const sessionRules = [sessionRule("bash", "ls")];
const result = checkTool(
manager,
"bash",
{ command: "ls" },
undefined,
sessionRules,
);
expect(result.state).toBe("allow");
expect(result.source).toBe("session");
} finally {
cleanup();
}
});
test("checkPermission falls back to config for bash when session rules do not match the command", () => {
const { manager, cleanup } = createManager({ permission: { bash: "deny" } });
try {
const sessionRules = [sessionRule("bash", "git *")];
const result = checkTool(
manager,
"bash",
{ command: "npm run build" },
undefined,
sessionRules,
);
expect(result.state).toBe("deny");
expect(result.source).toBe("bash");
} finally {
cleanup();
}
});
test("checkPermission returns source 'session' for mcp when session rules match the target", () => {
const { manager, cleanup } = createManager({ permission: {} });
try {
const sessionRules = [sessionRule("mcp", "exa:*")];
const result = checkTool(
manager,
"mcp",
{ tool: "exa:search" },
undefined,
sessionRules,
);
expect(result.state).toBe("allow");
expect(result.source).toBe("session");
} finally {
cleanup();
}
});
test("checkPermission returns source 'session' for skill when session rules match", () => {
const { manager, cleanup } = createManager({ permission: {} });
try {
const sessionRules = [sessionRule("skill", "librarian")];
const result = checkTool(
manager,
"skill",
{ name: "librarian" },
undefined,
sessionRules,
);
expect(result.state).toBe("allow");
expect(result.source).toBe("session");
expect(result.matchedPattern).toBe("librarian");
} finally {
cleanup();
}
});
test("checkPermission returns source 'session' for tool surface when session rules match", () => {
const { manager, cleanup } = createManager({ permission: {} });
try {
const sessionRules = [sessionRule("read", "*")];
const result = checkTool(manager, "read", {}, undefined, sessionRules);
expect(result.state).toBe("allow");
expect(result.source).toBe("session");
} finally {
cleanup();
}
});
test("bash session rules do not bleed into mcp checks", () => {
const { manager, cleanup } = createManager({ permission: {} });
try {
const sessionRules = [sessionRule("bash", "git *")];
const result = checkTool(
manager,
"mcp",
{ tool: "exa:search" },
undefined,
sessionRules,
);
expect(result.source).not.toBe("session");
} finally {
cleanup();
}
});
// ---------------------------------------------------------------------------
// getResolvedPolicyPaths — moved from catch-all (#342)
// ---------------------------------------------------------------------------
@@ -2258,54 +2090,6 @@ describe("checkPathPolicy", () => {
}
});
it("chooses the most specific pattern across the provided aliases", () => {
const { manager, cleanup } = createManagerWithConfig({
path: { "*": "ask", [`${cwd}/*`]: "allow", "src/*": "deny" },
});
try {
const result = checkPathValues(manager, [
`${cwd}/src/App.jsx`,
"src/App.jsx",
]);
expect(result.state).toBe("allow");
expect(result.matchedPattern).toBe(`${cwd}/*`);
} finally {
cleanup();
}
});
it("applies session rules over config", () => {
const { manager, cleanup } = createManagerWithConfig({
path: { "*": "ask", "src/*": "deny" },
});
try {
const sessionRules: Ruleset = [sessionRule("path", "src/*")];
const result = checkPathValues(
manager,
["src/App.jsx"],
undefined,
sessionRules,
);
expect(result.state).toBe("allow");
expect(result.source).toBe("session");
} finally {
cleanup();
}
});
it("falls back to the catch-all for an empty value list", () => {
const { manager, cleanup } = createManagerWithConfig({
path: { "*": "deny" },
});
try {
const result = checkPathValues(manager, []);
expect(result.state).toBe("deny");
expect(result.matchedPattern).toBe("*");
} finally {
cleanup();
}
});
});
// ---------------------------------------------------------------------------
@@ -22,7 +22,7 @@ describe("formatUnknownToolReason", () => {
test("includes MCP hint for non-mcp tool names", () => {
const result = formatUnknownToolReason("my-server:tool", ["mcp"]);
expect(result).toContain("mcp");
expect(result).toContain("call the registered 'mcp' tool");
});
test("omits MCP hint when tool name is 'mcp'", () => {
-9
View File
@@ -841,10 +841,6 @@ describe("rewriteAsksToYolo", () => {
expect(ruleset[0]?.origin).toBe("global");
});
test("'yolo' is a valid RuleOrigin", () => {
const origin: RuleOrigin = "yolo";
expect(origin).toBe("yolo");
});
});
describe("floorAllowsToAsk", () => {
@@ -903,9 +899,4 @@ describe("floorAllowsToAsk", () => {
expect(ruleset[0]?.action).toBe("allow");
expect(ruleset[0]?.origin).toBe("agent");
});
test("'fail-closed' is a valid RuleOrigin", () => {
const origin: RuleOrigin = "fail-closed";
expect(origin).toBe("fail-closed");
});
});
@@ -1,31 +1,12 @@
import { describe, expect, test } from "vitest";
import { isSafeSystemPath, SAFE_SYSTEM_PATHS } from "#src/safe-system-paths";
describe("SAFE_SYSTEM_PATHS", () => {
test("contains /dev/null, /dev/stdin, /dev/stdout, /dev/stderr", () => {
expect(SAFE_SYSTEM_PATHS.has("/dev/null")).toBe(true);
expect(SAFE_SYSTEM_PATHS.has("/dev/stdin")).toBe(true);
expect(SAFE_SYSTEM_PATHS.has("/dev/stdout")).toBe(true);
expect(SAFE_SYSTEM_PATHS.has("/dev/stderr")).toBe(true);
});
});
import { isSafeSystemPath } from "#src/safe-system-paths";
describe("isSafeSystemPath", () => {
test("returns true for /dev/null", () => {
expect(isSafeSystemPath("/dev/null")).toBe(true);
});
test("returns true for /dev/stdin", () => {
expect(isSafeSystemPath("/dev/stdin")).toBe(true);
});
test("returns true for /dev/stdout", () => {
expect(isSafeSystemPath("/dev/stdout")).toBe(true);
});
test("returns true for /dev/stderr", () => {
expect(isSafeSystemPath("/dev/stderr")).toBe(true);
test("returns true for the whitelisted device paths", () => {
for (const p of ["/dev/null", "/dev/stdin", "/dev/stdout", "/dev/stderr"]) {
expect(isSafeSystemPath(p)).toBe(true);
}
});
test("returns false for an arbitrary absolute path", () => {
@@ -0,0 +1,106 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
autoReviewConfigFileSchema,
normalizeAutoReviewConfig,
} from "#src/auto-review/config";
import { unifiedConfigSchema } from "#src/config-schema";
/** The bundle's authoritative policy seed, as shipped. */
const SEED_PATH = fileURLToPath(
new URL("../../config/pi-permission-policy.jsonc", import.meta.url),
);
function readSeed(): Record<string, unknown> {
const raw = readFileSync(SEED_PATH, "utf8");
const stripped = raw
.replace(/^\s*\/\/.*$/gm, "")
.replace(/\/\*[\s\S]*?\*\//g, "");
return JSON.parse(stripped) as Record<string, unknown>;
}
describe("bundled policy seed", () => {
it("follows the session model: enabled, with no provider/model pair", () => {
const seed = readSeed();
const parsed = autoReviewConfigFileSchema.safeParse(seed.autoReview);
expect(parsed.success).toBe(true);
if (!parsed.success) throw new Error("seed autoReview block is invalid");
expect(parsed.data).toEqual({ enabled: true });
});
it("ships no overrides, so the bundle runs the code-default review knobs", () => {
const seed = readSeed();
const parsed = autoReviewConfigFileSchema.parse(seed.autoReview);
// The seed writes `enabled` only; every effective value below lives in
// normalizeAutoReviewConfig's defaults. If either the seed or the
// defaults drift, this fails.
const effective = normalizeAutoReviewConfig(parsed);
expect(effective.enabled).toBe(true);
expect(effective.provider).toBeUndefined();
expect(effective.model).toBeUndefined();
expect(effective.reasoning).toBe("low");
expect(effective.timeoutMs).toBe(30_000);
expect(effective.maxAttempts).toBe(2);
expect(effective.maxOutputTokens).toBe(4_000);
expect(effective.transcript).toEqual({
messageTokens: 10_000,
toolTokens: 10_000,
maxUntrustedEntries: 40,
});
});
it("keeps auto-review in the authorizer chain", () => {
const seed = readSeed();
expect(seed.authorizerChain).toEqual(["auto-review"]);
});
it("passes the unified schema", () => {
const seed = readSeed();
expect(unifiedConfigSchema.safeParse(seed).success).toBe(true);
});
});
describe("backward compatibility with a deployed config", () => {
it("still accepts the retired annotators key and ignores it", () => {
// The bundle never overwrites a deployed policy.jsonc, so a machine that
// ran the annotator era keeps `annotators` in its file. Rejecting it
// would fail that machine closed instead of ignoring the key.
const result = unifiedConfigSchema.safeParse({
annotators: ["model-explanation"],
});
expect(result.success).toBe(true);
});
it("still accepts a configured provider/model pair", () => {
const parsed = autoReviewConfigFileSchema.safeParse({
enabled: true,
provider: "openai",
model: "gpt-5-mini",
reasoning: "off",
});
expect(parsed.success).toBe(true);
const effective = normalizeAutoReviewConfig(
parsed.success ? parsed.data : undefined,
);
expect(effective.provider).toBe("openai");
expect(effective.model).toBe("gpt-5-mini");
expect(effective.reasoning).toBe("off");
});
it("applies defaults when the block is absent", () => {
const effective = normalizeAutoReviewConfig(undefined);
expect(effective.enabled).toBe(true);
expect(effective.reasoning).toBe("low");
expect(effective.maxAttempts).toBe(2);
});
});
@@ -1,10 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RegisteredChildDetector } from "#src/authority/subagent-detection";
import type { PermissionsService } from "#src/service";
import {
PermissionServiceLifecycle,
type ServiceLifecycle,
} from "#src/service-lifecycle";
import { PermissionServiceLifecycle } from "#src/service-lifecycle";
import { makeCtx } from "#test/helpers/handler-fixtures";
@@ -61,14 +58,6 @@ beforeEach(() => {
mockEmitReadyEvent.mockReset();
});
// ── ServiceLifecycle interface shape ──────────────────────────────────────
it("PermissionServiceLifecycle satisfies ServiceLifecycle", () => {
const { lifecycle } = makeLifecycle();
const _: ServiceLifecycle = lifecycle;
expect(_).toBeDefined();
});
// ── activate ──────────────────────────────────────────────────────────────
describe("activate", () => {
-100
View File
@@ -164,103 +164,3 @@ describe("service round-trip through the global slot", () => {
expect(resolver.getToolPermission).toHaveBeenCalledWith("write", "Explore");
});
});
// ── registerToolInputFormatter delegation ─────────────────────────────────
describe("registerToolInputFormatter delegation", () => {
afterEach(() => {
const current = getPermissionsService();
if (current) {
unpublishPermissionsService(current);
}
});
it("delegates to the registry and returns its disposer", () => {
const registry = new ToolInputFormatterRegistry();
const formatter = () => "preview";
const service = makeService({
registerToolInputFormatter(toolName, fmt) {
return registry.register(toolName, fmt);
},
});
publishPermissionsService(service);
const dispose = getPermissionsService()!.registerToolInputFormatter(
"my-tool",
formatter,
);
// Registry received the registration
expect(registry.get("my-tool")).toBe(formatter);
// Disposer returned from service removes it from the registry
dispose();
expect(registry.get("my-tool")).toBeUndefined();
});
it("throws when a formatter is already registered for the tool name", () => {
const registry = new ToolInputFormatterRegistry();
registry.register("my-tool", () => undefined);
const service = makeService({
registerToolInputFormatter(toolName, fmt) {
return registry.register(toolName, fmt);
},
});
publishPermissionsService(service);
expect(() =>
getPermissionsService()!.registerToolInputFormatter("my-tool", () => ""),
).toThrow("my-tool");
});
});
// ── registerToolAccessExtractor delegation (#352) ────────────────────────
describe("registerToolAccessExtractor delegation", () => {
afterEach(() => {
const current = getPermissionsService();
if (current) {
unpublishPermissionsService(current);
}
});
it("delegates to the registry and returns its disposer", () => {
const registry = new ToolAccessExtractorRegistry();
const extractor = () => "/etc/hosts";
const service = makeService({
registerToolAccessExtractor(toolName, ext) {
return registry.register(toolName, ext);
},
});
publishPermissionsService(service);
const dispose = getPermissionsService()!.registerToolAccessExtractor(
"ffgrep",
extractor,
);
expect(registry.get("ffgrep")).toBe(extractor);
dispose();
expect(registry.get("ffgrep")).toBeUndefined();
});
it("throws when an extractor is already registered for the tool name", () => {
const registry = new ToolAccessExtractorRegistry();
registry.register("ffgrep", () => undefined);
const service = makeService({
registerToolAccessExtractor(toolName, ext) {
return registry.register(toolName, ext);
},
});
publishPermissionsService(service);
expect(() =>
getPermissionsService()!.registerToolAccessExtractor("ffgrep", () => ""),
).toThrow("ffgrep");
});
});
@@ -4,19 +4,11 @@ import { SessionApproval } from "#src/session-approval";
describe("SessionApproval", () => {
describe("single", () => {
it("stores surface and one pattern", () => {
it("stores surface and one pattern, exposing it as representative and gate approval", () => {
const approval = SessionApproval.single("bash", "git *");
expect(approval.surface).toBe("bash");
expect(approval.patterns).toEqual(["git *"]);
});
it("representativePattern returns the pattern", () => {
const approval = SessionApproval.single("bash", "git *");
expect(approval.representativePattern).toBe("git *");
});
it("toGateApproval returns { surface, pattern }", () => {
const approval = SessionApproval.single("bash", "git *");
expect(approval.toGateApproval()).toEqual({
surface: "bash",
pattern: "git *",
@@ -25,28 +17,14 @@ describe("SessionApproval", () => {
});
describe("multiple", () => {
it("stores surface and all patterns", () => {
it("stores all patterns, using the first as representative and gate approval", () => {
const approval = SessionApproval.multiple("external_directory", [
"/outside/a/*",
"/outside/b/*",
]);
expect(approval.surface).toBe("external_directory");
expect(approval.patterns).toEqual(["/outside/a/*", "/outside/b/*"]);
});
it("representativePattern returns the first pattern", () => {
const approval = SessionApproval.multiple("external_directory", [
"/outside/a/*",
"/outside/b/*",
]);
expect(approval.representativePattern).toBe("/outside/a/*");
});
it("toGateApproval returns { surface, pattern } using the first pattern", () => {
const approval = SessionApproval.multiple("external_directory", [
"/outside/a/*",
"/outside/b/*",
]);
expect(approval.toGateApproval()).toEqual({
surface: "external_directory",
pattern: "/outside/a/*",
@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
import { posixPathFlavor } from "#src/path/path-flavor";
import { evaluate } from "#src/rule";
import { SessionApproval } from "#src/session-approval";
import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
import { deriveApprovalPattern, SessionRules } from "#src/session-rules";
// ── SessionRules ───────────────────────────────────────────────────────────
@@ -69,11 +68,6 @@ describe("SessionRules", () => {
});
describe("recordSessionApproval", () => {
it("satisfies the SessionApprovalRecorder interface", () => {
const rules: SessionApprovalRecorder = new SessionRules();
expect(typeof rules.recordSessionApproval).toBe("function");
});
it("records a single-pattern approval as one rule", () => {
const rules = new SessionRules();
rules.recordSessionApproval(SessionApproval.single("bash", "git *"));
@@ -64,22 +64,6 @@ describe("synthesizeDefaults", () => {
const result = evaluate("read", "*", rules, posixPathFlavor);
expect(result.origin).toBe("project");
});
test("all RuleOrigin values are accepted", () => {
const origins: RuleOrigin[] = [
"global",
"project",
"agent",
"project-agent",
"builtin",
"baseline",
"session",
];
for (const origin of origins) {
const rules = synthesizeDefaults("ask", origin);
expect(rules[0].origin).toBe(origin);
}
});
});
// ── synthesizeBaseline ─────────────────────────────────────────────────────
@@ -153,45 +153,3 @@ describe("checkRequestedToolRegistration", () => {
expect(result.status).toBe("registered");
});
});
// ---------------------------------------------------------------------------
// Moved from permission-system.test.ts catch-all (#342)
// ---------------------------------------------------------------------------
test("Tool registry resolves event tool names from string and object payloads", () => {
expect(getToolNameFromValue(" read ")).toBe("read");
expect(getToolNameFromValue({ toolName: "write" })).toBe("write");
expect(getToolNameFromValue({ name: "find" })).toBe("find");
expect(getToolNameFromValue({ tool: "grep" })).toBe("grep");
expect(getToolNameFromValue({})).toBe(null);
});
test("Tool registry blocks unregistered tools and handles aliases", () => {
const registeredTools = [
{ toolName: "mcp" },
{ toolName: "read" },
{ toolName: "bash" },
];
const unknownCheck = checkRequestedToolRegistration(
"third_party_tool",
registeredTools,
);
expect(unknownCheck.status).toBe("unregistered");
if (unknownCheck.status === "unregistered") {
expect(unknownCheck.availableToolNames).toEqual(["bash", "mcp", "read"]);
}
const aliasCheck = checkRequestedToolRegistration(
"legacy_read",
registeredTools,
{ legacy_read: "read" },
);
expect(aliasCheck.status).toBe("registered");
const missingNameCheck = checkRequestedToolRegistration(
" ",
registeredTools,
);
expect(missingNameCheck.status).toBe("missing-tool-name");
});