Auto-reply: add /btw side questions across channels

This commit is contained in:
Vignesh Natarajan 2026-03-13 19:58:12 -07:00
parent 5c73ed62d5
commit ee590697c1
No known key found for this signature in database
GPG Key ID: C5E014CC92E2A144
14 changed files with 863 additions and 29 deletions

View File

@ -495,6 +495,22 @@ function buildChatCommands(): ChatCommandDefinition[] {
textAlias: "/stop",
category: "session",
}),
defineChatCommand({
key: "btw",
nativeName: "btw",
description: "Ask a side question without interrupting the current run.",
textAlias: "/btw",
category: "session",
acceptsArgs: true,
args: [
{
name: "message",
description: "Side question",
type: "string",
captureRemaining: true,
},
],
}),
defineChatCommand({
key: "restart",
nativeName: "restart",

View File

@ -38,6 +38,7 @@ describe("commands registry", () => {
const specs = listNativeCommandSpecs();
expect(specs.find((spec) => spec.name === "help")).toBeTruthy();
expect(specs.find((spec) => spec.name === "stop")).toBeTruthy();
expect(specs.find((spec) => spec.name === "btw")).toBeTruthy();
expect(specs.find((spec) => spec.name === "skill")).toBeTruthy();
expect(specs.find((spec) => spec.name === "whoami")).toBeTruthy();
expect(specs.find((spec) => spec.name === "compact")).toBeTruthy();

View File

@ -0,0 +1,175 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SpawnSubagentResult } from "../../agents/subagent-spawn.js";
import type { OpenClawConfig } from "../../config/config.js";
const hoisted = vi.hoisted(() => ({
spawnSubagentDirectMock: vi.fn(),
}));
vi.mock("../../agents/subagent-spawn.js", () => ({
spawnSubagentDirect: (...args: unknown[]) => hoisted.spawnSubagentDirectMock(...args),
SUBAGENT_SPAWN_MODES: ["run", "session"],
}));
const { handleBtwCommand } = await import("./commands-btw.js");
const { buildCommandTestParams } = await import("./commands.test-harness.js");
const baseCfg = {
session: { mainKey: "main", scope: "per-sender" },
} satisfies OpenClawConfig;
function acceptedResult(): SpawnSubagentResult {
return {
status: "accepted",
childSessionKey: "agent:main:subagent:btw-1",
runId: "run-btw-1",
};
}
describe("/btw command", () => {
beforeEach(() => {
hoisted.spawnSubagentDirectMock.mockReset();
});
it("returns null when text commands are disabled", async () => {
const params = buildCommandTestParams("/btw ping", baseCfg);
const result = await handleBtwCommand(params, false);
expect(result).toBeNull();
});
it("shows usage when no message is provided", async () => {
const params = buildCommandTestParams("/btw", baseCfg);
const result = await handleBtwCommand(params, true);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toBe("Usage: /btw <message>");
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
});
it("silently ignores unauthorized senders", async () => {
const params = buildCommandTestParams("/btw ping", baseCfg, {
CommandAuthorized: false,
});
params.command.isAuthorizedSender = false;
const result = await handleBtwCommand(params, true);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply).toBeUndefined();
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
});
it("spawns a side-question run with the current session", async () => {
hoisted.spawnSubagentDirectMock.mockResolvedValue(acceptedResult());
const params = buildCommandTestParams("/btw Can you summarize progress?", baseCfg, {
OriginatingTo: "channel:main",
To: "channel:fallback",
});
const result = await handleBtwCommand(params, true);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toContain("Sent side question with /btw");
const [spawnParams, spawnCtx] = hoisted.spawnSubagentDirectMock.mock.calls[0] as [
Record<string, unknown>,
Record<string, unknown>,
];
expect(spawnParams).toMatchObject({
agentId: "main",
mode: "run",
cleanup: "delete",
expectsCompletionMessage: true,
});
expect(spawnParams.task).toContain("Side-question mode: answer only this one question.");
expect(spawnParams.task).toContain("Do not use tools.");
expect(spawnParams.task).toContain("Question:");
expect(spawnParams.task).toContain("Can you summarize progress?");
expect(spawnCtx).toMatchObject({
agentSessionKey: "agent:main:main",
agentChannel: "whatsapp",
agentTo: "channel:main",
});
});
it("includes recent session context in the side-question task", async () => {
hoisted.spawnSubagentDirectMock.mockResolvedValue(acceptedResult());
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-btw-context-"));
const sessionFile = path.join(tmpDir, "session.jsonl");
await fs.writeFile(
sessionFile,
[
JSON.stringify({
type: "message",
message: { role: "user", content: [{ type: "text", text: "continue the migration" }] },
}),
JSON.stringify({
type: "message",
message: {
role: "assistant",
content: [{ type: "text", text: "I am updating auth.ts" }],
},
}),
].join("\n"),
"utf-8",
);
const params = buildCommandTestParams("/btw what file are you changing?", baseCfg);
params.sessionEntry = {
sessionId: "session-main",
updatedAt: Date.now(),
sessionFile,
};
try {
const result = await handleBtwCommand(params, true);
expect(result?.shouldContinue).toBe(false);
const [spawnParams] = hoisted.spawnSubagentDirectMock.mock.calls[0] as [
Record<string, unknown>,
Record<string, unknown>,
];
expect(spawnParams.task).toContain("Current session context (recent messages):");
expect(spawnParams.task).toContain("user: continue the migration");
expect(spawnParams.task).toContain("assistant: I am updating auth.ts");
expect(spawnParams.task).toContain("Question:");
expect(spawnParams.task).toContain("what file are you changing?");
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("prefers CommandTargetSessionKey for native commands", async () => {
hoisted.spawnSubagentDirectMock.mockResolvedValue(acceptedResult());
const params = buildCommandTestParams("/btw quick check", baseCfg, {
CommandSource: "native",
CommandTargetSessionKey: "agent:codex:main",
OriginatingChannel: "discord",
OriginatingTo: "channel:12345",
});
params.sessionKey = "agent:main:slack:slash:u1";
const result = await handleBtwCommand(params, true);
expect(result?.shouldContinue).toBe(false);
const [spawnParams, spawnCtx] = hoisted.spawnSubagentDirectMock.mock.calls[0] as [
Record<string, unknown>,
Record<string, unknown>,
];
expect(spawnParams.agentId).toBe("codex");
expect(spawnCtx).toMatchObject({
agentSessionKey: "agent:codex:main",
agentChannel: "discord",
agentTo: "channel:12345",
});
});
it("fails with a clear message when spawn is rejected", async () => {
hoisted.spawnSubagentDirectMock.mockResolvedValue({
status: "forbidden",
error: "sessions_spawn has reached max active children",
} satisfies SpawnSubagentResult);
const params = buildCommandTestParams("/btw quick check", baseCfg);
const result = await handleBtwCommand(params, true);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toContain("/btw failed");
expect(result?.reply?.text).toContain("max active children");
});
});

View File

@ -0,0 +1,219 @@
import fs from "node:fs/promises";
import { spawnSubagentDirect } from "../../agents/subagent-spawn.js";
import {
extractAssistantText,
resolveInternalSessionKey,
resolveMainSessionAlias,
} from "../../agents/tools/sessions-helpers.js";
import { logVerbose } from "../../globals.js";
import { parseAgentSessionKey } from "../../routing/session-key.js";
import { extractTextFromChatContent } from "../../shared/chat-content.js";
import type { CommandHandler } from "./commands-types.js";
const BTW_PREFIX = "/btw";
function resolveRequesterSessionKey(params: Parameters<CommandHandler>[0]): string | undefined {
const commandTarget = params.ctx.CommandTargetSessionKey?.trim();
const commandSession = params.sessionKey?.trim();
const preferCommandTarget = params.ctx.CommandSource === "native";
const raw = preferCommandTarget
? commandTarget || commandSession
: commandSession || commandTarget;
if (!raw) {
return undefined;
}
const { mainKey, alias } = resolveMainSessionAlias(params.cfg);
return resolveInternalSessionKey({ key: raw, alias, mainKey });
}
function resolveBtwMessage(commandBodyNormalized: string): string | null {
if (commandBodyNormalized === BTW_PREFIX) {
return "";
}
if (!commandBodyNormalized.startsWith(`${BTW_PREFIX} `)) {
return null;
}
return commandBodyNormalized.slice(BTW_PREFIX.length).trim();
}
function extractUserText(message: unknown): string | undefined {
if (!message || typeof message !== "object") {
return undefined;
}
if ((message as { role?: unknown }).role !== "user") {
return undefined;
}
const content = (message as { content?: unknown }).content;
if (!Array.isArray(content)) {
return undefined;
}
const joined =
extractTextFromChatContent(content, {
joinWith: " ",
normalizeText: (text) => text.trim(),
}) ?? "";
return joined.trim() || undefined;
}
function extractContextLine(message: unknown): string | null {
if (!message || typeof message !== "object") {
return null;
}
const role = (message as { role?: unknown }).role;
if (role === "assistant") {
const text = extractAssistantText(message)?.trim();
return text ? `assistant: ${text}` : null;
}
if (role === "user") {
const text = extractUserText(message)?.trim();
if (!text || text.toLowerCase().startsWith("/btw")) {
return null;
}
return `user: ${text}`;
}
return null;
}
async function buildRecentSessionContext(params: {
sessionFile?: string;
maxMessages?: number;
maxChars?: number;
}): Promise<string> {
const sessionFile = params.sessionFile?.trim();
if (!sessionFile) {
return "";
}
let content: string;
try {
content = await fs.readFile(sessionFile, "utf-8");
} catch {
return "";
}
const lines = content.split("\n");
const contextLines: string[] = [];
const maxMessages = Math.max(1, params.maxMessages ?? 8);
const maxChars = Math.max(200, params.maxChars ?? 2500);
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i]?.trim();
if (!line) {
continue;
}
try {
const parsed = JSON.parse(line) as { type?: unknown; message?: unknown };
if (parsed.type !== "message") {
continue;
}
const contextLine = extractContextLine(parsed.message);
if (!contextLine) {
continue;
}
contextLines.push(contextLine);
if (contextLines.length >= maxMessages) {
break;
}
} catch {
// Ignore malformed JSONL lines.
}
}
if (contextLines.length === 0) {
return "";
}
const ordered = contextLines.toReversed();
let joined = ordered.join("\n");
if (joined.length <= maxChars) {
return joined;
}
joined = joined.slice(joined.length - maxChars);
const firstNewline = joined.indexOf("\n");
return firstNewline >= 0 ? joined.slice(firstNewline + 1) : joined;
}
export const handleBtwCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) {
return null;
}
const message = resolveBtwMessage(params.command.commandBodyNormalized);
if (message === null) {
return null;
}
if (!params.command.isAuthorizedSender) {
logVerbose(`Ignoring /btw from unauthorized sender: ${params.command.senderId || "<unknown>"}`);
return { shouldContinue: false };
}
if (!message) {
return {
shouldContinue: false,
reply: { text: "Usage: /btw <message>" },
};
}
const requesterSessionKey = resolveRequesterSessionKey(params);
if (!requesterSessionKey) {
return {
shouldContinue: false,
reply: { text: "⚠️ Missing session key." },
};
}
const agentId =
parseAgentSessionKey(requesterSessionKey)?.agentId ??
parseAgentSessionKey(params.sessionKey)?.agentId;
const sessionContext = await buildRecentSessionContext({
sessionFile: params.sessionEntry?.sessionFile,
maxMessages: 8,
maxChars: 2500,
});
const sideQuestionTask = [
"Side-question mode: answer only this one question.",
"Do not use tools.",
...(sessionContext ? ["", "Current session context (recent messages):", sessionContext] : []),
"",
"Question:",
message,
].join("\n");
const normalizedTo =
(typeof params.ctx.OriginatingTo === "string" ? params.ctx.OriginatingTo.trim() : "") ||
(typeof params.command.to === "string" ? params.command.to.trim() : "") ||
(typeof params.ctx.To === "string" ? params.ctx.To.trim() : "") ||
undefined;
const result = await spawnSubagentDirect(
{
task: sideQuestionTask,
agentId,
mode: "run",
cleanup: "delete",
expectsCompletionMessage: true,
},
{
agentSessionKey: requesterSessionKey,
agentChannel: params.ctx.OriginatingChannel ?? params.command.channel,
agentAccountId: params.ctx.AccountId,
agentTo: normalizedTo,
agentThreadId: params.ctx.MessageThreadId,
agentGroupId: params.sessionEntry?.groupId ?? null,
agentGroupChannel: params.sessionEntry?.groupChannel ?? null,
agentGroupSpace: params.sessionEntry?.space ?? null,
},
);
if (result.status === "accepted") {
return {
shouldContinue: false,
reply: {
text: "Sent side question with /btw. I will post one answer here.",
},
};
}
return {
shouldContinue: false,
reply: { text: `⚠️ /btw failed: ${result.error ?? result.status}` },
};
};

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 {
@ -185,6 +186,7 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
handleHelpCommand,
handleCommandsListCommand,
handleStatusCommand,
handleBtwCommand,
handleAllowlistCommand,
handleApproveCommand,
handleContextCommand,

View File

@ -118,4 +118,94 @@ describe("handleSendChat", () => {
});
expect(host.chatModelOverrides.main).toBe("gpt-5-mini");
});
it("sends /btw immediately while a run is active", async () => {
const request = vi.fn(async (method: string, _params?: unknown) => {
if (method === "chat.send") {
return { runId: "run-btw", status: "started" };
}
throw new Error(`Unexpected request: ${method}`);
});
const host = makeHost({
client: { request } as unknown as ChatHost["client"],
sessionKey: "main",
chatRunId: "run-active",
chatMessage: "/btw can you also check lint output?",
});
await handleSendChat(host);
expect(request).toHaveBeenCalledWith("chat.send", {
sessionKey: expect.stringMatching(/^agent:main:btw:/),
message: [
"Side-question mode: answer only this one question.",
"Do not use tools.",
"",
"Question:",
"can you also check lint output?",
].join("\n"),
deliver: false,
idempotencyKey: expect.any(String),
});
expect(host.chatQueue).toHaveLength(0);
expect(host.chatRunId).toBe("run-active");
expect(host.chatMessage).toBe("");
expect(host.chatMessages.at(-1)).toEqual({
role: "system",
content: "Sent side question with `/btw`. I will post one answer here.",
timestamp: expect.any(Number),
});
});
it("includes recent visible chat context in /btw side questions", async () => {
const request = vi.fn(async (method: string, _params?: unknown) => {
if (method === "chat.send") {
return { runId: "run-btw", status: "started" };
}
throw new Error(`Unexpected request: ${method}`);
});
const host = makeHost({
client: { request } as unknown as ChatHost["client"],
sessionKey: "main",
chatRunId: "run-active",
chatStream: "I am editing commands-core.ts",
chatMessage: "/btw what file are you editing?",
chatMessages: [
{ role: "user", content: [{ type: "text", text: "continue with the btw command" }] },
{ role: "assistant", content: [{ type: "text", text: "I will implement it globally." }] },
],
});
await handleSendChat(host);
expect(request).toHaveBeenCalledWith("chat.send", {
sessionKey: expect.stringMatching(/^agent:main:btw:/),
message: expect.stringContaining("Current session context (recent messages):"),
deliver: false,
idempotencyKey: expect.any(String),
});
const message = request.mock.calls[0]?.[1] as { message?: string };
expect(message.message).toContain("user: continue with the btw command");
expect(message.message).toContain("assistant: I will implement it globally.");
expect(message.message).toContain("assistant (in-progress): I am editing commands-core.ts");
expect(message.message).toContain("Question:");
expect(message.message).toContain("what file are you editing?");
});
it("shows usage help for /btw without a message", async () => {
const request = vi.fn();
const host = makeHost({
client: { request } as unknown as ChatHost["client"],
chatMessage: "/btw",
});
await handleSendChat(host);
expect(request).not.toHaveBeenCalled();
expect(host.chatMessages.at(-1)).toEqual({
role: "system",
content: "Usage: `/btw <message>`",
timestamp: expect.any(Number),
});
});
});

