mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-09 03:22:40 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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*)?`);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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-");
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user