diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index c499f03c526..3bf9fc27084 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -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", diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index 326211560ee..f51e2e1d4fe 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -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(); diff --git a/src/auto-reply/reply/commands-btw.test.ts b/src/auto-reply/reply/commands-btw.test.ts new file mode 100644 index 00000000000..a969e2ed5b0 --- /dev/null +++ b/src/auto-reply/reply/commands-btw.test.ts @@ -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 "); + 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, + Record, + ]; + 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, + Record, + ]; + 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, + Record, + ]; + 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"); + }); +}); diff --git a/src/auto-reply/reply/commands-btw.ts b/src/auto-reply/reply/commands-btw.ts new file mode 100644 index 00000000000..a18e10ba6e1 --- /dev/null +++ b/src/auto-reply/reply/commands-btw.ts @@ -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[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 { + 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 || ""}`); + return { shouldContinue: false }; + } + + if (!message) { + return { + shouldContinue: false, + reply: { text: "Usage: /btw " }, + }; + } + + 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}` }, + }; +}; diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts index ca67bbc3549..7bbe7e7139d 100644 --- a/src/auto-reply/reply/commands-core.ts +++ b/src/auto-reply/reply/commands-core.ts @@ -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 { }); 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 `", + timestamp: expect.any(Number), + }); + }); }); diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index c877b4c5a5d..522600bf441 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -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[0]); } +async function sendBtwMessage(host: ChatHost, args: string) { + const message = args.trim(); + if (!message) { + injectCommandResult(host, "Usage: `/btw `"); + 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[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[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; diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index bcd8a866e4e..d85732265dc 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -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[0], payload.sessionKey, diff --git a/ui/src/ui/chat/slash-commands.node.test.ts b/ui/src/ui/chat/slash-commands.node.test.ts index 5b8dc2a8683..2a1cdebb583 100644 --- a/ui/src/ui/chat/slash-commands.node.test.ts +++ b/ui/src/ui/chat/slash-commands.node.test.ts @@ -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); diff --git a/ui/src/ui/chat/slash-commands.ts b/ui/src/ui/chat/slash-commands.ts index d6b5bc4c337..9a2aa314a99 100644 --- a/ui/src/ui/chat/slash-commands.ts +++ b/ui/src/ui/chat/slash-commands.ts @@ -46,6 +46,14 @@ export const SLASH_COMMANDS: SlashCommandDef[] = [ category: "session", executeLocal: true, }, + { + name: "btw", + description: "Send message without interrupting current run", + args: "", + icon: "send", + category: "session", + executeLocal: true, + }, { name: "clear", description: "Clear chat history", diff --git a/ui/src/ui/controllers/chat.test.ts b/ui/src/ui/controllers/chat.test.ts index ba102fe0919..6040c346402 100644 --- a/ui/src/ui/controllers/chat.test.ts +++ b/ui/src/ui/controllers/chat.test.ts @@ -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. diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index f2fccf57f92..173121f0556 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -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(); 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[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 { + 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 { 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; } diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index b21936e0bb8..4203f8a3916 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -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( diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 1d0b877d042..63dd2b43c6a 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -1377,6 +1377,7 @@ function groupMessages(items: ChatItem[]): Array { function buildChatItems(props: ChatProps): Array { 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 { }); 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 { } } + if (deferredSideReplies.length > 0) { + items.push(...deferredSideReplies); + } + return groupMessages(items); }