Files
openclaw/src/auto-reply/reply/commands-slash-parse.test.ts
infracore 5c614de29a fix(auto-reply): enforce word boundary in slash command prefix match (#84634)
`parseSlashCommandActionArgs` used a naive `startsWith` against the
configured slash prefix. When a skill name shares a prefix with a
built-in command (e.g. a skill named `config-check` vs the built-in
`/config`), the longer name was captured by the shorter built-in
handler and surfaced as an invalid action:

  ⚠️  /config is disabled. Set commands.config=true to enable.

Any skill whose name starts with a built-in command prefix
(`config-*`, `debug-*`, `models-*`, etc.) was unreachable via slash
invocation from any channel.

Fix: after the prefix match, require that the next character is
whitespace, a colon, or end-of-string. Otherwise the prefix
collided with a longer command name and we return `no-match` so the
longer handler — or the skill router — gets a chance to claim it.

Adds a regression test file `commands-slash-parse.test.ts` covering:
- `/config-check <args>` returns null (the reported case)
- `/configfoo` (no separator) returns null
- `/modelsy` returns null for the `/models` prefix
- `/config:json` still matches (colon is a valid boundary)
- `/config show enabled` still parses cleanly (whitespace boundary)
- empty body still returns the default action

Fixes #84572.

Co-authored-by: infracore <infracore@users.noreply.github.com>
2026-05-22 20:42:22 +01:00

54 lines
2.4 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { parseSlashCommandOrNull } from "./commands-slash-parse.js";
describe("parseSlashCommandOrNull", () => {
const opts = { invalidMessage: "invalid" };
it("returns null when the input doesn't start with the slash prefix", () => {
expect(parseSlashCommandOrNull("hello world", "/config", opts)).toBeNull();
});
it("parses action + args when the input has a clean word boundary", () => {
const result = parseSlashCommandOrNull("/config show enabled", "/config", opts);
expect(result).toEqual({ ok: true, action: "show", args: "enabled" });
});
it("returns the default action on an empty body", () => {
const result = parseSlashCommandOrNull("/config", "/config", { ...opts, defaultAction: "show" });
expect(result).toEqual({ ok: true, action: "show", args: "" });
});
describe("regression: #84572 — prefix match must require a word boundary", () => {
// Previously, `/config-check <args>` matched the `/config` handler
// via a naive `startsWith` and surfaced as an invalid action, blocking
// any skill whose name shared a prefix with a built-in command.
it("does not match a longer command name with a hyphen tail (`/config-check`)", () => {
expect(parseSlashCommandOrNull("/config-check arg1 arg2", "/config", opts)).toBeNull();
});
it("does not match a longer command name with no whitespace after prefix", () => {
expect(parseSlashCommandOrNull("/configfoo", "/config", opts)).toBeNull();
});
it("does not match when prefix sits in the middle of a longer word", () => {
// /modelsy should not be captured by /models
expect(parseSlashCommandOrNull("/modelsy", "/models", opts)).toBeNull();
});
it("still matches when the boundary is a colon (`/config:json`)", () => {
// Some clients allow `cmd:subkey` to pass through to the action parser
// when there's no whitespace — the boundary character is still a
// separator and not an alpha continuation.
const result = parseSlashCommandOrNull("/config:json", "/config", opts);
expect(result).not.toBeNull();
expect(result?.ok).toBe(true);
});
it("still matches the exact prefix with leading whitespace", () => {
const result = parseSlashCommandOrNull(" /config show ", "/config", opts);
expect(result).toEqual({ ok: true, action: "show", args: "" });
});
});
});