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>
This commit is contained in:
Vincent Koc
2026-06-16 22:47:15 +08:00
committed by GitHub
parent e48222175f
commit acc375ff75
9 changed files with 149 additions and 13 deletions

View File

@@ -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.

View File

@@ -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<string, TextAliasSpec> {
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*)?`);
}

View File

@@ -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 },

View File

@@ -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",

View File

@@ -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");

View File

@@ -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");
});
});

View File

@@ -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. */

View File

@@ -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-");

View File

@@ -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",