View File

@ -3,9 +3,16 @@ import { scheduleChatScroll } from "./app-scroll.ts";
import { setLastActiveSessionKey } from "./app-settings.ts";
import { resetToolStream } from "./app-tool-stream.ts";
import type { OpenClawApp } from "./app.ts";
import { extractText } from "./chat/message-extract.ts";
import { executeSlashCommand } from "./chat/slash-command-executor.ts";
import { parseSlashCommand } from "./chat/slash-commands.ts";
import { abortChatRun, loadChatHistory, sendChatMessage } from "./controllers/chat.ts";
import {
abortChatRun,
loadChatHistory,
sendChatMessage,
sendChatMessageBackground,
trackSideQuestionRun,
} from "./controllers/chat.ts";
import { loadModels } from "./controllers/models.ts";
import { loadSessions } from "./controllers/sessions.ts";
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
@ -257,7 +264,7 @@ export async function handleSendChat(
}
function shouldQueueLocalSlashCommand(name: string): boolean {
return !["stop", "focus", "export"].includes(name);
return !["btw", "stop", "focus", "export"].includes(name);
}
// ── Slash Command Dispatch ──
@ -295,6 +302,9 @@ async function dispatchSlashCommand(
case "export":
host.onSlashAction?.("export");
return;
case "btw":
await sendBtwMessage(host, args);
return;
}
if (!host.client) {
@ -322,6 +332,80 @@ async function dispatchSlashCommand(
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0]);
}
async function sendBtwMessage(host: ChatHost, args: string) {
const message = args.trim();
if (!message) {
injectCommandResult(host, "Usage: `/btw <message>`");
return;
}
const agentId = parseAgentSessionKey(host.sessionKey)?.agentId ?? "main";
const isolatedSessionKey = `agent:${agentId}:btw:${generateUUID()}`;
const sessionContext = buildRecentVisibleSessionContext(host);
const sideQuestionMessage = [
"Side-question mode: answer only this one question.",
"Do not use tools.",
...(sessionContext ? ["", "Current session context (recent messages):", sessionContext] : []),
"",
"Question:",
message,
].join("\n");
const runId = await sendChatMessageBackground(
host as unknown as Parameters<typeof sendChatMessageBackground>[0],
message,
{
appendUserMessage: false,
sessionKey: isolatedSessionKey,
rpcMessage: sideQuestionMessage,
},
);
if (!runId) {
return;
}
trackSideQuestionRun(runId);
injectCommandResult(host, "Sent side question with `/btw`. I will post one answer here.");
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0]);
}
function buildRecentVisibleSessionContext(host: ChatHost): string {
const history = Array.isArray(host.chatMessages) ? host.chatMessages : [];
const lines: string[] = [];
for (let i = history.length - 1; i >= 0; i--) {
const entry = history[i];
if (!entry || typeof entry !== "object") {
continue;
}
const roleValue = (entry as { role?: unknown }).role;
const role = typeof roleValue === "string" ? roleValue.toLowerCase() : "";
if (role !== "user" && role !== "assistant") {
continue;
}
const text = extractText(entry)?.trim();
if (!text) {
continue;
}
if (role === "user" && text.toLowerCase().startsWith("/btw")) {
continue;
}
lines.push(`${role}: ${text}`);
if (lines.length >= 8) {
break;
}
}
if (host.chatRunId && host.chatStream?.trim()) {
lines.push(`assistant (in-progress): ${host.chatStream.trim()}`);
}
if (lines.length === 0) {
return "";
}
const joined = lines.toReversed().join("\n");
if (joined.length <= 2500) {
return joined;
}
const clipped = joined.slice(joined.length - 2500);
const firstNewline = clipped.indexOf("\n");
return firstNewline >= 0 ? clipped.slice(firstNewline + 1) : clipped;
}
async function clearChatHistory(host: ChatHost) {
if (!host.client || !host.connected) {
return;

View File

@ -17,7 +17,11 @@ import { formatConnectError } from "./connect-error.ts";
import { loadAgents } from "./controllers/agents.ts";
import { loadAssistantIdentity } from "./controllers/assistant-identity.ts";
import { loadChatHistory } from "./controllers/chat.ts";
import { handleChatEvent, type ChatEventPayload } from "./controllers/chat.ts";
import {
handleChatEvent,
isTrackedSideQuestionRun,
type ChatEventPayload,
} from "./controllers/chat.ts";
import { loadDevices } from "./controllers/devices.ts";
import type { ExecApprovalRequest } from "./controllers/exec-approval.ts";
import {
@ -299,7 +303,7 @@ function handleTerminalChatEvent(
}
function handleChatGatewayEvent(host: GatewayHost, payload: ChatEventPayload | undefined) {
if (payload?.sessionKey) {
if (payload?.sessionKey && !isTrackedSideQuestionRun(payload.runId)) {
setLastActiveSessionKey(
host as unknown as Parameters<typeof setLastActiveSessionKey>[0],
payload.sessionKey,

View File

@ -31,6 +31,13 @@ describe("parseSlashCommand", () => {
});
});
it("parses /btw side messages", () => {
expect(parseSlashCommand("/btw check on step 2")).toMatchObject({
command: { name: "btw" },
args: "check on step 2",
});
});
it("keeps /status on the agent path", () => {
const status = SLASH_COMMANDS.find((entry) => entry.name === "status");
expect(status?.executeLocal).not.toBe(true);

View File

@ -46,6 +46,14 @@ export const SLASH_COMMANDS: SlashCommandDef[] = [
category: "session",
executeLocal: true,
},
{
name: "btw",
description: "Send message without interrupting current run",
args: "<message>",
icon: "send",
category: "session",
executeLocal: true,
},
{
name: "clear",
description: "Clear chat history",

View File

@ -3,8 +3,11 @@ import { GatewayRequestError } from "../gateway.ts";
import {
abortChatRun,
handleChatEvent,
isTrackedSideQuestionRun,
loadChatHistory,
sendChatMessage,
sendChatMessageBackground,
trackSideQuestionRun,
type ChatEventPayload,
type ChatState,
} from "./chat.ts";
@ -44,6 +47,37 @@ describe("handleChatEvent", () => {
expect(handleChatEvent(state, payload)).toBe(null);
});
it("handles tracked side-question final events outside the active session", () => {
const state = createState({
sessionKey: "main",
chatRunId: "run-user",
chatStream: "Working...",
chatStreamStartedAt: 123,
});
trackSideQuestionRun("run-btw");
const payload: ChatEventPayload = {
runId: "run-btw",
sessionKey: "agent:main:btw:123",
state: "final",
message: {
role: "assistant",
content: [{ type: "text", text: "Short side answer" }],
},
};
expect(handleChatEvent(state, payload)).toBe(null);
expect(isTrackedSideQuestionRun("run-btw")).toBe(false);
expect(state.chatRunId).toBe("run-user");
expect(state.chatStream).toBe("Working...");
expect(state.chatMessages.at(-1)).toEqual({
role: "assistant",
content: [{ type: "text", text: "**/btw**\n\nShort side answer" }],
timestamp: expect.any(Number),
__openclaw: { kind: "side-reply", id: "run-btw" },
});
});
it("returns null for delta from another run", () => {
const state = createState({
sessionKey: "main",
@ -574,6 +608,40 @@ describe("sendChatMessage", () => {
});
});
describe("sendChatMessageBackground", () => {
it("sends side messages without replacing the active run stream", async () => {
const request = vi.fn().mockResolvedValue({ runId: "run-btw", status: "started" });
const state = createState({
connected: true,
sessionKey: "main",
chatRunId: "run-active",
chatStream: "Working...",
chatStreamStartedAt: 123,
client: { request } as unknown as ChatState["client"],
});
const runId = await sendChatMessageBackground(state, "quick note");
expect(runId).toEqual(expect.any(String));
expect(request).toHaveBeenCalledWith("chat.send", {
sessionKey: "main",
message: "quick note",
deliver: false,
idempotencyKey: expect.any(String),
});
const payload = request.mock.calls[0]?.[1] as { idempotencyKey?: string } | undefined;
expect(runId).toBe(payload?.idempotencyKey);
expect(state.chatRunId).toBe("run-active");
expect(state.chatStream).toBe("Working...");
expect(state.chatStreamStartedAt).toBe(123);
expect(state.chatMessages.at(-1)).toEqual({
role: "user",
content: [{ type: "text", text: "quick note" }],
timestamp: expect.any(Number),
});
});
});
describe("abortChatRun", () => {
it("formats structured non-auth connect failures for chat abort", async () => {
// Abort now shares the same structured connect-error formatter as send.

View File

@ -6,6 +6,7 @@ import type { ChatAttachment } from "../ui-types.ts";
import { generateUUID } from "../uuid.ts";
const SILENT_REPLY_PATTERN = /^\s*NO_REPLY\s*$/;
const SIDE_QUESTION_RUN_IDS = new Set<string>();
function isSilentReplyStream(text: string): boolean {
return SILENT_REPLY_PATTERN.test(text);
@ -52,6 +53,12 @@ export type ChatEventPayload = {
errorMessage?: string;
};
export type BackgroundSendOptions = {
appendUserMessage?: boolean;
rpcMessage?: string;
sessionKey?: string;
};
function maybeResetToolStream(state: ChatState) {
const toolHost = state as ChatState & Partial<Parameters<typeof resetToolStream>[0]>;
if (
@ -101,6 +108,42 @@ function dataUrlToBase64(dataUrl: string): { content: string; mimeType: string }
return { mimeType: match[1], content: match[2] };
}
function buildUserContentBlocks(
message: string,
attachments?: ChatAttachment[],
): Array<{ type: string; text?: string; source?: unknown }> {
const contentBlocks: Array<{ type: string; text?: string; source?: unknown }> = [];
if (message) {
contentBlocks.push({ type: "text", text: message });
}
if (attachments && attachments.length > 0) {
for (const att of attachments) {
contentBlocks.push({
type: "image",
source: { type: "base64", media_type: att.mimeType, data: att.dataUrl },
});
}
}
return contentBlocks;
}
function appendUserMessage(
state: ChatState,
message: string,
attachments?: ChatAttachment[],
): number {
const now = Date.now();
state.chatMessages = [
...state.chatMessages,
{
role: "user",
content: buildUserContentBlocks(message, attachments),
timestamp: now,
},
];
return now;
}
type AssistantMessageNormalizationOptions = {
roleRequirement: "required" | "optional";
roleCaseSensitive?: boolean;
@ -164,31 +207,7 @@ export async function sendChatMessage(
return null;
}
const now = Date.now();
// Build user message content blocks
const contentBlocks: Array<{ type: string; text?: string; source?: unknown }> = [];
if (msg) {
contentBlocks.push({ type: "text", text: msg });
}
// Add image previews to the message for display
if (hasAttachments) {
for (const att of attachments) {
contentBlocks.push({
type: "image",
source: { type: "base64", media_type: att.mimeType, data: att.dataUrl },
});
}
}
state.chatMessages = [
...state.chatMessages,
{
role: "user",
content: contentBlocks,
timestamp: now,
},
];
const now = appendUserMessage(state, msg, attachments);
state.chatSending = true;
state.lastError = null;
@ -243,6 +262,66 @@ export async function sendChatMessage(
}
}
export async function sendChatMessageBackground(
state: ChatState,
message: string,
opts?: BackgroundSendOptions,
): Promise<string | null> {
if (!state.client || !state.connected) {
return null;
}
const msg = message.trim();
if (!msg) {
return null;
}
if (opts?.appendUserMessage !== false) {
appendUserMessage(state, msg);
}
state.lastError = null;
const runId = generateUUID();
try {
const rpcMessage = (opts?.rpcMessage ?? msg).trim();
if (!rpcMessage) {
return null;
}
await state.client.request("chat.send", {
sessionKey: opts?.sessionKey ?? state.sessionKey,
message: rpcMessage,
deliver: false,
idempotencyKey: runId,
});
return runId;
} catch (err) {
const error = formatConnectError(err);
state.lastError = error;
state.chatMessages = [
...state.chatMessages,
{
role: "assistant",
content: [{ type: "text", text: "Error: " + error }],
timestamp: Date.now(),
},
];
return null;
}
}
export function trackSideQuestionRun(runId: string): void {
if (!runId.trim()) {
return;
}
SIDE_QUESTION_RUN_IDS.add(runId);
}
export function isTrackedSideQuestionRun(runId: string | null | undefined): boolean {
if (!runId) {
return false;
}
return SIDE_QUESTION_RUN_IDS.has(runId);
}
export async function abortChatRun(state: ChatState): Promise<boolean> {
if (!state.client || !state.connected) {
return false;
@ -264,6 +343,41 @@ export function handleChatEvent(state: ChatState, payload?: ChatEventPayload) {
if (!payload) {
return null;
}
if (isTrackedSideQuestionRun(payload.runId)) {
if (payload.state === "final") {
const finalMessage = normalizeFinalAssistantMessage(payload.message);
const text = finalMessage ? extractText(finalMessage) : null;
if (typeof text === "string" && text.trim()) {
state.chatMessages = [
...state.chatMessages,
{
role: "assistant",
content: [{ type: "text", text: `**/btw**\n\n${text.trim()}` }],
timestamp: Date.now(),
__openclaw: { kind: "side-reply", id: payload.runId },
},
];
}
SIDE_QUESTION_RUN_IDS.delete(payload.runId);
return null;
}
if (payload.state === "error" || payload.state === "aborted") {
const summary =
payload.state === "error"
? `Side question failed: ${payload.errorMessage ?? "chat error"}`
: "Side question was aborted.";
state.chatMessages = [
...state.chatMessages,
{
role: "system",
content: summary,
timestamp: Date.now(),
},
];
SIDE_QUESTION_RUN_IDS.delete(payload.runId);
}
return null;
}
if (payload.sessionKey !== state.sessionKey) {
return null;
}

View File

@ -219,6 +219,37 @@ describe("chat view", () => {
expect(logoImage?.getAttribute("src")).toBe("/openclaw/favicon.svg");
});
it("renders side-question replies below the active stream", () => {
const container = document.createElement("div");
render(
renderChat(
createProps({
messages: [
{
role: "assistant",
content: [{ type: "text", text: "Main response so far" }],
timestamp: 10,
},
{
role: "assistant",
content: [{ type: "text", text: "**/btw**\n\nSide answer" }],
timestamp: 20,
__openclaw: { kind: "side-reply", id: "run-btw-1" },
},
],
stream: "Main stream still running",
streamStartedAt: 30,
}),
),
container,
);
const text = container.textContent ?? "";
expect(text.indexOf("Main stream still running")).toBeGreaterThanOrEqual(0);
expect(text.indexOf("Side answer")).toBeGreaterThanOrEqual(0);
expect(text.indexOf("Main stream still running")).toBeLessThan(text.indexOf("Side answer"));
});
it("keeps grouped assistant avatar fallbacks under the mounted base path", () => {
const container = document.createElement("div");
render(

View File

@ -1377,6 +1377,7 @@ function groupMessages(items: ChatItem[]): Array<ChatItem | MessageGroup> {
function buildChatItems(props: ChatProps): Array<ChatItem | MessageGroup> {
const items: ChatItem[] = [];
const deferredSideReplies: ChatItem[] = [];
const history = Array.isArray(props.messages) ? props.messages : [];
const tools = Array.isArray(props.toolMessages) ? props.toolMessages : [];
const historyStart = Math.max(0, history.length - CHAT_HISTORY_RENDER_LIMIT);
@ -1408,6 +1409,16 @@ function buildChatItems(props: ChatProps): Array<ChatItem | MessageGroup> {
});
continue;
}
// Keep side-question replies visually below the active stream so they
// remain visible while the main run is still producing output.
if (props.stream !== null && marker && marker.kind === "side-reply") {
deferredSideReplies.push({
kind: "message",
key: messageKey(msg, i),
message: msg,
});
continue;
}
if (!props.showThinking && normalized.role.toLowerCase() === "toolresult") {
continue;
@ -1461,6 +1472,10 @@ function buildChatItems(props: ChatProps): Array<ChatItem | MessageGroup> {
}
}
if (deferredSideReplies.length > 0) {
items.push(...deferredSideReplies);
}
return groupMessages(items);
}