test(auto-reply): isolate reply abort dispatch seams

This commit is contained in:
Peter Steinberger
2026-04-06 03:06:41 +01:00
parent 124c4c85ab
commit 9924627f49
3 changed files with 28 additions and 32 deletions

View File

@@ -231,14 +231,20 @@ export async function tryFastAbortFromMessage(params: {
}): Promise<{ handled: boolean; aborted: boolean; stoppedSubagents?: number }> {
const { ctx, cfg } = params;
const targetKey = resolveAbortTargetKey(ctx);
const agentId = resolveSessionAgentId({
sessionKey: targetKey ?? ctx.SessionKey ?? "",
config: cfg,
});
// Use RawBody/CommandBody for abort detection (clean message without structural context).
const raw = stripStructuralPrefixes(ctx.CommandBody ?? ctx.RawBody ?? ctx.Body ?? "");
const isGroup = ctx.ChatType?.trim().toLowerCase() === "group";
const stripped = isGroup ? stripMentions(raw, ctx, cfg, agentId) : raw;
const stripped = isGroup
? stripMentions(
raw,
ctx,
cfg,
resolveSessionAgentId({
sessionKey: targetKey ?? ctx.SessionKey ?? "",
config: cfg,
}),
)
: raw;
const abortRequested = isAbortRequestText(stripped);
if (!abortRequested) {
return { handled: false, aborted: false };
@@ -254,6 +260,10 @@ export async function tryFastAbortFromMessage(params: {
return { handled: false, aborted: false };
}
const agentId = resolveSessionAgentId({
sessionKey: targetKey ?? ctx.SessionKey ?? "",
config: cfg,
});
const abortKey = targetKey ?? auth.from ?? auth.to;
const requesterSessionKey = targetKey ?? ctx.SessionKey ?? abortKey;

View File

@@ -400,6 +400,8 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
ctx: createHookCtx(),
cfg: emptyConfig,
dispatcher: createDispatcher(),
fastAbortResolver: () => ({ handled: false, aborted: false }),
formatAbortReplyTextResolver: () => "⚙️ Agent was aborted.",
replyResolver: async () => ({ text: "model reply" }),
});
@@ -418,28 +420,4 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
counts: { tool: 1, block: 2, final: 3 },
});
});
it("still applies send-policy deny after an unhandled plugin dispatch", async () => {
hookMocks.runner.runReplyDispatch.mockResolvedValue({
handled: false,
});
const result = await dispatchReplyFromConfig({
ctx: createHookCtx(),
cfg: {
...emptyConfig,
session: {
sendPolicy: { default: "deny" },
},
},
dispatcher: createDispatcher(),
replyResolver: async () => ({ text: "model reply" }),
});
expect(hookMocks.runner.runReplyDispatch).toHaveBeenCalled();
expect(result).toEqual({
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
});
});
});

View File

@@ -190,6 +190,8 @@ export async function dispatchReplyFromConfig(params: {
dispatcher: ReplyDispatcher;
replyOptions?: Omit<GetReplyOptions, "onToolResult" | "onBlockReply">;
replyResolver?: typeof import("./get-reply-from-config.runtime.js").getReplyFromConfig;
fastAbortResolver?: typeof import("./abort.runtime.js").tryFastAbortFromMessage;
formatAbortReplyTextResolver?: typeof import("./abort.runtime.js").formatAbortReplyText;
/** Optional config override passed to getReplyFromConfig (e.g. per-sender timezone). */
configOverride?: OpenClawConfig;
}): Promise<DispatchFromConfigResult> {
@@ -498,11 +500,17 @@ export async function dispatchReplyFromConfig(params: {
markProcessing();
try {
const abortRuntime = await loadAbortRuntime();
const fastAbort = await abortRuntime.tryFastAbortFromMessage({ ctx, cfg });
const abortRuntime = params.fastAbortResolver ? null : await loadAbortRuntime();
const fastAbortResolver = params.fastAbortResolver ?? abortRuntime?.tryFastAbortFromMessage;
const formatAbortReplyTextResolver =
params.formatAbortReplyTextResolver ?? abortRuntime?.formatAbortReplyText;
if (!fastAbortResolver || !formatAbortReplyTextResolver) {
throw new Error("abort runtime unavailable");
}
const fastAbort = await fastAbortResolver({ ctx, cfg });
if (fastAbort.handled) {
const payload = {
text: abortRuntime.formatAbortReplyText(fastAbort.stoppedSubagents),
text: formatAbortReplyTextResolver(fastAbort.stoppedSubagents),
} satisfies ReplyPayload;
let queuedFinal = false;
let routedFinalCount = 0;