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",
|
"group": "Skills",
|
||||||
"pages": [
|
"pages": [
|
||||||
|
"tools/btw",
|
||||||
"tools/creating-skills",
|
"tools/creating-skills",
|
||||||
"tools/slash-commands",
|
"tools/slash-commands",
|
||||||
"tools/skills",
|
"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)
|
- `/allowlist` (list/add/remove allowlist entries)
|
||||||
- `/approve <id> allow-once|allow-always|deny` (resolve exec approval prompts)
|
- `/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)
|
- `/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)
|
- `/export-session [path]` (alias: `/export`) (export current session to HTML with full system prompt)
|
||||||
- `/whoami` (show your sender id; alias: `/id`)
|
- `/whoami` (show your sender id; alias: `/id`)
|
||||||
- `/session idle <duration|off>` (manage inactivity auto-unfocus for focused thread bindings)
|
- `/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.
|
- **`/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:** `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.
|
- 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";
|
import type { SessionEntry } from "../config/sessions.js";
|
||||||
|
|
||||||
const streamSimpleMock = vi.fn();
|
const streamSimpleMock = vi.fn();
|
||||||
const appendCustomEntryMock = vi.fn();
|
|
||||||
const buildSessionContextMock = vi.fn();
|
const buildSessionContextMock = vi.fn();
|
||||||
const getLeafEntryMock = vi.fn();
|
const getLeafEntryMock = vi.fn();
|
||||||
const branchMock = vi.fn();
|
const branchMock = vi.fn();
|
||||||
|
|
@ -13,11 +12,8 @@ const discoverModelsMock = vi.fn();
|
||||||
const resolveModelWithRegistryMock = vi.fn();
|
const resolveModelWithRegistryMock = vi.fn();
|
||||||
const getApiKeyForModelMock = vi.fn();
|
const getApiKeyForModelMock = vi.fn();
|
||||||
const requireApiKeyMock = vi.fn();
|
const requireApiKeyMock = vi.fn();
|
||||||
const acquireSessionWriteLockMock = vi.fn();
|
|
||||||
const resolveSessionAuthProfileOverrideMock = vi.fn();
|
const resolveSessionAuthProfileOverrideMock = vi.fn();
|
||||||
const getActiveEmbeddedRunSnapshotMock = vi.fn();
|
const getActiveEmbeddedRunSnapshotMock = vi.fn();
|
||||||
const waitForEmbeddedPiRunEndMock = vi.fn();
|
|
||||||
const diagWarnMock = vi.fn();
|
|
||||||
const diagDebugMock = vi.fn();
|
const diagDebugMock = vi.fn();
|
||||||
|
|
||||||
vi.mock("@mariozechner/pi-ai", () => ({
|
vi.mock("@mariozechner/pi-ai", () => ({
|
||||||
|
|
@ -31,7 +27,6 @@ vi.mock("@mariozechner/pi-coding-agent", () => ({
|
||||||
branch: branchMock,
|
branch: branchMock,
|
||||||
resetLeaf: resetLeafMock,
|
resetLeaf: resetLeafMock,
|
||||||
buildSessionContext: buildSessionContextMock,
|
buildSessionContext: buildSessionContextMock,
|
||||||
appendCustomEntry: appendCustomEntryMock,
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
@ -54,13 +49,8 @@ vi.mock("./model-auth.js", () => ({
|
||||||
requireApiKey: (...args: unknown[]) => requireApiKeyMock(...args),
|
requireApiKey: (...args: unknown[]) => requireApiKeyMock(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./session-write-lock.js", () => ({
|
|
||||||
acquireSessionWriteLock: (...args: unknown[]) => acquireSessionWriteLockMock(...args),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./pi-embedded-runner/runs.js", () => ({
|
vi.mock("./pi-embedded-runner/runs.js", () => ({
|
||||||
getActiveEmbeddedRunSnapshot: (...args: unknown[]) => getActiveEmbeddedRunSnapshotMock(...args),
|
getActiveEmbeddedRunSnapshot: (...args: unknown[]) => getActiveEmbeddedRunSnapshotMock(...args),
|
||||||
waitForEmbeddedPiRunEnd: (...args: unknown[]) => waitForEmbeddedPiRunEndMock(...args),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./auth-profiles/session-override.js", () => ({
|
vi.mock("./auth-profiles/session-override.js", () => ({
|
||||||
|
|
@ -70,12 +60,11 @@ vi.mock("./auth-profiles/session-override.js", () => ({
|
||||||
|
|
||||||
vi.mock("../logging/diagnostic.js", () => ({
|
vi.mock("../logging/diagnostic.js", () => ({
|
||||||
diagnosticLogger: {
|
diagnosticLogger: {
|
||||||
warn: (...args: unknown[]) => diagWarnMock(...args),
|
|
||||||
debug: (...args: unknown[]) => diagDebugMock(...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[]) {
|
function makeAsyncEvents(events: unknown[]) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -99,7 +88,6 @@ function createSessionEntry(overrides: Partial<SessionEntry> = {}): SessionEntry
|
||||||
describe("runBtwSideQuestion", () => {
|
describe("runBtwSideQuestion", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
streamSimpleMock.mockReset();
|
streamSimpleMock.mockReset();
|
||||||
appendCustomEntryMock.mockReset();
|
|
||||||
buildSessionContextMock.mockReset();
|
buildSessionContextMock.mockReset();
|
||||||
getLeafEntryMock.mockReset();
|
getLeafEntryMock.mockReset();
|
||||||
branchMock.mockReset();
|
branchMock.mockReset();
|
||||||
|
|
@ -110,11 +98,8 @@ describe("runBtwSideQuestion", () => {
|
||||||
resolveModelWithRegistryMock.mockReset();
|
resolveModelWithRegistryMock.mockReset();
|
||||||
getApiKeyForModelMock.mockReset();
|
getApiKeyForModelMock.mockReset();
|
||||||
requireApiKeyMock.mockReset();
|
requireApiKeyMock.mockReset();
|
||||||
acquireSessionWriteLockMock.mockReset();
|
|
||||||
resolveSessionAuthProfileOverrideMock.mockReset();
|
resolveSessionAuthProfileOverrideMock.mockReset();
|
||||||
getActiveEmbeddedRunSnapshotMock.mockReset();
|
getActiveEmbeddedRunSnapshotMock.mockReset();
|
||||||
waitForEmbeddedPiRunEndMock.mockReset();
|
|
||||||
diagWarnMock.mockReset();
|
|
||||||
diagDebugMock.mockReset();
|
diagDebugMock.mockReset();
|
||||||
|
|
||||||
buildSessionContextMock.mockReturnValue({
|
buildSessionContextMock.mockReturnValue({
|
||||||
|
|
@ -128,15 +113,11 @@ describe("runBtwSideQuestion", () => {
|
||||||
});
|
});
|
||||||
getApiKeyForModelMock.mockResolvedValue({ apiKey: "secret", mode: "api-key", source: "test" });
|
getApiKeyForModelMock.mockResolvedValue({ apiKey: "secret", mode: "api-key", source: "test" });
|
||||||
requireApiKeyMock.mockReturnValue("secret");
|
requireApiKeyMock.mockReturnValue("secret");
|
||||||
acquireSessionWriteLockMock.mockResolvedValue({
|
|
||||||
release: vi.fn().mockResolvedValue(undefined),
|
|
||||||
});
|
|
||||||
resolveSessionAuthProfileOverrideMock.mockResolvedValue("profile-1");
|
resolveSessionAuthProfileOverrideMock.mockResolvedValue("profile-1");
|
||||||
getActiveEmbeddedRunSnapshotMock.mockReturnValue(undefined);
|
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);
|
const onBlockReply = vi.fn().mockResolvedValue(undefined);
|
||||||
streamSimpleMock.mockReturnValue(
|
streamSimpleMock.mockReturnValue(
|
||||||
makeAsyncEvents([
|
makeAsyncEvents([
|
||||||
|
|
@ -212,17 +193,6 @@ describe("runBtwSideQuestion", () => {
|
||||||
text: "Side answer.",
|
text: "Side answer.",
|
||||||
btw: { question: "What changed?" },
|
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 () => {
|
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 () => {
|
it("returns the BTW answer without appending transcript custom entries", async () => {
|
||||||
acquireSessionWriteLockMock
|
|
||||||
.mockRejectedValueOnce(
|
|
||||||
new Error("session file locked (timeout 250ms): pid=123 /tmp/session.lock"),
|
|
||||||
)
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
release: vi.fn().mockResolvedValue(undefined),
|
|
||||||
});
|
|
||||||
streamSimpleMock.mockReturnValue(
|
streamSimpleMock.mockReturnValue(
|
||||||
makeAsyncEvents([
|
makeAsyncEvents([
|
||||||
{
|
{
|
||||||
|
|
@ -688,26 +651,10 @@ describe("runBtwSideQuestion", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual({ text: "323" });
|
expect(result).toEqual({ text: "323" });
|
||||||
await vi.waitFor(() => {
|
expect(buildSessionContextMock).toHaveBeenCalled();
|
||||||
expect(waitForEmbeddedPiRunEndMock).toHaveBeenCalledWith("session-1", 30000);
|
|
||||||
expect(appendCustomEntryMock).toHaveBeenCalledWith(
|
|
||||||
BTW_CUSTOM_TYPE,
|
|
||||||
expect.objectContaining({
|
|
||||||
question: "What is 17 * 19?",
|
|
||||||
answer: "323",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs deferred persistence failures through the diagnostic logger", async () => {
|
it("does not log transcript persistence warnings because BTW no longer writes to disk", 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"),
|
|
||||||
);
|
|
||||||
streamSimpleMock.mockReturnValue(
|
streamSimpleMock.mockReturnValue(
|
||||||
makeAsyncEvents([
|
makeAsyncEvents([
|
||||||
{
|
{
|
||||||
|
|
@ -747,11 +694,9 @@ describe("runBtwSideQuestion", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual({ text: "323" });
|
expect(result).toEqual({ text: "323" });
|
||||||
await vi.waitFor(() => {
|
expect(diagDebugMock).not.toHaveBeenCalledWith(
|
||||||
expect(diagWarnMock).toHaveBeenCalledWith(
|
expect.stringContaining("btw transcript persistence skipped"),
|
||||||
expect.stringContaining("btw transcript persistence skipped: sessionId=session-1"),
|
);
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("excludes tool results from BTW context to avoid replaying raw tool output", async () => {
|
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 { ensureOpenClawModelsJson } from "./models-config.js";
|
||||||
import { EmbeddedBlockChunker, type BlockReplyChunking } from "./pi-embedded-block-chunker.js";
|
import { EmbeddedBlockChunker, type BlockReplyChunking } from "./pi-embedded-block-chunker.js";
|
||||||
import { resolveModelWithRegistry } from "./pi-embedded-runner/model.js";
|
import { resolveModelWithRegistry } from "./pi-embedded-runner/model.js";
|
||||||
import {
|
import { getActiveEmbeddedRunSnapshot } from "./pi-embedded-runner/runs.js";
|
||||||
getActiveEmbeddedRunSnapshot,
|
|
||||||
waitForEmbeddedPiRunEnd,
|
|
||||||
} from "./pi-embedded-runner/runs.js";
|
|
||||||
import { mapThinkingLevel } from "./pi-embedded-runner/utils.js";
|
import { mapThinkingLevel } from "./pi-embedded-runner/utils.js";
|
||||||
import { discoverAuthStorage, discoverModels } from "./pi-model-discovery.js";
|
import { discoverAuthStorage, discoverModels } from "./pi-model-discovery.js";
|
||||||
import { stripToolResultDetails } from "./session-transcript-repair.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 = {
|
type SessionManagerLike = {
|
||||||
getLeafEntry?: () => {
|
getLeafEntry?: () => {
|
||||||
|
|
@ -47,97 +38,6 @@ type SessionManagerLike = {
|
||||||
buildSessionContext: () => { messages?: unknown[] };
|
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 {
|
function collectTextContent(content: Array<{ type?: string; text?: string }>): string {
|
||||||
return content
|
return content
|
||||||
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
.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.");
|
throw new Error("No active session context.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { model, authProfileId, authProfileIdSource } = await resolveRuntimeModel({
|
const { model, authProfileId } = await resolveRuntimeModel({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
provider: params.provider,
|
provider: params.provider,
|
||||||
model: params.model,
|
model: params.model,
|
||||||
|
|
@ -483,31 +383,9 @@ export async function runBtwSideQuestion(
|
||||||
throw new Error("No BTW response generated.");
|
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) {
|
if (emittedBlocks > 0) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { text: answer };
|
return { text: answer };
|
||||||
}
|
}
|
||||||
|
|
||||||
export { BTW_CUSTOM_TYPE };
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue