Merge branch 'main' into commands-list-clean
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasControlCommand } from "./command-detection.js";
|
||||
import { listChatCommands } from "./commands-registry.js";
|
||||
import { parseActivationCommand } from "./group-activation.js";
|
||||
import { parseSendPolicyCommand } from "./send-policy.js";
|
||||
|
||||
@@ -37,17 +38,20 @@ describe("control command parsing", () => {
|
||||
});
|
||||
|
||||
it("treats bare commands as non-control", () => {
|
||||
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("/commands")).toBe(true);
|
||||
expect(hasControlCommand("/commands:")).toBe(true);
|
||||
expect(hasControlCommand("commands")).toBe(false);
|
||||
expect(hasControlCommand("/status")).toBe(true);
|
||||
expect(hasControlCommand("/status:")).toBe(true);
|
||||
expect(hasControlCommand("status")).toBe(false);
|
||||
expect(hasControlCommand("usage")).toBe(false);
|
||||
|
||||
for (const command of listChatCommands()) {
|
||||
for (const alias of command.textAliases) {
|
||||
expect(hasControlCommand(alias)).toBe(true);
|
||||
expect(hasControlCommand(`${alias}:`)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("requires commands to be the full message", () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildCommandText,
|
||||
getCommandDetection,
|
||||
listChatCommands,
|
||||
listNativeCommandSpecs,
|
||||
shouldHandleTextCommands,
|
||||
} from "./commands-registry.js";
|
||||
@@ -21,15 +22,21 @@ describe("commands registry", () => {
|
||||
|
||||
it("detects known text commands", () => {
|
||||
const detection = getCommandDetection();
|
||||
expect(detection.exact.has("/help")).toBe(true);
|
||||
expect(detection.exact.has("/commands")).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("/debug set foo=bar")).toBe(true);
|
||||
expect(detection.regex.test("/models")).toBe(true);
|
||||
expect(detection.regex.test("/models list")).toBe(true);
|
||||
for (const command of listChatCommands()) {
|
||||
for (const alias of command.textAliases) {
|
||||
expect(detection.exact.has(alias.toLowerCase())).toBe(true);
|
||||
expect(detection.regex.test(alias)).toBe(true);
|
||||
expect(detection.regex.test(`${alias}:`)).toBe(true);
|
||||
|
||||
if (command.acceptsArgs) {
|
||||
expect(detection.regex.test(`${alias} list`)).toBe(true);
|
||||
expect(detection.regex.test(`${alias}: list`)).toBe(true);
|
||||
} else {
|
||||
expect(detection.regex.test(`${alias} list`)).toBe(false);
|
||||
expect(detection.regex.test(`${alias}: list`)).toBe(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(detection.regex.test("try /status")).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -14,123 +14,186 @@ export type NativeCommandSpec = {
|
||||
acceptsArgs: boolean;
|
||||
};
|
||||
|
||||
const CHAT_COMMANDS: ChatCommandDefinition[] = [
|
||||
{
|
||||
key: "help",
|
||||
nativeName: "help",
|
||||
description: "Show available commands.",
|
||||
textAliases: ["/help"],
|
||||
},
|
||||
{
|
||||
key: "commands",
|
||||
nativeName: "commands",
|
||||
description: "List all slash commands.",
|
||||
textAliases: ["/commands"],
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
nativeName: "status",
|
||||
description: "Show current status.",
|
||||
textAliases: ["/status"],
|
||||
},
|
||||
{
|
||||
key: "debug",
|
||||
nativeName: "debug",
|
||||
description: "Set runtime debug overrides.",
|
||||
textAliases: ["/debug"],
|
||||
acceptsArgs: true,
|
||||
},
|
||||
{
|
||||
key: "cost",
|
||||
nativeName: "cost",
|
||||
description: "Toggle per-response usage line.",
|
||||
textAliases: ["/cost"],
|
||||
acceptsArgs: true,
|
||||
},
|
||||
{
|
||||
key: "stop",
|
||||
nativeName: "stop",
|
||||
description: "Stop the current run.",
|
||||
textAliases: ["/stop"],
|
||||
},
|
||||
{
|
||||
key: "restart",
|
||||
nativeName: "restart",
|
||||
description: "Restart Clawdbot.",
|
||||
textAliases: ["/restart"],
|
||||
},
|
||||
{
|
||||
key: "activation",
|
||||
nativeName: "activation",
|
||||
description: "Set group activation mode.",
|
||||
textAliases: ["/activation"],
|
||||
acceptsArgs: true,
|
||||
},
|
||||
{
|
||||
key: "send",
|
||||
nativeName: "send",
|
||||
description: "Set send policy.",
|
||||
textAliases: ["/send"],
|
||||
acceptsArgs: true,
|
||||
},
|
||||
{
|
||||
key: "reset",
|
||||
nativeName: "reset",
|
||||
description: "Reset the current session.",
|
||||
textAliases: ["/reset"],
|
||||
},
|
||||
{
|
||||
key: "new",
|
||||
nativeName: "new",
|
||||
description: "Start a new session.",
|
||||
textAliases: ["/new"],
|
||||
},
|
||||
{
|
||||
key: "think",
|
||||
nativeName: "think",
|
||||
description: "Set thinking level.",
|
||||
textAliases: ["/thinking", "/think", "/t"],
|
||||
acceptsArgs: true,
|
||||
},
|
||||
{
|
||||
key: "verbose",
|
||||
nativeName: "verbose",
|
||||
description: "Toggle verbose mode.",
|
||||
textAliases: ["/verbose", "/v"],
|
||||
acceptsArgs: true,
|
||||
},
|
||||
{
|
||||
key: "reasoning",
|
||||
nativeName: "reasoning",
|
||||
description: "Toggle reasoning visibility.",
|
||||
textAliases: ["/reasoning", "/reason"],
|
||||
acceptsArgs: true,
|
||||
},
|
||||
{
|
||||
key: "elevated",
|
||||
nativeName: "elevated",
|
||||
description: "Toggle elevated mode.",
|
||||
textAliases: ["/elevated", "/elev"],
|
||||
acceptsArgs: true,
|
||||
},
|
||||
{
|
||||
key: "model",
|
||||
nativeName: "model",
|
||||
description: "Show or set the model.",
|
||||
textAliases: ["/model", "/models"],
|
||||
acceptsArgs: true,
|
||||
},
|
||||
{
|
||||
key: "queue",
|
||||
nativeName: "queue",
|
||||
description: "Adjust queue settings.",
|
||||
textAliases: ["/queue"],
|
||||
acceptsArgs: true,
|
||||
},
|
||||
];
|
||||
function defineChatCommand(
|
||||
command: Omit<ChatCommandDefinition, "textAliases"> & { textAlias: string },
|
||||
): ChatCommandDefinition {
|
||||
return {
|
||||
key: command.key,
|
||||
nativeName: command.nativeName,
|
||||
description: command.description,
|
||||
acceptsArgs: command.acceptsArgs,
|
||||
textAliases: [command.textAlias],
|
||||
};
|
||||
}
|
||||
|
||||
function registerAlias(
|
||||
commands: ChatCommandDefinition[],
|
||||
key: string,
|
||||
...aliases: string[]
|
||||
): void {
|
||||
const command = commands.find((entry) => entry.key === key);
|
||||
if (!command) {
|
||||
throw new Error(`registerAlias: unknown command key: ${key}`);
|
||||
}
|
||||
const existing = new Set(command.textAliases.map((alias) => alias.trim()));
|
||||
for (const alias of aliases) {
|
||||
const trimmed = alias.trim();
|
||||
if (!trimmed) continue;
|
||||
if (existing.has(trimmed)) continue;
|
||||
existing.add(trimmed);
|
||||
command.textAliases.push(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
|
||||
const commands: ChatCommandDefinition[] = [
|
||||
defineChatCommand({
|
||||
key: "help",
|
||||
nativeName: "help",
|
||||
description: "Show available commands.",
|
||||
textAlias: "/help",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "commands",
|
||||
nativeName: "commands",
|
||||
description: "List all slash commands.",
|
||||
textAlias: "/commands",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "status",
|
||||
nativeName: "status",
|
||||
description: "Show current status.",
|
||||
textAlias: "/status",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "debug",
|
||||
nativeName: "debug",
|
||||
description: "Set runtime debug overrides.",
|
||||
textAlias: "/debug",
|
||||
acceptsArgs: true,
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "cost",
|
||||
nativeName: "cost",
|
||||
description: "Toggle per-response usage line.",
|
||||
textAlias: "/cost",
|
||||
acceptsArgs: true,
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "stop",
|
||||
nativeName: "stop",
|
||||
description: "Stop the current run.",
|
||||
textAlias: "/stop",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "restart",
|
||||
nativeName: "restart",
|
||||
description: "Restart Clawdbot.",
|
||||
textAlias: "/restart",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "activation",
|
||||
nativeName: "activation",
|
||||
description: "Set group activation mode.",
|
||||
textAlias: "/activation",
|
||||
acceptsArgs: true,
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "send",
|
||||
nativeName: "send",
|
||||
description: "Set send policy.",
|
||||
textAlias: "/send",
|
||||
acceptsArgs: true,
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "reset",
|
||||
nativeName: "reset",
|
||||
description: "Reset the current session.",
|
||||
textAlias: "/reset",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "new",
|
||||
nativeName: "new",
|
||||
description: "Start a new session.",
|
||||
textAlias: "/new",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "think",
|
||||
nativeName: "think",
|
||||
description: "Set thinking level.",
|
||||
textAlias: "/think",
|
||||
acceptsArgs: true,
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "verbose",
|
||||
nativeName: "verbose",
|
||||
description: "Toggle verbose mode.",
|
||||
textAlias: "/verbose",
|
||||
acceptsArgs: true,
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "reasoning",
|
||||
nativeName: "reasoning",
|
||||
description: "Toggle reasoning visibility.",
|
||||
textAlias: "/reasoning",
|
||||
acceptsArgs: true,
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "elevated",
|
||||
nativeName: "elevated",
|
||||
description: "Toggle elevated mode.",
|
||||
textAlias: "/elevated",
|
||||
acceptsArgs: true,
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "model",
|
||||
nativeName: "model",
|
||||
description: "Show or set the model.",
|
||||
textAlias: "/model",
|
||||
acceptsArgs: true,
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "queue",
|
||||
nativeName: "queue",
|
||||
description: "Adjust queue settings.",
|
||||
textAlias: "/queue",
|
||||
acceptsArgs: true,
|
||||
}),
|
||||
];
|
||||
|
||||
registerAlias(commands, "status", "/usage");
|
||||
registerAlias(commands, "think", "/thinking", "/t");
|
||||
registerAlias(commands, "verbose", "/v");
|
||||
registerAlias(commands, "reasoning", "/reason");
|
||||
registerAlias(commands, "elevated", "/elev");
|
||||
registerAlias(commands, "model", "/models");
|
||||
|
||||
return commands;
|
||||
})();
|
||||
|
||||
const NATIVE_COMMAND_SURFACES = new Set(["discord", "slack", "telegram"]);
|
||||
|
||||
type TextAliasSpec = {
|
||||
canonical: string;
|
||||
acceptsArgs: boolean;
|
||||
};
|
||||
|
||||
const TEXT_ALIAS_MAP: Map<string, TextAliasSpec> = (() => {
|
||||
const map = new Map<string, TextAliasSpec>();
|
||||
for (const command of CHAT_COMMANDS) {
|
||||
const canonical = `/${command.key}`;
|
||||
const acceptsArgs = Boolean(command.acceptsArgs);
|
||||
for (const alias of command.textAliases) {
|
||||
const normalized = alias.trim().toLowerCase();
|
||||
if (!normalized) continue;
|
||||
if (!map.has(normalized)) {
|
||||
map.set(normalized, { canonical, acceptsArgs });
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
})();
|
||||
|
||||
let cachedDetection:
|
||||
| {
|
||||
exact: Set<string>;
|
||||
@@ -171,11 +234,31 @@ export function buildCommandText(commandName: string, args?: string): string {
|
||||
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}`;
|
||||
|
||||
const colonMatch = trimmed.match(/^\/([^\s:]+)\s*:(.*)$/);
|
||||
const normalized = colonMatch
|
||||
? (() => {
|
||||
const [, command, rest] = colonMatch;
|
||||
const normalizedRest = rest.trimStart();
|
||||
return normalizedRest ? `/${command} ${normalizedRest}` : `/${command}`;
|
||||
})()
|
||||
: trimmed;
|
||||
|
||||
const lowered = normalized.toLowerCase();
|
||||
const exact = TEXT_ALIAS_MAP.get(lowered);
|
||||
if (exact) return exact.canonical;
|
||||
|
||||
const tokenMatch = normalized.match(/^\/([^\s]+)(?:\s+([\s\S]+))?$/);
|
||||
if (!tokenMatch) return normalized;
|
||||
const [, token, rest] = tokenMatch;
|
||||
const tokenKey = `/${token.toLowerCase()}`;
|
||||
const tokenSpec = TEXT_ALIAS_MAP.get(tokenKey);
|
||||
if (!tokenSpec) return normalized;
|
||||
if (rest && !tokenSpec.acceptsArgs) return normalized;
|
||||
const normalizedRest = rest?.trimStart();
|
||||
return normalizedRest
|
||||
? `${tokenSpec.canonical} ${normalizedRest}`
|
||||
: tokenSpec.canonical;
|
||||
}
|
||||
|
||||
export function getCommandDetection(): { exact: Set<string>; regex: RegExp } {
|
||||
|
||||
@@ -144,6 +144,12 @@ describe("directive parsing", () => {
|
||||
expect(res.cleaned).toBe("thats not /tmp/hello");
|
||||
});
|
||||
|
||||
it("preserves spacing when stripping usage directives before paths", () => {
|
||||
const res = extractStatusDirective("thats not /usage:/tmp/hello");
|
||||
expect(res.hasDirective).toBe(true);
|
||||
expect(res.cleaned).toBe("thats not /tmp/hello");
|
||||
});
|
||||
|
||||
it("parses queue options and modes", () => {
|
||||
const res = extractQueueDirective(
|
||||
"please /queue steer+backlog debounce:2s cap:5 drop:summarize now",
|
||||
|
||||
@@ -249,6 +249,42 @@ describe("directive behavior", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("strips reply tags with whitespace and maps reply_to_current to MessageSid", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "hello [[ reply_to_current ]]" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "ping",
|
||||
From: "+1004",
|
||||
To: "+2000",
|
||||
MessageSid: "msg-123",
|
||||
},
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
},
|
||||
);
|
||||
|
||||
const payload = Array.isArray(res) ? res[0] : res;
|
||||
expect(payload?.text).toBe("hello");
|
||||
expect(payload?.replyToId).toBe("msg-123");
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers explicit reply_to id over reply_to_current", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
|
||||
@@ -242,6 +242,23 @@ describe("trigger handling", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reports status via /usage without invoking the agent", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/usage",
|
||||
From: "+1002",
|
||||
To: "+2000",
|
||||
},
|
||||
{},
|
||||
makeCfg(home),
|
||||
);
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("ClawdBot");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("reports active auth profile and key snippet in status", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const cfg = makeCfg(home);
|
||||
@@ -1240,6 +1257,7 @@ describe("trigger handling", () => {
|
||||
vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
|
||||
expect(prompt).toContain("Give me the status");
|
||||
expect(prompt).not.toContain("/thinking high");
|
||||
expect(prompt).not.toContain("/think high");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ export function extractStatusDirective(body?: string): {
|
||||
hasDirective: boolean;
|
||||
} {
|
||||
if (!body) return { cleaned: "", hasDirective: false };
|
||||
return extractSimpleDirective(body, ["status"]);
|
||||
return extractSimpleDirective(body, ["status", "usage"]);
|
||||
}
|
||||
|
||||
export type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel };
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
const REPLY_TAG_RE =
|
||||
/\[\[\s*(?:reply_to_current|reply_to\s*:\s*([^\]\n]+))\s*\]\]/gi;
|
||||
|
||||
function normalizeReplyText(text: string) {
|
||||
return text
|
||||
.replace(/[ \t]+/g, " ")
|
||||
.replace(/[ \t]*\n[ \t]*/g, "\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function extractReplyToTag(
|
||||
text?: string,
|
||||
currentMessageId?: string,
|
||||
@@ -7,29 +17,28 @@ export function extractReplyToTag(
|
||||
hasTag: boolean;
|
||||
} {
|
||||
if (!text) return { cleaned: "", hasTag: false };
|
||||
let cleaned = text;
|
||||
let replyToId: string | undefined;
|
||||
|
||||
let sawCurrent = false;
|
||||
let lastExplicitId: string | undefined;
|
||||
let hasTag = false;
|
||||
|
||||
const currentMatch = cleaned.match(/\[\[\s*reply_to_current\s*\]\]/i);
|
||||
if (currentMatch) {
|
||||
cleaned = cleaned.replace(/\[\[\s*reply_to_current\s*\]\]/gi, " ");
|
||||
hasTag = true;
|
||||
if (currentMessageId?.trim()) {
|
||||
replyToId = currentMessageId.trim();
|
||||
}
|
||||
}
|
||||
const cleaned = normalizeReplyText(
|
||||
text.replace(REPLY_TAG_RE, (_full, idRaw: string | undefined) => {
|
||||
hasTag = true;
|
||||
if (idRaw === undefined) {
|
||||
sawCurrent = true;
|
||||
return " ";
|
||||
}
|
||||
|
||||
const idMatch = cleaned.match(/\[\[\s*reply_to\s*:\s*([^\]\n]+)\s*\]\]/i);
|
||||
if (idMatch?.[1]) {
|
||||
cleaned = cleaned.replace(/\[\[\s*reply_to\s*:\s*[^\]\n]+\s*\]\]/gi, " ");
|
||||
replyToId = idMatch[1].trim();
|
||||
hasTag = true;
|
||||
}
|
||||
const id = idRaw.trim();
|
||||
if (id) lastExplicitId = id;
|
||||
return " ";
|
||||
}),
|
||||
);
|
||||
|
||||
const replyToId =
|
||||
lastExplicitId ??
|
||||
(sawCurrent ? currentMessageId?.trim() || undefined : undefined);
|
||||
|
||||
cleaned = cleaned
|
||||
.replace(/[ \t]+/g, " ")
|
||||
.replace(/[ \t]*\n[ \t]*/g, "\n")
|
||||
.trim();
|
||||
return { cleaned, replyToId, hasTag };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user