diff --git a/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts b/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts index b7dd295bb08..cd599bab77d 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts @@ -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 = { diff --git a/src/auto-reply/reply/get-reply-inline-actions.ts b/src/auto-reply/reply/get-reply-inline-actions.ts index 7e1c2412551..8907acd314c 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.ts @@ -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 | 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) {