diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ba4346c35f..5885a548e0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index e0a9f1aa365..0fe5f383f24 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -76,6 +76,7 @@ Text + native (when enabled): - `/allowlist` (list/add/remove allowlist entries) - `/approve 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 ` (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 ` (manage inactivity auto-unfocus for focused thread bindings) diff --git a/extensions/telegram/src/sequential-key.test.ts b/extensions/telegram/src/sequential-key.test.ts index 7dc09af2596..d06e1c547a3 100644 --- a/extensions/telegram/src/sequential-key.test.ts +++ b/extensions/telegram/src/sequential-key.test.ts @@ -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", diff --git a/extensions/telegram/src/sequential-key.ts b/extensions/telegram/src/sequential-key.ts index 7bf22f5e8e1..334c18dc485 100644 --- a/extensions/telegram/src/sequential-key.ts +++ b/extensions/telegram/src/sequential-key.ts @@ -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; diff --git a/extensions/whatsapp/src/auto-reply/monitor/process-message.inbound-contract.test.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.inbound-contract.test.ts index 85b784d03a8..238c675e12d 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/process-message.inbound-contract.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.inbound-contract.test.ts @@ -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(); diff --git a/extensions/whatsapp/src/outbound-adapter.ts b/extensions/whatsapp/src/outbound-adapter.ts index 12497df9d6b..428b8a3f8c8 100644 --- a/extensions/whatsapp/src/outbound-adapter.ts +++ b/extensions/whatsapp/src/outbound-adapter.ts @@ -42,7 +42,7 @@ export const whatsappOutbound: ChannelOutboundAdapter = { return { channel: "whatsapp", messageId: "" }; } const send = - resolveOutboundSendDep(deps, "whatsapp") ?? + resolveOutboundSendDep(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(deps, "whatsapp") ?? + resolveOutboundSendDep(deps, "whatsapp") ?? (await import("./send.js")).sendMessageWhatsApp; const result = await send(to, normalizedText, { verbose: false, diff --git a/src/agents/btw.test.ts b/src/agents/btw.test.ts new file mode 100644 index 00000000000..943cdd140d0 --- /dev/null +++ b/src/agents/btw.test.ts @@ -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 { + 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( + "\nWhat is 17 * 19?\n", + ), + }, + ], + }), + ]), + }), + 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( + "\nbuild me a tic-tac-toe game in brainfuck\n", + ), + }, + ], + }), + ], + }), + 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" })]), + ); + }); +}); diff --git a/src/agents/btw.ts b/src/agents/btw.ts new file mode 100644 index 00000000000..79ab9239479 --- /dev/null +++ b/src/agents/btw.ts @@ -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:", + "", + trimmedPrompt, + "", + "Do not continue or complete that task while answering the side question.", + ); + } + lines.push("", "", question.trim(), ""); + 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[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; + sessionKey?: string; + storePath?: string; + isNewSession: boolean; +}): Promise<{ + model: Model; + 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; + 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 { + 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 = 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 + | Extract + | 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 }; diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 7361f8b9e00..a4cf2d75260 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -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) { diff --git a/src/agents/pi-embedded-runner/runs.test.ts b/src/agents/pi-embedded-runner/runs.test.ts index d9bf90f961d..3a4eb6d3743 100644 --- a/src/agents/pi-embedded-runner/runs.test.ts +++ b/src/agents/pi-embedded-runner/runs.test.ts @@ -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(); + }); }); diff --git a/src/agents/pi-embedded-runner/runs.ts b/src/agents/pi-embedded-runner/runs.ts index 0d4cecc8372..d0a3d1063c7 100644 --- a/src/agents/pi-embedded-runner/runs.ts +++ b/src/agents/pi-embedded-runner/runs.ts @@ -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(), + snapshots: new Map(), waiters: new Map>(), })); 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(); }, }; diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index c499f03c526..80f8d4bd73f 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -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", diff --git a/src/auto-reply/reply/btw-command.ts b/src/auto-reply/reply/btw-command.ts new file mode 100644 index 00000000000..6f1a5be76de --- /dev/null +++ b/src/auto-reply/reply/btw-command.ts @@ -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() ?? ""; +} diff --git a/src/auto-reply/reply/commands-btw.test.ts b/src/auto-reply/reply/commands-btw.test.ts new file mode 100644 index 00000000000..b0251520fae --- /dev/null +++ b/src/auto-reply/reply/commands-btw.test.ts @@ -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 " }, + }); + }); + + 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?" } }, + }); + }); +}); diff --git a/src/auto-reply/reply/commands-btw.ts b/src/auto-reply/reply/commands-btw.ts new file mode 100644 index 00000000000..7c56473ca0c --- /dev/null +++ b/src/auto-reply/reply/commands-btw.ts @@ -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 "; + +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, + }, + }; + } +}; diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts index ca67bbc3549..7a6cc36c05e 100644 --- a/src/auto-reply/reply/commands-core.ts +++ b/src/auto-reply/reply/commands-core.ts @@ -11,6 +11,7 @@ import { resolveBoundAcpThreadSessionKey } from "./commands-acp/targets.js"; import { handleAllowlistCommand } from "./commands-allowlist.js"; import { handleApproveCommand } from "./commands-approve.js"; import { handleBashCommand } from "./commands-bash.js"; +import { handleBtwCommand } from "./commands-btw.js"; import { handleCompactCommand } from "./commands-compact.js"; import { handleConfigCommand, handleDebugCommand } from "./commands-config.js"; import { @@ -174,6 +175,7 @@ export async function handleCommands(params: HandleCommandsParams): Promise "always" | "mention"; resolvedThinkLevel?: ThinkLevel; resolvedVerboseLevel: VerboseLevel; resolvedReasoningLevel: ReasoningLevel; resolvedElevatedLevel?: ElevatedLevel; + blockReplyChunking?: BlockReplyChunking; + resolvedBlockStreamingBreak?: "text_end" | "message_end"; resolveDefaultThinkingLevel: () => Promise; provider: string; model: string; contextTokens: number; isGroup: boolean; skillCommands?: SkillCommandSpec[]; + typing?: TypingController; }; export type CommandHandlerResult = { diff --git a/src/auto-reply/reply/get-reply-inline-actions.ts b/src/auto-reply/reply/get-reply-inline-actions.ts index c312e1144e4..b4f921672f8 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.ts @@ -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 { 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 >["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) { diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index 81dd478a84a..9cee46cc2c9 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -332,6 +332,8 @@ export async function getReplyFromConfig( resolvedVerboseLevel, resolvedReasoningLevel, resolvedElevatedLevel, + blockReplyChunking, + resolvedBlockStreamingBreak, resolveDefaultThinkingLevel: modelState.resolveDefaultThinkingLevel, provider, model, diff --git a/src/auto-reply/reply/reply-payloads.ts b/src/auto-reply/reply/reply-payloads.ts index d0f38c745c7..63083941365 100644 --- a/src/auto-reply/reply/reply-payloads.ts +++ b/src/auto-reply/reply/reply-payloads.ts @@ -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; diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index b0818f62512..776a2374fbc 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -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( - "../../infra/outbound/deliver.js", +vi.mock("../../infra/outbound/deliver-runtime.js", async () => { + const actual = await vi.importActual( + "../../infra/outbound/deliver-runtime.js", ); return { ...actual, deliverOutboundPayloads: mocks.deliverOutboundPayloads, }; }); -const actualDeliver = await vi.importActual( - "../../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({ diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index a2d2dcc2f1f..8ef3ef563c5 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -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 0 && + typeof payload.text === "string" && + payload.text.trim().length > 0 + ); +} + +function broadcastSideResult(params: { + context: Pick; + 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; 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 | 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 | 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, diff --git a/src/gateway/server.chat.gateway-server-chat.test.ts b/src/gateway/server.chat.gateway-server-chat.test.ts index 9ecd16e35d3..77b6784b146 100644 --- a/src/gateway/server.chat.gateway-server-chat.test.ts +++ b/src/gateway/server.chat.gateway-server-chat.test.ts @@ -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 diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index 2df0510cccb..cb86483b4b5 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -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( + "../../config/sessions.js", + ); + return { + ...actual, + appendAssistantMessageToSessionTranscript: mocks.appendAssistantMessageToSessionTranscript, + }; +}); +vi.mock("../../config/sessions/transcript.js", async () => { + const actual = await vi.importActual( + "../../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[0]; type DeliverOutboundPayload = DeliverOutboundArgs["payloads"][number]; type DeliverSession = DeliverOutboundArgs["session"]; -function setMatrixTextOnlyPlugin(sendText: NonNullable) { - 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[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[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", + }, +]); diff --git a/src/infra/outbound/outbound.test.ts b/src/infra/outbound/outbound.test.ts index c20632099bd..9e58d5c6c05 100644 --- a/src/infra/outbound/outbound.test.ts +++ b/src/infra/outbound/outbound.test.ts @@ -1,3 +1,1309 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ReplyPayload } from "../../auto-reply/types.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { typedCases } from "../../test-utils/typed-cases.js"; +import { + ackDelivery, + computeBackoffMs, + type DeliverFn, + enqueueDelivery, + failDelivery, + isEntryEligibleForRecoveryRetry, + isPermanentDeliveryError, + loadPendingDeliveries, + MAX_RETRIES, + moveToFailed, + recoverPendingDeliveries, +} from "./delivery-queue.js"; +import { DirectoryCache } from "./directory-cache.js"; +import { buildOutboundResultEnvelope } from "./envelope.js"; +import type { OutboundDeliveryJson } from "./format.js"; +import { + buildOutboundDeliveryJson, + formatGatewaySummary, + formatOutboundDeliverySummary, +} from "./format.js"; +import { + applyCrossContextDecoration, + buildCrossContextDecoration, + enforceCrossContextPolicy, +} from "./outbound-policy.js"; +import { resolveOutboundSessionRoute } from "./outbound-session.js"; +import { + formatOutboundPayloadLog, + normalizeOutboundPayloads, + normalizeOutboundPayloadsForJson, +} from "./payloads.js"; import { runResolveOutboundTargetCoreTests } from "./targets.shared-test.js"; +describe("delivery-queue", () => { + let tmpDir: string; + let fixtureRoot = ""; + let fixtureCount = 0; + + beforeAll(() => { + fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-dq-suite-")); + }); + + beforeEach(() => { + tmpDir = path.join(fixtureRoot, `case-${fixtureCount++}`); + fs.mkdirSync(tmpDir, { recursive: true }); + }); + + afterAll(() => { + if (!fixtureRoot) { + return; + } + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + fixtureRoot = ""; + }); + + describe("enqueue + ack lifecycle", () => { + it("creates and removes a queue entry", async () => { + const id = await enqueueDelivery( + { + channel: "whatsapp", + to: "+1555", + payloads: [{ text: "hello" }], + bestEffort: true, + gifPlayback: true, + silent: true, + mirror: { + sessionKey: "agent:main:main", + text: "hello", + mediaUrls: ["https://example.com/file.png"], + }, + }, + tmpDir, + ); + + // Entry file exists after enqueue. + const queueDir = path.join(tmpDir, "delivery-queue"); + const files = fs.readdirSync(queueDir).filter((f) => f.endsWith(".json")); + expect(files).toHaveLength(1); + expect(files[0]).toBe(`${id}.json`); + + // Entry contents are correct. + const entry = JSON.parse(fs.readFileSync(path.join(queueDir, files[0]), "utf-8")); + expect(entry).toMatchObject({ + id, + channel: "whatsapp", + to: "+1555", + bestEffort: true, + gifPlayback: true, + silent: true, + mirror: { + sessionKey: "agent:main:main", + text: "hello", + mediaUrls: ["https://example.com/file.png"], + }, + retryCount: 0, + }); + expect(entry.payloads).toEqual([{ text: "hello" }]); + + // Ack removes the file. + await ackDelivery(id, tmpDir); + const remaining = fs.readdirSync(queueDir).filter((f) => f.endsWith(".json")); + expect(remaining).toHaveLength(0); + }); + + it("ack is idempotent (no error on missing file)", async () => { + await expect(ackDelivery("nonexistent-id", tmpDir)).resolves.toBeUndefined(); + }); + + it("ack cleans up leftover .delivered marker when .json is already gone", async () => { + const id = await enqueueDelivery( + { channel: "whatsapp", to: "+1", payloads: [{ text: "stale-marker" }] }, + tmpDir, + ); + const queueDir = path.join(tmpDir, "delivery-queue"); + + fs.renameSync(path.join(queueDir, `${id}.json`), path.join(queueDir, `${id}.delivered`)); + await expect(ackDelivery(id, tmpDir)).resolves.toBeUndefined(); + + expect(fs.existsSync(path.join(queueDir, `${id}.delivered`))).toBe(false); + }); + + it("ack removes .delivered marker so recovery does not replay", async () => { + const id = await enqueueDelivery( + { channel: "whatsapp", to: "+1", payloads: [{ text: "ack-test" }] }, + tmpDir, + ); + const queueDir = path.join(tmpDir, "delivery-queue"); + + await ackDelivery(id, tmpDir); + + // Neither .json nor .delivered should remain. + expect(fs.existsSync(path.join(queueDir, `${id}.json`))).toBe(false); + expect(fs.existsSync(path.join(queueDir, `${id}.delivered`))).toBe(false); + }); + + it("loadPendingDeliveries cleans up stale .delivered markers without replaying", async () => { + const id = await enqueueDelivery( + { channel: "telegram", to: "99", payloads: [{ text: "stale" }] }, + tmpDir, + ); + const queueDir = path.join(tmpDir, "delivery-queue"); + + // Simulate crash between ack phase 1 (rename) and phase 2 (unlink): + // rename .json → .delivered, then pretend the process died. + fs.renameSync(path.join(queueDir, `${id}.json`), path.join(queueDir, `${id}.delivered`)); + + const entries = await loadPendingDeliveries(tmpDir); + + // The .delivered entry must NOT appear as pending. + expect(entries).toHaveLength(0); + // And the marker file should have been cleaned up. + expect(fs.existsSync(path.join(queueDir, `${id}.delivered`))).toBe(false); + }); + }); + + describe("failDelivery", () => { + it("increments retryCount, records attempt time, and sets lastError", async () => { + const id = await enqueueDelivery( + { + channel: "telegram", + to: "123", + payloads: [{ text: "test" }], + }, + tmpDir, + ); + + await failDelivery(id, "connection refused", tmpDir); + + const queueDir = path.join(tmpDir, "delivery-queue"); + const entry = JSON.parse(fs.readFileSync(path.join(queueDir, `${id}.json`), "utf-8")); + expect(entry.retryCount).toBe(1); + expect(typeof entry.lastAttemptAt).toBe("number"); + expect(entry.lastAttemptAt).toBeGreaterThan(0); + expect(entry.lastError).toBe("connection refused"); + }); + }); + + describe("moveToFailed", () => { + it("moves entry to failed/ subdirectory", async () => { + const id = await enqueueDelivery( + { + channel: "slack", + to: "#general", + payloads: [{ text: "hi" }], + }, + tmpDir, + ); + + await moveToFailed(id, tmpDir); + + const queueDir = path.join(tmpDir, "delivery-queue"); + const failedDir = path.join(queueDir, "failed"); + expect(fs.existsSync(path.join(queueDir, `${id}.json`))).toBe(false); + expect(fs.existsSync(path.join(failedDir, `${id}.json`))).toBe(true); + }); + }); + + describe("isPermanentDeliveryError", () => { + it.each([ + "No conversation reference found for user:abc", + "Telegram send failed: chat not found (chat_id=user:123)", + "user not found", + "Bot was blocked by the user", + "Forbidden: bot was kicked from the group chat", + "chat_id is empty", + "Outbound not configured for channel: msteams", + ])("returns true for permanent error: %s", (msg) => { + expect(isPermanentDeliveryError(msg)).toBe(true); + }); + + it.each([ + "network down", + "ETIMEDOUT", + "socket hang up", + "rate limited", + "500 Internal Server Error", + ])("returns false for transient error: %s", (msg) => { + expect(isPermanentDeliveryError(msg)).toBe(false); + }); + }); + + describe("loadPendingDeliveries", () => { + it("returns empty array when queue directory does not exist", async () => { + const nonexistent = path.join(tmpDir, "no-such-dir"); + const entries = await loadPendingDeliveries(nonexistent); + expect(entries).toEqual([]); + }); + + it("loads multiple entries", async () => { + await enqueueDelivery({ channel: "whatsapp", to: "+1", payloads: [{ text: "a" }] }, tmpDir); + await enqueueDelivery({ channel: "telegram", to: "2", payloads: [{ text: "b" }] }, tmpDir); + + const entries = await loadPendingDeliveries(tmpDir); + expect(entries).toHaveLength(2); + }); + + it("backfills lastAttemptAt for legacy retry entries during load", async () => { + const id = await enqueueDelivery( + { channel: "whatsapp", to: "+1", payloads: [{ text: "legacy" }] }, + tmpDir, + ); + const filePath = path.join(tmpDir, "delivery-queue", `${id}.json`); + const legacyEntry = JSON.parse(fs.readFileSync(filePath, "utf-8")); + legacyEntry.retryCount = 2; + delete legacyEntry.lastAttemptAt; + fs.writeFileSync(filePath, JSON.stringify(legacyEntry), "utf-8"); + + const entries = await loadPendingDeliveries(tmpDir); + expect(entries).toHaveLength(1); + expect(entries[0]?.lastAttemptAt).toBe(entries[0]?.enqueuedAt); + + const persisted = JSON.parse(fs.readFileSync(filePath, "utf-8")); + expect(persisted.lastAttemptAt).toBe(persisted.enqueuedAt); + }); + }); + + describe("computeBackoffMs", () => { + it("returns scheduled backoff values and clamps at max retry", () => { + const cases = [ + { retryCount: 0, expected: 0 }, + { retryCount: 1, expected: 5_000 }, + { retryCount: 2, expected: 25_000 }, + { retryCount: 3, expected: 120_000 }, + { retryCount: 4, expected: 600_000 }, + // Beyond defined schedule -- clamps to last value. + { retryCount: 5, expected: 600_000 }, + ] as const; + + for (const testCase of cases) { + expect(computeBackoffMs(testCase.retryCount), String(testCase.retryCount)).toBe( + testCase.expected, + ); + } + }); + }); + + describe("isEntryEligibleForRecoveryRetry", () => { + it("allows first replay after crash for retryCount=0 without lastAttemptAt", () => { + const now = Date.now(); + const result = isEntryEligibleForRecoveryRetry( + { + id: "entry-1", + channel: "whatsapp", + to: "+1", + payloads: [{ text: "a" }], + enqueuedAt: now, + retryCount: 0, + }, + now, + ); + expect(result).toEqual({ eligible: true }); + }); + + it("defers retry entries until backoff window elapses", () => { + const now = Date.now(); + const result = isEntryEligibleForRecoveryRetry( + { + id: "entry-2", + channel: "whatsapp", + to: "+1", + payloads: [{ text: "a" }], + enqueuedAt: now - 30_000, + retryCount: 3, + lastAttemptAt: now, + }, + now, + ); + expect(result.eligible).toBe(false); + if (result.eligible) { + throw new Error("Expected ineligible retry entry"); + } + expect(result.remainingBackoffMs).toBeGreaterThan(0); + }); + }); + + describe("recoverPendingDeliveries", () => { + const baseCfg = {}; + const createLog = () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }); + const enqueueCrashRecoveryEntries = async () => { + await enqueueDelivery({ channel: "whatsapp", to: "+1", payloads: [{ text: "a" }] }, tmpDir); + await enqueueDelivery({ channel: "telegram", to: "2", payloads: [{ text: "b" }] }, tmpDir); + }; + const setEntryState = ( + id: string, + state: { retryCount: number; lastAttemptAt?: number; enqueuedAt?: number }, + ) => { + const filePath = path.join(tmpDir, "delivery-queue", `${id}.json`); + const entry = JSON.parse(fs.readFileSync(filePath, "utf-8")); + entry.retryCount = state.retryCount; + if (state.lastAttemptAt === undefined) { + delete entry.lastAttemptAt; + } else { + entry.lastAttemptAt = state.lastAttemptAt; + } + if (state.enqueuedAt !== undefined) { + entry.enqueuedAt = state.enqueuedAt; + } + fs.writeFileSync(filePath, JSON.stringify(entry), "utf-8"); + }; + const runRecovery = async ({ + deliver, + log = createLog(), + maxRecoveryMs, + }: { + deliver: ReturnType; + log?: ReturnType; + maxRecoveryMs?: number; + }) => { + const result = await recoverPendingDeliveries({ + deliver: deliver as DeliverFn, + log, + cfg: baseCfg, + stateDir: tmpDir, + ...(maxRecoveryMs === undefined ? {} : { maxRecoveryMs }), + }); + return { result, log }; + }; + + it("recovers entries from a simulated crash", async () => { + // Manually create queue entries as if gateway crashed before delivery. + await enqueueCrashRecoveryEntries(); + const deliver = vi.fn().mockResolvedValue([]); + const { result } = await runRecovery({ deliver }); + + expect(deliver).toHaveBeenCalledTimes(2); + expect(result.recovered).toBe(2); + expect(result.failed).toBe(0); + expect(result.skippedMaxRetries).toBe(0); + expect(result.deferredBackoff).toBe(0); + + // Queue should be empty after recovery. + const remaining = await loadPendingDeliveries(tmpDir); + expect(remaining).toHaveLength(0); + }); + + it("moves entries that exceeded max retries to failed/", async () => { + // Create an entry and manually set retryCount to MAX_RETRIES. + const id = await enqueueDelivery( + { channel: "whatsapp", to: "+1", payloads: [{ text: "a" }] }, + tmpDir, + ); + setEntryState(id, { retryCount: MAX_RETRIES }); + + const deliver = vi.fn(); + const { result } = await runRecovery({ deliver }); + + expect(deliver).not.toHaveBeenCalled(); + expect(result.skippedMaxRetries).toBe(1); + expect(result.deferredBackoff).toBe(0); + + // Entry should be in failed/ directory. + const failedDir = path.join(tmpDir, "delivery-queue", "failed"); + expect(fs.existsSync(path.join(failedDir, `${id}.json`))).toBe(true); + }); + + it("increments retryCount on failed recovery attempt", async () => { + await enqueueDelivery({ channel: "slack", to: "#ch", payloads: [{ text: "x" }] }, tmpDir); + + const deliver = vi.fn().mockRejectedValue(new Error("network down")); + const { result } = await runRecovery({ deliver }); + + expect(result.failed).toBe(1); + expect(result.recovered).toBe(0); + + // Entry should still be in queue with incremented retryCount. + const entries = await loadPendingDeliveries(tmpDir); + expect(entries).toHaveLength(1); + expect(entries[0].retryCount).toBe(1); + expect(entries[0].lastError).toBe("network down"); + }); + + it("moves entries to failed/ immediately on permanent delivery errors", async () => { + const id = await enqueueDelivery( + { channel: "msteams", to: "user:abc", payloads: [{ text: "hi" }] }, + tmpDir, + ); + const deliver = vi + .fn() + .mockRejectedValue(new Error("No conversation reference found for user:abc")); + const log = createLog(); + const { result } = await runRecovery({ deliver, log }); + + expect(result.failed).toBe(1); + expect(result.recovered).toBe(0); + const remaining = await loadPendingDeliveries(tmpDir); + expect(remaining).toHaveLength(0); + const failedDir = path.join(tmpDir, "delivery-queue", "failed"); + expect(fs.existsSync(path.join(failedDir, `${id}.json`))).toBe(true); + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("permanent error")); + }); + + it("passes skipQueue: true to prevent re-enqueueing during recovery", async () => { + await enqueueDelivery({ channel: "whatsapp", to: "+1", payloads: [{ text: "a" }] }, tmpDir); + + const deliver = vi.fn().mockResolvedValue([]); + await runRecovery({ deliver }); + + expect(deliver).toHaveBeenCalledWith(expect.objectContaining({ skipQueue: true })); + }); + + it("replays stored delivery options during recovery", async () => { + await enqueueDelivery( + { + channel: "whatsapp", + to: "+1", + payloads: [{ text: "a" }], + bestEffort: true, + gifPlayback: true, + silent: true, + mirror: { + sessionKey: "agent:main:main", + text: "a", + mediaUrls: ["https://example.com/a.png"], + }, + }, + tmpDir, + ); + + const deliver = vi.fn().mockResolvedValue([]); + await runRecovery({ deliver }); + + expect(deliver).toHaveBeenCalledWith( + expect.objectContaining({ + bestEffort: true, + gifPlayback: true, + silent: true, + mirror: { + sessionKey: "agent:main:main", + text: "a", + mediaUrls: ["https://example.com/a.png"], + }, + }), + ); + }); + + it("respects maxRecoveryMs time budget", async () => { + await enqueueCrashRecoveryEntries(); + await enqueueDelivery({ channel: "slack", to: "#c", payloads: [{ text: "c" }] }, tmpDir); + + const deliver = vi.fn().mockResolvedValue([]); + const { result, log } = await runRecovery({ + deliver, + maxRecoveryMs: 0, // Immediate timeout -- no entries should be processed. + }); + + expect(deliver).not.toHaveBeenCalled(); + expect(result.recovered).toBe(0); + expect(result.failed).toBe(0); + expect(result.skippedMaxRetries).toBe(0); + expect(result.deferredBackoff).toBe(0); + + // All entries should still be in the queue. + const remaining = await loadPendingDeliveries(tmpDir); + expect(remaining).toHaveLength(3); + + // Should have logged a warning about deferred entries. + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("deferred to next restart")); + }); + + it("defers entries until backoff becomes eligible", async () => { + const id = await enqueueDelivery( + { channel: "whatsapp", to: "+1", payloads: [{ text: "a" }] }, + tmpDir, + ); + setEntryState(id, { retryCount: 3, lastAttemptAt: Date.now() }); + + const deliver = vi.fn().mockResolvedValue([]); + const { result, log } = await runRecovery({ + deliver, + maxRecoveryMs: 60_000, + }); + + expect(deliver).not.toHaveBeenCalled(); + expect(result).toEqual({ + recovered: 0, + failed: 0, + skippedMaxRetries: 0, + deferredBackoff: 1, + }); + + const remaining = await loadPendingDeliveries(tmpDir); + expect(remaining).toHaveLength(1); + + expect(log.info).toHaveBeenCalledWith(expect.stringContaining("not ready for retry yet")); + }); + + it("continues past high-backoff entries and recovers ready entries behind them", async () => { + const now = Date.now(); + const blockedId = await enqueueDelivery( + { channel: "whatsapp", to: "+1", payloads: [{ text: "blocked" }] }, + tmpDir, + ); + const readyId = await enqueueDelivery( + { channel: "telegram", to: "2", payloads: [{ text: "ready" }] }, + tmpDir, + ); + + setEntryState(blockedId, { retryCount: 3, lastAttemptAt: now, enqueuedAt: now - 30_000 }); + setEntryState(readyId, { retryCount: 0, enqueuedAt: now - 10_000 }); + + const deliver = vi.fn().mockResolvedValue([]); + const { result } = await runRecovery({ deliver, maxRecoveryMs: 60_000 }); + + expect(result).toEqual({ + recovered: 1, + failed: 0, + skippedMaxRetries: 0, + deferredBackoff: 1, + }); + expect(deliver).toHaveBeenCalledTimes(1); + expect(deliver).toHaveBeenCalledWith( + expect.objectContaining({ channel: "telegram", to: "2", skipQueue: true }), + ); + + const remaining = await loadPendingDeliveries(tmpDir); + expect(remaining).toHaveLength(1); + expect(remaining[0]?.id).toBe(blockedId); + }); + + it("recovers deferred entries on a later restart once backoff elapsed", async () => { + vi.useFakeTimers(); + const start = new Date("2026-01-01T00:00:00.000Z"); + vi.setSystemTime(start); + + const id = await enqueueDelivery( + { channel: "whatsapp", to: "+1", payloads: [{ text: "later" }] }, + tmpDir, + ); + setEntryState(id, { retryCount: 3, lastAttemptAt: start.getTime() }); + + const firstDeliver = vi.fn().mockResolvedValue([]); + const firstRun = await runRecovery({ deliver: firstDeliver, maxRecoveryMs: 60_000 }); + expect(firstRun.result).toEqual({ + recovered: 0, + failed: 0, + skippedMaxRetries: 0, + deferredBackoff: 1, + }); + expect(firstDeliver).not.toHaveBeenCalled(); + + vi.setSystemTime(new Date(start.getTime() + 600_000 + 1)); + const secondDeliver = vi.fn().mockResolvedValue([]); + const secondRun = await runRecovery({ deliver: secondDeliver, maxRecoveryMs: 60_000 }); + expect(secondRun.result).toEqual({ + recovered: 1, + failed: 0, + skippedMaxRetries: 0, + deferredBackoff: 0, + }); + expect(secondDeliver).toHaveBeenCalledTimes(1); + + const remaining = await loadPendingDeliveries(tmpDir); + expect(remaining).toHaveLength(0); + + vi.useRealTimers(); + }); + + it("returns zeros when queue is empty", async () => { + const deliver = vi.fn(); + const { result } = await runRecovery({ deliver }); + + expect(result).toEqual({ + recovered: 0, + failed: 0, + skippedMaxRetries: 0, + deferredBackoff: 0, + }); + expect(deliver).not.toHaveBeenCalled(); + }); + }); +}); + +describe("DirectoryCache", () => { + const cfg = {} as OpenClawConfig; + + afterEach(() => { + vi.useRealTimers(); + }); + + it("expires entries after ttl", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const cache = new DirectoryCache(1000, 10); + + cache.set("a", "value-a", cfg); + expect(cache.get("a", cfg)).toBe("value-a"); + + vi.setSystemTime(new Date("2026-01-01T00:00:02.000Z")); + expect(cache.get("a", cfg)).toBeUndefined(); + }); + + it("evicts least-recent entries when capacity is exceeded", () => { + const cases = [ + { + actions: [ + ["set", "a", "value-a"], + ["set", "b", "value-b"], + ["set", "c", "value-c"], + ] as const, + expected: { a: undefined, b: "value-b", c: "value-c" }, + }, + { + actions: [ + ["set", "a", "value-a"], + ["set", "b", "value-b"], + ["set", "a", "value-a2"], + ["set", "c", "value-c"], + ] as const, + expected: { a: "value-a2", b: undefined, c: "value-c" }, + }, + ]; + + for (const testCase of cases) { + const cache = new DirectoryCache(60_000, 2); + for (const action of testCase.actions) { + cache.set(action[1], action[2], cfg); + } + expect(cache.get("a", cfg)).toBe(testCase.expected.a); + expect(cache.get("b", cfg)).toBe(testCase.expected.b); + expect(cache.get("c", cfg)).toBe(testCase.expected.c); + } + }); +}); + +describe("buildOutboundResultEnvelope", () => { + it("formats envelope variants", () => { + const whatsappDelivery: OutboundDeliveryJson = { + channel: "whatsapp", + via: "gateway", + to: "+1", + messageId: "m1", + mediaUrl: null, + }; + const telegramDelivery: OutboundDeliveryJson = { + channel: "telegram", + via: "direct", + to: "123", + messageId: "m2", + mediaUrl: null, + chatId: "c1", + }; + const discordDelivery: OutboundDeliveryJson = { + channel: "discord", + via: "gateway", + to: "channel:C1", + messageId: "m3", + mediaUrl: null, + channelId: "C1", + }; + const cases = typedCases<{ + name: string; + input: Parameters[0]; + expected: unknown; + }>([ + { + name: "flatten delivery by default", + input: { delivery: whatsappDelivery }, + expected: whatsappDelivery, + }, + { + name: "keep payloads + meta", + input: { + payloads: [{ text: "hi", mediaUrl: null, mediaUrls: undefined }], + meta: { foo: "bar" }, + }, + expected: { + payloads: [{ text: "hi", mediaUrl: null, mediaUrls: undefined }], + meta: { foo: "bar" }, + }, + }, + { + name: "include delivery when payloads exist", + input: { payloads: [], delivery: telegramDelivery, meta: { ok: true } }, + expected: { + payloads: [], + meta: { ok: true }, + delivery: telegramDelivery, + }, + }, + { + name: "keep wrapped delivery when flatten disabled", + input: { delivery: discordDelivery, flattenDelivery: false }, + expected: { delivery: discordDelivery }, + }, + ]); + for (const testCase of cases) { + expect(buildOutboundResultEnvelope(testCase.input), testCase.name).toEqual(testCase.expected); + } + }); +}); + +describe("formatOutboundDeliverySummary", () => { + it("formats fallback and channel-specific detail variants", () => { + const cases = [ + { + name: "fallback telegram", + channel: "telegram" as const, + result: undefined, + expected: "✅ Sent via Telegram. Message ID: unknown", + }, + { + name: "fallback imessage", + channel: "imessage" as const, + result: undefined, + expected: "✅ Sent via iMessage. Message ID: unknown", + }, + { + name: "telegram with chat detail", + channel: "telegram" as const, + result: { + channel: "telegram" as const, + messageId: "m1", + chatId: "c1", + }, + expected: "✅ Sent via Telegram. Message ID: m1 (chat c1)", + }, + { + name: "discord with channel detail", + channel: "discord" as const, + result: { + channel: "discord" as const, + messageId: "d1", + channelId: "chan", + }, + expected: "✅ Sent via Discord. Message ID: d1 (channel chan)", + }, + ]; + + for (const testCase of cases) { + expect(formatOutboundDeliverySummary(testCase.channel, testCase.result), testCase.name).toBe( + testCase.expected, + ); + } + }); +}); + +describe("buildOutboundDeliveryJson", () => { + it("builds direct delivery payloads across provider-specific fields", () => { + const cases = [ + { + name: "telegram direct payload", + input: { + channel: "telegram" as const, + to: "123", + result: { channel: "telegram" as const, messageId: "m1", chatId: "c1" }, + mediaUrl: "https://example.com/a.png", + }, + expected: { + channel: "telegram", + via: "direct", + to: "123", + messageId: "m1", + mediaUrl: "https://example.com/a.png", + chatId: "c1", + }, + }, + { + name: "whatsapp metadata", + input: { + channel: "whatsapp" as const, + to: "+1", + result: { channel: "whatsapp" as const, messageId: "w1", toJid: "jid" }, + }, + expected: { + channel: "whatsapp", + via: "direct", + to: "+1", + messageId: "w1", + mediaUrl: null, + toJid: "jid", + }, + }, + { + name: "signal timestamp", + input: { + channel: "signal" as const, + to: "+1", + result: { channel: "signal" as const, messageId: "s1", timestamp: 123 }, + }, + expected: { + channel: "signal", + via: "direct", + to: "+1", + messageId: "s1", + mediaUrl: null, + timestamp: 123, + }, + }, + ]; + + for (const testCase of cases) { + expect(buildOutboundDeliveryJson(testCase.input), testCase.name).toEqual(testCase.expected); + } + }); +}); + +describe("formatGatewaySummary", () => { + it("formats default and custom gateway action summaries", () => { + const cases = [ + { + name: "default send action", + input: { channel: "whatsapp", messageId: "m1" }, + expected: "✅ Sent via gateway (whatsapp). Message ID: m1", + }, + { + name: "custom action", + input: { action: "Poll sent", channel: "discord", messageId: "p1" }, + expected: "✅ Poll sent via gateway (discord). Message ID: p1", + }, + ]; + + for (const testCase of cases) { + expect(formatGatewaySummary(testCase.input), testCase.name).toBe(testCase.expected); + } + }); +}); + +const slackConfig = { + channels: { + slack: { + botToken: "xoxb-test", + appToken: "xapp-test", + }, + }, +} as OpenClawConfig; + +const discordConfig = { + channels: { + discord: {}, + }, +} as OpenClawConfig; + +describe("outbound policy", () => { + it("allows cross-provider sends when enabled", () => { + const cfg = { + ...slackConfig, + tools: { + message: { crossContext: { allowAcrossProviders: true } }, + }, + } as OpenClawConfig; + + expect(() => + enforceCrossContextPolicy({ + cfg, + channel: "telegram", + action: "send", + args: { to: "telegram:@ops" }, + toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" }, + }), + ).not.toThrow(); + }); + + it("uses components when available and preferred", async () => { + const decoration = await buildCrossContextDecoration({ + cfg: discordConfig, + channel: "discord", + target: "123", + toolContext: { currentChannelId: "C12345678", currentChannelProvider: "discord" }, + }); + + expect(decoration).not.toBeNull(); + const applied = applyCrossContextDecoration({ + message: "hello", + decoration: decoration!, + preferComponents: true, + }); + + expect(applied.usedComponents).toBe(true); + expect(applied.componentsBuilder).toBeDefined(); + expect(applied.componentsBuilder?.("hello").length).toBeGreaterThan(0); + expect(applied.message).toBe("hello"); + }); +}); + +describe("resolveOutboundSessionRoute", () => { + const baseConfig = {} as OpenClawConfig; + + it("resolves provider-specific session routes", async () => { + const perChannelPeerCfg = { session: { dmScope: "per-channel-peer" } } as OpenClawConfig; + const identityLinksCfg = { + session: { + dmScope: "per-peer", + identityLinks: { + alice: ["discord:123"], + }, + }, + } as OpenClawConfig; + const slackMpimCfg = { + channels: { + slack: { + dm: { + groupChannels: ["G123"], + }, + }, + }, + } as OpenClawConfig; + const cases: Array<{ + name: string; + cfg: OpenClawConfig; + channel: string; + target: string; + replyToId?: string; + threadId?: string; + expected: { + sessionKey: string; + from?: string; + to?: string; + threadId?: string | number; + chatType?: "direct" | "group"; + }; + }> = [ + { + name: "Slack thread", + cfg: baseConfig, + channel: "slack", + target: "channel:C123", + replyToId: "456", + expected: { + sessionKey: "agent:main:slack:channel:c123:thread:456", + from: "slack:channel:C123", + to: "channel:C123", + threadId: "456", + }, + }, + { + name: "Telegram topic group", + cfg: baseConfig, + channel: "telegram", + target: "-100123456:topic:42", + expected: { + sessionKey: "agent:main:telegram:group:-100123456:topic:42", + from: "telegram:group:-100123456:topic:42", + to: "telegram:-100123456", + threadId: 42, + }, + }, + { + name: "Telegram DM with topic", + cfg: perChannelPeerCfg, + channel: "telegram", + target: "123456789:topic:99", + expected: { + sessionKey: "agent:main:telegram:direct:123456789:thread:99", + from: "telegram:123456789:topic:99", + to: "telegram:123456789", + threadId: 99, + chatType: "direct", + }, + }, + { + name: "Telegram unresolved username DM", + cfg: perChannelPeerCfg, + channel: "telegram", + target: "@alice", + expected: { + sessionKey: "agent:main:telegram:direct:@alice", + chatType: "direct", + }, + }, + { + name: "Telegram DM scoped threadId fallback", + cfg: perChannelPeerCfg, + channel: "telegram", + target: "12345", + threadId: "12345:99", + expected: { + sessionKey: "agent:main:telegram:direct:12345:thread:99", + from: "telegram:12345:topic:99", + to: "telegram:12345", + threadId: 99, + chatType: "direct", + }, + }, + { + name: "identity-links per-peer", + cfg: identityLinksCfg, + channel: "discord", + target: "user:123", + expected: { + sessionKey: "agent:main:direct:alice", + }, + }, + { + name: "BlueBubbles chat_* prefix stripping", + cfg: baseConfig, + channel: "bluebubbles", + target: "chat_guid:ABC123", + expected: { + sessionKey: "agent:main:bluebubbles:group:abc123", + from: "group:ABC123", + }, + }, + { + name: "Zalo Personal DM target", + cfg: perChannelPeerCfg, + channel: "zalouser", + target: "123456", + expected: { + sessionKey: "agent:main:zalouser:direct:123456", + chatType: "direct", + }, + }, + { + name: "Slack mpim allowlist -> group key", + cfg: slackMpimCfg, + channel: "slack", + target: "channel:G123", + expected: { + sessionKey: "agent:main:slack:group:g123", + from: "slack:group:G123", + }, + }, + { + name: "Feishu explicit group prefix keeps group routing", + cfg: baseConfig, + channel: "feishu", + target: "group:oc_group_chat", + expected: { + sessionKey: "agent:main:feishu:group:oc_group_chat", + from: "feishu:group:oc_group_chat", + to: "oc_group_chat", + chatType: "group", + }, + }, + { + name: "Feishu explicit dm prefix keeps direct routing", + cfg: perChannelPeerCfg, + channel: "feishu", + target: "dm:oc_dm_chat", + expected: { + sessionKey: "agent:main:feishu:direct:oc_dm_chat", + from: "feishu:oc_dm_chat", + to: "oc_dm_chat", + chatType: "direct", + }, + }, + { + name: "Feishu bare oc_ target defaults to direct routing", + cfg: perChannelPeerCfg, + channel: "feishu", + target: "oc_ambiguous_chat", + expected: { + sessionKey: "agent:main:feishu:direct:oc_ambiguous_chat", + from: "feishu:oc_ambiguous_chat", + to: "oc_ambiguous_chat", + chatType: "direct", + }, + }, + ]; + + for (const testCase of cases) { + const route = await resolveOutboundSessionRoute({ + cfg: testCase.cfg, + channel: testCase.channel, + agentId: "main", + target: testCase.target, + replyToId: testCase.replyToId, + threadId: testCase.threadId, + }); + expect(route?.sessionKey, testCase.name).toBe(testCase.expected.sessionKey); + if (testCase.expected.from !== undefined) { + expect(route?.from, testCase.name).toBe(testCase.expected.from); + } + if (testCase.expected.to !== undefined) { + expect(route?.to, testCase.name).toBe(testCase.expected.to); + } + if (testCase.expected.threadId !== undefined) { + expect(route?.threadId, testCase.name).toBe(testCase.expected.threadId); + } + if (testCase.expected.chatType !== undefined) { + expect(route?.chatType, testCase.name).toBe(testCase.expected.chatType); + } + } + }); + + it("uses resolved Discord user targets to route bare numeric ids as DMs", async () => { + const route = await resolveOutboundSessionRoute({ + cfg: { session: { dmScope: "per-channel-peer" } } as OpenClawConfig, + channel: "discord", + agentId: "main", + target: "123", + resolvedTarget: { + to: "user:123", + kind: "user", + source: "directory", + }, + }); + + expect(route).toMatchObject({ + sessionKey: "agent:main:discord:direct:123", + from: "discord:123", + to: "user:123", + chatType: "direct", + }); + }); + + it("uses resolved Mattermost user targets to route bare ids as DMs", async () => { + const userId = "dthcxgoxhifn3pwh65cut3ud3w"; + const route = await resolveOutboundSessionRoute({ + cfg: { session: { dmScope: "per-channel-peer" } } as OpenClawConfig, + channel: "mattermost", + agentId: "main", + target: userId, + resolvedTarget: { + to: `user:${userId}`, + kind: "user", + source: "directory", + }, + }); + + expect(route).toMatchObject({ + sessionKey: `agent:main:mattermost:direct:${userId}`, + from: `mattermost:${userId}`, + to: `user:${userId}`, + chatType: "direct", + }); + }); + + it("rejects bare numeric Discord targets when the caller has no kind hint", async () => { + await expect( + resolveOutboundSessionRoute({ + cfg: { session: { dmScope: "per-channel-peer" } } as OpenClawConfig, + channel: "discord", + agentId: "main", + target: "123", + }), + ).rejects.toThrow(/Ambiguous Discord recipient/); + }); +}); + +describe("normalizeOutboundPayloadsForJson", () => { + it("normalizes payloads for JSON output", () => { + const cases = typedCases<{ + input: Parameters[0]; + expected: ReturnType; + }>([ + { + input: [ + { text: "hi" }, + { text: "photo", mediaUrl: "https://x.test/a.jpg" }, + { text: "multi", mediaUrls: ["https://x.test/1.png"] }, + ], + expected: [ + { text: "hi", mediaUrl: null, mediaUrls: undefined, channelData: undefined }, + { + text: "photo", + mediaUrl: "https://x.test/a.jpg", + mediaUrls: ["https://x.test/a.jpg"], + channelData: undefined, + }, + { + text: "multi", + mediaUrl: null, + mediaUrls: ["https://x.test/1.png"], + channelData: undefined, + }, + ], + }, + { + input: [ + { + text: "MEDIA:https://x.test/a.png\nMEDIA:https://x.test/b.png", + }, + ], + expected: [ + { + text: "", + mediaUrl: null, + mediaUrls: ["https://x.test/a.png", "https://x.test/b.png"], + channelData: undefined, + }, + ], + }, + ]); + + for (const testCase of cases) { + const input: ReplyPayload[] = testCase.input.map((payload) => + "mediaUrls" in payload + ? ({ + ...payload, + mediaUrls: payload.mediaUrls ? [...payload.mediaUrls] : undefined, + } as ReplyPayload) + : ({ ...payload } as ReplyPayload), + ); + expect(normalizeOutboundPayloadsForJson(input)).toEqual(testCase.expected); + } + }); + + it("suppresses reasoning payloads", () => { + const normalized = normalizeOutboundPayloadsForJson([ + { text: "Reasoning:\n_step_", isReasoning: true }, + { text: "final answer" }, + ]); + expect(normalized).toEqual([{ text: "final answer", mediaUrl: null, mediaUrls: undefined }]); + }); +}); + +describe("normalizeOutboundPayloads", () => { + it("keeps channelData-only payloads", () => { + const channelData = { line: { flexMessage: { altText: "Card", contents: {} } } }; + const normalized = normalizeOutboundPayloads([{ channelData }]); + expect(normalized).toEqual([{ text: "", mediaUrls: [], channelData }]); + }); + + it("suppresses reasoning payloads", () => { + const normalized = normalizeOutboundPayloads([ + { text: "Reasoning:\n_step_", isReasoning: true }, + { text: "final answer" }, + ]); + expect(normalized).toEqual([{ text: "final answer", mediaUrls: [] }]); + }); + + it("formats BTW replies prominently for external delivery", () => { + const normalized = normalizeOutboundPayloads([ + { + text: "323", + btw: { question: "what is 17 * 19?" }, + }, + ]); + expect(normalized).toEqual([{ text: "BTW\nQuestion: what is 17 * 19?\n\n323", mediaUrls: [] }]); + }); +}); + +describe("formatOutboundPayloadLog", () => { + it("formats text+media and media-only logs", () => { + const cases = typedCases<{ + name: string; + input: Parameters[0]; + expected: string; + }>([ + { + name: "text with media lines", + input: { + text: "hello ", + mediaUrls: ["https://x.test/a.png", "https://x.test/b.png"], + }, + expected: "hello\nMEDIA:https://x.test/a.png\nMEDIA:https://x.test/b.png", + }, + { + name: "media only", + input: { + text: "", + mediaUrls: ["https://x.test/a.png"], + }, + expected: "MEDIA:https://x.test/a.png", + }, + ]); + + for (const testCase of cases) { + expect( + formatOutboundPayloadLog({ + ...testCase.input, + mediaUrls: [...testCase.input.mediaUrls], + }), + testCase.name, + ).toBe(testCase.expected); + } + }); +}); + runResolveOutboundTargetCoreTests(); diff --git a/src/infra/outbound/payloads.ts b/src/infra/outbound/payloads.ts index 9dae6a6c1e6..754d3434445 100644 --- a/src/infra/outbound/payloads.ts +++ b/src/infra/outbound/payloads.ts @@ -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, diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index f0ec39539c8..00e4b3b34ae 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -37,6 +37,7 @@ const RESERVED_COMMANDS = new Set([ "status", "whoami", "context", + "btw", // Session management "stop", "restart", diff --git a/src/tui/components/btw-inline-message.test.ts b/src/tui/components/btw-inline-message.test.ts new file mode 100644 index 00000000000..8cd323708c2 --- /dev/null +++ b/src/tui/components/btw-inline-message.test.ts @@ -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"); + }); +}); diff --git a/src/tui/components/btw-inline-message.ts b/src/tui/components/btw-inline-message.ts new file mode 100644 index 00000000000..7aa813a457e --- /dev/null +++ b/src/tui/components/btw-inline-message.ts @@ -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)); + } +} diff --git a/src/tui/components/chat-log.test.ts b/src/tui/components/chat-log.test.ts index b81740a2e8c..700a2abb9d2 100644 --- a/src/tui/components/chat-log.test.ts +++ b/src/tui/components/chat-log.test.ts @@ -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); + }); }); diff --git a/src/tui/components/chat-log.ts b/src/tui/components/chat-log.ts index 76ac7d93654..c46e6065b9b 100644 --- a/src/tui/components/chat-log.ts +++ b/src/tui/components/chat-log.ts @@ -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(); private streamingRuns = new Map(); + 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) { diff --git a/src/tui/tui-command-handlers.test.ts b/src/tui/tui-command-handlers.test.ts index 4e4bfe3c36f..026b63350be 100644 --- a/src/tui/tui-command-handlers.test.ts +++ b/src/tui/tui-command-handlers.test.ts @@ -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; diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index dd5113a17af..f3fc095c101 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -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(); } }; diff --git a/src/tui/tui-event-handlers.test.ts b/src/tui/tui-event-handlers.test.ts index 7b08ddceaf5..2073afe308d 100644 --- a/src/tui/tui-event-handlers.test.ts +++ b/src/tui/tui-event-handlers.test.ts @@ -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; 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 => ({ 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(); + const localBtwRunIds = new Set(); 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; 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: { diff --git a/src/tui/tui-event-handlers.ts b/src/tui/tui-event-handlers.ts index 54e4654ee96..6fda2d85163 100644 --- a/src/tui/tui-event-handlers.ts +++ b/src/tui/tui-event-handlers.ts @@ -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(); const sessionRuns = new Map(); @@ -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 }; } diff --git a/src/tui/tui-session-actions.test.ts b/src/tui/tui-session-actions.test.ts index 5e4a427c4a9..67f5e4d8798 100644 --- a/src/tui/tui-session-actions.test.ts +++ b/src/tui/tui-session-actions.test.ts @@ -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(); }); }); diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts index 406b584599f..99f2b8ab2ee 100644 --- a/src/tui/tui-session-actions.ts +++ b/src/tui/tui-session-actions.ts @@ -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(); diff --git a/src/tui/tui-types.ts b/src/tui/tui-types.ts index 0f780b0a6bb..eeda9693ebf 100644 --- a/src/tui/tui-types.ts +++ b/src/tui/tui-types.ts @@ -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; diff --git a/src/tui/tui.ts b/src/tui/tui.ts index e1eae539f50..b9c67e76a29 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -344,6 +344,7 @@ export async function runTui(opts: TuiOptions) { let showThinking = false; let pairingHintShown = false; const localRunIds = new Set(); + const localBtwRunIds = new Set(); 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); }