fix(btw): stop persisting side questions (#46328)

* fix(btw): stop persisting side questions

* docs(btw): document side-question behavior
This commit is contained in:
Nimrod Gutman 2026-03-14 19:01:13 +02:00 committed by GitHub
parent d9c285e930
commit 133cce23ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 178 additions and 188 deletions

View File

@ -1033,6 +1033,7 @@
{
"group": "Skills",
"pages": [
"tools/btw",
"tools/creating-skills",
"tools/slash-commands",
"tools/skills",

142
docs/tools/btw.md Normal file
View File

@ -0,0 +1,142 @@
---
summary: "Ephemeral side questions with /btw"
read_when:
- You want to ask a quick side question about the current session
- You are implementing or debugging BTW behavior across clients
title: "BTW Side Questions"
---
# BTW Side Questions
`/btw` lets you ask a quick side question about the **current session** without
turning that question into normal conversation history.
It is modeled after Claude Code's `/btw` behavior, but adapted to OpenClaw's
Gateway and multi-channel architecture.
## What it does
When you send:
```text
/btw what changed?
```
OpenClaw:
1. snapshots the current session context,
2. runs a separate **tool-less** model call,
3. answers only the side question,
4. leaves the main run alone,
5. does **not** write the BTW question or answer to session history,
6. emits the answer as a **live side result** rather than a normal assistant message.
The important mental model is:
- same session context
- separate one-shot side query
- no tool calls
- no future context pollution
- no transcript persistence
## What it does not do
`/btw` does **not**:
- create a new durable session,
- continue the unfinished main task,
- run tools or agent tool loops,
- write BTW question/answer data to transcript history,
- appear in `chat.history`,
- survive a reload.
It is intentionally **ephemeral**.
## How context works
BTW uses the current session as **background context only**.
If the main run is currently active, OpenClaw snapshots the current message
state and includes the in-flight main prompt as background context, while
explicitly telling the model:
- answer only the side question,
- do not resume or complete the unfinished main task,
- do not emit tool calls or pseudo-tool calls.
That keeps BTW isolated from the main run while still making it aware of what
the session is about.
## Delivery model
BTW is **not** delivered as a normal assistant transcript message.
At the Gateway protocol level:
- normal assistant chat uses the `chat` event
- BTW uses the `chat.side_result` event
This separation is intentional. If BTW reused the normal `chat` event path,
clients would treat it like regular conversation history.
Because BTW uses a separate live event and is not replayed from
`chat.history`, it disappears after reload.
## Surface behavior
### TUI
In TUI, BTW is rendered inline in the current session view, but it remains
ephemeral:
- visibly distinct from a normal assistant reply
- dismissible with `Enter` or `Esc`
- not replayed on reload
### External channels
On channels like Telegram, WhatsApp, and Discord, BTW is delivered as a
clearly labeled one-off reply because those surfaces do not have a local
ephemeral overlay concept.
The answer is still treated as a side result, not normal session history.
### Control UI / web
The Gateway emits BTW correctly as `chat.side_result`, and BTW is not included
in `chat.history`, so the persistence contract is already correct for web.
The current Control UI still needs a dedicated `chat.side_result` consumer to
render BTW live in the browser. Until that client-side support lands, BTW is a
Gateway-level feature with full TUI and external-channel behavior, but not yet
a complete browser UX.
## When to use BTW
Use `/btw` when you want:
- a quick clarification about the current work,
- a factual side answer while a long run is still in progress,
- a temporary answer that should not become part of future session context.
Examples:
```text
/btw what file are we editing?
/btw what does this error mean?
/btw summarize the current task in one sentence
/btw what is 17 * 19?
```
## When not to use BTW
Do not use `/btw` when you want the answer to become part of the session's
future working context.
In that case, ask normally in the main session instead of using BTW.
## Related
- [Slash commands](/tools/slash-commands)
- [Thinking Levels](/tools/thinking)
- [Session](/concepts/session)

View File

@ -76,7 +76,7 @@ Text + native (when enabled):
- `/allowlist` (list/add/remove allowlist entries)
- `/approve <id> allow-once|allow-always|deny` (resolve exec approval prompts)
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
- `/btw <question>` (ask a quick side question about the current session without changing future session context)
- `/btw <question>` (ask an ephemeral side question about the current session without changing future session context; see [/tools/btw](/tools/btw))
- `/export-session [path]` (alias: `/export`) (export current session to HTML with full system prompt)
- `/whoami` (show your sender id; alias: `/id`)
- `/session idle <duration|off>` (manage inactivity auto-unfocus for focused thread bindings)
@ -224,3 +224,27 @@ Notes:
- **`/stop`** targets the active chat session so it can abort the current run.
- **Slack:** `channels.slack.slashCommand` is still supported for a single `/openclaw`-style command. If you enable `commands.native`, you must create one Slack slash command per built-in command (same names as `/help`). Command argument menus for Slack are delivered as ephemeral Block Kit buttons.
- Slack native exception: register `/agentstatus` (not `/status`) because Slack reserves `/status`. Text `/status` still works in Slack messages.
## BTW side questions
`/btw` is a quick **side question** about the current session.
Unlike normal chat:
- it uses the current session as background context,
- it runs as a separate **tool-less** one-shot call,
- it does not change future session context,
- it is not written to transcript history,
- it is delivered as a live side result instead of a normal assistant message.
That makes `/btw` useful when you want a temporary clarification while the main
task keeps going.
Example:
```text
/btw what are we doing right now?
```
See [BTW Side Questions](/tools/btw) for the full behavior and client UX
details.

View File

@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SessionEntry } from "../config/sessions.js";
const streamSimpleMock = vi.fn();
const appendCustomEntryMock = vi.fn();
const buildSessionContextMock = vi.fn();
const getLeafEntryMock = vi.fn();
const branchMock = vi.fn();
@ -13,11 +12,8 @@ const discoverModelsMock = vi.fn();
const resolveModelWithRegistryMock = vi.fn();
const getApiKeyForModelMock = vi.fn();
const requireApiKeyMock = vi.fn();
const acquireSessionWriteLockMock = vi.fn();
const resolveSessionAuthProfileOverrideMock = vi.fn();
const getActiveEmbeddedRunSnapshotMock = vi.fn();
const waitForEmbeddedPiRunEndMock = vi.fn();
const diagWarnMock = vi.fn();
const diagDebugMock = vi.fn();
vi.mock("@mariozechner/pi-ai", () => ({
@ -31,7 +27,6 @@ vi.mock("@mariozechner/pi-coding-agent", () => ({
branch: branchMock,
resetLeaf: resetLeafMock,
buildSessionContext: buildSessionContextMock,
appendCustomEntry: appendCustomEntryMock,
}),
},
}));
@ -54,13 +49,8 @@ vi.mock("./model-auth.js", () => ({
requireApiKey: (...args: unknown[]) => requireApiKeyMock(...args),
}));
vi.mock("./session-write-lock.js", () => ({
acquireSessionWriteLock: (...args: unknown[]) => acquireSessionWriteLockMock(...args),
}));
vi.mock("./pi-embedded-runner/runs.js", () => ({
getActiveEmbeddedRunSnapshot: (...args: unknown[]) => getActiveEmbeddedRunSnapshotMock(...args),
waitForEmbeddedPiRunEnd: (...args: unknown[]) => waitForEmbeddedPiRunEndMock(...args),
}));
vi.mock("./auth-profiles/session-override.js", () => ({
@ -70,12 +60,11 @@ vi.mock("./auth-profiles/session-override.js", () => ({
vi.mock("../logging/diagnostic.js", () => ({
diagnosticLogger: {
warn: (...args: unknown[]) => diagWarnMock(...args),
debug: (...args: unknown[]) => diagDebugMock(...args),
},
}));
const { BTW_CUSTOM_TYPE, runBtwSideQuestion } = await import("./btw.js");
const { runBtwSideQuestion } = await import("./btw.js");
function makeAsyncEvents(events: unknown[]) {
return {
@ -99,7 +88,6 @@ function createSessionEntry(overrides: Partial<SessionEntry> = {}): SessionEntry
describe("runBtwSideQuestion", () => {
beforeEach(() => {
streamSimpleMock.mockReset();
appendCustomEntryMock.mockReset();
buildSessionContextMock.mockReset();
getLeafEntryMock.mockReset();
branchMock.mockReset();
@ -110,11 +98,8 @@ describe("runBtwSideQuestion", () => {
resolveModelWithRegistryMock.mockReset();
getApiKeyForModelMock.mockReset();
requireApiKeyMock.mockReset();
acquireSessionWriteLockMock.mockReset();
resolveSessionAuthProfileOverrideMock.mockReset();
getActiveEmbeddedRunSnapshotMock.mockReset();
waitForEmbeddedPiRunEndMock.mockReset();
diagWarnMock.mockReset();
diagDebugMock.mockReset();
buildSessionContextMock.mockReturnValue({
@ -128,15 +113,11 @@ describe("runBtwSideQuestion", () => {
});
getApiKeyForModelMock.mockResolvedValue({ apiKey: "secret", mode: "api-key", source: "test" });
requireApiKeyMock.mockReturnValue("secret");
acquireSessionWriteLockMock.mockResolvedValue({
release: vi.fn().mockResolvedValue(undefined),
});
resolveSessionAuthProfileOverrideMock.mockResolvedValue("profile-1");
getActiveEmbeddedRunSnapshotMock.mockReturnValue(undefined);
waitForEmbeddedPiRunEndMock.mockResolvedValue(true);
});
it("streams blocks and persists a non-context custom entry", async () => {
it("streams blocks without persisting BTW data to disk", async () => {
const onBlockReply = vi.fn().mockResolvedValue(undefined);
streamSimpleMock.mockReturnValue(
makeAsyncEvents([
@ -212,17 +193,6 @@ describe("runBtwSideQuestion", () => {
text: "Side answer.",
btw: { question: "What changed?" },
});
await vi.waitFor(() => {
expect(appendCustomEntryMock).toHaveBeenCalledWith(
BTW_CUSTOM_TYPE,
expect.objectContaining({
question: "What changed?",
answer: "Side answer.",
provider: "anthropic",
model: "claude-sonnet-4-5",
}),
);
});
});
it("returns a final payload when block streaming is unavailable", async () => {
@ -641,14 +611,7 @@ describe("runBtwSideQuestion", () => {
);
});
it("returns the BTW answer and retries transcript persistence after a session lock", async () => {
acquireSessionWriteLockMock
.mockRejectedValueOnce(
new Error("session file locked (timeout 250ms): pid=123 /tmp/session.lock"),
)
.mockResolvedValueOnce({
release: vi.fn().mockResolvedValue(undefined),
});
it("returns the BTW answer without appending transcript custom entries", async () => {
streamSimpleMock.mockReturnValue(
makeAsyncEvents([
{
@ -688,26 +651,10 @@ describe("runBtwSideQuestion", () => {
});
expect(result).toEqual({ text: "323" });
await vi.waitFor(() => {
expect(waitForEmbeddedPiRunEndMock).toHaveBeenCalledWith("session-1", 30000);
expect(appendCustomEntryMock).toHaveBeenCalledWith(
BTW_CUSTOM_TYPE,
expect.objectContaining({
question: "What is 17 * 19?",
answer: "323",
}),
);
});
expect(buildSessionContextMock).toHaveBeenCalled();
});
it("logs deferred persistence failures through the diagnostic logger", async () => {
acquireSessionWriteLockMock
.mockRejectedValueOnce(
new Error("session file locked (timeout 250ms): pid=123 /tmp/session.lock"),
)
.mockRejectedValueOnce(
new Error("session file locked (timeout 10000ms): pid=123 /tmp/session.lock"),
);
it("does not log transcript persistence warnings because BTW no longer writes to disk", async () => {
streamSimpleMock.mockReturnValue(
makeAsyncEvents([
{
@ -747,12 +694,10 @@ describe("runBtwSideQuestion", () => {
});
expect(result).toEqual({ text: "323" });
await vi.waitFor(() => {
expect(diagWarnMock).toHaveBeenCalledWith(
expect.stringContaining("btw transcript persistence skipped: sessionId=session-1"),
expect(diagDebugMock).not.toHaveBeenCalledWith(
expect.stringContaining("btw transcript persistence skipped"),
);
});
});
it("excludes tool results from BTW context to avoid replaying raw tool output", async () => {
getActiveEmbeddedRunSnapshotMock.mockReturnValue({

View File

@ -21,19 +21,10 @@ import { getApiKeyForModel, requireApiKey } from "./model-auth.js";
import { ensureOpenClawModelsJson } from "./models-config.js";
import { EmbeddedBlockChunker, type BlockReplyChunking } from "./pi-embedded-block-chunker.js";
import { resolveModelWithRegistry } from "./pi-embedded-runner/model.js";
import {
getActiveEmbeddedRunSnapshot,
waitForEmbeddedPiRunEnd,
} from "./pi-embedded-runner/runs.js";
import { getActiveEmbeddedRunSnapshot } from "./pi-embedded-runner/runs.js";
import { mapThinkingLevel } from "./pi-embedded-runner/utils.js";
import { discoverAuthStorage, discoverModels } from "./pi-model-discovery.js";
import { stripToolResultDetails } from "./session-transcript-repair.js";
import { acquireSessionWriteLock } from "./session-write-lock.js";
const BTW_CUSTOM_TYPE = "openclaw:btw";
const BTW_PERSIST_TIMEOUT_MS = 250;
const BTW_PERSIST_RETRY_WAIT_MS = 30_000;
const BTW_PERSIST_RETRY_LOCK_MS = 10_000;
type SessionManagerLike = {
getLeafEntry?: () => {
@ -47,97 +38,6 @@ type SessionManagerLike = {
buildSessionContext: () => { messages?: unknown[] };
};
type BtwCustomEntryData = {
timestamp: number;
question: string;
answer: string;
provider: string;
model: string;
thinkingLevel: ThinkLevel | "off";
reasoningLevel: ReasoningLevel;
sessionKey?: string;
authProfileId?: string;
authProfileIdSource?: "auto" | "user";
usage?: unknown;
};
async function appendBtwCustomEntry(params: {
sessionFile: string;
timeoutMs: number;
entry: BtwCustomEntryData;
}) {
const lock = await acquireSessionWriteLock({
sessionFile: params.sessionFile,
timeoutMs: params.timeoutMs,
allowReentrant: false,
});
try {
const persisted = SessionManager.open(params.sessionFile);
persisted.appendCustomEntry(BTW_CUSTOM_TYPE, params.entry);
} finally {
await lock.release();
}
}
function isSessionLockError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return message.includes("session file locked");
}
function deferBtwCustomEntryPersist(params: {
sessionId: string;
sessionFile: string;
entry: BtwCustomEntryData;
}) {
void (async () => {
try {
await waitForEmbeddedPiRunEnd(params.sessionId, BTW_PERSIST_RETRY_WAIT_MS);
await appendBtwCustomEntry({
sessionFile: params.sessionFile,
timeoutMs: BTW_PERSIST_RETRY_LOCK_MS,
entry: params.entry,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
diag.warn(`btw transcript persistence skipped: sessionId=${params.sessionId} err=${message}`);
}
})();
}
async function persistBtwCustomEntry(params: {
sessionId: string;
sessionFile: string;
entry: BtwCustomEntryData;
}) {
try {
await appendBtwCustomEntry({
sessionFile: params.sessionFile,
timeoutMs: BTW_PERSIST_TIMEOUT_MS,
entry: params.entry,
});
} catch (error) {
if (!isSessionLockError(error)) {
throw error;
}
deferBtwCustomEntryPersist({
sessionId: params.sessionId,
sessionFile: params.sessionFile,
entry: params.entry,
});
}
}
function persistBtwCustomEntryInBackground(params: {
sessionId: string;
sessionFile: string;
entry: BtwCustomEntryData;
}) {
void persistBtwCustomEntry(params).catch((error) => {
const message = error instanceof Error ? error.message : String(error);
diag.warn(`btw transcript persistence skipped: sessionId=${params.sessionId} err=${message}`);
});
}
function collectTextContent(content: Array<{ type?: string; text?: string }>): string {
return content
.filter((part): part is { type: "text"; text: string } => part.type === "text")
@ -347,7 +247,7 @@ export async function runBtwSideQuestion(
throw new Error("No active session context.");
}
const { model, authProfileId, authProfileIdSource } = await resolveRuntimeModel({
const { model, authProfileId } = await resolveRuntimeModel({
cfg: params.cfg,
provider: params.provider,
model: params.model,
@ -483,31 +383,9 @@ export async function runBtwSideQuestion(
throw new Error("No BTW response generated.");
}
const customEntry = {
timestamp: Date.now(),
question: params.question,
answer,
provider: model.provider,
model: model.id,
thinkingLevel: params.resolvedThinkLevel ?? "off",
reasoningLevel: params.resolvedReasoningLevel,
sessionKey: params.sessionKey,
authProfileId,
authProfileIdSource,
usage: finalMessage?.usage,
} satisfies BtwCustomEntryData;
persistBtwCustomEntryInBackground({
sessionId,
sessionFile,
entry: customEntry,
});
if (emittedBlocks > 0) {
return undefined;
}
return { text: answer };
}
export { BTW_CUSTOM_TYPE };