Add /btw side questions (#45444)

* feat(agent): add /btw side questions

* fix(agent): gate and log /btw reviews

* feat(btw): isolate side-question delivery

* test(reply): update route reply runtime mocks

* fix(btw): complete side-result delivery across clients

* fix(gateway): handle streamed btw side results

* fix(telegram): unblock btw side questions

* fix(reply): make external btw replies explicit

* fix(chat): keep btw side results ephemeral in internal history

* fix(btw): address remaining review feedback

* fix(chat): preserve btw history on mobile refresh

* fix(acp): keep btw replies out of prompt history

* refactor(btw): narrow side questions to live channels

* fix(btw): preserve channel typing indicators

* fix(btw): keep side questions isolated in chat

* fix(outbound): restore typed channel send deps

* fix(btw): avoid blocking replies on transcript persistence

* fix(btw): keep side questions fast

* docs(commands): document btw slash command

* docs(changelog): add btw side questions entry

* test(outbound): align session transcript mocks
This commit is contained in:
Nimrod Gutman 2026-03-14 17:27:54 +02:00 committed by GitHub
parent b5ba2101c7
commit 9aac55d306
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 4254 additions and 143 deletions

View File

@ -6,7 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Placeholder: replace with the first 2026.3.14 user-facing change.
- Commands/btw: add `/btw` side questions for quick tool-less answers about the current session without changing future session context, with dismissible in-session TUI answers and explicit BTW replies on external channels. (#45444) Thanks @ngutman.
- Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob.
### Fixes

View File

@ -76,6 +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)
- `/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)

View File

@ -60,6 +60,20 @@ describe("getTelegramSequentialKey", () => {
"telegram:123:control",
],
[{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/status" }) }, "telegram:123"],
[
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/btw what is the time?" }) },
"telegram:123:btw:1",
],
[
{
me: { username: "openclaw_bot" } as never,
message: mockMessage({
chat: mockChat({ id: 123 }),
text: "/btw@openclaw_bot what is the time?",
}),
},
"telegram:123:btw:1",
],
[
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "stop" }) },
"telegram:123:control",

View File

@ -1,5 +1,6 @@
import { type Message, type UserFromGetMe } from "@grammyjs/types";
import { isAbortRequestText } from "../../../src/auto-reply/reply/abort.js";
import { isBtwRequestText } from "../../../src/auto-reply/reply/btw-command.js";
import { resolveTelegramForumThreadId } from "./bot/helpers.js";
export type TelegramSequentialKeyContext = {
@ -41,6 +42,16 @@ export function getTelegramSequentialKey(ctx: TelegramSequentialKeyContext): str
}
return "telegram:control";
}
if (isBtwRequestText(rawText, botUsername ? { botUsername } : undefined)) {
const messageId = msg?.message_id;
if (typeof chatId === "number" && typeof messageId === "number") {
return `telegram:${chatId}:btw:${messageId}`;
}
if (typeof chatId === "number") {
return `telegram:${chatId}:btw`;
}
return "telegram:btw";
}
const isGroup = msg?.chat?.type === "group" || msg?.chat?.type === "supergroup";
const messageThreadId = msg?.message_thread_id;
const isForum = msg?.chat?.is_forum;

View File

@ -308,6 +308,21 @@ describe("web processMessage inbound contract", () => {
expect(replyOptions?.disableBlockStreaming).toBe(true);
});
it("passes sendComposing through as the reply typing callback", async () => {
const sendComposing = vi.fn(async () => undefined);
const args = createWhatsAppDirectStreamingArgs();
args.msg = {
...args.msg,
sendComposing,
};
await processMessage(args);
// oxlint-disable-next-line typescript/no-explicit-any
const dispatcherOptions = (capturedDispatchParams as any)?.dispatcherOptions;
expect(dispatcherOptions?.onReplyStart).toBe(sendComposing);
});
it("updates main last route for DM when session key matches main session key", async () => {
const updateLastRouteMock = vi.mocked(updateLastRouteInBackground);
updateLastRouteMock.mockClear();

View File

@ -42,7 +42,7 @@ export const whatsappOutbound: ChannelOutboundAdapter = {
return { channel: "whatsapp", messageId: "" };
}
const send =
resolveOutboundSendDep<typeof sendMessageWhatsApp>(deps, "whatsapp") ??
resolveOutboundSendDep<typeof import("./send.js").sendMessageWhatsApp>(deps, "whatsapp") ??
(await import("./send.js")).sendMessageWhatsApp;
const result = await send(to, normalizedText, {
verbose: false,
@ -55,7 +55,7 @@ export const whatsappOutbound: ChannelOutboundAdapter = {
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps, gifPlayback }) => {
const normalizedText = trimLeadingWhitespace(text);
const send =
resolveOutboundSendDep<typeof sendMessageWhatsApp>(deps, "whatsapp") ??
resolveOutboundSendDep<typeof import("./send.js").sendMessageWhatsApp>(deps, "whatsapp") ??
(await import("./send.js")).sendMessageWhatsApp;
const result = await send(to, normalizedText, {
verbose: false,

829
src/agents/btw.test.ts Normal file
View File

@ -0,0 +1,829 @@
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();
const resetLeafMock = vi.fn();
const ensureOpenClawModelsJsonMock = vi.fn();
const discoverAuthStorageMock = vi.fn();
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", () => ({
streamSimple: (...args: unknown[]) => streamSimpleMock(...args),
}));
vi.mock("@mariozechner/pi-coding-agent", () => ({
SessionManager: {
open: () => ({
getLeafEntry: getLeafEntryMock,
branch: branchMock,
resetLeaf: resetLeafMock,
buildSessionContext: buildSessionContextMock,
appendCustomEntry: appendCustomEntryMock,
}),
},
}));
vi.mock("./models-config.js", () => ({
ensureOpenClawModelsJson: (...args: unknown[]) => ensureOpenClawModelsJsonMock(...args),
}));
vi.mock("./pi-model-discovery.js", () => ({
discoverAuthStorage: (...args: unknown[]) => discoverAuthStorageMock(...args),
discoverModels: (...args: unknown[]) => discoverModelsMock(...args),
}));
vi.mock("./pi-embedded-runner/model.js", () => ({
resolveModelWithRegistry: (...args: unknown[]) => resolveModelWithRegistryMock(...args),
}));
vi.mock("./model-auth.js", () => ({
getApiKeyForModel: (...args: unknown[]) => getApiKeyForModelMock(...args),
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", () => ({
resolveSessionAuthProfileOverride: (...args: unknown[]) =>
resolveSessionAuthProfileOverrideMock(...args),
}));
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");
function makeAsyncEvents(events: unknown[]) {
return {
async *[Symbol.asyncIterator]() {
for (const event of events) {
yield event;
}
},
};
}
function createSessionEntry(overrides: Partial<SessionEntry> = {}): SessionEntry {
return {
sessionId: "session-1",
sessionFile: "session-1.jsonl",
updatedAt: Date.now(),
...overrides,
};
}
describe("runBtwSideQuestion", () => {
beforeEach(() => {
streamSimpleMock.mockReset();
appendCustomEntryMock.mockReset();
buildSessionContextMock.mockReset();
getLeafEntryMock.mockReset();
branchMock.mockReset();
resetLeafMock.mockReset();
ensureOpenClawModelsJsonMock.mockReset();
discoverAuthStorageMock.mockReset();
discoverModelsMock.mockReset();
resolveModelWithRegistryMock.mockReset();
getApiKeyForModelMock.mockReset();
requireApiKeyMock.mockReset();
acquireSessionWriteLockMock.mockReset();
resolveSessionAuthProfileOverrideMock.mockReset();
getActiveEmbeddedRunSnapshotMock.mockReset();
waitForEmbeddedPiRunEndMock.mockReset();
diagWarnMock.mockReset();
diagDebugMock.mockReset();
buildSessionContextMock.mockReturnValue({
messages: [{ role: "user", content: [{ type: "text", text: "hi" }], timestamp: 1 }],
});
getLeafEntryMock.mockReturnValue(null);
resolveModelWithRegistryMock.mockReturnValue({
provider: "anthropic",
id: "claude-sonnet-4-5",
api: "anthropic-messages",
});
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 () => {
const onBlockReply = vi.fn().mockResolvedValue(undefined);
streamSimpleMock.mockReturnValue(
makeAsyncEvents([
{
type: "text_delta",
delta: "Side answer.",
partial: {
role: "assistant",
content: [],
provider: "anthropic",
model: "claude-sonnet-4-5",
},
},
{
type: "text_end",
content: "Side answer.",
contentIndex: 0,
partial: {
role: "assistant",
content: [],
provider: "anthropic",
model: "claude-sonnet-4-5",
},
},
{
type: "done",
reason: "stop",
message: {
role: "assistant",
content: [{ type: "text", text: "Side answer." }],
provider: "anthropic",
api: "anthropic-messages",
model: "claude-sonnet-4-5",
stopReason: "stop",
usage: {
input: 1,
output: 2,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 3,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: Date.now(),
},
},
]),
);
const result = await runBtwSideQuestion({
cfg: {} as never,
agentDir: "/tmp/agent",
provider: "anthropic",
model: "claude-sonnet-4-5",
question: "What changed?",
sessionEntry: createSessionEntry(),
sessionStore: {},
sessionKey: "agent:main:main",
storePath: "/tmp/sessions.json",
resolvedThinkLevel: "low",
resolvedReasoningLevel: "off",
blockReplyChunking: {
minChars: 1,
maxChars: 200,
breakPreference: "paragraph",
},
resolvedBlockStreamingBreak: "text_end",
opts: { onBlockReply },
isNewSession: false,
});
expect(result).toBeUndefined();
expect(onBlockReply).toHaveBeenCalledWith({
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 () => {
streamSimpleMock.mockReturnValue(
makeAsyncEvents([
{
type: "done",
reason: "stop",
message: {
role: "assistant",
content: [{ type: "text", text: "Final answer." }],
provider: "anthropic",
api: "anthropic-messages",
model: "claude-sonnet-4-5",
stopReason: "stop",
usage: {
input: 1,
output: 2,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 3,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: Date.now(),
},
},
]),
);
const result = await runBtwSideQuestion({
cfg: {} as never,
agentDir: "/tmp/agent",
provider: "anthropic",
model: "claude-sonnet-4-5",
question: "What changed?",
sessionEntry: createSessionEntry(),
resolvedReasoningLevel: "off",
opts: {},
isNewSession: false,
});
expect(result).toEqual({ text: "Final answer." });
});
it("fails when the current branch has no messages", async () => {
buildSessionContextMock.mockReturnValue({ messages: [] });
streamSimpleMock.mockReturnValue(makeAsyncEvents([]));
await expect(
runBtwSideQuestion({
cfg: {} as never,
agentDir: "/tmp/agent",
provider: "anthropic",
model: "claude-sonnet-4-5",
question: "What changed?",
sessionEntry: createSessionEntry(),
resolvedReasoningLevel: "off",
opts: {},
isNewSession: false,
}),
).rejects.toThrow("No active session context.");
});
it("uses active-run snapshot messages for BTW context while the main run is in flight", async () => {
buildSessionContextMock.mockReturnValue({ messages: [] });
getActiveEmbeddedRunSnapshotMock.mockReturnValue({
transcriptLeafId: "assistant-1",
messages: [
{
role: "user",
content: [
{ type: "text", text: "write some things then wait 30 seconds and write more" },
],
timestamp: 1,
},
],
});
streamSimpleMock.mockReturnValue(
makeAsyncEvents([
{
type: "done",
reason: "stop",
message: {
role: "assistant",
content: [{ type: "text", text: "323" }],
provider: "anthropic",
api: "anthropic-messages",
model: "claude-sonnet-4-5",
stopReason: "stop",
usage: {
input: 1,
output: 2,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 3,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: Date.now(),
},
},
]),
);
const result = await runBtwSideQuestion({
cfg: {} as never,
agentDir: "/tmp/agent",
provider: "anthropic",
model: "claude-sonnet-4-5",
question: "What is 17 * 19?",
sessionEntry: createSessionEntry(),
resolvedReasoningLevel: "off",
opts: {},
isNewSession: false,
});
expect(result).toEqual({ text: "323" });
expect(streamSimpleMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
systemPrompt: expect.stringContaining("ephemeral /btw side question"),
messages: expect.arrayContaining([
expect.objectContaining({ role: "user" }),
expect.objectContaining({
role: "user",
content: [
{
type: "text",
text: expect.stringContaining(
"<btw_side_question>\nWhat is 17 * 19?\n</btw_side_question>",
),
},
],
}),
]),
}),
expect.anything(),
);
});
it("uses the in-flight prompt as background only when there is no prior transcript context", async () => {
buildSessionContextMock.mockReturnValue({ messages: [] });
getActiveEmbeddedRunSnapshotMock.mockReturnValue({
transcriptLeafId: null,
messages: [],
inFlightPrompt: "build me a tic-tac-toe game in brainfuck",
});
streamSimpleMock.mockReturnValue(
makeAsyncEvents([
{
type: "done",
reason: "stop",
message: {
role: "assistant",
content: [{ type: "text", text: "You're building a tic-tac-toe game in Brainfuck." }],
provider: "anthropic",
api: "anthropic-messages",
model: "claude-sonnet-4-5",
stopReason: "stop",
usage: {
input: 1,
output: 2,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 3,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: Date.now(),
},
},
]),
);
const result = await runBtwSideQuestion({
cfg: {} as never,
agentDir: "/tmp/agent",
provider: "anthropic",
model: "claude-sonnet-4-5",
question: "what are we doing?",
sessionEntry: createSessionEntry(),
resolvedReasoningLevel: "off",
opts: {},
isNewSession: false,
});
expect(result).toEqual({ text: "You're building a tic-tac-toe game in Brainfuck." });
expect(streamSimpleMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
messages: [
expect.objectContaining({
role: "user",
content: [
{
type: "text",
text: expect.stringContaining(
"<in_flight_main_task>\nbuild me a tic-tac-toe game in brainfuck\n</in_flight_main_task>",
),
},
],
}),
],
}),
expect.anything(),
);
});
it("wraps the side question so the model does not treat it as a main-task continuation", async () => {
streamSimpleMock.mockReturnValue(
makeAsyncEvents([
{
type: "done",
reason: "stop",
message: {
role: "assistant",
content: [{ type: "text", text: "About 93 million miles." }],
provider: "anthropic",
api: "anthropic-messages",
model: "claude-sonnet-4-5",
stopReason: "stop",
usage: {
input: 1,
output: 2,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 3,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: Date.now(),
},
},
]),
);
await runBtwSideQuestion({
cfg: {} as never,
agentDir: "/tmp/agent",
provider: "anthropic",
model: "claude-sonnet-4-5",
question: "what is the distance to the sun?",
sessionEntry: createSessionEntry(),
resolvedReasoningLevel: "off",
opts: {},
isNewSession: false,
});
const [, context] = streamSimpleMock.mock.calls[0] ?? [];
expect(context).toMatchObject({
systemPrompt: expect.stringContaining(
"Do not continue, resume, or complete any unfinished task",
),
});
expect(context).toMatchObject({
messages: expect.arrayContaining([
expect.objectContaining({
role: "user",
content: [
{
type: "text",
text: expect.stringContaining(
"Ignore any unfinished task in the conversation while answering it.",
),
},
],
}),
]),
});
});
it("branches away from an unresolved trailing user turn before building BTW context", async () => {
getLeafEntryMock.mockReturnValue({
type: "message",
parentId: "assistant-1",
message: { role: "user" },
});
streamSimpleMock.mockReturnValue(
makeAsyncEvents([
{
type: "done",
reason: "stop",
message: {
role: "assistant",
content: [{ type: "text", text: "323" }],
provider: "anthropic",
api: "anthropic-messages",
model: "claude-sonnet-4-5",
stopReason: "stop",
usage: {
input: 1,
output: 2,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 3,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: Date.now(),
},
},
]),
);
const result = await runBtwSideQuestion({
cfg: {} as never,
agentDir: "/tmp/agent",
provider: "anthropic",
model: "claude-sonnet-4-5",
question: "What is 17 * 19?",
sessionEntry: createSessionEntry(),
resolvedReasoningLevel: "off",
opts: {},
isNewSession: false,
});
expect(branchMock).toHaveBeenCalledWith("assistant-1");
expect(resetLeafMock).not.toHaveBeenCalled();
expect(buildSessionContextMock).toHaveBeenCalledTimes(1);
expect(result).toEqual({ text: "323" });
});
it("branches to the active run snapshot leaf when the session is busy", async () => {
getActiveEmbeddedRunSnapshotMock.mockReturnValue({
transcriptLeafId: "assistant-seed",
});
streamSimpleMock.mockReturnValue(
makeAsyncEvents([
{
type: "done",
reason: "stop",
message: {
role: "assistant",
content: [{ type: "text", text: "323" }],
provider: "anthropic",
api: "anthropic-messages",
model: "claude-sonnet-4-5",
stopReason: "stop",
usage: {
input: 1,
output: 2,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 3,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: Date.now(),
},
},
]),
);
const result = await runBtwSideQuestion({
cfg: {} as never,
agentDir: "/tmp/agent",
provider: "anthropic",
model: "claude-sonnet-4-5",
question: "What is 17 * 19?",
sessionEntry: createSessionEntry(),
resolvedReasoningLevel: "off",
opts: {},
isNewSession: false,
});
expect(branchMock).toHaveBeenCalledWith("assistant-seed");
expect(getLeafEntryMock).not.toHaveBeenCalled();
expect(result).toEqual({ text: "323" });
});
it("falls back when the active run snapshot leaf no longer exists", async () => {
getActiveEmbeddedRunSnapshotMock.mockReturnValue({
transcriptLeafId: "assistant-gone",
});
branchMock.mockImplementationOnce(() => {
throw new Error("Entry 3235c7c4 not found");
});
streamSimpleMock.mockReturnValue(
makeAsyncEvents([
{
type: "done",
reason: "stop",
message: {
role: "assistant",
content: [{ type: "text", text: "323" }],
provider: "anthropic",
api: "anthropic-messages",
model: "claude-sonnet-4-5",
stopReason: "stop",
usage: {
input: 1,
output: 2,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 3,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: Date.now(),
},
},
]),
);
const result = await runBtwSideQuestion({
cfg: {} as never,
agentDir: "/tmp/agent",
provider: "anthropic",
model: "claude-sonnet-4-5",
question: "What is 17 * 19?",
sessionEntry: createSessionEntry(),
resolvedReasoningLevel: "off",
opts: {},
isNewSession: false,
});
expect(branchMock).toHaveBeenCalledWith("assistant-gone");
expect(resetLeafMock).toHaveBeenCalled();
expect(result).toEqual({ text: "323" });
expect(diagDebugMock).toHaveBeenCalledWith(
expect.stringContaining("btw snapshot leaf unavailable: sessionId=session-1"),
);
});
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),
});
streamSimpleMock.mockReturnValue(
makeAsyncEvents([
{
type: "done",
reason: "stop",
message: {
role: "assistant",
content: [{ type: "text", text: "323" }],
provider: "anthropic",
api: "anthropic-messages",
model: "claude-sonnet-4-5",
stopReason: "stop",
usage: {
input: 1,
output: 2,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 3,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: Date.now(),
},
},
]),
);
const result = await runBtwSideQuestion({
cfg: {} as never,
agentDir: "/tmp/agent",
provider: "anthropic",
model: "claude-sonnet-4-5",
question: "What is 17 * 19?",
sessionEntry: createSessionEntry(),
resolvedReasoningLevel: "off",
opts: {},
isNewSession: false,
});
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",
}),
);
});
});
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"),
);
streamSimpleMock.mockReturnValue(
makeAsyncEvents([
{
type: "done",
reason: "stop",
message: {
role: "assistant",
content: [{ type: "text", text: "323" }],
provider: "anthropic",
api: "anthropic-messages",
model: "claude-sonnet-4-5",
stopReason: "stop",
usage: {
input: 1,
output: 2,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 3,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: Date.now(),
},
},
]),
);
const result = await runBtwSideQuestion({
cfg: {} as never,
agentDir: "/tmp/agent",
provider: "anthropic",
model: "claude-sonnet-4-5",
question: "What is 17 * 19?",
sessionEntry: createSessionEntry(),
resolvedReasoningLevel: "off",
opts: {},
isNewSession: false,
});
expect(result).toEqual({ text: "323" });
await vi.waitFor(() => {
expect(diagWarnMock).toHaveBeenCalledWith(
expect.stringContaining("btw transcript persistence skipped: sessionId=session-1"),
);
});
});
it("excludes tool results from BTW context to avoid replaying raw tool output", async () => {
getActiveEmbeddedRunSnapshotMock.mockReturnValue({
transcriptLeafId: "assistant-1",
messages: [
{
role: "user",
content: [{ type: "text", text: "seed" }],
timestamp: 1,
},
{
role: "toolResult",
content: [{ type: "text", text: "sensitive tool output" }],
details: { raw: "secret" },
timestamp: 2,
},
{
role: "assistant",
content: [{ type: "text", text: "done" }],
timestamp: 3,
},
],
});
streamSimpleMock.mockReturnValue(
makeAsyncEvents([
{
type: "done",
reason: "stop",
message: {
role: "assistant",
content: [{ type: "text", text: "323" }],
provider: "anthropic",
api: "anthropic-messages",
model: "claude-sonnet-4-5",
stopReason: "stop",
usage: {
input: 1,
output: 2,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 3,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: Date.now(),
},
},
]),
);
await runBtwSideQuestion({
cfg: {} as never,
agentDir: "/tmp/agent",
provider: "anthropic",
model: "claude-sonnet-4-5",
question: "What is 17 * 19?",
sessionEntry: createSessionEntry(),
resolvedReasoningLevel: "off",
opts: {},
isNewSession: false,
});
const [, context] = streamSimpleMock.mock.calls[0] ?? [];
expect(context).toMatchObject({
messages: [
expect.objectContaining({ role: "user" }),
expect.objectContaining({ role: "assistant" }),
expect.objectContaining({ role: "user" }),
],
});
expect((context as { messages?: Array<{ role?: string }> }).messages).not.toEqual(
expect.arrayContaining([expect.objectContaining({ role: "toolResult" })]),
);
});
});

513
src/agents/btw.ts Normal file
View File

@ -0,0 +1,513 @@
import {
streamSimple,
type Api,
type AssistantMessageEvent,
type ThinkingLevel as SimpleThinkingLevel,
type Message,
type Model,
} from "@mariozechner/pi-ai";
import { SessionManager } from "@mariozechner/pi-coding-agent";
import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js";
import type { GetReplyOptions, ReplyPayload } from "../auto-reply/types.js";
import type { OpenClawConfig } from "../config/config.js";
import {
resolveSessionFilePath,
resolveSessionFilePathOptions,
type SessionEntry,
} from "../config/sessions.js";
import { diagnosticLogger as diag } from "../logging/diagnostic.js";
import { resolveSessionAuthProfileOverride } from "./auth-profiles/session-override.js";
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 { 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?: () => {
id?: string;
type?: string;
parentId?: string | null;
message?: { role?: string };
} | null;
branch?: (parentId: string) => void;
resetLeaf?: () => void;
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")
.map((part) => part.text)
.join("");
}
function collectThinkingContent(content: Array<{ type?: string; thinking?: string }>): string {
return content
.filter((part): part is { type: "thinking"; thinking: string } => part.type === "thinking")
.map((part) => part.thinking)
.join("");
}
function buildBtwSystemPrompt(): string {
return [
"You are answering an ephemeral /btw side question about the current conversation.",
"Use the conversation only as background context.",
"Answer only the side question in the last user message.",
"Do not continue, resume, or complete any unfinished task from the conversation.",
"Do not emit tool calls, pseudo-tool calls, shell commands, file writes, patches, or code unless the side question explicitly asks for them.",
"Do not say you will continue the main task after answering.",
"If the question can be answered briefly, answer briefly.",
].join("\n");
}
function buildBtwQuestionPrompt(question: string, inFlightPrompt?: string): string {
const lines = [
"Answer this side question only.",
"Ignore any unfinished task in the conversation while answering it.",
];
const trimmedPrompt = inFlightPrompt?.trim();
if (trimmedPrompt) {
lines.push(
"",
"Current in-flight main task request for background context only:",
"<in_flight_main_task>",
trimmedPrompt,
"</in_flight_main_task>",
"Do not continue or complete that task while answering the side question.",
);
}
lines.push("", "<btw_side_question>", question.trim(), "</btw_side_question>");
return lines.join("\n");
}
function toSimpleContextMessages(messages: unknown[]): Message[] {
const contextMessages = messages.filter((message): message is Message => {
if (!message || typeof message !== "object") {
return false;
}
const role = (message as { role?: unknown }).role;
return role === "user" || role === "assistant";
});
return stripToolResultDetails(
contextMessages as Parameters<typeof stripToolResultDetails>[0],
) as Message[];
}
function resolveSimpleThinkingLevel(level?: ThinkLevel): SimpleThinkingLevel | undefined {
if (!level || level === "off") {
return undefined;
}
return mapThinkingLevel(level) as SimpleThinkingLevel;
}
function resolveSessionTranscriptPath(params: {
sessionId: string;
sessionEntry?: SessionEntry;
sessionKey?: string;
storePath?: string;
}): string | undefined {
try {
const agentId = params.sessionKey?.split(":")[1];
const pathOpts = resolveSessionFilePathOptions({
agentId,
storePath: params.storePath,
});
return resolveSessionFilePath(params.sessionId, params.sessionEntry, pathOpts);
} catch (error) {
diag.debug(
`resolveSessionTranscriptPath failed: sessionId=${params.sessionId} err=${String(error)}`,
);
return undefined;
}
}
async function resolveRuntimeModel(params: {
cfg: OpenClawConfig;
provider: string;
model: string;
agentDir: string;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
storePath?: string;
isNewSession: boolean;
}): Promise<{
model: Model<Api>;
authProfileId?: string;
authProfileIdSource?: "auto" | "user";
}> {
await ensureOpenClawModelsJson(params.cfg, params.agentDir);
const authStorage = discoverAuthStorage(params.agentDir);
const modelRegistry = discoverModels(authStorage, params.agentDir);
const model = resolveModelWithRegistry({
provider: params.provider,
modelId: params.model,
modelRegistry,
cfg: params.cfg,
});
if (!model) {
throw new Error(`Unknown model: ${params.provider}/${params.model}`);
}
const authProfileId = await resolveSessionAuthProfileOverride({
cfg: params.cfg,
provider: params.provider,
agentDir: params.agentDir,
sessionEntry: params.sessionEntry,
sessionStore: params.sessionStore,
sessionKey: params.sessionKey,
storePath: params.storePath,
isNewSession: params.isNewSession,
});
return {
model,
authProfileId,
authProfileIdSource: params.sessionEntry?.authProfileOverrideSource,
};
}
type RunBtwSideQuestionParams = {
cfg: OpenClawConfig;
agentDir: string;
provider: string;
model: string;
question: string;
sessionEntry: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
storePath?: string;
resolvedThinkLevel?: ThinkLevel;
resolvedReasoningLevel: ReasoningLevel;
blockReplyChunking?: BlockReplyChunking;
resolvedBlockStreamingBreak?: "text_end" | "message_end";
opts?: GetReplyOptions;
isNewSession: boolean;
};
export async function runBtwSideQuestion(
params: RunBtwSideQuestionParams,
): Promise<ReplyPayload | undefined> {
const sessionId = params.sessionEntry.sessionId?.trim();
if (!sessionId) {
throw new Error("No active session context.");
}
const sessionFile = resolveSessionTranscriptPath({
sessionId,
sessionEntry: params.sessionEntry,
sessionKey: params.sessionKey,
storePath: params.storePath,
});
if (!sessionFile) {
throw new Error("No active session transcript.");
}
const sessionManager = SessionManager.open(sessionFile) as SessionManagerLike;
const activeRunSnapshot = getActiveEmbeddedRunSnapshot(sessionId);
let messages: Message[] = [];
let inFlightPrompt: string | undefined;
if (Array.isArray(activeRunSnapshot?.messages) && activeRunSnapshot.messages.length > 0) {
messages = toSimpleContextMessages(activeRunSnapshot.messages);
inFlightPrompt = activeRunSnapshot.inFlightPrompt;
} else if (activeRunSnapshot) {
inFlightPrompt = activeRunSnapshot.inFlightPrompt;
if (activeRunSnapshot.transcriptLeafId && sessionManager.branch) {
try {
sessionManager.branch(activeRunSnapshot.transcriptLeafId);
} catch (error) {
diag.debug(
`btw snapshot leaf unavailable: sessionId=${sessionId} leaf=${activeRunSnapshot.transcriptLeafId} err=${String(error)}`,
);
sessionManager.resetLeaf?.();
}
} else {
sessionManager.resetLeaf?.();
}
} else {
const leafEntry = sessionManager.getLeafEntry?.();
if (leafEntry?.type === "message" && leafEntry.message?.role === "user") {
if (leafEntry.parentId && sessionManager.branch) {
sessionManager.branch(leafEntry.parentId);
} else {
sessionManager.resetLeaf?.();
}
}
}
if (messages.length === 0) {
const sessionContext = sessionManager.buildSessionContext();
messages = toSimpleContextMessages(
Array.isArray(sessionContext.messages) ? sessionContext.messages : [],
);
}
if (messages.length === 0 && !inFlightPrompt?.trim()) {
throw new Error("No active session context.");
}
const { model, authProfileId, authProfileIdSource } = await resolveRuntimeModel({
cfg: params.cfg,
provider: params.provider,
model: params.model,
agentDir: params.agentDir,
sessionEntry: params.sessionEntry,
sessionStore: params.sessionStore,
sessionKey: params.sessionKey,
storePath: params.storePath,
isNewSession: params.isNewSession,
});
const apiKeyInfo = await getApiKeyForModel({
model,
cfg: params.cfg,
profileId: authProfileId,
agentDir: params.agentDir,
});
const apiKey = requireApiKey(apiKeyInfo, model.provider);
const chunker =
params.opts?.onBlockReply && params.blockReplyChunking
? new EmbeddedBlockChunker(params.blockReplyChunking)
: undefined;
let emittedBlocks = 0;
let blockEmitChain: Promise<void> = Promise.resolve();
let answerText = "";
let reasoningText = "";
let assistantStarted = false;
let sawTextEvent = false;
const emitBlockChunk = async (text: string) => {
const trimmed = text.trim();
if (!trimmed || !params.opts?.onBlockReply) {
return;
}
emittedBlocks += 1;
blockEmitChain = blockEmitChain.then(async () => {
await params.opts?.onBlockReply?.({
text,
btw: { question: params.question },
});
});
await blockEmitChain;
};
const stream = streamSimple(
model,
{
systemPrompt: buildBtwSystemPrompt(),
messages: [
...messages,
{
role: "user",
content: [
{
type: "text",
text: buildBtwQuestionPrompt(params.question, inFlightPrompt),
},
],
timestamp: Date.now(),
},
],
},
{
apiKey,
reasoning: resolveSimpleThinkingLevel(params.resolvedThinkLevel),
signal: params.opts?.abortSignal,
},
);
let finalEvent:
| Extract<AssistantMessageEvent, { type: "done" }>
| Extract<AssistantMessageEvent, { type: "error" }>
| undefined;
for await (const event of stream) {
finalEvent = event.type === "done" || event.type === "error" ? event : finalEvent;
if (!assistantStarted && (event.type === "text_start" || event.type === "start")) {
assistantStarted = true;
await params.opts?.onAssistantMessageStart?.();
}
if (event.type === "text_delta") {
sawTextEvent = true;
answerText += event.delta;
chunker?.append(event.delta);
if (chunker && params.resolvedBlockStreamingBreak === "text_end") {
chunker.drain({ force: false, emit: (chunk) => void emitBlockChunk(chunk) });
}
continue;
}
if (event.type === "text_end" && chunker && params.resolvedBlockStreamingBreak === "text_end") {
chunker.drain({ force: true, emit: (chunk) => void emitBlockChunk(chunk) });
continue;
}
if (event.type === "thinking_delta") {
reasoningText += event.delta;
if (params.resolvedReasoningLevel !== "off") {
await params.opts?.onReasoningStream?.({ text: reasoningText, isReasoning: true });
}
continue;
}
if (event.type === "thinking_end" && params.resolvedReasoningLevel !== "off") {
await params.opts?.onReasoningEnd?.();
}
}
if (chunker && params.resolvedBlockStreamingBreak !== "text_end" && chunker.hasBuffered()) {
chunker.drain({ force: true, emit: (chunk) => void emitBlockChunk(chunk) });
}
await blockEmitChain;
if (finalEvent?.type === "error") {
const message = collectTextContent(finalEvent.error.content);
throw new Error(message || finalEvent.error.errorMessage || "BTW failed.");
}
const finalMessage = finalEvent?.type === "done" ? finalEvent.message : undefined;
if (finalMessage) {
if (!sawTextEvent) {
answerText = collectTextContent(finalMessage.content);
}
if (!reasoningText) {
reasoningText = collectThinkingContent(finalMessage.content);
}
}
const answer = answerText.trim();
if (!answer) {
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 };

View File

@ -111,6 +111,7 @@ import {
clearActiveEmbeddedRun,
type EmbeddedPiQueueHandle,
setActiveEmbeddedRun,
updateActiveEmbeddedRunSnapshot,
} from "../runs.js";
import { buildEmbeddedSandboxInfo } from "../sandbox-info.js";
import { prewarmSessionFile, trackSessionManagerAccess } from "../session-manager-cache.js";
@ -830,6 +831,7 @@ function extractBalancedJsonPrefix(raw: string): string | null {
const MAX_TOOLCALL_REPAIR_BUFFER_CHARS = 64_000;
const MAX_TOOLCALL_REPAIR_TRAILING_CHARS = 3;
const TOOLCALL_REPAIR_ALLOWED_TRAILING_RE = /^[^\s{}[\]":,\\]{1,3}$/;
const MAX_BTW_SNAPSHOT_MESSAGES = 100;
function shouldAttemptMalformedToolCallRepair(partialJson: string, delta: string): boolean {
if (/[}\]]/.test(delta)) {
@ -2376,6 +2378,8 @@ export async function runEmbeddedAttempt(
`runId=${params.runId} sessionId=${params.sessionId}`,
);
}
const transcriptLeafId =
(sessionManager.getLeafEntry() as { id?: string } | null | undefined)?.id ?? null;
try {
// Idempotent cleanup for legacy sessions with persisted image payloads.
@ -2454,6 +2458,13 @@ export async function runEmbeddedAttempt(
});
}
const btwSnapshotMessages = activeSession.messages.slice(-MAX_BTW_SNAPSHOT_MESSAGES);
updateActiveEmbeddedRunSnapshot(params.sessionId, {
transcriptLeafId,
messages: btwSnapshotMessages,
inFlightPrompt: effectivePrompt,
});
// Only pass images option if there are actually images to pass
// This avoids potential issues with models that don't expect the images parameter
if (imageResult.images.length > 0) {

View File

@ -4,7 +4,9 @@ import {
__testing,
abortEmbeddedPiRun,
clearActiveEmbeddedRun,
getActiveEmbeddedRunSnapshot,
setActiveEmbeddedRun,
updateActiveEmbeddedRunSnapshot,
waitForActiveEmbeddedRuns,
} from "./runs.js";
@ -137,4 +139,28 @@ describe("pi-embedded runner run registry", () => {
runsB.__testing.resetActiveEmbeddedRuns();
}
});
it("tracks and clears per-session transcript snapshots for active runs", () => {
const handle = {
queueMessage: async () => {},
isStreaming: () => true,
isCompacting: () => false,
abort: vi.fn(),
};
setActiveEmbeddedRun("session-snapshot", handle);
updateActiveEmbeddedRunSnapshot("session-snapshot", {
transcriptLeafId: "assistant-1",
messages: [{ role: "user", content: [{ type: "text", text: "hello" }], timestamp: 1 }],
inFlightPrompt: "keep going",
});
expect(getActiveEmbeddedRunSnapshot("session-snapshot")).toEqual({
transcriptLeafId: "assistant-1",
messages: [{ role: "user", content: [{ type: "text", text: "hello" }], timestamp: 1 }],
inFlightPrompt: "keep going",
});
clearActiveEmbeddedRun("session-snapshot", handle);
expect(getActiveEmbeddedRunSnapshot("session-snapshot")).toBeUndefined();
});
});

View File

@ -12,6 +12,12 @@ type EmbeddedPiQueueHandle = {
abort: () => void;
};
export type ActiveEmbeddedRunSnapshot = {
transcriptLeafId: string | null;
messages?: unknown[];
inFlightPrompt?: string;
};
type EmbeddedRunWaiter = {
resolve: (ended: boolean) => void;
timer: NodeJS.Timeout;
@ -25,9 +31,11 @@ const EMBEDDED_RUN_STATE_KEY = Symbol.for("openclaw.embeddedRunState");
const embeddedRunState = resolveGlobalSingleton(EMBEDDED_RUN_STATE_KEY, () => ({
activeRuns: new Map<string, EmbeddedPiQueueHandle>(),
snapshots: new Map<string, ActiveEmbeddedRunSnapshot>(),
waiters: new Map<string, Set<EmbeddedRunWaiter>>(),
}));
const ACTIVE_EMBEDDED_RUNS = embeddedRunState.activeRuns;
const ACTIVE_EMBEDDED_RUN_SNAPSHOTS = embeddedRunState.snapshots;
const EMBEDDED_RUN_WAITERS = embeddedRunState.waiters;
export function queueEmbeddedPiMessage(sessionId: string, text: string): boolean {
@ -135,6 +143,12 @@ export function getActiveEmbeddedRunCount(): number {
return ACTIVE_EMBEDDED_RUNS.size;
}
export function getActiveEmbeddedRunSnapshot(
sessionId: string,
): ActiveEmbeddedRunSnapshot | undefined {
return ACTIVE_EMBEDDED_RUN_SNAPSHOTS.get(sessionId);
}
/**
* Wait for active embedded runs to drain.
*
@ -230,6 +244,16 @@ export function setActiveEmbeddedRun(
}
}
export function updateActiveEmbeddedRunSnapshot(
sessionId: string,
snapshot: ActiveEmbeddedRunSnapshot,
) {
if (!ACTIVE_EMBEDDED_RUNS.has(sessionId)) {
return;
}
ACTIVE_EMBEDDED_RUN_SNAPSHOTS.set(sessionId, snapshot);
}
export function clearActiveEmbeddedRun(
sessionId: string,
handle: EmbeddedPiQueueHandle,
@ -237,6 +261,7 @@ export function clearActiveEmbeddedRun(
) {
if (ACTIVE_EMBEDDED_RUNS.get(sessionId) === handle) {
ACTIVE_EMBEDDED_RUNS.delete(sessionId);
ACTIVE_EMBEDDED_RUN_SNAPSHOTS.delete(sessionId);
logSessionStateChange({ sessionId, sessionKey, state: "idle", reason: "run_completed" });
if (!sessionId.startsWith("probe-")) {
diag.debug(`run cleared: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
@ -257,6 +282,7 @@ export const __testing = {
}
EMBEDDED_RUN_WAITERS.clear();
ACTIVE_EMBEDDED_RUNS.clear();
ACTIVE_EMBEDDED_RUN_SNAPSHOTS.clear();
},
};

