Add /btw side questions (#45444)

* feat(agent): add /btw side questions

* fix(agent): gate and log /btw reviews

* feat(btw): isolate side-question delivery

* test(reply): update route reply runtime mocks

* fix(btw): complete side-result delivery across clients

* fix(gateway): handle streamed btw side results

* fix(telegram): unblock btw side questions

* fix(reply): make external btw replies explicit

* fix(chat): keep btw side results ephemeral in internal history

* fix(btw): address remaining review feedback

* fix(chat): preserve btw history on mobile refresh

* fix(acp): keep btw replies out of prompt history

* refactor(btw): narrow side questions to live channels

* fix(btw): preserve channel typing indicators

* fix(btw): keep side questions isolated in chat

* fix(outbound): restore typed channel send deps

* fix(btw): avoid blocking replies on transcript persistence

* fix(btw): keep side questions fast

* docs(commands): document btw slash command

* docs(changelog): add btw side questions entry

* test(outbound): align session transcript mocks
This commit is contained in:
Nimrod Gutman
2026-03-14 17:27:54 +02:00
committed by GitHub
parent b5ba2101c7
commit 9aac55d306
41 changed files with 4254 additions and 143 deletions

View File

@@ -196,6 +196,14 @@ function buildChatCommands(): ChatCommandDefinition[] {
acceptsArgs: true,
category: "status",
}),
defineChatCommand({
key: "btw",
nativeName: "btw",
description: "Ask a side question without changing future session context.",
textAlias: "/btw",
acceptsArgs: true,
category: "tools",
}),
defineChatCommand({
key: "export-session",
nativeName: "export-session",

View File

@@ -0,0 +1,26 @@
import { normalizeCommandBody, type CommandNormalizeOptions } from "../commands-registry.js";
const BTW_COMMAND_RE = /^\/btw(?::|\s|$)/i;
export function isBtwRequestText(text?: string, options?: CommandNormalizeOptions): boolean {
if (!text) {
return false;
}
const normalized = normalizeCommandBody(text, options).trim();
return BTW_COMMAND_RE.test(normalized);
}
export function extractBtwQuestion(
text?: string,
options?: CommandNormalizeOptions,
): string | null {
if (!text) {
return null;
}
const normalized = normalizeCommandBody(text, options).trim();
const match = normalized.match(/^\/btw(?:\s+(.*))?$/i);
if (!match) {
return null;
}
return match[1]?.trim() ?? "";
}

View File

@@ -0,0 +1,131 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { buildCommandTestParams } from "./commands.test-harness.js";
import { createMockTypingController } from "./test-helpers.js";
const runBtwSideQuestionMock = vi.fn();
vi.mock("../../agents/btw.js", () => ({
runBtwSideQuestion: (...args: unknown[]) => runBtwSideQuestionMock(...args),
}));
const { handleBtwCommand } = await import("./commands-btw.js");
function buildParams(commandBody: string) {
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as OpenClawConfig;
return buildCommandTestParams(commandBody, cfg, undefined, { workspaceDir: "/tmp/workspace" });
}
describe("handleBtwCommand", () => {
beforeEach(() => {
runBtwSideQuestionMock.mockReset();
});
it("returns usage when the side question is missing", async () => {
const result = await handleBtwCommand(buildParams("/btw"), true);
expect(result).toEqual({
shouldContinue: false,
reply: { text: "Usage: /btw <side question>" },
});
});
it("ignores /btw when text commands are disabled", async () => {
const result = await handleBtwCommand(buildParams("/btw what changed?"), false);
expect(result).toBeNull();
expect(runBtwSideQuestionMock).not.toHaveBeenCalled();
});
it("ignores /btw from unauthorized senders", async () => {
const params = buildParams("/btw what changed?");
params.command.isAuthorizedSender = false;
const result = await handleBtwCommand(params, true);
expect(result).toEqual({ shouldContinue: false });
expect(runBtwSideQuestionMock).not.toHaveBeenCalled();
});
it("requires an active session context", async () => {
const params = buildParams("/btw what changed?");
params.sessionEntry = undefined;
const result = await handleBtwCommand(params, true);
expect(result).toEqual({
shouldContinue: false,
reply: { text: "⚠️ /btw requires an active session with existing context." },
});
});
it("still delegates while the session is actively running", async () => {
const params = buildParams("/btw what changed?");
params.agentDir = "/tmp/agent";
params.sessionEntry = {
sessionId: "session-1",
updatedAt: Date.now(),
};
runBtwSideQuestionMock.mockResolvedValue({ text: "snapshot answer" });
const result = await handleBtwCommand(params, true);
expect(runBtwSideQuestionMock).toHaveBeenCalledWith(
expect.objectContaining({
question: "what changed?",
sessionEntry: params.sessionEntry,
resolvedThinkLevel: "off",
resolvedReasoningLevel: "off",
}),
);
expect(result).toEqual({
shouldContinue: false,
reply: { text: "snapshot answer", btw: { question: "what changed?" } },
});
});
it("starts the typing keepalive while the side question runs", async () => {
const params = buildParams("/btw what changed?");
const typing = createMockTypingController();
params.typing = typing;
params.agentDir = "/tmp/agent";
params.sessionEntry = {
sessionId: "session-1",
updatedAt: Date.now(),
};
runBtwSideQuestionMock.mockResolvedValue({ text: "snapshot answer" });
await handleBtwCommand(params, true);
expect(typing.startTypingLoop).toHaveBeenCalledTimes(1);
});
it("delegates to the side-question runner", async () => {
const params = buildParams("/btw what changed?");
params.agentDir = "/tmp/agent";
params.sessionEntry = {
sessionId: "session-1",
updatedAt: Date.now(),
};
runBtwSideQuestionMock.mockResolvedValue({ text: "nothing important" });
const result = await handleBtwCommand(params, true);
expect(runBtwSideQuestionMock).toHaveBeenCalledWith(
expect.objectContaining({
question: "what changed?",
agentDir: "/tmp/agent",
sessionEntry: params.sessionEntry,
resolvedThinkLevel: "off",
resolvedReasoningLevel: "off",
}),
);
expect(result).toEqual({
shouldContinue: false,
reply: { text: "nothing important", btw: { question: "what changed?" } },
});
});
});

View File

@@ -0,0 +1,80 @@
import { runBtwSideQuestion } from "../../agents/btw.js";
import { extractBtwQuestion } from "./btw-command.js";
import { rejectUnauthorizedCommand } from "./command-gates.js";
import type { CommandHandler } from "./commands-types.js";
const BTW_USAGE = "Usage: /btw <side question>";
export const handleBtwCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) {
return null;
}
const question = extractBtwQuestion(params.command.commandBodyNormalized);
if (question === null) {
return null;
}
const unauthorized = rejectUnauthorizedCommand(params, "/btw");
if (unauthorized) {
return unauthorized;
}
if (!question) {
return {
shouldContinue: false,
reply: { text: BTW_USAGE },
};
}
if (!params.sessionEntry?.sessionId) {
return {
shouldContinue: false,
reply: { text: "⚠️ /btw requires an active session with existing context." },
};
}
if (!params.agentDir) {
return {
shouldContinue: false,
reply: {
text: "⚠️ /btw is unavailable because the active agent directory could not be resolved.",
},
};
}
try {
await params.typing?.startTypingLoop();
const reply = await runBtwSideQuestion({
cfg: params.cfg,
agentDir: params.agentDir,
provider: params.provider,
model: params.model,
question,
sessionEntry: params.sessionEntry,
sessionStore: params.sessionStore,
sessionKey: params.sessionKey,
storePath: params.storePath,
// BTW is intentionally a quick side question, so do not inherit slower
// session-level think/reasoning settings from the main run.
resolvedThinkLevel: "off",
resolvedReasoningLevel: "off",
blockReplyChunking: params.blockReplyChunking,
resolvedBlockStreamingBreak: params.resolvedBlockStreamingBreak,
opts: params.opts,
isNewSession: false,
});
return {
shouldContinue: false,
reply: reply ? { ...reply, btw: { question } } : reply,
};
} catch (error) {
const message = error instanceof Error ? error.message.trim() : "";
return {
shouldContinue: false,
reply: {
text: `⚠️ /btw failed${message ? `: ${message}` : "."}`,
btw: { question },
isError: true,
},
};
}
};

