mirror of https://github.com/openclaw/openclaw.git
fix(btw): stop persisting side questions (#46328)
* fix(btw): stop persisting side questions * docs(btw): document side-question behavior
This commit is contained in:
parent
d9c285e930
commit
133cce23ce
|
|
@ -1033,6 +1033,7 @@
|
|||
{
|
||||
"group": "Skills",
|
||||
"pages": [
|
||||
"tools/btw",
|
||||
"tools/creating-skills",
|
||||
"tools/slash-commands",
|
||||
"tools/skills",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,11 +694,9 @@ 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 () => {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Reference in New Issue