From acc375ff75794bb853787e028080ac46b0924f51 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 16 Jun 2026 22:47:15 +0800 Subject: [PATCH] fix(commands): preserve multiline slash skill args (#93672) Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com> Co-authored-by: Blind Dev <264741654+web3blind@users.noreply.github.com> --- CHANGELOG.md | 1 + src/auto-reply/commands-registry-normalize.ts | 28 ++++++++++++++--- src/auto-reply/commands-registry.test.ts | 25 +++++++++++++++ src/auto-reply/reply/commands-context.test.ts | 24 ++++++++++++++ .../reply/get-reply.fast-path.test.ts | 18 +++++++++++ src/auto-reply/reply/mentions.test.ts | 7 +++-- src/auto-reply/reply/mentions.ts | 10 +++--- src/auto-reply/reply/session.test.ts | 31 +++++++++++++++++++ src/skills/discovery/chat-commands.test.ts | 18 +++++++++++ 9 files changed, 149 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18185f5009c3..be8cec401750 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai - Onboarding/skills: show the Homebrew install recommendation only on macOS and Linux, so FreeBSD and other unsupported platforms no longer get a misleading brew prompt. Fixes #68893; carries forward #68894, #68910, #68941, #68943, #69002, and #69545. Thanks @yurivict, @Sanjays2402, @Eruditi, @JustInCache, @nnish16, and @Mlightsnow. - Channels and delivery: preserve account-scoped DM channel send policy, rich Telegram final replies, rich Telegram tables and lists, Telegram thread-create CLI remapping, Slack outbound `message_sent` hooks, contributed message-tool schema optionality, same-channel generated media completions, and channel chunking around surrogate pairs and Infinity limits. (#92788, #92679, #89421, #89943, #91137, #91246, #92735) Thanks @yetval, @obviyus, @spacegeologist, @rishitamrakar, @lundog, @TurboTheTurtle, and @yhterrance. - Auto-reply/groups: keep ordinary group text replies on automatic final-reply delivery while allowing `message(action=send)` for files, images, and other attachments to the same group or topic. Carries forward #43276; refs #48004. Thanks @NayukiChiba and @ShakaRover. +- Auto-reply/skills: preserve multiline payloads for `/skill` and direct skill slash commands while keeping command-head normalization for aliases, colon syntax, and bot mentions. Fixes #79155; carries forward #81305. Thanks @web3blind. - iMessage: normalize leading NUL sent-message echo prefixes while preserving interior NUL bytes and the leading attributedBody marker handling from #73942. Carries forward #63581. Thanks @drvoss. - Discord: give generated auto-thread titles a 60-second timeout and 4,096-token reasoning-model output budget, clamped to the selected model output cap. (#64734) Thanks @hanamizuki. - Agent, cron, and Gateway runtime: mark active main sessions before restart shutdown aborts, pause yielded subagent runs whose terminal also signals abort, preserve yielded media completions, de-duplicate main-session heartbeat events, expose session identity in runtime prompts, reject unknown OpenAI agent selectors, keep generated media completions and slash-command block replies in WebChat, preserve fresh post-compaction usage while clearing stale usage snapshots, and require admin privileges for HTTP session/model override surfaces. (#91357, #92631, #92146, #91287, #92468, #92510, #91246, #50795, #50845, #82874, #92651, #92646) Thanks @ooiuuii, @openperf, @IWhatsskill, @ZengWen-DT, @zhangguiping-xydt, @Hollychou924, @leno23, and @TurboTheTurtle. diff --git a/src/auto-reply/commands-registry-normalize.ts b/src/auto-reply/commands-registry-normalize.ts index 6c632b929654..6660f135c28b 100644 --- a/src/auto-reply/commands-registry-normalize.ts +++ b/src/auto-reply/commands-registry-normalize.ts @@ -24,6 +24,20 @@ let cachedTextAliasCommands: ChatCommandDefinition[] | null = null; let cachedDetection: CommandDetection | undefined; let cachedDetectionCommands: ChatCommandDefinition[] | null = null; +function appendMultilineTail(head: string, tail: string | undefined, spec?: TextAliasSpec): string { + if (!tail) { + return head; + } + if (!spec || spec.key === "skill") { + return `${head}\n${tail}`; + } + if (spec.key === "reset") { + const flattened = tail.replace(/\s+/g, " ").trim(); + return flattened ? `${head} ${flattened}` : head; + } + return head; +} + function getTextAliasMap(): Map { const commands = getChatCommands(); if (cachedTextAliasMap && cachedTextAliasCommands === commands) { @@ -59,6 +73,7 @@ export function normalizeCommandBody(raw: string, options?: CommandNormalizeOpti const newline = trimmed.indexOf("\n"); const singleLine = newline === -1 ? trimmed : trimmed.slice(0, newline).trim(); + const multilineTail = newline === -1 ? undefined : trimmed.slice(newline + 1).trimStart(); // `/cmd: value` is accepted as `/cmd value` because some channels insert colon syntax. const colonMatch = singleLine.match(/^\/([^\s:]+)\s*:(.*)$/); @@ -83,24 +98,27 @@ export function normalizeCommandBody(raw: string, options?: CommandNormalizeOpti const textAliasMap = getTextAliasMap(); const exact = textAliasMap.get(lowered); if (exact) { - return exact.canonical; + return appendMultilineTail(exact.canonical, multilineTail, exact); } const tokenMatch = commandBody.match(/^\/([^\s]+)(?:\s+([\s\S]+))?$/); if (!tokenMatch) { - return commandBody; + return appendMultilineTail(commandBody, multilineTail); } const [, token, rest] = tokenMatch; const tokenKey = `/${normalizeLowercaseStringOrEmpty(token)}`; const tokenSpec = textAliasMap.get(tokenKey); if (!tokenSpec) { - return commandBody; + return appendMultilineTail(commandBody, multilineTail); } if (rest && !tokenSpec.acceptsArgs) { return commandBody; } const normalizedRest = rest?.trimStart(); - return normalizedRest ? `${tokenSpec.canonical} ${normalizedRest}` : tokenSpec.canonical; + const normalizedHead = normalizedRest + ? `${tokenSpec.canonical} ${normalizedRest}` + : tokenSpec.canonical; + return appendMultilineTail(normalizedHead, multilineTail, tokenSpec); } /** Returns cached exact and regex detectors for the current command registry instance. */ @@ -123,7 +141,7 @@ export function getCommandDetection(_cfg?: OpenClawConfig): CommandDetection { continue; } if (cmd.acceptsArgs) { - patterns.push(`${escaped}(?:\\s+.+|\\s*:\\s*.*)?`); + patterns.push(`${escaped}(?:\\s+[\\s\\S]+|\\s*:\\s*[\\s\\S]*)?`); } else { patterns.push(`${escaped}(?:\\s*:\\s*)?`); } diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index 32c0c2fd83c2..f9ab1a2d88f0 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -246,6 +246,31 @@ describe("commands registry", () => { ); }); + it("preserves multiline payloads for skill slash commands", () => { + expect(normalizeCommandBody("/skill demo_skill first line\nsecond line")).toBe( + "/skill demo_skill first line\nsecond line", + ); + expect( + normalizeCommandBody("/skill@openclaw: demo_skill first line\nsecond line", { + botUsername: "openclaw", + }), + ).toBe("/skill demo_skill first line\nsecond line"); + expect(resolveTextCommand("/skill demo_skill first line\nsecond line")?.args).toBe( + "demo_skill first line\nsecond line", + ); + }); + + it("preserves multiline payloads for direct skill slash aliases only when unregistered", () => { + expect(normalizeCommandBody("/demo_skill first line\nsecond line")).toBe( + "/demo_skill first line\nsecond line", + ); + expect(normalizeCommandBody("/reset soft\nre-read persona files")).toBe( + "/reset soft re-read persona files", + ); + expect(normalizeCommandBody("/side first line\nsecond line")).toBe("/btw first line"); + expect(normalizeCommandBody("/id\nignored")).toBe("/whoami"); + }); + it("filters commands based on config flags", () => { const disabled = listChatCommandsForConfig({ commands: { config: false, plugins: false, debug: false }, diff --git a/src/auto-reply/reply/commands-context.test.ts b/src/auto-reply/reply/commands-context.test.ts index e6c7b71c4b03..06f123aded8d 100644 --- a/src/auto-reply/reply/commands-context.test.ts +++ b/src/auto-reply/reply/commands-context.test.ts @@ -52,6 +52,30 @@ describe("buildCommandContext", () => { expect(result.commandBodyNormalized).toBe("/reset soft re-read persona files"); }); + it("preserves multiline slash skill payloads after structural normalization", () => { + const body = "/skill demo_skill first line\nsecond line"; + const ctx = buildTestCtx({ + Provider: "whatsapp", + Surface: "whatsapp", + From: "user", + To: "bot", + Body: body, + RawBody: body, + CommandBody: body, + BodyForCommands: body, + }); + + const result = buildCommandContext({ + ctx, + cfg: {} as OpenClawConfig, + isGroup: false, + triggerBodyNormalized: stripStructuralPrefixes(body), + commandAuthorized: true, + }); + + expect(result.commandBodyNormalized).toBe("/skill demo_skill first line\nsecond line"); + }); + it("maps explicit gateway origin into command context", () => { const ctx = buildTestCtx({ Provider: "internal", diff --git a/src/auto-reply/reply/get-reply.fast-path.test.ts b/src/auto-reply/reply/get-reply.fast-path.test.ts index 1bb7946c1427..722d569f1d75 100644 --- a/src/auto-reply/reply/get-reply.fast-path.test.ts +++ b/src/auto-reply/reply/get-reply.fast-path.test.ts @@ -687,6 +687,24 @@ describe("getReplyFromConfig fast test bootstrap", () => { expect(command.to).toBe("user:U123"); }); + it("preserves multiline slash skill payloads in fast command context", () => { + const body = "/skill demo_skill first line\nsecond line"; + const command = buildFastReplyCommandContext({ + ctx: buildGetReplyCtx({ + Body: body, + RawBody: body, + CommandBody: body, + }), + cfg: {} as OpenClawConfig, + sessionKey: "main", + isGroup: false, + triggerBodyNormalized: body, + commandAuthorized: true, + }); + + expect(command.commandBodyNormalized).toBe("/skill demo_skill first line\nsecond line"); + }); + it("keeps the existing session for /reset newline soft during fast bootstrap", async () => { const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fast-reset-newline-soft-")); const storePath = path.join(home, "sessions.json"); diff --git a/src/auto-reply/reply/mentions.test.ts b/src/auto-reply/reply/mentions.test.ts index df9fa54d2571..85ba2768f70b 100644 --- a/src/auto-reply/reply/mentions.test.ts +++ b/src/auto-reply/reply/mentions.test.ts @@ -42,10 +42,11 @@ describe("stripStructuralPrefixes", () => { expect(stripStructuralPrefixes("just a message")).toBe("just a message"); }); - it("flattens multiline soft reset commands before downstream parsing", () => { + it("preserves real line breaks in slash commands for downstream command parsing", () => { expect(stripStructuralPrefixes("/reset soft\nre-read persona files")).toBe( - "/reset soft re-read persona files", + "/reset soft\nre-read persona files", ); - expect(stripStructuralPrefixes("/reset \nsoft")).toBe("/reset soft"); + expect(stripStructuralPrefixes("/skill demo\nline two")).toBe("/skill demo\nline two"); + expect(stripStructuralPrefixes("/reset \\nsoft")).toBe("/reset soft"); }); }); diff --git a/src/auto-reply/reply/mentions.ts b/src/auto-reply/reply/mentions.ts index d07081cd9af6..f48d5baf65a5 100644 --- a/src/auto-reply/reply/mentions.ts +++ b/src/auto-reply/reply/mentions.ts @@ -196,11 +196,11 @@ export function stripStructuralPrefixes(text: string): string { ? /^[ \t]*(?!\/)[^\n:]{1,120}:\s+/gm : /^[ \t]*[^\n:]{1,120}:\s+/gm; - return afterEnvelope - .replace(senderPrefixPattern, "") - .replace(/\\n/g, " ") - .replace(/\s+/g, " ") - .trim(); + const stripped = afterEnvelope.replace(senderPrefixPattern, "").replace(/\\n/g, " ").trim(); + if (stripped.startsWith("/")) { + return stripped.replace(/[ \t]+/g, " "); + } + return stripped.replace(/\s+/g, " "); } /** Removes bot mentions from command text before command normalization. */ diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 337cc2d542c5..9c8e48bb40c5 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -2079,6 +2079,37 @@ describe("initSessionState reset policy", () => { }); }); + it("keeps multiline slash skill payloads on the current session", async () => { + const root = await makeCaseDir("openclaw-skill-multiline-session-"); + const storePath = path.join(root, "sessions.json"); + const sessionKey = "agent:main:whatsapp:dm:skill-multiline"; + const existingSessionId = "skill-multiline-session-id"; + const body = "/skill demo_skill first line\nsecond line"; + + await writeSessionStoreFast(storePath, { + [sessionKey]: { + sessionId: existingSessionId, + updatedAt: Date.now(), + }, + }); + + const result = await initSessionState({ + ctx: { + Body: body, + RawBody: body, + CommandBody: body, + SessionKey: sessionKey, + }, + cfg: { session: { store: storePath } } as OpenClawConfig, + commandAuthorized: true, + }); + + expect(result.resetTriggered).toBe(false); + expect(result.isNewSession).toBe(false); + expect(result.sessionId).toBe(existingSessionId); + expect(result.triggerBodyNormalized).toBe(body); + }); + it("does not preserve a stale session for unauthorized /reset soft", async () => { vi.setSystemTime(new Date(2026, 0, 18, 5, 30, 0)); const root = await makeCaseDir("openclaw-reset-soft-stale-unauthorized-"); diff --git a/src/skills/discovery/chat-commands.test.ts b/src/skills/discovery/chat-commands.test.ts index 97ebc3c4af94..4561d107a19a 100644 --- a/src/skills/discovery/chat-commands.test.ts +++ b/src/skills/discovery/chat-commands.test.ts @@ -190,6 +190,24 @@ describe("resolveSkillCommandInvocation", () => { expect(invocation?.args).toBe("do the thing"); }); + it("preserves multiline args for /skill invocations", () => { + const invocation = resolveSkillCommandInvocation({ + commandBodyNormalized: "/skill demo_skill first line\nsecond line", + skillCommands: [{ name: "demo_skill", skillName: "demo-skill", description: "Demo" }], + }); + expect(invocation?.command.name).toBe("demo_skill"); + expect(invocation?.args).toBe("first line\nsecond line"); + }); + + it("preserves multiline args for direct skill slash invocations", () => { + const invocation = resolveSkillCommandInvocation({ + commandBodyNormalized: "/demo_skill first line\nsecond line", + skillCommands: [{ name: "demo_skill", skillName: "demo-skill", description: "Demo" }], + }); + expect(invocation?.command.name).toBe("demo_skill"); + expect(invocation?.args).toBe("first line\nsecond line"); + }); + it("normalizes /skill lookup names", () => { const invocation = resolveSkillCommandInvocation({ commandBodyNormalized: "/skill demo-skill",