mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-06 15:18:58 +00:00
feat(commands): add side alias for btw
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user