Skip to content

Commit acc375f

Browse files
vincentkocopenclaw-clownfish[bot]web3blind
authored
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 <[email protected]>
1 parent e482221 commit acc375f

9 files changed

Lines changed: 149 additions & 13 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai
2626
- 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.
2727
- 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.
2828
- 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.
29+
- 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.
2930
- 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.
3031
- 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.
3132
- 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.

src/auto-reply/commands-registry-normalize.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,20 @@ let cachedTextAliasCommands: ChatCommandDefinition[] | null = null;
2424
let cachedDetection: CommandDetection | undefined;
2525
let cachedDetectionCommands: ChatCommandDefinition[] | null = null;
2626

27+
function appendMultilineTail(head: string, tail: string | undefined, spec?: TextAliasSpec): string {
28+
if (!tail) {
29+
return head;
30+
}
31+
if (!spec || spec.key === "skill") {
32+
return `${head}\n${tail}`;
33+
}
34+
if (spec.key === "reset") {
35+
const flattened = tail.replace(/\s+/g, " ").trim();
36+
return flattened ? `${head} ${flattened}` : head;
37+
}
38+
return head;
39+
}
40+
2741
function getTextAliasMap(): Map<string, TextAliasSpec> {
2842
const commands = getChatCommands();
2943
if (cachedTextAliasMap && cachedTextAliasCommands === commands) {
@@ -59,6 +73,7 @@ export function normalizeCommandBody(raw: string, options?: CommandNormalizeOpti
5973

6074
const newline = trimmed.indexOf("\n");
6175
const singleLine = newline === -1 ? trimmed : trimmed.slice(0, newline).trim();
76+
const multilineTail = newline === -1 ? undefined : trimmed.slice(newline + 1).trimStart();
6277

6378
// `/cmd: value` is accepted as `/cmd value` because some channels insert colon syntax.
6479
const colonMatch = singleLine.match(/^\/([^\s:]+)\s*:(.*)$/);
@@ -83,24 +98,27 @@ export function normalizeCommandBody(raw: string, options?: CommandNormalizeOpti
8398
const textAliasMap = getTextAliasMap();
8499
const exact = textAliasMap.get(lowered);
85100
if (exact) {
86-
return exact.canonical;
101+
return appendMultilineTail(exact.canonical, multilineTail, exact);
87102
}
88103

89104
const tokenMatch = commandBody.match(/^\/([^\s]+)(?:\s+([\s\S]+))?$/);
90105
if (!tokenMatch) {
91-
return commandBody;
106+
return appendMultilineTail(commandBody, multilineTail);
92107
}
93108
const [, token, rest] = tokenMatch;
94109
const tokenKey = `/${normalizeLowercaseStringOrEmpty(token)}`;
95110
const tokenSpec = textAliasMap.get(tokenKey);
96111
if (!tokenSpec) {
97-
return commandBody;
112+
return appendMultilineTail(commandBody, multilineTail);
98113
}
99114
if (rest && !tokenSpec.acceptsArgs) {
100115
return commandBody;
101116
}
102117
const normalizedRest = rest?.trimStart();
103-
return normalizedRest ? `${tokenSpec.canonical} ${normalizedRest}` : tokenSpec.canonical;
118+
const normalizedHead = normalizedRest
119+
? `${tokenSpec.canonical} ${normalizedRest}`
120+
: tokenSpec.canonical;
121+
return appendMultilineTail(normalizedHead, multilineTail, tokenSpec);
104122
}
105123

106124
/** Returns cached exact and regex detectors for the current command registry instance. */
@@ -123,7 +141,7 @@ export function getCommandDetection(_cfg?: OpenClawConfig): CommandDetection {
123141
continue;
124142
}
125143
if (cmd.acceptsArgs) {
126-
patterns.push(`${escaped}(?:\\s+.+|\\s*:\\s*.*)?`);
144+
patterns.push(`${escaped}(?:\\s+[\\s\\S]+|\\s*:\\s*[\\s\\S]*)?`);
127145
} else {
128146
patterns.push(`${escaped}(?:\\s*:\\s*)?`);
129147
}

src/auto-reply/commands-registry.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,31 @@ describe("commands registry", () => {
246246
);
247247
});
248248

249+
it("preserves multiline payloads for skill slash commands", () => {
250+
expect(normalizeCommandBody("/skill demo_skill first line\nsecond line")).toBe(
251+
"/skill demo_skill first line\nsecond line",
252+
);
253+
expect(
254+
normalizeCommandBody("/skill@openclaw: demo_skill first line\nsecond line", {
255+
botUsername: "openclaw",
256+
}),
257+
).toBe("/skill demo_skill first line\nsecond line");
258+
expect(resolveTextCommand("/skill demo_skill first line\nsecond line")?.args).toBe(
259+
"demo_skill first line\nsecond line",
260+
);
261+
});
262+
263+
it("preserves multiline payloads for direct skill slash aliases only when unregistered", () => {
264+
expect(normalizeCommandBody("/demo_skill first line\nsecond line")).toBe(
265+
"/demo_skill first line\nsecond line",
266+
);
267+
expect(normalizeCommandBody("/reset soft\nre-read persona files")).toBe(
268+
"/reset soft re-read persona files",
269+
);
270+
expect(normalizeCommandBody("/side first line\nsecond line")).toBe("/btw first line");
271+
expect(normalizeCommandBody("/id\nignored")).toBe("/whoami");
272+
});
273+
249274
it("filters commands based on config flags", () => {
250275
const disabled = listChatCommandsForConfig({
251276
commands: { config: false, plugins: false, debug: false },

src/auto-reply/reply/commands-context.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,30 @@ describe("buildCommandContext", () => {
5252
expect(result.commandBodyNormalized).toBe("/reset soft re-read persona files");
5353
});
5454

55+
it("preserves multiline slash skill payloads after structural normalization", () => {
56+
const body = "/skill demo_skill first line\nsecond line";
57+
const ctx = buildTestCtx({
58+
Provider: "whatsapp",
59+
Surface: "whatsapp",
60+
From: "user",
61+
To: "bot",
62+
Body: body,
63+
RawBody: body,
64+
CommandBody: body,
65+
BodyForCommands: body,
66+
});
67+
68+
const result = buildCommandContext({
69+
ctx,
70+
cfg: {} as OpenClawConfig,
71+
isGroup: false,
72+
triggerBodyNormalized: stripStructuralPrefixes(body),
73+
commandAuthorized: true,
74+
});
75+
76+
expect(result.commandBodyNormalized).toBe("/skill demo_skill first line\nsecond line");
77+
});
78+
5579
it("maps explicit gateway origin into command context", () => {
5680
const ctx = buildTestCtx({
5781
Provider: "internal",

src/auto-reply/reply/get-reply.fast-path.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,24 @@ describe("getReplyFromConfig fast test bootstrap", () => {
687687
expect(command.to).toBe("user:U123");
688688
});
689689

690+
it("preserves multiline slash skill payloads in fast command context", () => {
691+
const body = "/skill demo_skill first line\nsecond line";
692+
const command = buildFastReplyCommandContext({
693+
ctx: buildGetReplyCtx({
694+
Body: body,
695+
RawBody: body,
696+
CommandBody: body,
697+
}),
698+
cfg: {} as OpenClawConfig,
699+
sessionKey: "main",
700+
isGroup: false,
701+
triggerBodyNormalized: body,
702+
commandAuthorized: true,
703+
});
704+
705+
expect(command.commandBodyNormalized).toBe("/skill demo_skill first line\nsecond line");
706+
});
707+
690708
it("keeps the existing session for /reset newline soft during fast bootstrap", async () => {
691709
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fast-reset-newline-soft-"));
692710
const storePath = path.join(home, "sessions.json");

src/auto-reply/reply/mentions.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,11 @@ describe("stripStructuralPrefixes", () => {
4242
expect(stripStructuralPrefixes("just a message")).toBe("just a message");
4343
});
4444

45-
it("flattens multiline soft reset commands before downstream parsing", () => {
45+
it("preserves real line breaks in slash commands for downstream command parsing", () => {
4646
expect(stripStructuralPrefixes("/reset soft\nre-read persona files")).toBe(
47-
"/reset soft re-read persona files",
47+
"/reset soft\nre-read persona files",
4848
);
49-
expect(stripStructuralPrefixes("/reset \nsoft")).toBe("/reset soft");
49+
expect(stripStructuralPrefixes("/skill demo\nline two")).toBe("/skill demo\nline two");
50+
expect(stripStructuralPrefixes("/reset \\nsoft")).toBe("/reset soft");
5051
});
5152
});

src/auto-reply/reply/mentions.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -196,11 +196,11 @@ export function stripStructuralPrefixes(text: string): string {
196196
? /^[ \t]*(?!\/)[^\n:]{1,120}:\s+/gm
197197
: /^[ \t]*[^\n:]{1,120}:\s+/gm;
198198

199-
return afterEnvelope
200-
.replace(senderPrefixPattern, "")
201-
.replace(/\\n/g, " ")
202-
.replace(/\s+/g, " ")
203-
.trim();
199+
const stripped = afterEnvelope.replace(senderPrefixPattern, "").replace(/\\n/g, " ").trim();
200+
if (stripped.startsWith("/")) {
201+
return stripped.replace(/[ \t]+/g, " ");
202+
}
203+
return stripped.replace(/\s+/g, " ");
204204
}
205205

206206
/** Removes bot mentions from command text before command normalization. */

src/auto-reply/reply/session.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2079,6 +2079,37 @@ describe("initSessionState reset policy", () => {
20792079
});
20802080
});
20812081

2082+
it("keeps multiline slash skill payloads on the current session", async () => {
2083+
const root = await makeCaseDir("openclaw-skill-multiline-session-");
2084+
const storePath = path.join(root, "sessions.json");
2085+
const sessionKey = "agent:main:whatsapp:dm:skill-multiline";
2086+
const existingSessionId = "skill-multiline-session-id";
2087+
const body = "/skill demo_skill first line\nsecond line";
2088+
2089+
await writeSessionStoreFast(storePath, {
2090+
[sessionKey]: {
2091+
sessionId: existingSessionId,
2092+
updatedAt: Date.now(),
2093+
},
2094+
});
2095+
2096+
const result = await initSessionState({
2097+
ctx: {
2098+
Body: body,
2099+
RawBody: body,
2100+
CommandBody: body,
2101+
SessionKey: sessionKey,
2102+
},
2103+
cfg: { session: { store: storePath } } as OpenClawConfig,
2104+
commandAuthorized: true,
2105+
});
2106+
2107+
expect(result.resetTriggered).toBe(false);
2108+
expect(result.isNewSession).toBe(false);
2109+
expect(result.sessionId).toBe(existingSessionId);
2110+
expect(result.triggerBodyNormalized).toBe(body);
2111+
});
2112+
20822113
it("does not preserve a stale session for unauthorized /reset soft", async () => {
20832114
vi.setSystemTime(new Date(2026, 0, 18, 5, 30, 0));
20842115
const root = await makeCaseDir("openclaw-reset-soft-stale-unauthorized-");

src/skills/discovery/chat-commands.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,24 @@ describe("resolveSkillCommandInvocation", () => {
190190
expect(invocation?.args).toBe("do the thing");
191191
});
192192

193+
it("preserves multiline args for /skill invocations", () => {
194+
const invocation = resolveSkillCommandInvocation({
195+
commandBodyNormalized: "/skill demo_skill first line\nsecond line",
196+
skillCommands: [{ name: "demo_skill", skillName: "demo-skill", description: "Demo" }],
197+
});
198+
expect(invocation?.command.name).toBe("demo_skill");
199+
expect(invocation?.args).toBe("first line\nsecond line");
200+
});
201+
202+
it("preserves multiline args for direct skill slash invocations", () => {
203+
const invocation = resolveSkillCommandInvocation({
204+
commandBodyNormalized: "/demo_skill first line\nsecond line",
205+
skillCommands: [{ name: "demo_skill", skillName: "demo-skill", description: "Demo" }],
206+
});
207+
expect(invocation?.command.name).toBe("demo_skill");
208+
expect(invocation?.args).toBe("first line\nsecond line");
209+
});
210+
193211
it("normalizes /skill lookup names", () => {
194212
const invocation = resolveSkillCommandInvocation({
195213
commandBodyNormalized: "/skill demo-skill",

0 commit comments

Comments
 (0)