View File

@@ -11,6 +11,7 @@ import { resolveBoundAcpThreadSessionKey } from "./commands-acp/targets.js";
import { handleAllowlistCommand } from "./commands-allowlist.js";
import { handleApproveCommand } from "./commands-approve.js";
import { handleBashCommand } from "./commands-bash.js";
import { handleBtwCommand } from "./commands-btw.js";
import { handleCompactCommand } from "./commands-compact.js";
import { handleConfigCommand, handleDebugCommand } from "./commands-config.js";
import {
@@ -174,6 +175,7 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
HANDLERS = [
// Plugin commands are processed first, before built-in commands
handlePluginCommand,
handleBtwCommand,
handleBashCommand,
handleActivationCommand,
handleSendPolicyCommand,

View File

@@ -1,11 +1,13 @@
import type { BlockReplyChunking } from "../../agents/pi-embedded-block-chunker.js";
import type { SkillCommandSpec } from "../../agents/skills.js";
import type { ChannelId } from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry, SessionScope } from "../../config/sessions.js";
import type { MsgContext } from "../templating.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js";
import type { ReplyPayload } from "../types.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import type { InlineDirectives } from "./directive-handling.js";
import type { TypingController } from "./typing.js";
export type CommandContext = {
surface: string;
@@ -44,17 +46,21 @@ export type HandleCommandsParams = {
storePath?: string;
sessionScope?: SessionScope;
workspaceDir: string;
opts?: GetReplyOptions;
defaultGroupActivation: () => "always" | "mention";
resolvedThinkLevel?: ThinkLevel;
resolvedVerboseLevel: VerboseLevel;
resolvedReasoningLevel: ReasoningLevel;
resolvedElevatedLevel?: ElevatedLevel;
blockReplyChunking?: BlockReplyChunking;
resolvedBlockStreamingBreak?: "text_end" | "message_end";
resolveDefaultThinkingLevel: () => Promise<ThinkLevel | undefined>;
provider: string;
model: string;
contextTokens: number;
isGroup: boolean;
skillCommands?: SkillCommandSpec[];
typing?: TypingController;
};
export type CommandHandlerResult = {

View File

@@ -1,5 +1,6 @@
import { collectTextContentBlocks } from "../../agents/content-blocks.js";
import { createOpenClawTools } from "../../agents/openclaw-tools.js";
import type { BlockReplyChunking } from "../../agents/pi-embedded-block-chunker.js";
import type { SkillCommandSpec } from "../../agents/skills.js";
import { applyOwnerOnlyToolPolicy } from "../../agents/tool-policy.js";
import { getChannelDock } from "../../channels/dock.js";
@@ -37,6 +38,7 @@ function getBuiltinSlashCommands(): Set<string> {
return builtinSlashCommands;
}
builtinSlashCommands = listReservedChatSlashCommandNames([
"btw",
"think",
"verbose",
"reasoning",
@@ -113,6 +115,8 @@ export async function handleInlineActions(params: {
resolvedVerboseLevel: VerboseLevel | undefined;
resolvedReasoningLevel: ReasoningLevel;
resolvedElevatedLevel: ElevatedLevel;
blockReplyChunking?: BlockReplyChunking;
resolvedBlockStreamingBreak?: "text_end" | "message_end";
resolveDefaultThinkingLevel: Awaited<
ReturnType<typeof createModelSelectionState>
>["resolveDefaultThinkingLevel"];
@@ -152,6 +156,8 @@ export async function handleInlineActions(params: {
resolvedVerboseLevel,
resolvedReasoningLevel,
resolvedElevatedLevel,
blockReplyChunking,
resolvedBlockStreamingBreak,
resolveDefaultThinkingLevel,
provider,
model,
@@ -357,17 +363,21 @@ export async function handleInlineActions(params: {
storePath,
sessionScope,
workspaceDir,
opts,
defaultGroupActivation: defaultActivation,
resolvedThinkLevel,
resolvedVerboseLevel: resolvedVerboseLevel ?? "off",
resolvedReasoningLevel,
resolvedElevatedLevel,
blockReplyChunking,
resolvedBlockStreamingBreak,
resolveDefaultThinkingLevel,
provider,
model,
contextTokens,
isGroup,
skillCommands,
typing,
});
if (inlineCommand) {

View File

@@ -332,6 +332,8 @@ export async function getReplyFromConfig(
resolvedVerboseLevel,
resolvedReasoningLevel,
resolvedElevatedLevel,
blockReplyChunking,
resolvedBlockStreamingBreak,
resolveDefaultThinkingLevel: modelState.resolveDefaultThinkingLevel,
provider,
model,

View File

@@ -10,6 +10,19 @@ import type { ReplyPayload } from "../types.js";
import { extractReplyToTag } from "./reply-tags.js";
import { createReplyToModeFilterForChannel } from "./reply-threading.js";
export function formatBtwTextForExternalDelivery(payload: ReplyPayload): string | undefined {
const text = payload.text?.trim();
if (!text) {
return payload.text;
}
const question = payload.btw?.question?.trim();
if (!question) {
return payload.text;
}
const formatted = `BTW\nQuestion: ${question}\n\n${text}`;
return text === formatted || text.startsWith("BTW\nQuestion:") ? text : formatted;
}
function resolveReplyThreadingForPayload(params: {
payload: ReplyPayload;
implicitReplyToId?: string;

View File

@@ -44,25 +44,33 @@ vi.mock("../../../extensions/slack/src/send.js", () => ({
vi.mock("../../../extensions/telegram/src/send.js", () => ({
sendMessageTelegram: mocks.sendMessageTelegram,
}));
vi.mock("../../../extensions/telegram/src/send.js", () => ({
sendMessageTelegram: mocks.sendMessageTelegram,
}));
vi.mock("../../../extensions/whatsapp/src/send.js", () => ({
sendMessageWhatsApp: mocks.sendMessageWhatsApp,
sendPollWhatsApp: mocks.sendMessageWhatsApp,
}));
vi.mock("../../../extensions/discord/src/send.js", () => ({
sendMessageDiscord: mocks.sendMessageDiscord,
sendPollDiscord: mocks.sendMessageDiscord,
sendWebhookMessageDiscord: vi.fn(),
}));
vi.mock("../../../extensions/mattermost/src/mattermost/send.js", () => ({
sendMessageMattermost: mocks.sendMessageMattermost,
}));
vi.mock("../../infra/outbound/deliver.js", async () => {
const actual = await vi.importActual<typeof import("../../infra/outbound/deliver.js")>(
"../../infra/outbound/deliver.js",
vi.mock("../../infra/outbound/deliver-runtime.js", async () => {
const actual = await vi.importActual<typeof import("../../infra/outbound/deliver-runtime.js")>(
"../../infra/outbound/deliver-runtime.js",
);
return {
...actual,
deliverOutboundPayloads: mocks.deliverOutboundPayloads,
};
});
const actualDeliver = await vi.importActual<typeof import("../../infra/outbound/deliver.js")>(
"../../infra/outbound/deliver.js",
);
const actualDeliver = await vi.importActual<
typeof import("../../infra/outbound/deliver-runtime.js")
>("../../infra/outbound/deliver-runtime.js");
const { routeReply } = await import("./route-reply.js");
@@ -294,6 +302,36 @@ describe("routeReply", () => {
);
});
it("formats BTW replies prominently on routed sends", async () => {
mocks.sendMessageSlack.mockClear();
await routeReply({
payload: { text: "323", btw: { question: "what is 17 * 19?" } },
channel: "slack",
to: "channel:C123",
cfg: {} as never,
});
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
"channel:C123",
"BTW\nQuestion: what is 17 * 19?\n\n323",
expect.any(Object),
);
});
it("formats BTW replies prominently on routed discord sends", async () => {
mocks.sendMessageDiscord.mockClear();
await routeReply({
payload: { text: "323", btw: { question: "what is 17 * 19?" } },
channel: "discord",
to: "channel:123456",
cfg: {} as never,
});
expect(mocks.sendMessageDiscord).toHaveBeenCalledWith(
"channel:123456",
"BTW\nQuestion: what is 17 * 19?\n\n323",
expect.any(Object),
);
});
it("passes replyToId to Telegram sends", async () => {
mocks.sendMessageTelegram.mockClear();
await routeReply({

View File

@@ -18,7 +18,10 @@ import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/m
import type { OriginatingChannelType } from "../templating.js";
import type { ReplyPayload } from "../types.js";
import { normalizeReplyPayload } from "./normalize-reply.js";
import { shouldSuppressReasoningPayload } from "./reply-payloads.js";
import {
formatBtwTextForExternalDelivery,
shouldSuppressReasoningPayload,
} from "./reply-payloads.js";
let deliverRuntimePromise: Promise<
typeof import("../../infra/outbound/deliver-runtime.js")
@@ -102,24 +105,28 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
if (!normalized) {
return { ok: true };
}
const externalPayload: ReplyPayload = {
...normalized,
text: formatBtwTextForExternalDelivery(normalized),
};
let text = normalized.text ?? "";
let mediaUrls = (normalized.mediaUrls?.filter(Boolean) ?? []).length
? (normalized.mediaUrls?.filter(Boolean) as string[])
: normalized.mediaUrl
? [normalized.mediaUrl]
let text = externalPayload.text ?? "";
let mediaUrls = (externalPayload.mediaUrls?.filter(Boolean) ?? []).length
? (externalPayload.mediaUrls?.filter(Boolean) as string[])
: externalPayload.mediaUrl
? [externalPayload.mediaUrl]
: [];
const replyToId = normalized.replyToId;
const replyToId = externalPayload.replyToId;
let hasSlackBlocks = false;
if (
channel === "slack" &&
normalized.channelData?.slack &&
typeof normalized.channelData.slack === "object" &&
!Array.isArray(normalized.channelData.slack)
externalPayload.channelData?.slack &&
typeof externalPayload.channelData.slack === "object" &&
!Array.isArray(externalPayload.channelData.slack)
) {
try {
hasSlackBlocks = Boolean(
parseSlackBlocksInput((normalized.channelData.slack as { blocks?: unknown }).blocks)
parseSlackBlocksInput((externalPayload.channelData.slack as { blocks?: unknown }).blocks)
?.length,
);
} catch {
@@ -168,7 +175,7 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
channel: channelId,
to,
accountId: accountId ?? undefined,
payloads: [normalized],
payloads: [externalPayload],
replyToId: resolvedReplyToId ?? null,
threadId: resolvedThreadId,
session: outboundSession,

View File

@@ -76,6 +76,9 @@ export type ReplyPayload = {
text?: string;
mediaUrl?: string;
mediaUrls?: string[];
btw?: {
question: string;
};
replyToId?: string;
replyToTag?: boolean;
/** True when [[reply_to_current]] was present but not yet mapped to a message id. */