Files
openclaw/src/plugins/hook-decision-types.test.ts
Jesse Merhi 1c42c77433 feat: add user input blocking lifecycle gates (#75035)
Summary:
- The PR adds a `before_agent_run` plugin hook with pass/block decisions, redacted blocked-turn persistence, diagnostics/docs/changelog updates, and focused runner, gateway, session, and plugin tests.
- Reproducibility: not applicable. as a feature PR rather than a current-main bug report. Current main lacks ` ... un`, while the PR head adds source coverage and copied live Gateway/WebChat log proof for the new behavior.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix: trim before agent hook PR scope
- PR branch already contained follow-up commit before automerge: fix: keep before-agent blocks redacted
- PR branch already contained follow-up commit before automerge: fix: keep runtime context out of model prompt
- PR branch already contained follow-up commit before automerge: docs: refresh config baseline after rebase
- PR branch already contained follow-up commit before automerge: fix: align blocked turn clients with redacted content
- PR branch already contained follow-up commit before automerge: fix: remove out-of-scope client block UI changes

Validation:
- ClawSweeper review passed for head 767e46fde8.
- Required merge gates passed before the squash merge.

Prepared head SHA: 767e46fde8
Review: https://github.com/openclaw/openclaw/pull/75035#issuecomment-4351843275

Co-authored-by: Jesse Merhi <jessejmerhi@gmail.com>
Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
2026-05-06 11:41:04 +00:00

82 lines
3.4 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
BLOCK_MESSAGE_PREFIX,
type HookDecision,
type HookDecisionBlock,
mergeHookDecisions,
isHookDecision,
resolveBlockMessage,
} from "./hook-decision-types.js";
describe("HookDecision helpers", () => {
describe("isHookDecision", () => {
it("recognizes supported outcomes", () => {
expect(isHookDecision({ outcome: "pass" })).toBe(true);
expect(isHookDecision({ outcome: "block", reason: "policy" })).toBe(true);
});
it("rejects non-decision values", () => {
expect(isHookDecision(null)).toBe(false);
expect(isHookDecision(undefined)).toBe(false);
expect(isHookDecision("pass")).toBe(false);
expect(isHookDecision({ block: true })).toBe(false);
expect(isHookDecision({ outcome: "ask", reason: "check" })).toBe(false);
expect(isHookDecision({ outcome: "invalid" })).toBe(false);
expect(isHookDecision({ outcome: "pass", message: "typo" })).toBe(false);
expect(isHookDecision({ outcome: "pass", reason: "typo" })).toBe(false);
expect(isHookDecision({ outcome: "block" })).toBe(false);
expect(isHookDecision({ outcome: "block", reason: "" })).toBe(false);
expect(isHookDecision({ outcome: "block", reason: "policy", message: "" })).toBe(false);
expect(isHookDecision({ outcome: "block", reason: "policy", message: 3 })).toBe(false);
expect(isHookDecision({ outcome: "block", reason: "policy", ask: true })).toBe(false);
expect(isHookDecision({ outcome: "block", reason: "policy", metadata: [] })).toBe(false);
});
});
describe("mergeHookDecisions", () => {
const passDecision: HookDecision = { outcome: "pass" };
const blockDecision: HookDecision = { outcome: "block", reason: "policy" };
it("uses most-restrictive-wins ordering", () => {
expect(mergeHookDecisions(undefined, passDecision)).toBe(passDecision);
expect(mergeHookDecisions(passDecision, blockDecision)).toBe(blockDecision);
expect(mergeHookDecisions(blockDecision, passDecision)).toBe(blockDecision);
});
it("keeps the first decision when outcomes have the same severity", () => {
const secondBlock: HookDecision = { outcome: "block", reason: "second" };
expect(mergeHookDecisions(passDecision, { outcome: "pass" })).toBe(passDecision);
expect(mergeHookDecisions(blockDecision, secondBlock)).toBe(blockDecision);
});
});
describe("resolveBlockMessage", () => {
it("returns explicit or default block messages", () => {
const explicit: HookDecisionBlock = {
outcome: "block",
reason: "policy",
message: "Please rephrase your request.",
};
const fallback: HookDecisionBlock = {
outcome: "block",
reason: "policy",
};
expect(resolveBlockMessage(explicit)).toBe(
`${BLOCK_MESSAGE_PREFIX}: Please rephrase your request.`,
);
expect(resolveBlockMessage(fallback)).toBe(`${BLOCK_MESSAGE_PREFIX}: blocked`);
expect(resolveBlockMessage(fallback, { blockedBy: "policy-plugin" })).toBe(
`${BLOCK_MESSAGE_PREFIX}: blocked by policy-plugin`,
);
expect(resolveBlockMessage(explicit, { blockedBy: "policy-plugin" })).toBe(
`${BLOCK_MESSAGE_PREFIX}: Please rephrase your request. (blocked by policy-plugin)`,
);
expect(resolveBlockMessage({ ...explicit, message: " " })).toBe(
`${BLOCK_MESSAGE_PREFIX}: blocked`,
);
});
});
});