fix(reply): avoid double status replies

This commit is contained in:
Vincent Koc 2026-04-01 06:12:47 +09:00
parent 968bc3d5b0
commit e1d2b299f6
2 changed files with 45 additions and 2 deletions

View File

@ -3,12 +3,14 @@ import type { SkillCommandSpec } from "../../agents/skills.js";
import type { SessionEntry } from "../../config/sessions.js";
import type { TemplateContext } from "../templating.js";
import { clearInlineDirectives } from "./get-reply-directives-utils.js";
import { stripInlineStatus } from "./reply-inline.js";
import { buildTestCtx } from "./test-ctx.js";
import type { TypingController } from "./typing.js";
const handleCommandsMock = vi.fn();
const getChannelPluginMock = vi.fn();
const createOpenClawToolsMock = vi.fn();
const buildStatusReplyMock = vi.fn();
let handleInlineActions: typeof import("./get-reply-inline-actions.js").handleInlineActions;
type HandleInlineActionsInput = Parameters<
@ -19,7 +21,7 @@ async function loadFreshInlineActionsModuleForTest() {
vi.resetModules();
vi.doMock("./commands.runtime.js", () => ({
handleCommands: (...args: unknown[]) => handleCommandsMock(...args),
buildStatusReply: vi.fn(),
buildStatusReply: (...args: unknown[]) => buildStatusReplyMock(...args),
}));
vi.doMock("../../agents/openclaw-tools.runtime.js", () => ({
createOpenClawTools: (...args: unknown[]) => createOpenClawToolsMock(...args),
@ -120,6 +122,8 @@ describe("handleInlineActions", () => {
handleCommandsMock.mockResolvedValue({ shouldContinue: true, reply: undefined });
getChannelPluginMock.mockReset();
createOpenClawToolsMock.mockReset();
buildStatusReplyMock.mockReset();
buildStatusReplyMock.mockResolvedValue({ text: "status" });
createOpenClawToolsMock.mockReturnValue([]);
getChannelPluginMock.mockImplementation((channelId?: string) =>
channelId === "whatsapp" ? { commands: { skipWhenConfigEmpty: true } } : undefined,
@ -180,6 +184,36 @@ describe("handleInlineActions", () => {
);
});
it("does not run command handlers after replying to an inline status-only turn", async () => {
const typing = createTypingController();
const ctx = buildTestCtx({
Body: "/status",
CommandBody: "/status",
});
const result = await handleInlineActions(
createHandleInlineActionsInput({
ctx,
typing,
cleanedBody: stripInlineStatus("/status").cleaned,
command: {
isAuthorizedSender: true,
rawBodyNormalized: "/status",
commandBodyNormalized: "/status",
},
overrides: {
allowTextCommands: true,
inlineStatusRequested: true,
},
}),
);
expect(result).toEqual({ kind: "reply", reply: undefined });
expect(buildStatusReplyMock).toHaveBeenCalledTimes(1);
expect(handleCommandsMock).not.toHaveBeenCalled();
expect(typing.cleanup).toHaveBeenCalled();
});
it("skips stale queued messages that are at or before the /stop cutoff", async () => {
const typing = createTypingController();
const sessionEntry: SessionEntry = {

View File

@ -26,7 +26,7 @@ import type { InlineDirectives } from "./directive-handling.parse.js";
import { isDirectiveOnly } from "./directive-handling.parse.js";
import { extractExplicitGroupId } from "./group-id.js";
import type { createModelSelectionState } from "./model-selection.js";
import { extractInlineSimpleCommand } from "./reply-inline.js";
import { extractInlineSimpleCommand, stripInlineStatus } from "./reply-inline.js";
import type { TypingController } from "./typing.js";
let builtinSlashCommands: Set<string> | null = null;
@ -337,6 +337,7 @@ export async function handleInlineActions(params: {
agentId,
isGroup,
}) && inlineStatusRequested;
let didSendInlineStatus = false;
if (handleInlineStatus) {
const { buildStatusReply } = await import("./commands.runtime.js");
const inlineStatusReply = await buildStatusReply({
@ -359,6 +360,7 @@ export async function handleInlineActions(params: {
mediaDecisions: ctx.MediaUnderstandingDecisions,
});
await sendInlineReply(inlineStatusReply);
didSendInlineStatus = true;
directives = { ...directives, hasStatusDirective: false };
}
@ -456,6 +458,13 @@ export async function handleInlineActions(params: {
abortedLastRun,
};
}
const statusOnlyCommand =
command.commandBodyNormalized.trim().length > 0 &&
stripInlineStatus(command.commandBodyNormalized).cleaned.length === 0;
if (didSendInlineStatus && statusOnlyCommand) {
typing.cleanup();
return { kind: "reply", reply: undefined };
}
const commandResult = await runCommands(command);
if (!commandResult.shouldContinue) {