mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
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:
co-authored by
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
dec48dab6d
commit
7e6a8faae1
@@ -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,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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user