fix: fast-path slash commands
This commit is contained in:
@@ -73,6 +73,7 @@
|
||||
- Providers: skip DM history limit handling for non-DM sessions. (#728) — thanks @pkrmf.
|
||||
- Sandbox: fix non-main mode incorrectly sandboxing the main DM session and align `/status` runtime reporting with effective sandbox state.
|
||||
- Sandbox/Gateway: treat `agent:<id>:main` as a main-session alias when `session.mainKey` is customized (backwards compatible).
|
||||
- Auto-reply: fast-path allowlisted slash commands (inline `/help`/`/commands`/`/status`/`/whoami` stripped before model).
|
||||
|
||||
## 2026.1.10
|
||||
|
||||
|
||||
@@ -68,6 +68,9 @@ Notes:
|
||||
- `/restart` is disabled by default; set `commands.restart: true` to enable it.
|
||||
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
|
||||
- `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats.
|
||||
- **Fast path:** command-only messages from allowlisted senders are handled immediately (bypass queue + model).
|
||||
- **Inline shortcuts:** `/help`, `/commands`, `/status` (`/usage`), `/whoami` (`/id`) are also parsed when embedded in text. They run immediately, are stripped before the model sees the message, and the remaining text continues through the normal flow.
|
||||
- Unauthorized command-only messages are silently ignored.
|
||||
|
||||
## Debug overrides
|
||||
|
||||
|
||||
@@ -520,7 +520,7 @@ describe("trigger handling", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores inline /status and runs the agent", async () => {
|
||||
it("handles inline /status and still runs the agent", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
@@ -529,21 +529,132 @@ describe("trigger handling", () => {
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
const blockReplies: Array<{ text?: string }> = [];
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "please /status now",
|
||||
From: "+1002",
|
||||
To: "+2000",
|
||||
},
|
||||
{},
|
||||
{
|
||||
onBlockReply: async (payload) => {
|
||||
blockReplies.push(payload);
|
||||
},
|
||||
},
|
||||
makeCfg(home),
|
||||
);
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).not.toContain("Status");
|
||||
expect(blockReplies.length).toBe(1);
|
||||
expect(blockReplies[0]?.text).toBeTruthy();
|
||||
expect(runEmbeddedPiAgent).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("handles inline /help and strips it before the agent", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: {
|
||||
durationMs: 1,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
const blockReplies: Array<{ text?: string }> = [];
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "please /help now",
|
||||
From: "+1002",
|
||||
To: "+2000",
|
||||
},
|
||||
{
|
||||
onBlockReply: async (payload) => {
|
||||
blockReplies.push(payload);
|
||||
},
|
||||
},
|
||||
makeCfg(home),
|
||||
);
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(blockReplies.length).toBe(1);
|
||||
expect(blockReplies[0]?.text).toContain("Help");
|
||||
expect(runEmbeddedPiAgent).toHaveBeenCalled();
|
||||
const prompt =
|
||||
vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
|
||||
expect(prompt).not.toContain("/help");
|
||||
expect(text).toBe("ok");
|
||||
});
|
||||
});
|
||||
|
||||
it("drops /status for unauthorized senders", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
whatsapp: {
|
||||
allowFrom: ["+1000"],
|
||||
},
|
||||
session: { store: join(home, "sessions.json") },
|
||||
};
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/status",
|
||||
From: "+2001",
|
||||
To: "+2000",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+2001",
|
||||
},
|
||||
{},
|
||||
cfg,
|
||||
);
|
||||
expect(res).toBeUndefined();
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps inline /help for unauthorized senders", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: {
|
||||
durationMs: 1,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
whatsapp: {
|
||||
allowFrom: ["+1000"],
|
||||
},
|
||||
session: { store: join(home, "sessions.json") },
|
||||
};
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "please /help now",
|
||||
From: "+2001",
|
||||
To: "+2000",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+2001",
|
||||
},
|
||||
{},
|
||||
cfg,
|
||||
);
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toBe("ok");
|
||||
expect(runEmbeddedPiAgent).toHaveBeenCalled();
|
||||
const prompt =
|
||||
vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
|
||||
expect(prompt).toContain("/help");
|
||||
});
|
||||
});
|
||||
|
||||
it("returns help without invoking the agent", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const res = await getReplyFromConfig(
|
||||
|
||||
@@ -148,6 +148,29 @@ function stripSenderPrefix(value?: string) {
|
||||
return trimmed.replace(SENDER_PREFIX_RE, "");
|
||||
}
|
||||
|
||||
const INLINE_SIMPLE_COMMAND_ALIASES = new Map<string, string>([
|
||||
["/help", "/help"],
|
||||
["/commands", "/commands"],
|
||||
["/whoami", "/whoami"],
|
||||
["/id", "/whoami"],
|
||||
]);
|
||||
const INLINE_SIMPLE_COMMAND_RE =
|
||||
/(?:^|\s)\/(help|commands|whoami|id)(?=$|\s|:)/i;
|
||||
|
||||
function extractInlineSimpleCommand(body?: string): {
|
||||
command: string;
|
||||
cleaned: string;
|
||||
} | null {
|
||||
if (!body) return null;
|
||||
const match = body.match(INLINE_SIMPLE_COMMAND_RE);
|
||||
if (!match || match.index === undefined) return null;
|
||||
const alias = `/${match[1].toLowerCase()}`;
|
||||
const command = INLINE_SIMPLE_COMMAND_ALIASES.get(alias);
|
||||
if (!command) return null;
|
||||
const cleaned = body.replace(match[0], " ").replace(/\s+/g, " ").trim();
|
||||
return { command, cleaned };
|
||||
}
|
||||
|
||||
function resolveElevatedAllowList(
|
||||
allowFrom: AgentElevatedAllowFromConfig | undefined,
|
||||
provider: string,
|
||||
@@ -489,7 +512,7 @@ export async function getReplyFromConfig(
|
||||
}
|
||||
}
|
||||
}
|
||||
const directives = commandAuthorized
|
||||
let directives = commandAuthorized
|
||||
? parsedDirectives
|
||||
: {
|
||||
...parsedDirectives,
|
||||
@@ -502,7 +525,7 @@ export async function getReplyFromConfig(
|
||||
queueReset: false,
|
||||
};
|
||||
const existingBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
|
||||
const cleanedBody = (() => {
|
||||
let cleanedBody = (() => {
|
||||
if (!existingBody) return parsedDirectives.cleaned;
|
||||
if (!sessionCtx.CommandBody && !sessionCtx.RawBody) {
|
||||
return parseInlineDirectives(existingBody, {
|
||||
@@ -660,6 +683,19 @@ export async function getReplyFromConfig(
|
||||
surface: command.surface,
|
||||
commandSource: ctx.CommandSource,
|
||||
});
|
||||
if (!command.isAuthorizedSender) {
|
||||
directives = {
|
||||
...directives,
|
||||
hasThinkDirective: false,
|
||||
hasVerboseDirective: false,
|
||||
hasReasoningDirective: false,
|
||||
hasElevatedDirective: false,
|
||||
hasStatusDirective: false,
|
||||
hasModelDirective: false,
|
||||
hasQueueDirective: false,
|
||||
queueReset: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
isDirectiveOnly({
|
||||
@@ -671,6 +707,10 @@ export async function getReplyFromConfig(
|
||||
isGroup,
|
||||
})
|
||||
) {
|
||||
if (!command.isAuthorizedSender) {
|
||||
typing.cleanup();
|
||||
return undefined;
|
||||
}
|
||||
const resolvedDefaultThinkLevel =
|
||||
(sessionEntry?.thinkingLevel as ThinkLevel | undefined) ??
|
||||
(agentCfg?.thinkingDefault as ThinkLevel | undefined) ??
|
||||
@@ -711,7 +751,11 @@ export async function getReplyFromConfig(
|
||||
currentElevatedLevel,
|
||||
});
|
||||
let statusReply: ReplyPayload | undefined;
|
||||
if (directives.hasStatusDirective && allowTextCommands) {
|
||||
if (
|
||||
directives.hasStatusDirective &&
|
||||
allowTextCommands &&
|
||||
command.isAuthorizedSender
|
||||
) {
|
||||
statusReply = await buildStatusReply({
|
||||
cfg,
|
||||
command,
|
||||
@@ -776,6 +820,94 @@ export async function getReplyFromConfig(
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const sendInlineReply = async (reply?: ReplyPayload) => {
|
||||
if (!reply) return;
|
||||
if (!opts?.onBlockReply) return;
|
||||
await opts.onBlockReply(reply);
|
||||
};
|
||||
|
||||
const inlineCommand =
|
||||
allowTextCommands && command.isAuthorizedSender
|
||||
? extractInlineSimpleCommand(cleanedBody)
|
||||
: null;
|
||||
if (inlineCommand) {
|
||||
cleanedBody = inlineCommand.cleaned;
|
||||
sessionCtx.Body = cleanedBody;
|
||||
sessionCtx.BodyStripped = cleanedBody;
|
||||
}
|
||||
|
||||
const handleInlineStatus =
|
||||
!isDirectiveOnly({
|
||||
directives,
|
||||
cleanedBody: directives.cleaned,
|
||||
ctx,
|
||||
cfg,
|
||||
agentId,
|
||||
isGroup,
|
||||
}) &&
|
||||
directives.hasStatusDirective &&
|
||||
allowTextCommands &&
|
||||
command.isAuthorizedSender;
|
||||
if (handleInlineStatus) {
|
||||
const inlineStatusReply = await buildStatusReply({
|
||||
cfg,
|
||||
command,
|
||||
sessionEntry,
|
||||
sessionKey,
|
||||
sessionScope,
|
||||
provider,
|
||||
model,
|
||||
contextTokens,
|
||||
resolvedThinkLevel,
|
||||
resolvedVerboseLevel: resolvedVerboseLevel ?? "off",
|
||||
resolvedReasoningLevel,
|
||||
resolvedElevatedLevel,
|
||||
resolveDefaultThinkingLevel: modelState.resolveDefaultThinkingLevel,
|
||||
isGroup,
|
||||
defaultGroupActivation: () => defaultActivation,
|
||||
});
|
||||
await sendInlineReply(inlineStatusReply);
|
||||
directives = { ...directives, hasStatusDirective: false };
|
||||
}
|
||||
|
||||
if (inlineCommand) {
|
||||
const inlineCommandContext = {
|
||||
...command,
|
||||
rawBodyNormalized: inlineCommand.command,
|
||||
commandBodyNormalized: inlineCommand.command,
|
||||
};
|
||||
const inlineResult = await handleCommands({
|
||||
ctx,
|
||||
cfg,
|
||||
command: inlineCommandContext,
|
||||
agentId,
|
||||
directives,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
sessionScope,
|
||||
workspaceDir,
|
||||
defaultGroupActivation: () => defaultActivation,
|
||||
resolvedThinkLevel,
|
||||
resolvedVerboseLevel: resolvedVerboseLevel ?? "off",
|
||||
resolvedReasoningLevel,
|
||||
resolvedElevatedLevel,
|
||||
resolveDefaultThinkingLevel: modelState.resolveDefaultThinkingLevel,
|
||||
provider,
|
||||
model,
|
||||
contextTokens,
|
||||
isGroup,
|
||||
});
|
||||
if (inlineResult.reply) {
|
||||
if (!inlineCommand.cleaned) {
|
||||
typing.cleanup();
|
||||
return inlineResult.reply;
|
||||
}
|
||||
await sendInlineReply(inlineResult.reply);
|
||||
}
|
||||
}
|
||||
|
||||
const isEmptyConfig = Object.keys(cfg).length === 0;
|
||||
const skipWhenConfigEmpty = command.providerId
|
||||
? Boolean(
|
||||
@@ -876,7 +1008,7 @@ export async function getReplyFromConfig(
|
||||
const baseBodyTrimmedRaw = baseBody.trim();
|
||||
if (
|
||||
allowTextCommands &&
|
||||
!commandAuthorized &&
|
||||
(!commandAuthorized || !command.isAuthorizedSender) &&
|
||||
!baseBodyTrimmedRaw &&
|
||||
hasControlCommand(commandSource, cfg)
|
||||
) {
|
||||
|
||||
@@ -610,6 +610,12 @@ export async function handleCommands(params: {
|
||||
directives.hasStatusDirective ||
|
||||
command.commandBodyNormalized === "/status";
|
||||
if (allowTextCommands && statusRequested) {
|
||||
if (!command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /status from unauthorized sender: ${command.senderId || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
const reply = await buildStatusReply({
|
||||
cfg,
|
||||
command,
|
||||
@@ -632,6 +638,12 @@ export async function handleCommands(params: {
|
||||
|
||||
const whoamiRequested = command.commandBodyNormalized === "/whoami";
|
||||
if (allowTextCommands && whoamiRequested) {
|
||||
if (!command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /whoami from unauthorized sender: ${command.senderId || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
const senderId = ctx.SenderId ?? "";
|
||||
const senderUsername = ctx.SenderUsername ?? "";
|
||||
const lines = ["🧭 Identity", `Provider: ${command.provider}`];
|
||||
|
||||
Reference in New Issue
Block a user