diff --git a/src/agents/btw.test.ts b/src/agents/btw.test.ts index 76d7e0a8572..b2c222f5ac1 100644 --- a/src/agents/btw.test.ts +++ b/src/agents/btw.test.ts @@ -17,6 +17,7 @@ const acquireSessionWriteLockMock = vi.fn(); const resolveSessionAuthProfileOverrideMock = vi.fn(); const getActiveEmbeddedRunSnapshotMock = vi.fn(); const waitForEmbeddedPiRunEndMock = vi.fn(); +const diagWarnMock = vi.fn(); vi.mock("@mariozechner/pi-ai", () => ({ streamSimple: (...args: unknown[]) => streamSimpleMock(...args), @@ -66,6 +67,12 @@ vi.mock("./auth-profiles/session-override.js", () => ({ resolveSessionAuthProfileOverrideMock(...args), })); +vi.mock("../logging/diagnostic.js", () => ({ + diagnosticLogger: { + warn: (...args: unknown[]) => diagWarnMock(...args), + }, +})); + const { BTW_CUSTOM_TYPE, runBtwSideQuestion } = await import("./btw.js"); function makeAsyncEvents(events: unknown[]) { @@ -105,6 +112,7 @@ describe("runBtwSideQuestion", () => { resolveSessionAuthProfileOverrideMock.mockReset(); getActiveEmbeddedRunSnapshotMock.mockReset(); waitForEmbeddedPiRunEndMock.mockReset(); + diagWarnMock.mockReset(); buildSessionContextMock.mockReturnValue({ messages: [{ role: "user", content: [{ type: "text", text: "hi" }], timestamp: 1 }], @@ -425,4 +433,58 @@ describe("runBtwSideQuestion", () => { ); }); }); + + 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"), + ); + }); + }); }); diff --git a/src/agents/btw.ts b/src/agents/btw.ts index ff0b8b63412..52fd4d24a46 100644 --- a/src/agents/btw.ts +++ b/src/agents/btw.ts @@ -15,6 +15,7 @@ import { 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"; @@ -97,7 +98,7 @@ function deferBtwCustomEntryPersist(params: { }); } catch (error) { const message = error instanceof Error ? error.message : String(error); - console.warn(`[btw] skipped transcript persistence: ${message}`); + diag.warn(`btw transcript persistence skipped: sessionId=${params.sessionId} err=${message}`); } })(); } diff --git a/src/auto-reply/reply/commands-btw.test.ts b/src/auto-reply/reply/commands-btw.test.ts index 85f181db4d4..53e15667f91 100644 --- a/src/auto-reply/reply/commands-btw.test.ts +++ b/src/auto-reply/reply/commands-btw.test.ts @@ -32,6 +32,23 @@ describe("handleBtwCommand", () => { }); }); + 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; diff --git a/src/auto-reply/reply/commands-btw.ts b/src/auto-reply/reply/commands-btw.ts index c35b3f115c4..2d4f8bf6a31 100644 --- a/src/auto-reply/reply/commands-btw.ts +++ b/src/auto-reply/reply/commands-btw.ts @@ -1,13 +1,21 @@ import { runBtwSideQuestion } from "../../agents/btw.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) => { +export const handleBtwCommand: CommandHandler = async (params, allowTextCommands) => { + if (!allowTextCommands) { + return null; + } const match = params.command.commandBodyNormalized.match(/^\/btw(?:\s+(.*))?$/i); if (!match) { return null; } + const unauthorized = rejectUnauthorizedCommand(params, "/btw"); + if (unauthorized) { + return unauthorized; + } const question = match[1]?.trim() ?? ""; if (!question) { diff --git a/src/auto-reply/reply/get-reply-inline-actions.ts b/src/auto-reply/reply/get-reply-inline-actions.ts index ff56526d9ce..91bcd68eb56 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.ts @@ -37,6 +37,7 @@ function getBuiltinSlashCommands(): Set { return builtinSlashCommands; } builtinSlashCommands = listReservedChatSlashCommandNames([ + "btw", "think", "verbose", "reasoning",