View File

@ -196,6 +196,14 @@ function buildChatCommands(): ChatCommandDefinition[] {
acceptsArgs: true,
category: "status",
}),
defineChatCommand({
key: "btw",
nativeName: "btw",
description: "Ask a side question without changing future session context.",
textAlias: "/btw",
acceptsArgs: true,
category: "tools",
}),
defineChatCommand({
key: "export-session",
nativeName: "export-session",

View File

@ -0,0 +1,26 @@
import { normalizeCommandBody, type CommandNormalizeOptions } from "../commands-registry.js";
const BTW_COMMAND_RE = /^\/btw(?::|\s|$)/i;
export function isBtwRequestText(text?: string, options?: CommandNormalizeOptions): boolean {
if (!text) {
return false;
}
const normalized = normalizeCommandBody(text, options).trim();
return BTW_COMMAND_RE.test(normalized);
}
export function extractBtwQuestion(
text?: string,
options?: CommandNormalizeOptions,
): string | null {
if (!text) {
return null;
}
const normalized = normalizeCommandBody(text, options).trim();
const match = normalized.match(/^\/btw(?:\s+(.*))?$/i);
if (!match) {
return null;
}
return match[1]?.trim() ?? "";
}

View File

@ -0,0 +1,131 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { buildCommandTestParams } from "./commands.test-harness.js";
import { createMockTypingController } from "./test-helpers.js";
const runBtwSideQuestionMock = vi.fn();
vi.mock("../../agents/btw.js", () => ({
runBtwSideQuestion: (...args: unknown[]) => runBtwSideQuestionMock(...args),
}));
const { handleBtwCommand } = await import("./commands-btw.js");
function buildParams(commandBody: string) {
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as OpenClawConfig;
return buildCommandTestParams(commandBody, cfg, undefined, { workspaceDir: "/tmp/workspace" });
}
describe("handleBtwCommand", () => {
beforeEach(() => {
runBtwSideQuestionMock.mockReset();
});
it("returns usage when the side question is missing", async () => {
const result = await handleBtwCommand(buildParams("/btw"), true);
expect(result).toEqual({
shouldContinue: false,
reply: { text: "Usage: /btw <side question>" },
});
});
it("ignores /btw when text commands are disabled", async () => {
const result = await handleBtwCommand(buildParams("/btw what changed?"), false);
expect(result).toBeNull();
expect(runBtwSideQuestionMock).not.toHaveBeenCalled();
});
it("ignores /btw from unauthorized senders", async () => {
const params = buildParams("/btw what changed?");
params.command.isAuthorizedSender = false;
const result = await handleBtwCommand(params, true);
expect(result).toEqual({ shouldContinue: false });
expect(runBtwSideQuestionMock).not.toHaveBeenCalled();
});
it("requires an active session context", async () => {
const params = buildParams("/btw what changed?");
params.sessionEntry = undefined;
const result = await handleBtwCommand(params, true);
expect(result).toEqual({
shouldContinue: false,
reply: { text: "⚠️ /btw requires an active session with existing context." },
});
});
it("still delegates while the session is actively running", async () => {
const params = buildParams("/btw what changed?");
params.agentDir = "/tmp/agent";
params.sessionEntry = {
sessionId: "session-1",
updatedAt: Date.now(),
};
runBtwSideQuestionMock.mockResolvedValue({ text: "snapshot answer" });
const result = await handleBtwCommand(params, true);
expect(runBtwSideQuestionMock).toHaveBeenCalledWith(
expect.objectContaining({
question: "what changed?",
sessionEntry: params.sessionEntry,
resolvedThinkLevel: "off",
resolvedReasoningLevel: "off",
}),
);
expect(result).toEqual({
shouldContinue: false,
reply: { text: "snapshot answer", btw: { question: "what changed?" } },
});
});
it("starts the typing keepalive while the side question runs", async () => {
const params = buildParams("/btw what changed?");
const typing = createMockTypingController();
params.typing = typing;
params.agentDir = "/tmp/agent";
params.sessionEntry = {
sessionId: "session-1",
updatedAt: Date.now(),
};
runBtwSideQuestionMock.mockResolvedValue({ text: "snapshot answer" });
await handleBtwCommand(params, true);
expect(typing.startTypingLoop).toHaveBeenCalledTimes(1);
});
it("delegates to the side-question runner", async () => {
const params = buildParams("/btw what changed?");
params.agentDir = "/tmp/agent";
params.sessionEntry = {
sessionId: "session-1",
updatedAt: Date.now(),
};
runBtwSideQuestionMock.mockResolvedValue({ text: "nothing important" });
const result = await handleBtwCommand(params, true);
expect(runBtwSideQuestionMock).toHaveBeenCalledWith(
expect.objectContaining({
question: "what changed?",
agentDir: "/tmp/agent",
sessionEntry: params.sessionEntry,
resolvedThinkLevel: "off",
resolvedReasoningLevel: "off",
}),
);
expect(result).toEqual({
shouldContinue: false,
reply: { text: "nothing important", btw: { question: "what changed?" } },
});
});
});

View File

@ -0,0 +1,80 @@
import { runBtwSideQuestion } from "../../agents/btw.js";
import { extractBtwQuestion } from "./btw-command.js";
import { rejectUnauthorizedCommand } from "./command-gates.js";
import type { CommandHandler } from "./commands-types.js";
const BTW_USAGE = "Usage: /btw <side question>";
export const handleBtwCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) {
return null;
}
const question = extractBtwQuestion(params.command.commandBodyNormalized);
if (question === null) {
return null;
}
const unauthorized = rejectUnauthorizedCommand(params, "/btw");
if (unauthorized) {
return unauthorized;
}
if (!question) {
return {
shouldContinue: false,
reply: { text: BTW_USAGE },
};
}
if (!params.sessionEntry?.sessionId) {
return {
shouldContinue: false,
reply: { text: "⚠️ /btw requires an active session with existing context." },
};
}
if (!params.agentDir) {
return {
shouldContinue: false,
reply: {
text: "⚠️ /btw is unavailable because the active agent directory could not be resolved.",
},
};
}
try {
await params.typing?.startTypingLoop();
const reply = await runBtwSideQuestion({
cfg: params.cfg,
agentDir: params.agentDir,
provider: params.provider,
model: params.model,
question,
sessionEntry: params.sessionEntry,
sessionStore: params.sessionStore,
sessionKey: params.sessionKey,
storePath: params.storePath,
// BTW is intentionally a quick side question, so do not inherit slower
// session-level think/reasoning settings from the main run.
resolvedThinkLevel: "off",
resolvedReasoningLevel: "off",
blockReplyChunking: params.blockReplyChunking,
resolvedBlockStreamingBreak: params.resolvedBlockStreamingBreak,
opts: params.opts,
isNewSession: false,
});
return {
shouldContinue: false,
reply: reply ? { ...reply, btw: { question } } : reply,
};
} catch (error) {
const message = error instanceof Error ? error.message.trim() : "";
return {
shouldContinue: false,
reply: {
text: `⚠️ /btw failed${message ? `: ${message}` : "."}`,
btw: { question },
isError: true,
},
};
}
};

