feat(pi-permission-system): AutoReview 改为跟随主模型的对话式工具调用审查

AutoReview:

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

删除:

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

测试:

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

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
云服务部-叶林立
2026-10-19 01:54:17 -07:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent dec48dab6d
commit 7e6a8faae1
87 changed files with 2831 additions and 2658 deletions
@@ -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" });
});
});
});