fix: relax slash command parsing

This commit is contained in:
Peter Steinberger
2026-01-08 03:22:14 +01:00
parent 36b443f4f3
commit ad5c87c193
18 changed files with 226 additions and 31 deletions

View File

@@ -9,7 +9,12 @@ describe("control command parsing", () => {
hasCommand: true,
mode: "allow",
});
expect(parseSendPolicyCommand("/send: on")).toEqual({
hasCommand: true,
mode: "allow",
});
expect(parseSendPolicyCommand("/send")).toEqual({ hasCommand: true });
expect(parseSendPolicyCommand("/send:")).toEqual({ hasCommand: true });
expect(parseSendPolicyCommand("send on")).toEqual({ hasCommand: false });
expect(parseSendPolicyCommand("send")).toEqual({ hasCommand: false });
});
@@ -19,6 +24,13 @@ describe("control command parsing", () => {
hasCommand: true,
mode: "mention",
});
expect(parseActivationCommand("/activation: mention")).toEqual({
hasCommand: true,
mode: "mention",
});
expect(parseActivationCommand("/activation:")).toEqual({
hasCommand: true,
});
expect(parseActivationCommand("activation mention")).toEqual({
hasCommand: false,
});
@@ -28,8 +40,10 @@ describe("control command parsing", () => {
expect(hasControlCommand("/send")).toBe(true);
expect(hasControlCommand("send")).toBe(false);
expect(hasControlCommand("/help")).toBe(true);
expect(hasControlCommand("/help:")).toBe(true);
expect(hasControlCommand("help")).toBe(false);
expect(hasControlCommand("/status")).toBe(true);
expect(hasControlCommand("/status:")).toBe(true);
expect(hasControlCommand("status")).toBe(false);
});

View File

@@ -1,17 +1,19 @@
import { listChatCommands } from "./commands-registry.js";
import { listChatCommands, normalizeCommandBody } from "./commands-registry.js";
export function hasControlCommand(text?: string): boolean {
if (!text) return false;
const trimmed = text.trim();
if (!trimmed) return false;
const lowered = trimmed.toLowerCase();
const normalizedBody = normalizeCommandBody(trimmed);
if (!normalizedBody) return false;
const lowered = normalizedBody.toLowerCase();
for (const command of listChatCommands()) {
for (const alias of command.textAliases) {
const normalized = alias.trim().toLowerCase();
if (!normalized) continue;
if (lowered === normalized) return true;
if (command.acceptsArgs && lowered.startsWith(normalized)) {
const nextChar = trimmed.charAt(normalized.length);
const nextChar = normalizedBody.charAt(normalized.length);
if (nextChar && /\s/.test(nextChar)) return true;
}
}

View File

@@ -23,7 +23,9 @@ describe("commands registry", () => {
const detection = getCommandDetection();
expect(detection.exact.has("/help")).toBe(true);
expect(detection.regex.test("/status")).toBe(true);
expect(detection.regex.test("/status:")).toBe(true);
expect(detection.regex.test("/stop")).toBe(true);
expect(detection.regex.test("/send:")).toBe(true);
expect(detection.regex.test("try /status")).toBe(false);
});

View File

@@ -148,6 +148,16 @@ export function buildCommandText(commandName: string, args?: string): string {
return trimmedArgs ? `/${commandName} ${trimmedArgs}` : `/${commandName}`;
}
export function normalizeCommandBody(raw: string): string {
const trimmed = raw.trim();
if (!trimmed.startsWith("/")) return trimmed;
const match = trimmed.match(/^\/([^\s:]+)\s*:(.*)$/);
if (!match) return trimmed;
const [, command, rest] = match;
const normalizedRest = rest.trimStart();
return normalizedRest ? `/${command} ${normalizedRest}` : `/${command}`;
}
export function getCommandDetection(): { exact: Set<string>; regex: RegExp } {
if (cachedDetection) return cachedDetection;
const exact = new Set<string>();
@@ -160,9 +170,9 @@ export function getCommandDetection(): { exact: Set<string>; regex: RegExp } {
const escaped = escapeRegExp(normalized);
if (!escaped) continue;
if (command.acceptsArgs) {
patterns.push(`${escaped}(?:\\s+.+)?`);
patterns.push(`${escaped}(?:\\s+.+|\\s*:\\s*.*)?`);
} else {
patterns.push(escaped);
patterns.push(`${escaped}(?:\\s*:\\s*)?`);
}
}
}

View File

@@ -16,8 +16,11 @@ export function parseActivationCommand(raw?: string): {
if (!raw) return { hasCommand: false };
const trimmed = raw.trim();
if (!trimmed) return { hasCommand: false };
const match = trimmed.match(/^\/activation(?:\s+([a-zA-Z]+))?\s*$/i);
const match = trimmed.match(
/^\/activation(?:\s*:\s*([a-zA-Z]+)?\s*|\s+([a-zA-Z]+)\s*)?$/i,
);
if (!match) return { hasCommand: false };
const mode = normalizeGroupActivation(match[1]);
const token = match[1] ?? match[2];
const mode = normalizeGroupActivation(token);
return { hasCommand: true, mode };
}

View File

@@ -40,6 +40,15 @@ describe("extractModelDirective", () => {
expect(result.cleaned).toBe("");
});
it("recognizes /gpt: as model directive when alias is configured", () => {
const result = extractModelDirective("/gpt:", {
aliases: ["gpt", "sonnet", "opus"],
});
expect(result.hasDirective).toBe(true);
expect(result.rawModel).toBe("gpt");
expect(result.cleaned).toBe("");
});
it("recognizes /sonnet as model directive", () => {
const result = extractModelDirective("/sonnet", {
aliases: ["gpt", "sonnet", "opus"],

View File

@@ -25,7 +25,7 @@ export function extractModelDirective(
? null
: body.match(
new RegExp(
`(?:^|\\s)\\/(${aliases.map(escapeRegExp).join("|")})(?=$|\\s|:)`,
`(?:^|\\s)\\/(${aliases.map(escapeRegExp).join("|")})(?=$|\\s|:)(?:\\s*:\\s*)?`,
"i",
),
);

View File

@@ -143,6 +143,38 @@ describe("directive parsing", () => {
expect(res.thinkLevel).toBeUndefined();
});
it("matches think with no argument and consumes colon", () => {
const res = extractThinkDirective("/think:");
expect(res.hasDirective).toBe(true);
expect(res.thinkLevel).toBeUndefined();
expect(res.rawLevel).toBeUndefined();
expect(res.cleaned).toBe("");
});
it("matches verbose with no argument", () => {
const res = extractVerboseDirective("/verbose:");
expect(res.hasDirective).toBe(true);
expect(res.verboseLevel).toBeUndefined();
expect(res.rawLevel).toBeUndefined();
expect(res.cleaned).toBe("");
});
it("matches reasoning with no argument", () => {
const res = extractReasoningDirective("/reasoning:");
expect(res.hasDirective).toBe(true);
expect(res.reasoningLevel).toBeUndefined();
expect(res.rawLevel).toBeUndefined();
expect(res.cleaned).toBe("");
});
it("matches elevated with no argument", () => {
const res = extractElevatedDirective("/elevated:");
expect(res.hasDirective).toBe(true);
expect(res.elevatedLevel).toBeUndefined();
expect(res.rawLevel).toBeUndefined();
expect(res.cleaned).toBe("");
});
it("matches queue directive", () => {
const res = extractQueueDirective("please /queue interrupt now");
expect(res.hasDirective).toBe(true);
@@ -419,6 +451,84 @@ describe("directive parsing", () => {
});
});
it("shows current verbose level when /verbose has no argument", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const res = await getReplyFromConfig(
{ Body: "/verbose", From: "+1222", To: "+1222" },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
verboseDefault: "on",
},
session: { store: path.join(home, "sessions.json") },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Current verbose level: on");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("shows current reasoning level when /reasoning has no argument", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const res = await getReplyFromConfig(
{ Body: "/reasoning", From: "+1222", To: "+1222" },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
session: { store: path.join(home, "sessions.json") },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Current reasoning level: off");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("shows current elevated level when /elevated has no argument", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const res = await getReplyFromConfig(
{
Body: "/elevated",
From: "+1222",
To: "+1222",
Provider: "whatsapp",
SenderE164: "+1222",
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
elevatedDefault: "on",
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
},
whatsapp: { allowFrom: ["+1222"] },
session: { store: path.join(home, "sessions.json") },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Current elevated level: on");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("rejects invalid elevated level", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();

View File

@@ -472,6 +472,14 @@ export async function getReplyFromConfig(
const currentThinkLevel =
(sessionEntry?.thinkingLevel as ThinkLevel | undefined) ??
(agentCfg?.thinkingDefault as ThinkLevel | undefined);
const currentVerboseLevel =
(sessionEntry?.verboseLevel as VerboseLevel | undefined) ??
(agentCfg?.verboseDefault as VerboseLevel | undefined);
const currentReasoningLevel =
(sessionEntry?.reasoningLevel as ReasoningLevel | undefined) ?? "off";
const currentElevatedLevel =
(sessionEntry?.elevatedLevel as ElevatedLevel | undefined) ??
(agentCfg?.elevatedDefault as ElevatedLevel | undefined);
const directiveReply = await handleDirectiveOnly({
cfg,
directives,
@@ -492,6 +500,9 @@ export async function getReplyFromConfig(
initialModelLabel,
formatModelSwitchEvent,
currentThinkLevel,
currentVerboseLevel,
currentReasoningLevel,
currentElevatedLevel,
});
typing.cleanup();
return directiveReply;

View File

@@ -30,7 +30,10 @@ import { parseAgentSessionKey } from "../../routing/session-key.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { normalizeE164 } from "../../utils.js";
import { resolveCommandAuthorization } from "../command-auth.js";
import { shouldHandleTextCommands } from "../commands-registry.js";
import {
normalizeCommandBody,
shouldHandleTextCommands,
} from "../commands-registry.js";
import {
normalizeGroupActivation,
parseActivationCommand,
@@ -154,9 +157,9 @@ export function buildCommandContext(params: {
const abortKey =
sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined);
const rawBodyNormalized = triggerBodyNormalized;
const commandBodyNormalized = isGroup
? stripMentions(rawBodyNormalized, ctx, cfg)
: rawBodyNormalized;
const commandBodyNormalized = normalizeCommandBody(
isGroup ? stripMentions(rawBodyNormalized, ctx, cfg) : rawBodyNormalized,
);
return {
surface,

View File

@@ -310,6 +310,9 @@ export async function handleDirectiveOnly(params: {
initialModelLabel: string;
formatModelSwitchEvent: (label: string, alias?: string) => string;
currentThinkLevel?: ThinkLevel;
currentVerboseLevel?: VerboseLevel;
currentReasoningLevel?: ReasoningLevel;
currentElevatedLevel?: ElevatedLevel;
}): Promise<ReplyPayload | undefined> {
const {
directives,
@@ -328,6 +331,9 @@ export async function handleDirectiveOnly(params: {
initialModelLabel,
formatModelSwitchEvent,
currentThinkLevel,
currentVerboseLevel,
currentReasoningLevel,
currentElevatedLevel,
} = params;
if (directives.hasModelDirective) {
@@ -391,18 +397,33 @@ export async function handleDirectiveOnly(params: {
};
}
if (directives.hasVerboseDirective && !directives.verboseLevel) {
if (!directives.rawVerboseLevel) {
const level = currentVerboseLevel ?? "off";
return { text: `Current verbose level: ${level}.` };
}
return {
text: `Unrecognized verbose level "${directives.rawVerboseLevel ?? ""}". Valid levels: off, on.`,
text: `Unrecognized verbose level "${directives.rawVerboseLevel}". Valid levels: off, on.`,
};
}
if (directives.hasReasoningDirective && !directives.reasoningLevel) {
if (!directives.rawReasoningLevel) {
const level = currentReasoningLevel ?? "off";
return { text: `Current reasoning level: ${level}.` };
}
return {
text: `Unrecognized reasoning level "${directives.rawReasoningLevel ?? ""}". Valid levels: on, off, stream.`,
text: `Unrecognized reasoning level "${directives.rawReasoningLevel}". Valid levels: on, off, stream.`,
};
}
if (directives.hasElevatedDirective && !directives.elevatedLevel) {
if (!directives.rawElevatedLevel) {
if (!elevatedEnabled || !elevatedAllowed) {
return { text: "elevated is not available right now." };
}
const level = currentElevatedLevel ?? "off";
return { text: `Current elevated level: ${level}.` };
}
return {
text: `Unrecognized elevated level "${directives.rawElevatedLevel ?? ""}". Valid levels: off, on.`,
text: `Unrecognized elevated level "${directives.rawElevatedLevel}". Valid levels: off, on.`,
};
}
if (

View File

@@ -18,7 +18,7 @@ export function extractThinkDirective(body?: string): {
if (!body) return { cleaned: "", hasDirective: false };
// Match with optional argument - require word boundary via lookahead after keyword
const match = body.match(
/(?:^|\s)\/(?:thinking|think|t)(?=$|\s|:)(?:\s*:?\s*([a-zA-Z-]+)\b)?/i,
/(?:^|\s)\/(?:thinking|think|t)(?=$|\s|:)(?:\s*:?\s*(?:([a-zA-Z-]+)\b)?)?/i,
);
const thinkLevel = normalizeThinkLevel(match?.[1]);
const cleaned = match
@@ -40,7 +40,7 @@ export function extractVerboseDirective(body?: string): {
} {
if (!body) return { cleaned: "", hasDirective: false };
const match = body.match(
/(?:^|\s)\/(?:verbose|v)(?=$|\s|:)\s*:?\s*([a-zA-Z-]+)\b/i,
/(?:^|\s)\/(?:verbose|v)(?=$|\s|:)(?:\s*:?\s*(?:([a-zA-Z-]+)\b)?)?/i,
);
const verboseLevel = normalizeVerboseLevel(match?.[1]);
const cleaned = match
@@ -62,7 +62,7 @@ export function extractElevatedDirective(body?: string): {
} {
if (!body) return { cleaned: "", hasDirective: false };
const match = body.match(
/(?:^|\s)\/(?:elevated|elev)(?=$|\s|:)\s*:?\s*([a-zA-Z-]+)\b/i,
/(?:^|\s)\/(?:elevated|elev)(?=$|\s|:)(?:\s*:?\s*(?:([a-zA-Z-]+)\b)?)?/i,
);
const elevatedLevel = normalizeElevatedLevel(match?.[1]);
const cleaned = match
@@ -84,7 +84,7 @@ export function extractReasoningDirective(body?: string): {
} {
if (!body) return { cleaned: "", hasDirective: false };
const match = body.match(
/(?:^|\s)\/(?:reasoning|reason)(?=$|\s|:)\s*:?\s*([a-zA-Z-]+)\b/i,
/(?:^|\s)\/(?:reasoning|reason)(?=$|\s|:)(?:\s*:?\s*(?:([a-zA-Z-]+)\b)?)?/i,
);
const reasoningLevel = normalizeReasoningLevel(match?.[1]);
const cleaned = match
@@ -103,7 +103,7 @@ export function extractStatusDirective(body?: string): {
hasDirective: boolean;
} {
if (!body) return { cleaned: "", hasDirective: false };
const match = body.match(/(?:^|\s)\/status(?=$|\s|:)\b/i);
const match = body.match(/(?:^|\s)\/status(?=$|\s|:)(?:\s*:\s*)?/i);
const cleaned = match
? body.replace(match[0], "").replace(/\s+/g, " ").trim()
: body.trim();

View File

@@ -17,9 +17,11 @@ export function parseSendPolicyCommand(raw?: string): {
if (!raw) return { hasCommand: false };
const trimmed = raw.trim();
if (!trimmed) return { hasCommand: false };
const match = trimmed.match(/^\/send(?:\s+([a-zA-Z]+))?\s*$/i);
const match = trimmed.match(
/^\/send(?:\s*:\s*([a-zA-Z]+)?\s*|\s+([a-zA-Z]+)\s*)?$/i,
);
if (!match) return { hasCommand: false };
const token = match[1]?.trim().toLowerCase();
const token = (match[1] ?? match[2])?.trim().toLowerCase();
if (!token) return { hasCommand: true };
if (token === "inherit" || token === "default" || token === "reset") {
return { hasCommand: true, mode: "inherit" };