View File

@ -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 {
@ -174,6 +175,7 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
HANDLERS = [
// Plugin commands are processed first, before built-in commands
handlePluginCommand,
handleBtwCommand,
handleBashCommand,
handleActivationCommand,
handleSendPolicyCommand,

View File

@ -1,11 +1,13 @@
import type { BlockReplyChunking } from "../../agents/pi-embedded-block-chunker.js";
import type { SkillCommandSpec } from "../../agents/skills.js";
import type { ChannelId } from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry, SessionScope } from "../../config/sessions.js";
import type { MsgContext } from "../templating.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js";
import type { ReplyPayload } from "../types.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import type { InlineDirectives } from "./directive-handling.js";
import type { TypingController } from "./typing.js";
export type CommandContext = {
surface: string;
@ -44,17 +46,21 @@ export type HandleCommandsParams = {
storePath?: string;
sessionScope?: SessionScope;
workspaceDir: string;
opts?: GetReplyOptions;
defaultGroupActivation: () => "always" | "mention";
resolvedThinkLevel?: ThinkLevel;
resolvedVerboseLevel: VerboseLevel;
resolvedReasoningLevel: ReasoningLevel;
resolvedElevatedLevel?: ElevatedLevel;
blockReplyChunking?: BlockReplyChunking;
resolvedBlockStreamingBreak?: "text_end" | "message_end";
resolveDefaultThinkingLevel: () => Promise<ThinkLevel | undefined>;
provider: string;
model: string;
contextTokens: number;
isGroup: boolean;
skillCommands?: SkillCommandSpec[];
typing?: TypingController;
};
export type CommandHandlerResult = {

View File

@ -1,5 +1,6 @@
import { collectTextContentBlocks } from "../../agents/content-blocks.js";
import { createOpenClawTools } from "../../agents/openclaw-tools.js";
import type { BlockReplyChunking } from "../../agents/pi-embedded-block-chunker.js";
import type { SkillCommandSpec } from "../../agents/skills.js";
import { applyOwnerOnlyToolPolicy } from "../../agents/tool-policy.js";
import { getChannelDock } from "../../channels/dock.js";
@ -37,6 +38,7 @@ function getBuiltinSlashCommands(): Set<string> {
return builtinSlashCommands;
}
builtinSlashCommands = listReservedChatSlashCommandNames([
"btw",
"think",
"verbose",
"reasoning",
@ -113,6 +115,8 @@ export async function handleInlineActions(params: {
resolvedVerboseLevel: VerboseLevel | undefined;
resolvedReasoningLevel: ReasoningLevel;
resolvedElevatedLevel: ElevatedLevel;
blockReplyChunking?: BlockReplyChunking;
resolvedBlockStreamingBreak?: "text_end" | "message_end";
resolveDefaultThinkingLevel: Awaited<
ReturnType<typeof createModelSelectionState>
>["resolveDefaultThinkingLevel"];
@ -152,6 +156,8 @@ export async function handleInlineActions(params: {
resolvedVerboseLevel,
resolvedReasoningLevel,
resolvedElevatedLevel,
blockReplyChunking,
resolvedBlockStreamingBreak,
resolveDefaultThinkingLevel,
provider,
model,
@ -357,17 +363,21 @@ export async function handleInlineActions(params: {
storePath,
sessionScope,
workspaceDir,
opts,
defaultGroupActivation: defaultActivation,
resolvedThinkLevel,
resolvedVerboseLevel: resolvedVerboseLevel ?? "off",
resolvedReasoningLevel,
resolvedElevatedLevel,
blockReplyChunking,
resolvedBlockStreamingBreak,
resolveDefaultThinkingLevel,
provider,
model,
contextTokens,
isGroup,
skillCommands,
typing,
});
if (inlineCommand) {

View File

@ -332,6 +332,8 @@ export async function getReplyFromConfig(
resolvedVerboseLevel,
resolvedReasoningLevel,
resolvedElevatedLevel,
blockReplyChunking,
resolvedBlockStreamingBreak,
resolveDefaultThinkingLevel: modelState.resolveDefaultThinkingLevel,
provider,
model,

View File

@ -10,6 +10,19 @@ import type { ReplyPayload } from "../types.js";
import { extractReplyToTag } from "./reply-tags.js";
import { createReplyToModeFilterForChannel } from "./reply-threading.js";
export function formatBtwTextForExternalDelivery(payload: ReplyPayload): string | undefined {
const text = payload.text?.trim();
if (!text) {
return payload.text;
}
const question = payload.btw?.question?.trim();
if (!question) {
return payload.text;
}
const formatted = `BTW\nQuestion: ${question}\n\n${text}`;
return text === formatted || text.startsWith("BTW\nQuestion:") ? text : formatted;
}
function resolveReplyThreadingForPayload(params: {
payload: ReplyPayload;
implicitReplyToId?: string;

View File

@ -44,25 +44,33 @@ vi.mock("../../../extensions/slack/src/send.js", () => ({
vi.mock("../../../extensions/telegram/src/send.js", () => ({
sendMessageTelegram: mocks.sendMessageTelegram,
}));
vi.mock("../../../extensions/telegram/src/send.js", () => ({
sendMessageTelegram: mocks.sendMessageTelegram,
}));
vi.mock("../../../extensions/whatsapp/src/send.js", () => ({
sendMessageWhatsApp: mocks.sendMessageWhatsApp,
sendPollWhatsApp: mocks.sendMessageWhatsApp,
}));
vi.mock("../../../extensions/discord/src/send.js", () => ({
sendMessageDiscord: mocks.sendMessageDiscord,
sendPollDiscord: mocks.sendMessageDiscord,
sendWebhookMessageDiscord: vi.fn(),
}));
vi.mock("../../../extensions/mattermost/src/mattermost/send.js", () => ({
sendMessageMattermost: mocks.sendMessageMattermost,
}));
vi.mock("../../infra/outbound/deliver.js", async () => {
const actual = await vi.importActual<typeof import("../../infra/outbound/deliver.js")>(
"../../infra/outbound/deliver.js",
vi.mock("../../infra/outbound/deliver-runtime.js", async () => {
const actual = await vi.importActual<typeof import("../../infra/outbound/deliver-runtime.js")>(
"../../infra/outbound/deliver-runtime.js",
);
return {
...actual,
deliverOutboundPayloads: mocks.deliverOutboundPayloads,
};
});
const actualDeliver = await vi.importActual<typeof import("../../infra/outbound/deliver.js")>(
"../../infra/outbound/deliver.js",
);
const actualDeliver = await vi.importActual<
typeof import("../../infra/outbound/deliver-runtime.js")
>("../../infra/outbound/deliver-runtime.js");
const { routeReply } = await import("./route-reply.js");
@ -294,6 +302,36 @@ describe("routeReply", () => {
);
});
it("formats BTW replies prominently on routed sends", async () => {
mocks.sendMessageSlack.mockClear();
await routeReply({
payload: { text: "323", btw: { question: "what is 17 * 19?" } },
channel: "slack",
to: "channel:C123",
cfg: {} as never,
});
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
"channel:C123",
"BTW\nQuestion: what is 17 * 19?\n\n323",
expect.any(Object),
);
});
it("formats BTW replies prominently on routed discord sends", async () => {
mocks.sendMessageDiscord.mockClear();
await routeReply({
payload: { text: "323", btw: { question: "what is 17 * 19?" } },
channel: "discord",
to: "channel:123456",
cfg: {} as never,
});
expect(mocks.sendMessageDiscord).toHaveBeenCalledWith(
"channel:123456",
"BTW\nQuestion: what is 17 * 19?\n\n323",
expect.any(Object),
);
});
it("passes replyToId to Telegram sends", async () => {
mocks.sendMessageTelegram.mockClear();
await routeReply({

View File

@ -18,7 +18,10 @@ import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/m
import type { OriginatingChannelType } from "../templating.js";
import type { ReplyPayload } from "../types.js";
import { normalizeReplyPayload } from "./normalize-reply.js";
import { shouldSuppressReasoningPayload } from "./reply-payloads.js";
import {
formatBtwTextForExternalDelivery,
shouldSuppressReasoningPayload,
} from "./reply-payloads.js";
let deliverRuntimePromise: Promise<
typeof import("../../infra/outbound/deliver-runtime.js")
@ -102,24 +105,28 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
if (!normalized) {
return { ok: true };
}
const externalPayload: ReplyPayload = {
...normalized,
text: formatBtwTextForExternalDelivery(normalized),
};
let text = normalized.text ?? "";
let mediaUrls = (normalized.mediaUrls?.filter(Boolean) ?? []).length
? (normalized.mediaUrls?.filter(Boolean) as string[])
: normalized.mediaUrl
? [normalized.mediaUrl]
let text = externalPayload.text ?? "";
let mediaUrls = (externalPayload.mediaUrls?.filter(Boolean) ?? []).length
? (externalPayload.mediaUrls?.filter(Boolean) as string[])
: externalPayload.mediaUrl
? [externalPayload.mediaUrl]
: [];
const replyToId = normalized.replyToId;
const replyToId = externalPayload.replyToId;
let hasSlackBlocks = false;
if (
channel === "slack" &&
normalized.channelData?.slack &&
typeof normalized.channelData.slack === "object" &&
!Array.isArray(normalized.channelData.slack)
externalPayload.channelData?.slack &&
typeof externalPayload.channelData.slack === "object" &&
!Array.isArray(externalPayload.channelData.slack)
) {
try {
hasSlackBlocks = Boolean(
parseSlackBlocksInput((normalized.channelData.slack as { blocks?: unknown }).blocks)
parseSlackBlocksInput((externalPayload.channelData.slack as { blocks?: unknown }).blocks)
?.length,
);
} catch {
@ -168,7 +175,7 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
channel: channelId,
to,
accountId: accountId ?? undefined,
payloads: [normalized],
payloads: [externalPayload],
replyToId: resolvedReplyToId ?? null,
threadId: resolvedThreadId,
session: outboundSession,

View File

@ -76,6 +76,9 @@ export type ReplyPayload = {
text?: string;
mediaUrl?: string;
mediaUrls?: string[];
btw?: {
question: string;
};
replyToId?: string;
replyToTag?: boolean;
/** True when [[reply_to_current]] was present but not yet mapped to a message id. */

View File

@ -8,6 +8,7 @@ import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
import type { MsgContext } from "../../auto-reply/templating.js";
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
import { resolveSessionFilePath } from "../../config/sessions.js";
import { jsonUtf8Bytes } from "../../infra/json-utf8-bytes.js";
@ -130,6 +131,16 @@ type ChatSendOriginatingRoute = {
explicitDeliverRoute: boolean;
};
type SideResultPayload = {
kind: "btw";
runId: string;
sessionKey: string;
question: string;
text: string;
isError?: boolean;
ts: number;
};
function resolveChatSendOriginatingRoute(params: {
client?: { mode?: string | null; id?: string | null } | null;
deliver?: boolean;
@ -900,6 +911,33 @@ function broadcastChatFinal(params: {
params.context.agentRunSeq.delete(params.runId);
}
function isBtwReplyPayload(payload: ReplyPayload | undefined): payload is ReplyPayload & {
btw: { question: string };
text: string;
} {
return (
typeof payload?.btw?.question === "string" &&
payload.btw.question.trim().length > 0 &&
typeof payload.text === "string" &&
payload.text.trim().length > 0
);
}
function broadcastSideResult(params: {
context: Pick<GatewayRequestContext, "broadcast" | "nodeSendToSession" | "agentRunSeq">;
payload: SideResultPayload;
}) {
const seq = nextChatSeq({ agentRunSeq: params.context.agentRunSeq }, params.payload.runId);
params.context.broadcast("chat.side_result", {
...params.payload,
seq,
});
params.context.nodeSendToSession(params.payload.sessionKey, "chat.side_result", {
...params.payload,
seq,
});
}
function broadcastChatError(params: {
context: Pick<GatewayRequestContext, "broadcast" | "nodeSendToSession" | "agentRunSeq">;
runId: string;
@ -1284,21 +1322,17 @@ export const chatHandlers: GatewayRequestHandlers = {
agentId,
channel: INTERNAL_MESSAGE_CHANNEL,
});
const finalReplyParts: string[] = [];
const deliveredReplies: Array<{ payload: ReplyPayload; kind: "block" | "final" }> = [];
const dispatcher = createReplyDispatcher({
...prefixOptions,
onError: (err) => {
context.logGateway.warn(`webchat dispatch failed: ${formatForLog(err)}`);
},
deliver: async (payload, info) => {
if (info.kind !== "final") {
if (info.kind !== "block" && info.kind !== "final") {
return;
}
const text = payload.text?.trim() ?? "";
if (!text) {
return;
}
finalReplyParts.push(text);
deliveredReplies.push({ payload, kind: info.kind });
},
});
@ -1335,48 +1369,78 @@ export const chatHandlers: GatewayRequestHandlers = {
})
.then(() => {
if (!agentRunStarted) {
const combinedReply = finalReplyParts
.map((part) => part.trim())
const btwReplies = deliveredReplies
.map((entry) => entry.payload)
.filter(isBtwReplyPayload);
const btwText = btwReplies
.map((payload) => payload.text.trim())
.filter(Boolean)
.join("\n\n")
.trim();
let message: Record<string, unknown> | undefined;
if (combinedReply) {
const { storePath: latestStorePath, entry: latestEntry } =
loadSessionEntry(sessionKey);
const sessionId = latestEntry?.sessionId ?? entry?.sessionId ?? clientRunId;
const appended = appendAssistantTranscriptMessage({
message: combinedReply,
sessionId,
storePath: latestStorePath,
sessionFile: latestEntry?.sessionFile,
agentId,
createIfMissing: true,
if (btwReplies.length > 0 && btwText) {
broadcastSideResult({
context,
payload: {
kind: "btw",
runId: clientRunId,
sessionKey: rawSessionKey,
question: btwReplies[0].btw.question.trim(),
text: btwText,
isError: btwReplies.some((payload) => payload.isError),
ts: Date.now(),
},
});
if (appended.ok) {
message = appended.message;
} else {
context.logGateway.warn(
`webchat transcript append failed: ${appended.error ?? "unknown error"}`,
);
const now = Date.now();
message = {
role: "assistant",
content: [{ type: "text", text: combinedReply }],
timestamp: now,
// Keep this compatible with Pi stopReason enums even though this message isn't
// persisted to the transcript due to the append failure.
stopReason: "stop",
usage: { input: 0, output: 0, totalTokens: 0 },
};
broadcastChatFinal({
context,
runId: clientRunId,
sessionKey: rawSessionKey,
});
} else {
const combinedReply = deliveredReplies
.filter((entry) => entry.kind === "final")
.map((entry) => entry.payload)
.map((part) => part.text?.trim() ?? "")
.filter(Boolean)
.join("\n\n")
.trim();
let message: Record<string, unknown> | undefined;
if (combinedReply) {
const { storePath: latestStorePath, entry: latestEntry } =
loadSessionEntry(sessionKey);
const sessionId = latestEntry?.sessionId ?? entry?.sessionId ?? clientRunId;
const appended = appendAssistantTranscriptMessage({
message: combinedReply,
sessionId,
storePath: latestStorePath,
sessionFile: latestEntry?.sessionFile,
agentId,
createIfMissing: true,
});
if (appended.ok) {
message = appended.message;
} else {
context.logGateway.warn(
`webchat transcript append failed: ${appended.error ?? "unknown error"}`,
);
const now = Date.now();
message = {
role: "assistant",
content: [{ type: "text", text: combinedReply }],
timestamp: now,
// Keep this compatible with Pi stopReason enums even though this message isn't
// persisted to the transcript due to the append failure.
stopReason: "stop",
usage: { input: 0, output: 0, totalTokens: 0 },
};
}
}
broadcastChatFinal({
context,
runId: clientRunId,
sessionKey: rawSessionKey,
message,
});
}
broadcastChatFinal({
context,
runId: clientRunId,
sessionKey: rawSessionKey,
message,
});
}
setGatewayDedupeEntry({
dedupe: context.dedupe,

View File

@ -497,6 +497,103 @@ describe("gateway server chat", () => {
});
});
test("routes /btw replies through side-result events without transcript injection", async () => {
await withMainSessionStore(async () => {
const replyMock = vi.mocked(getReplyFromConfig);
replyMock.mockResolvedValueOnce({
text: "323",
btw: { question: "what is 17 * 19?" },
});
const sideResultPromise = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "chat.side_result" &&
o.payload?.kind === "btw" &&
o.payload?.runId === "idem-btw-1",
8000,
);
const finalPromise = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "chat" &&
o.payload?.state === "final" &&
o.payload?.runId === "idem-btw-1",
8000,
);
const res = await rpcReq(ws, "chat.send", {
sessionKey: "main",
message: "/btw what is 17 * 19?",
idempotencyKey: "idem-btw-1",
});
expect(res.ok).toBe(true);
const sideResult = await sideResultPromise;
const finalEvent = await finalPromise;
expect(sideResult.payload).toMatchObject({
kind: "btw",
runId: "idem-btw-1",
sessionKey: "main",
question: "what is 17 * 19?",
text: "323",
});
expect(finalEvent.payload).toMatchObject({
runId: "idem-btw-1",
sessionKey: "main",
state: "final",
});
const historyRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
sessionKey: "main",
});
expect(historyRes.ok).toBe(true);
expect(historyRes.payload?.messages ?? []).toEqual([]);
});
});
test("routes block-streamed /btw replies through side-result events", async () => {
await withMainSessionStore(async () => {
const replyMock = vi.mocked(getReplyFromConfig);
replyMock.mockImplementationOnce(async (_ctx, opts) => {
await opts?.onBlockReply?.({
text: "first chunk",
btw: { question: "what changed?" },
});
await opts?.onBlockReply?.({
text: "second chunk",
btw: { question: "what changed?" },
});
return undefined;
});
const sideResultPromise = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "chat.side_result" &&
o.payload?.kind === "btw" &&
o.payload?.runId === "idem-btw-block-1",
8000,
);
const res = await rpcReq(ws, "chat.send", {
sessionKey: "main",
message: "/btw what changed?",
idempotencyKey: "idem-btw-block-1",
});
expect(res.ok).toBe(true);
const sideResult = await sideResultPromise;
expect(sideResult.payload).toMatchObject({
kind: "btw",
runId: "idem-btw-block-1",
question: "what changed?",
text: "first chunk\n\nsecond chunk",
});
});
});
test("chat.history hides assistant NO_REPLY-only entries and keeps mixed-content assistant entries", async () => {
const historyMessages = await loadChatHistoryWithMessages(buildNoReplyHistoryFixture(true));
const roleAndText = historyMessages

View File

@ -1,22 +1,82 @@
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { markdownToSignalTextChunks } from "../../../extensions/signal/src/format.js";
import type { ChannelOutboundAdapter } from "../../channels/plugins/types.adapters.js";
import { signalOutbound } from "../../channels/plugins/outbound/signal.js";
import { telegramOutbound } from "../../channels/plugins/outbound/telegram.js";
import { whatsappOutbound } from "../../channels/plugins/outbound/whatsapp.js";
import type { OpenClawConfig } from "../../config/config.js";
import { STATE_DIR } from "../../config/paths.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
import { withEnvAsync } from "../../test-utils/env.js";
import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js";
import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js";
import { resolvePreferredOpenClawTmpDir } from "../tmp-openclaw-dir.js";
import {
clearDeliverTestRegistry,
hookMocks,
resetDeliverTestState,
resetDeliverTestMocks,
runChunkedWhatsAppDelivery as runChunkedWhatsAppDeliveryHelper,
whatsappChunkConfig,
} from "./deliver.test-helpers.js";
const mocks = vi.hoisted(() => ({
appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })),
}));
const hookMocks = vi.hoisted(() => ({
runner: {
hasHooks: vi.fn(() => false),
runMessageSent: vi.fn(async () => {}),
},
}));
const internalHookMocks = vi.hoisted(() => ({
createInternalHookEvent: vi.fn(),
triggerInternalHook: vi.fn(async () => {}),
}));
const queueMocks = vi.hoisted(() => ({
enqueueDelivery: vi.fn(async () => "mock-queue-id"),
ackDelivery: vi.fn(async () => {}),
failDelivery: vi.fn(async () => {}),
}));
const logMocks = vi.hoisted(() => ({
warn: vi.fn(),
}));
vi.mock("../../config/sessions.js", async () => {
const actual = await vi.importActual<typeof import("../../config/sessions.js")>(
"../../config/sessions.js",
);
return {
...actual,
appendAssistantMessageToSessionTranscript: mocks.appendAssistantMessageToSessionTranscript,
};
});
vi.mock("../../config/sessions/transcript.js", async () => {
const actual = await vi.importActual<typeof import("../../config/sessions/transcript.js")>(
"../../config/sessions/transcript.js",
);
return {
...actual,
appendAssistantMessageToSessionTranscript: mocks.appendAssistantMessageToSessionTranscript,
};
});
vi.mock("../../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: () => hookMocks.runner,
}));
vi.mock("../../hooks/internal-hooks.js", () => ({
createInternalHookEvent: internalHookMocks.createInternalHookEvent,
triggerInternalHook: internalHookMocks.triggerInternalHook,
}));
vi.mock("./delivery-queue.js", () => ({
enqueueDelivery: queueMocks.enqueueDelivery,
ackDelivery: queueMocks.ackDelivery,
failDelivery: queueMocks.failDelivery,
}));
vi.mock("../../logging/subsystem.js", () => ({
createSubsystemLogger: () => {
const makeLogger = () => ({
warn: logMocks.warn,
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
child: vi.fn(() => makeLogger()),
});
return makeLogger();
},
}));
const { deliverOutboundPayloads, normalizeOutboundPayloads } = await import("./deliver.js");
@ -24,39 +84,19 @@ const telegramChunkConfig: OpenClawConfig = {
channels: { telegram: { botToken: "tok-1", textChunkLimit: 2 } },
};
const whatsappChunkConfig: OpenClawConfig = {
channels: { whatsapp: { textChunkLimit: 4000 } },
};
type DeliverOutboundArgs = Parameters<typeof deliverOutboundPayloads>[0];
type DeliverOutboundPayload = DeliverOutboundArgs["payloads"][number];
type DeliverSession = DeliverOutboundArgs["session"];
function setMatrixTextOnlyPlugin(sendText: NonNullable<ChannelOutboundAdapter["sendText"]>) {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "matrix",
source: "test",
plugin: createOutboundTestPlugin({
id: "matrix",
outbound: { deliveryMode: "direct", sendText },
}),
},
]),
);
}
async function deliverMatrixPayloads(payloads: DeliverOutboundPayload[]) {
return deliverOutboundPayloads({
cfg: {},
channel: "matrix",
to: "!room:1",
payloads,
});
}
async function deliverWhatsAppPayload(params: {
sendWhatsApp: NonNullable<
NonNullable<Parameters<typeof deliverOutboundPayloads>[0]["deps"]>["sendWhatsApp"]
>;
payload: { text: string; mediaUrl?: string };
payload: DeliverOutboundPayload;
cfg?: OpenClawConfig;
}) {
return deliverOutboundPayloads({
@ -86,14 +126,97 @@ async function deliverTelegramPayload(params: {
});
}
async function runChunkedWhatsAppDelivery(params?: {
mirror?: Parameters<typeof deliverOutboundPayloads>[0]["mirror"];
}) {
const sendWhatsApp = vi
.fn()
.mockResolvedValueOnce({ messageId: "w1", toJid: "jid" })
.mockResolvedValueOnce({ messageId: "w2", toJid: "jid" });
const cfg: OpenClawConfig = {
channels: { whatsapp: { textChunkLimit: 2 } },
};
const results = await deliverOutboundPayloads({
cfg,
channel: "whatsapp",
to: "+1555",
payloads: [{ text: "abcd" }],
deps: { sendWhatsApp },
...(params?.mirror ? { mirror: params.mirror } : {}),
});
return { sendWhatsApp, results };
}
async function deliverSingleWhatsAppForHookTest(params?: { sessionKey?: string }) {
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" });
await deliverOutboundPayloads({
cfg: whatsappChunkConfig,
channel: "whatsapp",
to: "+1555",
payloads: [{ text: "hello" }],
deps: { sendWhatsApp },
...(params?.sessionKey ? { session: { key: params.sessionKey } } : {}),
});
}
async function runBestEffortPartialFailureDelivery() {
const sendWhatsApp = vi
.fn()
.mockRejectedValueOnce(new Error("fail"))
.mockResolvedValueOnce({ messageId: "w2", toJid: "jid" });
const onError = vi.fn();
const cfg: OpenClawConfig = {};
const results = await deliverOutboundPayloads({
cfg,
channel: "whatsapp",
to: "+1555",
payloads: [{ text: "a" }, { text: "b" }],
deps: { sendWhatsApp },
bestEffort: true,
onError,
});
return { sendWhatsApp, onError, results };
}
function expectSuccessfulWhatsAppInternalHookPayload(
expected: Partial<{
content: string;
messageId: string;
isGroup: boolean;
groupId: string;
}>,
) {
return expect.objectContaining({
to: "+1555",
success: true,
channelId: "whatsapp",
conversationId: "+1555",
...expected,
});
}
describe("deliverOutboundPayloads", () => {
beforeEach(() => {
resetDeliverTestState();
resetDeliverTestMocks();
setActivePluginRegistry(defaultRegistry);
mocks.appendAssistantMessageToSessionTranscript.mockClear();
hookMocks.runner.hasHooks.mockClear();
hookMocks.runner.hasHooks.mockReturnValue(false);
hookMocks.runner.runMessageSent.mockClear();
hookMocks.runner.runMessageSent.mockResolvedValue(undefined);
internalHookMocks.createInternalHookEvent.mockClear();
internalHookMocks.createInternalHookEvent.mockImplementation(createInternalHookEventPayload);
internalHookMocks.triggerInternalHook.mockClear();
queueMocks.enqueueDelivery.mockClear();
queueMocks.enqueueDelivery.mockResolvedValue("mock-queue-id");
queueMocks.ackDelivery.mockClear();
queueMocks.ackDelivery.mockResolvedValue(undefined);
queueMocks.failDelivery.mockClear();
queueMocks.failDelivery.mockResolvedValue(undefined);
logMocks.warn.mockClear();
});
afterEach(() => {
clearDeliverTestRegistry();
setActivePluginRegistry(emptyRegistry);
});
it("chunks telegram markdown and passes through accountId", async () => {
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
@ -175,6 +298,24 @@ describe("deliverOutboundPayloads", () => {
);
});
it("formats BTW replies prominently for telegram delivery", async () => {
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
await deliverTelegramPayload({
sendTelegram,
cfg: {
channels: { telegram: { botToken: "tok-1", textChunkLimit: 100 } },
},
payload: { text: "323", btw: { question: "what is 17 * 19?" } },
});
expect(sendTelegram).toHaveBeenCalledWith(
"123",
"BTW\nQuestion: what is 17 * 19?\n\n323",
expect.objectContaining({ verbose: false, textMode: "html" }),
);
});
it("preserves HTML text for telegram sendPayload channelData path", async () => {
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
@ -416,9 +557,7 @@ describe("deliverOutboundPayloads", () => {
});
it("chunks WhatsApp text and returns all results", async () => {
const { sendWhatsApp, results } = await runChunkedWhatsAppDeliveryHelper({
deliverOutboundPayloads,
});
const { sendWhatsApp, results } = await runChunkedWhatsAppDelivery();
expect(sendWhatsApp).toHaveBeenCalledTimes(2);
expect(results.map((r) => r.messageId)).toEqual(["w1", "w2"]);
@ -614,6 +753,226 @@ describe("deliverOutboundPayloads", () => {
]);
});
it("formats BTW replies prominently for whatsapp delivery", async () => {
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" });
await deliverWhatsAppPayload({
sendWhatsApp,
payload: { text: "323", btw: { question: "what is 17 * 19?" } },
});
expect(sendWhatsApp).toHaveBeenCalledWith(
"+1555",
"BTW\nQuestion: what is 17 * 19?\n\n323",
expect.any(Object),
);
});
it("continues on errors when bestEffort is enabled", async () => {
const { sendWhatsApp, onError, results } = await runBestEffortPartialFailureDelivery();
expect(sendWhatsApp).toHaveBeenCalledTimes(2);
expect(onError).toHaveBeenCalledTimes(1);
expect(results).toEqual([{ channel: "whatsapp", messageId: "w2", toJid: "jid" }]);
});
it("emits internal message:sent hook with success=true for chunked payload delivery", async () => {
const { sendWhatsApp } = await runChunkedWhatsAppDelivery({
mirror: {
sessionKey: "agent:main:main",
isGroup: true,
groupId: "whatsapp:group:123",
},
});
expect(sendWhatsApp).toHaveBeenCalledTimes(2);
expect(internalHookMocks.createInternalHookEvent).toHaveBeenCalledTimes(1);
expect(internalHookMocks.createInternalHookEvent).toHaveBeenCalledWith(
"message",
"sent",
"agent:main:main",
expectSuccessfulWhatsAppInternalHookPayload({
content: "abcd",
messageId: "w2",
isGroup: true,
groupId: "whatsapp:group:123",
}),
);
expect(internalHookMocks.triggerInternalHook).toHaveBeenCalledTimes(1);
});
it("does not emit internal message:sent hook when neither mirror nor sessionKey is provided", async () => {
await deliverSingleWhatsAppForHookTest();
expect(internalHookMocks.createInternalHookEvent).not.toHaveBeenCalled();
expect(internalHookMocks.triggerInternalHook).not.toHaveBeenCalled();
});
it("emits internal message:sent hook when sessionKey is provided without mirror", async () => {
await deliverSingleWhatsAppForHookTest({ sessionKey: "agent:main:main" });
expect(internalHookMocks.createInternalHookEvent).toHaveBeenCalledTimes(1);
expect(internalHookMocks.createInternalHookEvent).toHaveBeenCalledWith(
"message",
"sent",
"agent:main:main",
expectSuccessfulWhatsAppInternalHookPayload({ content: "hello", messageId: "w1" }),
);
expect(internalHookMocks.triggerInternalHook).toHaveBeenCalledTimes(1);
});
it("warns when session.agentId is set without a session key", async () => {
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" });
hookMocks.runner.hasHooks.mockReturnValue(true);
await deliverOutboundPayloads({
cfg: whatsappChunkConfig,
channel: "whatsapp",
to: "+1555",
payloads: [{ text: "hello" }],
deps: { sendWhatsApp },
session: { agentId: "agent-main" },
});
expect(logMocks.warn).toHaveBeenCalledWith(
"deliverOutboundPayloads: session.agentId present without session key; internal message:sent hook will be skipped",
expect.objectContaining({ channel: "whatsapp", to: "+1555", agentId: "agent-main" }),
);
});
it("calls failDelivery instead of ackDelivery on bestEffort partial failure", async () => {
const { onError } = await runBestEffortPartialFailureDelivery();
// onError was called for the first payload's failure.
expect(onError).toHaveBeenCalledTimes(1);
// Queue entry should NOT be acked — failDelivery should be called instead.
expect(queueMocks.ackDelivery).not.toHaveBeenCalled();
expect(queueMocks.failDelivery).toHaveBeenCalledWith(
"mock-queue-id",
"partial delivery failure (bestEffort)",
);
});
it("acks the queue entry when delivery is aborted", async () => {
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" });
const abortController = new AbortController();
abortController.abort();
const cfg: OpenClawConfig = {};
await expect(
deliverOutboundPayloads({
cfg,
channel: "whatsapp",
to: "+1555",
payloads: [{ text: "a" }],
deps: { sendWhatsApp },
abortSignal: abortController.signal,
}),
).rejects.toThrow("Operation aborted");
expect(queueMocks.ackDelivery).toHaveBeenCalledWith("mock-queue-id");
expect(queueMocks.failDelivery).not.toHaveBeenCalled();
expect(sendWhatsApp).not.toHaveBeenCalled();
});
it("passes normalized payload to onError", async () => {
const sendWhatsApp = vi.fn().mockRejectedValue(new Error("boom"));
const onError = vi.fn();
const cfg: OpenClawConfig = {};
await deliverOutboundPayloads({
cfg,
channel: "whatsapp",
to: "+1555",
payloads: [{ text: "hi", mediaUrl: "https://x.test/a.jpg" }],
deps: { sendWhatsApp },
bestEffort: true,
onError,
});
expect(onError).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({ text: "hi", mediaUrls: ["https://x.test/a.jpg"] }),
);
});
it("mirrors delivered output when mirror options are provided", async () => {
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
mocks.appendAssistantMessageToSessionTranscript.mockClear();
await deliverOutboundPayloads({
cfg: telegramChunkConfig,
channel: "telegram",
to: "123",
payloads: [{ text: "caption", mediaUrl: "https://example.com/files/report.pdf?sig=1" }],
deps: { sendTelegram },
mirror: {
sessionKey: "agent:main:main",
text: "caption",
mediaUrls: ["https://example.com/files/report.pdf?sig=1"],
idempotencyKey: "idem-deliver-1",
},
});
expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith(
expect.objectContaining({
text: "report.pdf",
idempotencyKey: "idem-deliver-1",
}),
);
});
it("emits message_sent success for text-only deliveries", async () => {
hookMocks.runner.hasHooks.mockReturnValue(true);
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" });
await deliverOutboundPayloads({
cfg: {},
channel: "whatsapp",
to: "+1555",
payloads: [{ text: "hello" }],
deps: { sendWhatsApp },
});
expect(hookMocks.runner.runMessageSent).toHaveBeenCalledWith(
expect.objectContaining({ to: "+1555", content: "hello", success: true }),
expect.objectContaining({ channelId: "whatsapp" }),
);
});
it("emits message_sent success for sendPayload deliveries", async () => {
hookMocks.runner.hasHooks.mockReturnValue(true);
const sendPayload = vi.fn().mockResolvedValue({ channel: "matrix", messageId: "mx-1" });
const sendText = vi.fn();
const sendMedia = vi.fn();
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "matrix",
source: "test",
plugin: createOutboundTestPlugin({
id: "matrix",
outbound: { deliveryMode: "direct", sendPayload, sendText, sendMedia },
}),
},
]),
);
await deliverOutboundPayloads({
cfg: {},
channel: "matrix",
to: "!room:1",
payloads: [{ text: "payload text", channelData: { mode: "custom" } }],
});
expect(hookMocks.runner.runMessageSent).toHaveBeenCalledWith(
expect.objectContaining({ to: "!room:1", content: "payload text", success: true }),
expect.objectContaining({ channelId: "matrix" }),
);
});
it("preserves channelData-only payloads with empty text for non-WhatsApp sendPayload channels", async () => {
const sendPayload = vi.fn().mockResolvedValue({ channel: "line", messageId: "ln-1" });
const sendText = vi.fn();
@ -649,11 +1008,25 @@ describe("deliverOutboundPayloads", () => {
it("falls back to sendText when plugin outbound omits sendMedia", async () => {
const sendText = vi.fn().mockResolvedValue({ channel: "matrix", messageId: "mx-1" });
setMatrixTextOnlyPlugin(sendText);
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "matrix",
source: "test",
plugin: createOutboundTestPlugin({
id: "matrix",
outbound: { deliveryMode: "direct", sendText },
}),
},
]),
);
const results = await deliverMatrixPayloads([
{ text: "caption", mediaUrl: "https://example.com/file.png" },
]);
const results = await deliverOutboundPayloads({
cfg: {},
channel: "matrix",
to: "!room:1",
payloads: [{ text: "caption", mediaUrl: "https://example.com/file.png" }],
});
expect(sendText).toHaveBeenCalledTimes(1);
expect(sendText).toHaveBeenCalledWith(
@ -661,19 +1034,42 @@ describe("deliverOutboundPayloads", () => {
text: "caption",
}),
);
expect(logMocks.warn).toHaveBeenCalledWith(
"Plugin outbound adapter does not implement sendMedia; media URLs will be dropped and text fallback will be used",
expect.objectContaining({
channel: "matrix",
mediaCount: 1,
}),
);
expect(results).toEqual([{ channel: "matrix", messageId: "mx-1" }]);
});
it("falls back to one sendText call for multi-media payloads when sendMedia is omitted", async () => {
const sendText = vi.fn().mockResolvedValue({ channel: "matrix", messageId: "mx-2" });
setMatrixTextOnlyPlugin(sendText);
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "matrix",
source: "test",
plugin: createOutboundTestPlugin({
id: "matrix",
outbound: { deliveryMode: "direct", sendText },
}),
},
]),
);
const results = await deliverMatrixPayloads([
{
text: "caption",
mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"],
},
]);
const results = await deliverOutboundPayloads({
cfg: {},
channel: "matrix",
to: "!room:1",
payloads: [
{
text: "caption",
mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"],
},
],
});
expect(sendText).toHaveBeenCalledTimes(1);
expect(sendText).toHaveBeenCalledWith(
@ -681,20 +1077,109 @@ describe("deliverOutboundPayloads", () => {
text: "caption",
}),
);
expect(logMocks.warn).toHaveBeenCalledWith(
"Plugin outbound adapter does not implement sendMedia; media URLs will be dropped and text fallback will be used",
expect.objectContaining({
channel: "matrix",
mediaCount: 2,
}),
);
expect(results).toEqual([{ channel: "matrix", messageId: "mx-2" }]);
});
it("fails media-only payloads when plugin outbound omits sendMedia", async () => {
hookMocks.runner.hasHooks.mockReturnValue(true);
const sendText = vi.fn().mockResolvedValue({ channel: "matrix", messageId: "mx-3" });
setMatrixTextOnlyPlugin(sendText);
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "matrix",
source: "test",
plugin: createOutboundTestPlugin({
id: "matrix",
outbound: { deliveryMode: "direct", sendText },
}),
},
]),
);
await expect(
deliverMatrixPayloads([{ text: " ", mediaUrl: "https://example.com/file.png" }]),
deliverOutboundPayloads({
cfg: {},
channel: "matrix",
to: "!room:1",
payloads: [{ text: " ", mediaUrl: "https://example.com/file.png" }],
}),
).rejects.toThrow(
"Plugin outbound adapter does not implement sendMedia and no text fallback is available for media payload",
);
expect(sendText).not.toHaveBeenCalled();
expect(logMocks.warn).toHaveBeenCalledWith(
"Plugin outbound adapter does not implement sendMedia; media URLs will be dropped and text fallback will be used",
expect.objectContaining({
channel: "matrix",
mediaCount: 1,
}),
);
expect(hookMocks.runner.runMessageSent).toHaveBeenCalledWith(
expect.objectContaining({
to: "!room:1",
content: "",
success: false,
error:
"Plugin outbound adapter does not implement sendMedia and no text fallback is available for media payload",
}),
expect.objectContaining({ channelId: "matrix" }),
);
});
it("emits message_sent failure when delivery errors", async () => {
hookMocks.runner.hasHooks.mockReturnValue(true);
const sendWhatsApp = vi.fn().mockRejectedValue(new Error("downstream failed"));
await expect(
deliverOutboundPayloads({
cfg: {},
channel: "whatsapp",
to: "+1555",
payloads: [{ text: "hi" }],
deps: { sendWhatsApp },
}),
).rejects.toThrow("downstream failed");
expect(hookMocks.runner.runMessageSent).toHaveBeenCalledWith(
expect.objectContaining({
to: "+1555",
content: "hi",
success: false,
error: "downstream failed",
}),
expect.objectContaining({ channelId: "whatsapp" }),
);
});
});
const emptyRegistry = createTestRegistry([]);
const defaultRegistry = createTestRegistry([
{
pluginId: "telegram",
plugin: createOutboundTestPlugin({ id: "telegram", outbound: telegramOutbound }),
source: "test",
},
{
pluginId: "signal",
plugin: createOutboundTestPlugin({ id: "signal", outbound: signalOutbound }),
source: "test",
},
{
pluginId: "whatsapp",
plugin: createOutboundTestPlugin({ id: "whatsapp", outbound: whatsappOutbound }),
source: "test",
},
{
pluginId: "imessage",
plugin: createIMessageTestPlugin(),
source: "test",
},
]);

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,6 @@
import { parseReplyDirectives } from "../../auto-reply/reply/reply-directives.js";
import {
formatBtwTextForExternalDelivery,
isRenderablePayload,
shouldSuppressReasoningPayload,
} from "../../auto-reply/reply/reply-payloads.js";
@ -59,7 +60,11 @@ export function normalizeReplyPayloadsForDelivery(
const resolvedMediaUrl = hasMultipleMedia ? undefined : explicitMediaUrl;
const next: ReplyPayload = {
...payload,
text: parsed.text ?? "",
text:
formatBtwTextForExternalDelivery({
...payload,
text: parsed.text ?? "",
}) ?? "",
mediaUrls: mergedMedia.length ? mergedMedia : undefined,
mediaUrl: resolvedMediaUrl,
replyToId: payload.replyToId ?? parsed.replyToId,

View File

@ -37,6 +37,7 @@ const RESERVED_COMMANDS = new Set([
"status",
"whoami",
"context",
"btw",
// Session management
"stop",
"restart",

View File

@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { BtwInlineMessage } from "./btw-inline-message.js";
describe("btw inline message", () => {
it("renders the BTW question, answer, and dismiss hint inline", () => {
const message = new BtwInlineMessage({
question: "what is 17 * 19?",
text: "323",
});
const rendered = message.render(80).join("\n");
expect(rendered).toContain("BTW: what is 17 * 19?");
expect(rendered).toContain("323");
expect(rendered).toContain("Press Enter or Esc to dismiss");
});
});

View File

@ -0,0 +1,28 @@
import { Container, Spacer, Text } from "@mariozechner/pi-tui";
import { theme } from "../theme/theme.js";
import { AssistantMessageComponent } from "./assistant-message.js";
type BtwInlineMessageParams = {
question: string;
text: string;
isError?: boolean;
};
export class BtwInlineMessage extends Container {
constructor(params: BtwInlineMessageParams) {
super();
this.setResult(params);
}
setResult(params: BtwInlineMessageParams) {
this.clear();
this.addChild(new Spacer(1));
this.addChild(new Text(theme.header(`BTW: ${params.question}`), 1, 0));
if (params.isError) {
this.addChild(new Text(theme.error(params.text), 1, 0));
} else {
this.addChild(new AssistantMessageComponent(params.text));
}
this.addChild(new Text(theme.dim("Press Enter or Esc to dismiss"), 1, 0));
}
}

View File

@ -52,4 +52,25 @@ describe("ChatLog", () => {
expect(chatLog.children.length).toBe(20);
});
it("renders BTW inline and removes it when dismissed", () => {
const chatLog = new ChatLog(40);
chatLog.addSystem("session agent:main:main");
chatLog.showBtw({
question: "what is 17 * 19?",
text: "323",
});
let rendered = chatLog.render(120).join("\n");
expect(rendered).toContain("BTW: what is 17 * 19?");
expect(rendered).toContain("323");
expect(chatLog.hasVisibleBtw()).toBe(true);
chatLog.dismissBtw();
rendered = chatLog.render(120).join("\n");
expect(rendered).not.toContain("BTW: what is 17 * 19?");
expect(chatLog.hasVisibleBtw()).toBe(false);
});
});

View File

@ -2,6 +2,7 @@ import type { Component } from "@mariozechner/pi-tui";
import { Container, Spacer, Text } from "@mariozechner/pi-tui";
import { theme } from "../theme/theme.js";
import { AssistantMessageComponent } from "./assistant-message.js";
import { BtwInlineMessage } from "./btw-inline-message.js";
import { ToolExecutionComponent } from "./tool-execution.js";
import { UserMessageComponent } from "./user-message.js";
@ -9,6 +10,7 @@ export class ChatLog extends Container {
private readonly maxComponents: number;
private toolById = new Map<string, ToolExecutionComponent>();
private streamingRuns = new Map<string, AssistantMessageComponent>();
private btwMessage: BtwInlineMessage | null = null;
private toolsExpanded = false;
constructor(maxComponents = 180) {
@ -27,6 +29,9 @@ export class ChatLog extends Container {
this.streamingRuns.delete(runId);
}
}
if (this.btwMessage === component) {
this.btwMessage = null;
}
}
private pruneOverflow() {
@ -49,6 +54,7 @@ export class ChatLog extends Container {
this.clear();
this.toolById.clear();
this.streamingRuns.clear();
this.btwMessage = null;
}
addSystem(text: string) {
@ -108,6 +114,33 @@ export class ChatLog extends Container {
this.streamingRuns.delete(effectiveRunId);
}
showBtw(params: { question: string; text: string; isError?: boolean }) {
if (this.btwMessage) {
this.btwMessage.setResult(params);
if (this.children[this.children.length - 1] !== this.btwMessage) {
this.removeChild(this.btwMessage);
this.append(this.btwMessage);
}
return this.btwMessage;
}
const component = new BtwInlineMessage(params);
this.btwMessage = component;
this.append(component);
return component;
}
dismissBtw() {
if (!this.btwMessage) {
return;
}
this.removeChild(this.btwMessage);
this.btwMessage = null;
}
hasVisibleBtw() {
return this.btwMessage !== null;
}
startTool(toolCallId: string, toolName: string, args: unknown) {
const existing = this.toolById.get(toolCallId);
if (existing) {

View File

@ -12,6 +12,7 @@ function createHarness(params?: {
loadHistory?: LoadHistoryMock;
setActivityStatus?: SetActivityStatusMock;
isConnected?: boolean;
activeChatRunId?: string | null;
}) {
const sendChat = params?.sendChat ?? vi.fn().mockResolvedValue({ runId: "r1" });
const resetSession = params?.resetSession ?? vi.fn().mockResolvedValue({ ok: true });
@ -19,21 +20,24 @@ function createHarness(params?: {
const addUser = vi.fn();
const addSystem = vi.fn();
const requestRender = vi.fn();
const noteLocalRunId = vi.fn();
const noteLocalBtwRunId = vi.fn();
const loadHistory =
params?.loadHistory ?? (vi.fn().mockResolvedValue(undefined) as LoadHistoryMock);
const setActivityStatus = params?.setActivityStatus ?? (vi.fn() as SetActivityStatusMock);
const state = {
currentSessionKey: "agent:main:main",
activeChatRunId: params?.activeChatRunId ?? null,
isConnected: params?.isConnected ?? true,
sessionInfo: {},
};
const { handleCommand } = createCommandHandlers({
client: { sendChat, resetSession } as never,
chatLog: { addUser, addSystem } as never,
tui: { requestRender } as never,
opts: {},
state: {
currentSessionKey: "agent:main:main",
activeChatRunId: null,
isConnected: params?.isConnected ?? true,
sessionInfo: {},
} as never,
state: state as never,
deliverDefault: false,
openOverlay: vi.fn(),
closeOverlay: vi.fn(),
@ -45,8 +49,10 @@ function createHarness(params?: {
setActivityStatus,
formatSessionKey: vi.fn(),
applySessionInfoFromPatch: vi.fn(),
noteLocalRunId: vi.fn(),
noteLocalRunId,
noteLocalBtwRunId,
forgetLocalRunId: vi.fn(),
forgetLocalBtwRunId: vi.fn(),
requestExit: vi.fn(),
});
@ -60,6 +66,9 @@ function createHarness(params?: {
requestRender,
loadHistory,
setActivityStatus,
noteLocalRunId,
noteLocalBtwRunId,
state,
};
}
@ -108,6 +117,29 @@ describe("tui command handlers", () => {
expect(requestRender).toHaveBeenCalled();
});
it("sends /btw without hijacking the active main run", async () => {
const setActivityStatus = vi.fn();
const { handleCommand, sendChat, addUser, noteLocalRunId, noteLocalBtwRunId, state } =
createHarness({
activeChatRunId: "run-main",
setActivityStatus,
});
await handleCommand("/btw what changed?");
expect(addUser).not.toHaveBeenCalled();
expect(noteLocalRunId).not.toHaveBeenCalled();
expect(noteLocalBtwRunId).toHaveBeenCalledTimes(1);
expect(state.activeChatRunId).toBe("run-main");
expect(setActivityStatus).not.toHaveBeenCalledWith("sending");
expect(setActivityStatus).not.toHaveBeenCalledWith("waiting");
expect(sendChat).toHaveBeenCalledWith(
expect.objectContaining({
message: "/btw what changed?",
}),
);
});
it("creates unique session for /new and resets shared session for /reset", async () => {
const loadHistory = vi.fn().mockResolvedValue(undefined);
const setSessionMock = vi.fn().mockResolvedValue(undefined) as SetSessionMock;

View File

@ -43,10 +43,16 @@ type CommandHandlerContext = {
formatSessionKey: (key: string) => string;
applySessionInfoFromPatch: (result: SessionsPatchResult) => void;
noteLocalRunId: (runId: string) => void;
noteLocalBtwRunId?: (runId: string) => void;
forgetLocalRunId?: (runId: string) => void;
forgetLocalBtwRunId?: (runId: string) => void;
requestExit: () => void;
};
function isBtwCommand(text: string): boolean {
return /^\/btw(?::|\s|$)/i.test(text.trim());
}
export function createCommandHandlers(context: CommandHandlerContext) {
const {
client,
@ -66,7 +72,9 @@ export function createCommandHandlers(context: CommandHandlerContext) {
formatSessionKey,
applySessionInfoFromPatch,
noteLocalRunId,
noteLocalBtwRunId,
forgetLocalRunId,
forgetLocalBtwRunId,
requestExit,
} = context;
@ -501,13 +509,17 @@ export function createCommandHandlers(context: CommandHandlerContext) {
tui.requestRender();
return;
}
const isBtw = isBtwCommand(text);
const runId = randomUUID();
try {
chatLog.addUser(text);
tui.requestRender();
const runId = randomUUID();
noteLocalRunId(runId);
state.activeChatRunId = runId;
setActivityStatus("sending");
if (!isBtw) {
chatLog.addUser(text);
noteLocalRunId(runId);
state.activeChatRunId = runId;
setActivityStatus("sending");
} else {
noteLocalBtwRunId?.(runId);
}
tui.requestRender();
await client.sendChat({
sessionKey: state.currentSessionKey,
@ -517,15 +529,24 @@ export function createCommandHandlers(context: CommandHandlerContext) {
timeoutMs: opts.timeoutMs,
runId,
});
setActivityStatus("waiting");
tui.requestRender();
if (!isBtw) {
setActivityStatus("waiting");
tui.requestRender();
}
} catch (err) {
if (state.activeChatRunId) {
if (isBtw) {
forgetLocalBtwRunId?.(runId);
}
if (!isBtw && state.activeChatRunId) {
forgetLocalRunId?.(state.activeChatRunId);
}
state.activeChatRunId = null;
chatLog.addSystem(`send failed: ${String(err)}`);
setActivityStatus("error");
if (!isBtw) {
state.activeChatRunId = null;
}
chatLog.addSystem(`${isBtw ? "btw failed" : "send failed"}: ${String(err)}`);
if (!isBtw) {
setActivityStatus("error");
}
tui.requestRender();
}
};

View File

@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { createEventHandlers } from "./tui-event-handlers.js";
import type { AgentEvent, ChatEvent, TuiStateAccess } from "./tui-types.js";
import type { AgentEvent, BtwEvent, ChatEvent, TuiStateAccess } from "./tui-types.js";
type MockFn = ReturnType<typeof vi.fn>;
type HandlerChatLog = {
@ -11,6 +11,10 @@ type HandlerChatLog = {
finalizeAssistant: (...args: unknown[]) => void;
dropAssistant: (...args: unknown[]) => void;
};
type HandlerBtwPresenter = {
showResult: (...args: unknown[]) => void;
clear: (...args: unknown[]) => void;
};
type HandlerTui = { requestRender: (...args: unknown[]) => void };
type MockChatLog = {
startTool: MockFn;
@ -20,6 +24,10 @@ type MockChatLog = {
finalizeAssistant: MockFn;
dropAssistant: MockFn;
};
type MockBtwPresenter = {
showResult: MockFn;
clear: MockFn;
};
type MockTui = { requestRender: MockFn };
function createMockChatLog(): MockChatLog & HandlerChatLog {
@ -33,6 +41,13 @@ function createMockChatLog(): MockChatLog & HandlerChatLog {
} as unknown as MockChatLog & HandlerChatLog;
}
function createMockBtwPresenter(): MockBtwPresenter & HandlerBtwPresenter {
return {
showResult: vi.fn(),
clear: vi.fn(),
} as unknown as MockBtwPresenter & HandlerBtwPresenter;
}
describe("tui-event-handlers: handleAgentEvent", () => {
const makeState = (overrides?: Partial<TuiStateAccess>): TuiStateAccess => ({
agentDefaultId: "main",
@ -59,50 +74,69 @@ describe("tui-event-handlers: handleAgentEvent", () => {
const makeContext = (state: TuiStateAccess) => {
const chatLog = createMockChatLog();
const btw = createMockBtwPresenter();
const tui = { requestRender: vi.fn() } as unknown as MockTui & HandlerTui;
const setActivityStatus = vi.fn();
const loadHistory = vi.fn();
const localRunIds = new Set<string>();
const localBtwRunIds = new Set<string>();
const noteLocalRunId = (runId: string) => {
localRunIds.add(runId);
};
const forgetLocalRunId = localRunIds.delete.bind(localRunIds);
const isLocalRunId = localRunIds.has.bind(localRunIds);
const clearLocalRunIds = localRunIds.clear.bind(localRunIds);
const noteLocalBtwRunId = (runId: string) => {
localBtwRunIds.add(runId);
};
const forgetLocalBtwRunId = localBtwRunIds.delete.bind(localBtwRunIds);
const isLocalBtwRunId = localBtwRunIds.has.bind(localBtwRunIds);
const clearLocalBtwRunIds = localBtwRunIds.clear.bind(localBtwRunIds);
return {
chatLog,
btw,
tui,
state,
setActivityStatus,
loadHistory,
noteLocalRunId,
noteLocalBtwRunId,
forgetLocalRunId,
isLocalRunId,
clearLocalRunIds,
forgetLocalBtwRunId,
isLocalBtwRunId,
clearLocalBtwRunIds,
};
};
const createHandlersHarness = (params?: {
state?: Partial<TuiStateAccess>;
chatLog?: HandlerChatLog;
btw?: HandlerBtwPresenter;
}) => {
const state = makeState(params?.state);
const context = makeContext(state);
const chatLog = (params?.chatLog ?? context.chatLog) as MockChatLog & HandlerChatLog;
const handlers = createEventHandlers({
chatLog,
btw: (params?.btw ?? context.btw) as MockBtwPresenter & HandlerBtwPresenter,
tui: context.tui,
state,
setActivityStatus: context.setActivityStatus,
loadHistory: context.loadHistory,
isLocalRunId: context.isLocalRunId,
forgetLocalRunId: context.forgetLocalRunId,
isLocalBtwRunId: context.isLocalBtwRunId,
forgetLocalBtwRunId: context.forgetLocalBtwRunId,
clearLocalBtwRunIds: context.clearLocalBtwRunIds,
});
return {
...context,
state,
chatLog,
btw: (params?.btw ?? context.btw) as MockBtwPresenter & HandlerBtwPresenter,
...handlers,
};
};
@ -212,6 +246,62 @@ describe("tui-event-handlers: handleAgentEvent", () => {
expect(chatLog.updateAssistant).toHaveBeenCalledWith("hello", "run-alias");
});
it("renders BTW results separately without disturbing the active run", () => {
const { state, btw, setActivityStatus, loadHistory, tui, handleBtwEvent } =
createHandlersHarness({
state: { activeChatRunId: "run-main" },
});
const evt: BtwEvent = {
kind: "btw",
runId: "run-btw",
sessionKey: state.currentSessionKey,
question: "what changed?",
text: "nothing important",
};
handleBtwEvent(evt);
expect(state.activeChatRunId).toBe("run-main");
expect(btw.showResult).toHaveBeenCalledWith({
question: "what changed?",
text: "nothing important",
isError: undefined,
});
expect(setActivityStatus).not.toHaveBeenCalled();
expect(loadHistory).not.toHaveBeenCalled();
expect(tui.requestRender).toHaveBeenCalledTimes(1);
});
it("keeps a local BTW result visible when its empty final chat event arrives", () => {
const { state, btw, loadHistory, noteLocalBtwRunId, handleBtwEvent, handleChatEvent } =
createHandlersHarness({
state: { activeChatRunId: null },
});
noteLocalBtwRunId("run-btw");
handleBtwEvent({
kind: "btw",
runId: "run-btw",
sessionKey: state.currentSessionKey,
question: "what changed?",
text: "nothing important",
} satisfies BtwEvent);
handleChatEvent({
runId: "run-btw",
sessionKey: state.currentSessionKey,
state: "final",
} satisfies ChatEvent);
expect(loadHistory).not.toHaveBeenCalled();
expect(btw.showResult).toHaveBeenCalledWith({
question: "what changed?",
text: "nothing important",
isError: undefined,
});
});
it("does not cross-match canonical session keys from different agents", () => {
const { chatLog, handleChatEvent } = createHandlersHarness({
state: {

View File

@ -1,7 +1,7 @@
import { parseAgentSessionKey } from "../sessions/session-key-utils.js";
import { asString, extractTextFromMessage, isCommandMessage } from "./tui-formatters.js";
import { TuiStreamAssembler } from "./tui-stream-assembler.js";
import type { AgentEvent, ChatEvent, TuiStateAccess } from "./tui-types.js";
import type { AgentEvent, BtwEvent, ChatEvent, TuiStateAccess } from "./tui-types.js";
type EventHandlerChatLog = {
startTool: (toolCallId: string, toolName: string, args: unknown) => void;
@ -20,8 +20,14 @@ type EventHandlerTui = {
requestRender: () => void;
};
type EventHandlerBtwPresenter = {
showResult: (params: { question: string; text: string; isError?: boolean }) => void;
clear: () => void;
};
type EventHandlerContext = {
chatLog: EventHandlerChatLog;
btw: EventHandlerBtwPresenter;
tui: EventHandlerTui;
state: TuiStateAccess;
setActivityStatus: (text: string) => void;
@ -30,11 +36,15 @@ type EventHandlerContext = {
isLocalRunId?: (runId: string) => boolean;
forgetLocalRunId?: (runId: string) => void;
clearLocalRunIds?: () => void;
isLocalBtwRunId?: (runId: string) => boolean;
forgetLocalBtwRunId?: (runId: string) => void;
clearLocalBtwRunIds?: () => void;
};
export function createEventHandlers(context: EventHandlerContext) {
const {
chatLog,
btw,
tui,
state,
setActivityStatus,
@ -43,6 +53,9 @@ export function createEventHandlers(context: EventHandlerContext) {
isLocalRunId,
forgetLocalRunId,
clearLocalRunIds,
isLocalBtwRunId,
forgetLocalBtwRunId,
clearLocalBtwRunIds,
} = context;
const finalizedRuns = new Map<string, number>();
const sessionRuns = new Map<string, number>();
@ -81,6 +94,8 @@ export function createEventHandlers(context: EventHandlerContext) {
sessionRuns.clear();
streamAssembler = new TuiStreamAssembler();
clearLocalRunIds?.();
clearLocalBtwRunIds?.();
btw.clear();
};
const noteSessionRun = (runId: string) => {
@ -194,7 +209,7 @@ export function createEventHandlers(context: EventHandlerContext) {
}
}
noteSessionRun(evt.runId);
if (!state.activeChatRunId) {
if (!state.activeChatRunId && !isLocalBtwRunId?.(evt.runId)) {
state.activeChatRunId = evt.runId;
}
if (evt.state === "delta") {
@ -206,7 +221,14 @@ export function createEventHandlers(context: EventHandlerContext) {
setActivityStatus("streaming");
}
if (evt.state === "final") {
const isLocalBtwRun = isLocalBtwRunId?.(evt.runId) ?? false;
const wasActiveRun = state.activeChatRunId === evt.runId;
if (!evt.message && isLocalBtwRun) {
forgetLocalBtwRunId?.(evt.runId);
noteFinalizedRun(evt.runId);
tui.requestRender();
return;
}
if (!evt.message) {
maybeRefreshHistoryForRun(evt.runId, {
allowLocalWithoutDisplayableFinal: true,
@ -254,12 +276,14 @@ export function createEventHandlers(context: EventHandlerContext) {
});
}
if (evt.state === "aborted") {
forgetLocalBtwRunId?.(evt.runId);
const wasActiveRun = state.activeChatRunId === evt.runId;
chatLog.addSystem("run aborted");
terminateRun({ runId: evt.runId, wasActiveRun, status: "aborted" });
maybeRefreshHistoryForRun(evt.runId);
}
if (evt.state === "error") {
forgetLocalBtwRunId?.(evt.runId);
const wasActiveRun = state.activeChatRunId === evt.runId;
chatLog.addSystem(`run error: ${evt.errorMessage ?? "unknown"}`);
terminateRun({ runId: evt.runId, wasActiveRun, status: "error" });
@ -335,5 +359,30 @@ export function createEventHandlers(context: EventHandlerContext) {
}
};
return { handleChatEvent, handleAgentEvent };
const handleBtwEvent = (payload: unknown) => {
if (!payload || typeof payload !== "object") {
return;
}
const evt = payload as BtwEvent;
syncSessionKey();
if (!isSameSessionKey(evt.sessionKey, state.currentSessionKey)) {
return;
}
if (evt.kind !== "btw") {
return;
}
const question = evt.question.trim();
const text = evt.text.trim();
if (!question || !text) {
return;
}
btw.showResult({
question,
text,
isError: evt.isError,
});
tui.requestRender();
};
return { handleChatEvent, handleAgentEvent, handleBtwEvent };
}

View File

@ -4,6 +4,11 @@ import { createSessionActions } from "./tui-session-actions.js";
import type { TuiStateAccess } from "./tui-types.js";
describe("tui session actions", () => {
const createBtwPresenter = () => ({
clear: vi.fn(),
showResult: vi.fn(),
});
it("queues session refreshes and applies the latest result", async () => {
let resolveFirst: ((value: unknown) => void) | undefined;
let resolveSecond: ((value: unknown) => void) | undefined;
@ -52,6 +57,7 @@ describe("tui session actions", () => {
const { refreshSessionInfo } = createSessionActions({
client: { listSessions } as unknown as GatewayChatClient,
chatLog: { addSystem: vi.fn() } as unknown as import("./components/chat-log.js").ChatLog,
btw: createBtwPresenter(),
tui: { requestRender } as unknown as import("@mariozechner/pi-tui").TUI,
opts: {},
state,
@ -157,6 +163,7 @@ describe("tui session actions", () => {
const { applySessionInfoFromPatch, refreshSessionInfo } = createSessionActions({
client: { listSessions } as unknown as GatewayChatClient,
chatLog: { addSystem: vi.fn() } as unknown as import("./components/chat-log.js").ChatLog,
btw: createBtwPresenter(),
tui: { requestRender: vi.fn() } as unknown as import("@mariozechner/pi-tui").TUI,
opts: {},
state,
@ -211,6 +218,7 @@ describe("tui session actions", () => {
sessionId: "session-2",
messages: [],
});
const btw = createBtwPresenter();
const state: TuiStateAccess = {
agentDefaultId: "main",
@ -247,6 +255,7 @@ describe("tui session actions", () => {
addSystem: vi.fn(),
clearAll: vi.fn(),
} as unknown as import("./components/chat-log.js").ChatLog,
btw,
tui: { requestRender: vi.fn() } as unknown as import("@mariozechner/pi-tui").TUI,
opts: {},
state,
@ -270,5 +279,6 @@ describe("tui session actions", () => {
expect(state.sessionInfo.model).toBe("session-model");
expect(state.sessionInfo.modelProvider).toBe("openai");
expect(state.sessionInfo.updatedAt).toBe(50);
expect(btw.clear).toHaveBeenCalled();
});
});

View File

@ -10,9 +10,14 @@ import type { GatewayAgentsList, GatewayChatClient } from "./gateway-chat.js";
import { asString, extractTextFromMessage, isCommandMessage } from "./tui-formatters.js";
import type { SessionInfo, TuiOptions, TuiStateAccess } from "./tui-types.js";
type SessionActionBtwPresenter = {
clear: () => void;
};
type SessionActionContext = {
client: GatewayChatClient;
chatLog: ChatLog;
btw: SessionActionBtwPresenter;
tui: TUI;
opts: TuiOptions;
state: TuiStateAccess;
@ -42,6 +47,7 @@ export function createSessionActions(context: SessionActionContext) {
const {
client,
chatLog,
btw,
tui,
opts,
state,
@ -298,6 +304,7 @@ export function createSessionActions(context: SessionActionContext) {
state.sessionInfo.verboseLevel = record.verboseLevel ?? state.sessionInfo.verboseLevel;
const showTools = (state.sessionInfo.verboseLevel ?? "off") !== "off";
chatLog.clearAll();
btw.clear();
chatLog.addSystem(`session ${state.currentSessionKey}`);
for (const entry of record.messages ?? []) {
if (!entry || typeof entry !== "object") {
@ -367,6 +374,7 @@ export function createSessionActions(context: SessionActionContext) {
state.sessionInfo.updatedAt = null;
state.historyLoaded = false;
clearLocalRunIds?.();
btw.clear();
updateHeader();
updateFooter();
await loadHistory();

View File

@ -18,6 +18,17 @@ export type ChatEvent = {
errorMessage?: string;
};
export type BtwEvent = {
kind: "btw";
runId?: string;
sessionKey?: string;
question: string;
text: string;
isError?: boolean;
seq?: number;
ts?: number;
};
export type AgentEvent = {
runId: string;
stream: string;

View File

@ -344,6 +344,7 @@ export async function runTui(opts: TuiOptions) {
let showThinking = false;
let pairingHintShown = false;
const localRunIds = new Set<string>();
const localBtwRunIds = new Set<string>();
const deliverDefault = opts.deliver ?? false;
const autoMessage = opts.message?.trim();
@ -498,6 +499,29 @@ export async function runTui(opts: TuiOptions) {
localRunIds.clear();
};
const noteLocalBtwRunId = (runId: string) => {
if (!runId) {
return;
}
localBtwRunIds.add(runId);
if (localBtwRunIds.size > 200) {
const [first] = localBtwRunIds;
if (first) {
localBtwRunIds.delete(first);
}
}
};
const forgetLocalBtwRunId = (runId: string) => {
localBtwRunIds.delete(runId);
};
const isLocalBtwRunId = (runId: string) => localBtwRunIds.has(runId);
const clearLocalBtwRunIds = () => {
localBtwRunIds.clear();
};
const client = await GatewayChatClient.connect({
url: opts.url,
token: opts.token,
@ -771,6 +795,14 @@ export async function runTui(opts: TuiOptions) {
};
const { openOverlay, closeOverlay } = createOverlayHandlers(tui, editor);
const btw = {
showResult: (params: { question: string; text: string; isError?: boolean }) => {
chatLog.showBtw(params);
},
clear: () => {
chatLog.dismissBtw();
},
};
const initialSessionAgentId = (() => {
if (!initialSessionInput) {
@ -783,6 +815,7 @@ export async function runTui(opts: TuiOptions) {
const sessionActions = createSessionActions({
client,
chatLog,
btw,
tui,
opts,
state,
@ -805,8 +838,9 @@ export async function runTui(opts: TuiOptions) {
abortActive,
} = sessionActions;
const { handleChatEvent, handleAgentEvent } = createEventHandlers({
const { handleChatEvent, handleAgentEvent, handleBtwEvent } = createEventHandlers({
chatLog,
btw,
tui,
state,
setActivityStatus,
@ -815,6 +849,9 @@ export async function runTui(opts: TuiOptions) {
isLocalRunId,
forgetLocalRunId,
clearLocalRunIds,
isLocalBtwRunId,
forgetLocalBtwRunId,
clearLocalBtwRunIds,
});
const requestExit = () => {
@ -846,7 +883,9 @@ export async function runTui(opts: TuiOptions) {
setActivityStatus,
formatSessionKey,
noteLocalRunId,
noteLocalBtwRunId,
forgetLocalRunId,
forgetLocalBtwRunId,
requestExit,
});
@ -869,6 +908,11 @@ export async function runTui(opts: TuiOptions) {
});
editor.onEscape = () => {
if (chatLog.hasVisibleBtw()) {
chatLog.dismissBtw();
tui.requestRender();
return;
}
void abortActive();
};
const handleCtrlC = () => {
@ -918,10 +962,28 @@ export async function runTui(opts: TuiOptions) {
void loadHistory();
};
tui.addInputListener((data) => {
if (!chatLog.hasVisibleBtw()) {
return undefined;
}
if (editor.getText().length > 0) {
return undefined;
}
if (matchesKey(data, "enter")) {
chatLog.dismissBtw();
tui.requestRender();
return { consume: true };
}
return undefined;
});
client.onEvent = (evt) => {
if (evt.event === "chat") {
handleChatEvent(evt.payload);
}
if (evt.event === "chat.side_result") {
handleBtwEvent(evt.payload);
}
if (evt.event === "agent") {
handleAgentEvent(evt.payload);
}