feat(commands): add side alias for btw

This commit is contained in:
Peter Steinberger
2026-05-03 18:22:13 +01:00
parent c30531ddaf
commit 2805bbd3d7
16 changed files with 174 additions and 14 deletions

View File

@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Tools/BTW: add `/side` as a text and native slash-command alias for `/btw` side questions.
- Agents/tools: skip optional media and PDF tool factories when the effective tool denylist already blocks them, avoiding unnecessary hot-path setup for tools that will be filtered out before model use. (#76773) Thanks @dorukardahan.
- Discord/status: let explicit reaction tool calls opt into tracking subsequent tool progress on the reacted message with `trackToolCalls: true`, and use the shared tool display emoji table for status reactions.
- Gateway/config: stop Gateway startup and hot reload from auto-restoring invalid config; invalid config now fails closed and `openclaw doctor --fix` owns last-known-good repair.

View File

@@ -387,6 +387,11 @@ The default manifest enables the Slack App Home **Home** tab and subscribes to `
"description": "Ask a side question without changing session context",
"usage_hint": "<question>"
},
{
"command": "/side",
"description": "Ask a side question without changing session context",
"usage_hint": "<question>"
},
{
"command": "/usage",
"description": "Control the usage footer or show cost summary",

View File

@@ -7,7 +7,7 @@ title: "BTW side questions"
---
`/btw` lets you ask a quick side question about the **current session** without
turning that question into normal conversation history.
turning that question into normal conversation history. `/side` is an alias.
It is modeled after Claude Code's `/btw` behavior, but adapted to OpenClaw's
Gateway and multi-channel architecture.
@@ -121,6 +121,7 @@ Examples:
```text
/btw what file are we editing?
/side what changed while the main run continued?
/btw what does this error mean?
/btw summarize the current task in one sentence
/btw what is 17 * 19?

View File

@@ -164,7 +164,7 @@ Current source-of-truth:
- `/skill <name> [input]` runs a skill by name.
- `/allowlist [list|add|remove] ...` manages allowlist entries. Text-only.
- `/approve <id> <decision>` resolves exec approval prompts.
- `/btw <question>` asks a side question without changing future session context. See [BTW](/tools/btw).
- `/btw <question>` asks a side question without changing future session context. Alias: `/side`. See [BTW](/tools/btw).
</Accordion>
<Accordion title="Subagents and ACP">
@@ -457,7 +457,7 @@ Examples:
## BTW side questions
`/btw` is a quick **side question** about the current session.
`/btw` is a quick **side question** about the current session. `/side` is an alias.
Unlike normal chat:
@@ -473,6 +473,7 @@ Example:
```text
/btw what are we doing right now?
/side what changed while the main run continued?
```
See [BTW Side Questions](/tools/btw) for the full behavior and client UX details.

View File

@@ -25,6 +25,7 @@ const BROWSER_SAFE_THINKING_LEVELS: ThinkLevel[] = [
type DefineChatCommandInput = {
key: string;
nativeName?: string;
nativeAliases?: string[];
description: string;
args?: ChatCommandDefinition["args"];
argsParsing?: ChatCommandDefinition["argsParsing"];
@@ -50,6 +51,7 @@ export function defineChatCommand(command: DefineChatCommandInput): ChatCommandD
return {
key: command.key,
nativeName: command.nativeName,
nativeAliases: command.nativeAliases?.map((alias) => alias.trim()).filter(Boolean),
description: command.description,
acceptsArgs,
args: command.args,
@@ -105,17 +107,22 @@ export function assertCommandRegistry(commands: ChatCommandDefinition[]): void {
if (nativeName) {
throw new Error(`Text-only command has native name: ${command.key}`);
}
if (command.nativeAliases?.length) {
throw new Error(`Text-only command has native aliases: ${command.key}`);
}
if (command.textAliases.length === 0) {
throw new Error(`Text-only command missing text alias: ${command.key}`);
}
} else if (!nativeName) {
throw new Error(`Native command missing native name: ${command.key}`);
} else {
const nativeKey = normalizeOptionalLowercaseString(nativeName) ?? "";
if (nativeNames.has(nativeKey)) {
throw new Error(`Duplicate native command: ${nativeName}`);
for (const alias of [nativeName, ...(command.nativeAliases ?? [])]) {
const nativeKey = normalizeOptionalLowercaseString(alias) ?? "";
if (nativeNames.has(nativeKey)) {
throw new Error(`Duplicate native command: ${alias}`);
}
nativeNames.add(nativeKey);
}
nativeNames.add(nativeKey);
}
if (command.scope === "native" && command.textAliases.length > 0) {
@@ -268,8 +275,9 @@ export function buildBuiltinChatCommands(
defineChatCommand({
key: "btw",
nativeName: "btw",
nativeAliases: ["side"],
description: "Ask a side question without changing future session context.",
textAlias: "/btw",
textAliases: ["/btw", "/side"],
acceptsArgs: true,
category: "tools",
tier: "standard",

View File

@@ -109,6 +109,20 @@ describe("commands registry", () => {
expect(specs.find((spec) => spec.name === "compact")).toBeTruthy();
});
it("exposes /side as a BTW text and native alias", () => {
const btw = listChatCommands().find((command) => command.key === "btw");
expect(btw).toMatchObject({
nativeName: "btw",
nativeAliases: ["side"],
textAliases: ["/btw", "/side"],
});
expect(normalizeCommandBody("/side what changed?")).toBe("/btw what changed?");
expect(findCommandByNativeName("side")?.key).toBe("btw");
expect(listNativeCommandSpecs().find((spec) => spec.name === "side")).toMatchObject({
acceptsArgs: true,
});
});
it("filters commands based on config flags", () => {
const disabled = listChatCommandsForConfig({
commands: { config: false, plugins: false, debug: false },

View File

@@ -96,13 +96,36 @@ function toNativeCommandSpec(command: ChatCommandDefinition, provider?: string):
return spec;
}
function resolveNativeNames(command: ChatCommandDefinition, provider?: string): string[] {
const primary = resolveNativeName(command, provider);
return [primary, ...(command.nativeAliases ?? [])].filter((name): name is string =>
Boolean(name),
);
}
function listNativeSpecsFromCommands(
commands: ChatCommandDefinition[],
provider?: string,
): NativeCommandSpec[] {
return commands
.filter((command) => command.scope !== "text" && command.nativeName)
.map((command) => toNativeCommandSpec(command, provider));
.flatMap((command) => {
const spec = toNativeCommandSpec(command, provider);
return resolveNativeNames(command, provider).map((name) => {
const nativeSpec: NativeCommandSpec = {
name,
description: spec.description,
acceptsArgs: spec.acceptsArgs,
};
if (spec.args) {
nativeSpec.args = spec.args;
}
if (spec.descriptionLocalizations) {
nativeSpec.descriptionLocalizations = spec.descriptionLocalizations;
}
return nativeSpec;
});
});
}
export function listNativeCommandSpecs(params?: {
@@ -134,8 +157,9 @@ export function findCommandByNativeName(
return getChatCommands().find(
(command) =>
command.scope !== "text" &&
normalizeOptionalLowercaseString(resolveNativeName(command, provider, options)) ===
normalized,
[resolveNativeName(command, provider, options), ...(command.nativeAliases ?? [])].some(
(name) => normalizeOptionalLowercaseString(name) === normalized,
),
);
}

View File

@@ -58,6 +58,7 @@ export type CommandArgsParsing = "none" | "positional";
export type ChatCommandDefinition = {
key: string;
nativeName?: string;
nativeAliases?: string[];
description: string;
/** Localized descriptions for native command surfaces that support them. */
descriptionLocalizations?: Record<string, string>;

View File

@@ -139,6 +139,28 @@ describe("handleBtwCommand", () => {
});
});
it("accepts /side as a /btw alias", async () => {
const params = buildParams("/side what changed?");
params.agentDir = "/tmp/agent";
params.sessionEntry = {
sessionId: "session-1",
updatedAt: Date.now(),
};
runBtwSideQuestionMock.mockResolvedValue({ text: "alias answer" });
const result = await handleBtwCommand(params, true);
expect(runBtwSideQuestionMock).toHaveBeenCalledWith(
expect.objectContaining({
question: "what changed?",
}),
);
expect(result).toEqual({
shouldContinue: false,
reply: { text: "alias answer", btw: { question: "what changed?" } },
});
});
it("falls back to the resolved agent dir when the caller omits it", async () => {
const params = buildParams("/btw what changed?");
params.agentId = "worker-1";

View File

@@ -332,6 +332,39 @@ describe("EmbeddedTuiBackend", () => {
]);
});
it("emits side-result events for local /side alias runs", async () => {
const { EmbeddedTuiBackend } = await import("./embedded-backend.js");
agentCommandFromIngressMock.mockResolvedValueOnce({
payloads: [{ text: "alias answer" }],
meta: {},
});
const backend = new EmbeddedTuiBackend();
const events: Array<{ event: string; payload: unknown }> = [];
backend.onEvent = (evt) => {
events.push({ event: evt.event, payload: evt.payload });
};
backend.start();
await backend.sendChat({
sessionKey: "agent:main:main",
message: "/side what changed?",
runId: "run-side-1",
});
await flushMicrotasks();
expect(events).toContainEqual({
event: "chat.side_result",
payload: {
kind: "btw",
runId: "run-side-1",
sessionKey: "agent:main:main",
question: "what changed?",
text: "alias answer",
},
});
});
it("registers tool-first local runs before forwarding agent events", async () => {
const { EmbeddedTuiBackend } = await import("./embedded-backend.js");
const pending = deferred<{

View File

@@ -75,7 +75,7 @@ const silentRuntime = {
};
function resolveBtwQuestion(message: string): string | undefined {
const match = /^\/btw(?::|\s)+(.*)$/i.exec(message.trim());
const match = /^\/(?:btw|side)(?::|\s)+(.*)$/i.exec(message.trim());
const question = match?.[1]?.trim();
return question ? question : undefined;
}

View File

@@ -315,6 +315,25 @@ describe("tui command handlers", () => {
);
});
it("sends /side without hijacking the active main run", async () => {
const { handleCommand, sendChat, addUser, noteLocalRunId, noteLocalBtwRunId, state } =
createHarness({
activeChatRunId: "run-main",
});
await handleCommand("/side what changed?");
expect(addUser).not.toHaveBeenCalled();
expect(noteLocalRunId).not.toHaveBeenCalled();
expect(noteLocalBtwRunId).toHaveBeenCalledTimes(1);
expect(state.activeChatRunId).toBe("run-main");
expect(sendChat).toHaveBeenCalledWith(
expect.objectContaining({
message: "/side what changed?",
}),
);
});
it("creates unique session for /new and resets shared session for /reset", async () => {
const loadHistory = vi.fn().mockResolvedValue(undefined);
const setSessionMock = vi.fn().mockResolvedValue(undefined) as SetSessionMock;

View File

@@ -55,7 +55,7 @@ type CommandHandlerContext = {
};
function isBtwCommand(text: string): boolean {
return /^\/btw(?::|\s|$)/i.test(text.trim());
return /^\/(?:btw|side)(?::|\s|$)/i.test(text.trim());
}
export function createCommandHandlers(context: CommandHandlerContext) {

View File

@@ -715,6 +715,33 @@ describe("handleSendChat", () => {
expect(host.chatMessage).toBe("/btw what changed?");
});
it("sends /side through the detached BTW path", async () => {
const request = vi.fn(async (method: string) => {
if (method === "chat.send") {
return {};
}
throw new Error(`Unexpected request: ${method}`);
});
const host = makeHost({
client: { request } as unknown as ChatHost["client"],
chatRunId: "run-main",
chatStream: "Working...",
chatMessage: "/side what changed?",
});
await handleSendChat(host);
expect(request).toHaveBeenCalledWith(
"chat.send",
expect.objectContaining({
message: "/side what changed?",
deliver: false,
}),
);
expect(host.chatQueue).toEqual([]);
expect(host.chatRunId).toBe("run-main");
});
it("sends /btw without adopting a main chat run when idle", async () => {
const request = vi.fn(async (method: string) => {
if (method === "chat.send") {

View File

@@ -146,7 +146,7 @@ function confirmChatResetCommand(text: string) {
}
function isBtwCommand(text: string) {
return /^\/btw(?::|\s|$)/i.test(text.trim());
return /^\/(?:btw|side)(?::|\s|$)/i.test(text.trim());
}
export async function handleAbortChat(host: ChatHost) {

View File

@@ -79,6 +79,10 @@ describe("parseSlashCommand", () => {
command: { key: "export-session" },
args: "",
});
expect(parseSlashCommand("/side what changed?")).toMatchObject({
command: { key: "btw", name: "btw", aliases: expect.arrayContaining(["side"]) },
args: "what changed?",
});
});
it("keeps canonical long-form slash names as the primary menu command", () => {