feat: add user input blocking lifecycle gates

This commit is contained in:
clawsweeper
2026-05-06 11:04:01 +00:00
parent 96348c437a
commit 767e46fde8
4 changed files with 64 additions and 9 deletions

View File

@@ -4581,6 +4581,38 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () =>
);
});
it("preserves hook-blocked metadata when source delivery is message-tool-only", async () => {
setNoAbort();
sessionStoreMocks.currentEntry = {
sessionId: "s1",
updatedAt: 0,
sendPolicy: "allow",
};
const dispatcher = createDispatcher();
const blockedReply = setReplyPayloadMetadata(
{ text: "Your message could not be sent: blocked by policy-plugin", isError: true },
{ beforeAgentRunBlocked: true },
);
const replyResolver = vi.fn(async () => blockedReply satisfies ReplyPayload);
const result = await dispatchReplyFromConfig({
ctx: buildTestCtx({ SessionKey: "test:session" }),
cfg: emptyConfig,
dispatcher,
replyResolver,
replyOptions: {
sourceReplyDeliveryMode: "message_tool_only",
},
});
expect(replyResolver).toHaveBeenCalledTimes(1);
expect(result.queuedFinal).toBe(false);
expect(result.beforeAgentRunBlocked).toBe(true);
expect(result.sourceReplyDeliveryMode).toBe("message_tool_only");
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
expect(dispatcher.sendBlockReply).not.toHaveBeenCalled();
});
it("delivers marked runtime failure notices in message-tool-only mode", async () => {
setNoAbort();
sessionStoreMocks.currentEntry = {

View File

@@ -95,7 +95,10 @@ import { withFullRuntimeReplyConfig } from "./get-reply-fast-path.js";
import { claimInboundDedupe, commitInboundDedupe, releaseInboundDedupe } from "./inbound-dedupe.js";
import { resolveOriginMessageProvider } from "./origin-routing.js";
import { resolveReplyRoutingDecision } from "./routing-policy.js";
import { resolveSourceReplyVisibilityPolicy } from "./source-reply-delivery-mode.js";
import {
isExplicitSourceReplyCommand,
resolveSourceReplyVisibilityPolicy,
} from "./source-reply-delivery-mode.js";
import { resolveRunTypingPolicy } from "./typing-policy.js";
const routeReplyRuntimeLoader = createLazyImportLoader(() => import("./route-reply.runtime.js"));
@@ -711,7 +714,7 @@ export async function dispatchReplyFromConfig(
const prefersMessageToolDelivery =
params.replyOptions?.sourceReplyDeliveryMode === "message_tool_only" ||
(params.replyOptions?.sourceReplyDeliveryMode === undefined &&
ctx.CommandSource !== "native" &&
!isExplicitSourceReplyCommand(ctx) &&
(chatType === "group" || chatType === "channel"
? effectiveVisibleReplies !== "automatic"
: effectiveVisibleReplies === "message_tool"));

View File

@@ -97,13 +97,23 @@ describe("resolveSourceReplyDeliveryMode", () => {
expect(
resolveSourceReplyDeliveryMode({
cfg: emptyConfig,
ctx: { ChatType: "group", CommandSource: "text", CommandAuthorized: true },
ctx: {
ChatType: "group",
CommandSource: "text",
CommandAuthorized: true,
CommandBody: "/status",
},
}),
).toBe("automatic");
expect(
resolveSourceReplyDeliveryMode({
cfg: emptyConfig,
ctx: { ChatType: "group", CommandSource: "text" },
ctx: {
ChatType: "group",
CommandSource: "text",
CommandAuthorized: false,
CommandBody: "/status",
},
}),
).toBe("message_tool_only");
});
@@ -192,7 +202,12 @@ describe("resolveSourceReplyVisibilityPolicy", () => {
it("keeps native and authorized text command replies visible in groups", () => {
for (const ctx of [
{ ChatType: "group", CommandSource: "native" },
{ ChatType: "group", CommandSource: "text", CommandAuthorized: true },
{
ChatType: "group",
CommandSource: "text",
CommandAuthorized: true,
CommandBody: "/status",
},
] as const) {
expect(
resolveSourceReplyVisibilityPolicy({

View File

@@ -6,9 +6,17 @@ import type { SourceReplyDeliveryMode } from "../get-reply-options.types.js";
export type SourceReplyDeliveryModeContext = {
ChatType?: string;
CommandAuthorized?: boolean;
CommandBody?: string;
CommandSource?: "text" | "native";
};
export function isExplicitSourceReplyCommand(ctx: SourceReplyDeliveryModeContext): boolean {
if (ctx.CommandSource === "native") {
return true;
}
return ctx.CommandSource === "text" && ctx.CommandAuthorized === true;
}
export function resolveSourceReplyDeliveryMode(params: {
cfg: OpenClawConfig;
ctx: SourceReplyDeliveryModeContext;
@@ -21,10 +29,7 @@ export function resolveSourceReplyDeliveryMode(params: {
? "automatic"
: params.requested;
}
if (
params.ctx.CommandSource === "native" ||
(params.ctx.CommandSource === "text" && params.ctx.CommandAuthorized === true)
) {
if (isExplicitSourceReplyCommand(params.ctx)) {
return "automatic";
}
const chatType = normalizeChatType(params.ctx.ChatType);