mirror of https://github.com/openclaw/openclaw.git
Auto-reply: add /btw side questions across channels
This commit is contained in:
parent
5c73ed62d5
commit
ee590697c1
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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}` },
|
||||
};
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue