diff --git a/src/web/auto-reply.partial-reply-gating.test.ts b/src/web/auto-reply.partial-reply-gating.test.ts deleted file mode 100644 index 0f7579144..000000000 --- a/src/web/auto-reply.partial-reply-gating.test.ts +++ /dev/null @@ -1,326 +0,0 @@ -import "./test-helpers.js"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("../agents/pi-embedded.js", () => ({ - abortEmbeddedPiRun: vi.fn().mockReturnValue(false), - isEmbeddedPiRunActive: vi.fn().mockReturnValue(false), - isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false), - runEmbeddedPiAgent: vi.fn(), - queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), - resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, -})); - -import type { OpenClawConfig } from "../config/config.js"; -import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; -import { getReplyFromConfig } from "../auto-reply/reply.js"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { monitorWebChannel } from "./auto-reply.js"; -import { resetLoadConfigMock, setLoadConfigMock } from "./test-helpers.js"; - -let previousHome: string | undefined; -let tempHome: string | undefined; - -const rmDirWithRetries = async (dir: string): Promise => { - // Some tests can leave async session-store writes in-flight; recursive deletion can race and throw ENOTEMPTY. - for (let attempt = 0; attempt < 10; attempt += 1) { - try { - await fs.rm(dir, { recursive: true, force: true }); - return; - } catch (err) { - const code = - err && typeof err === "object" && "code" in err - ? String((err as { code?: unknown }).code) - : null; - if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") { - await new Promise((resolve) => setTimeout(resolve, 5)); - continue; - } - throw err; - } - } - - await fs.rm(dir, { recursive: true, force: true }); -}; - -beforeEach(async () => { - resetInboundDedupe(); - previousHome = process.env.HOME; - tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-web-home-")); - process.env.HOME = tempHome; -}); - -afterEach(async () => { - process.env.HOME = previousHome; - if (tempHome) { - await rmDirWithRetries(tempHome); - tempHome = undefined; - } -}); - -const makeSessionStore = async ( - entries: Record = {}, -): Promise<{ storePath: string; cleanup: () => Promise }> => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-")); - const storePath = path.join(dir, "sessions.json"); - await fs.writeFile(storePath, JSON.stringify(entries)); - const cleanup = async () => { - // Session store writes can be in-flight when the test finishes (e.g. updateLastRoute - // after a message flush). `fs.rm({ recursive })` can race and throw ENOTEMPTY. - for (let attempt = 0; attempt < 10; attempt += 1) { - try { - await fs.rm(dir, { recursive: true, force: true }); - return; - } catch (err) { - const code = - err && typeof err === "object" && "code" in err - ? String((err as { code?: unknown }).code) - : null; - if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") { - await new Promise((resolve) => setTimeout(resolve, 5)); - continue; - } - throw err; - } - } - - await fs.rm(dir, { recursive: true, force: true }); - }; - return { - storePath, - cleanup, - }; -}; - -describe("partial reply gating", () => { - it("does not send partial replies for WhatsApp provider", async () => { - const reply = vi.fn().mockResolvedValue(undefined); - const sendComposing = vi.fn().mockResolvedValue(undefined); - const sendMedia = vi.fn().mockResolvedValue(undefined); - - const replyResolver = vi.fn().mockResolvedValue({ text: "final reply" }); - - const mockConfig: OpenClawConfig = { - channels: { whatsapp: { allowFrom: ["*"] } }, - }; - - setLoadConfigMock(mockConfig); - - await monitorWebChannel( - false, - async ({ onMessage }) => { - await onMessage({ - id: "m1", - from: "+1000", - conversationId: "+1000", - to: "+2000", - body: "hello", - timestamp: Date.now(), - chatType: "direct", - chatId: "direct:+1000", - sendComposing, - reply, - sendMedia, - }); - return { close: vi.fn().mockResolvedValue(undefined) }; - }, - false, - replyResolver, - ); - - resetLoadConfigMock(); - - expect(replyResolver).toHaveBeenCalledTimes(1); - const resolverOptions = replyResolver.mock.calls[0]?.[1] ?? {}; - expect("onPartialReply" in resolverOptions).toBe(false); - expect(reply).toHaveBeenCalledTimes(1); - expect(reply).toHaveBeenCalledWith("final reply"); - }); - it("falls back from empty senderJid to senderE164 for SenderId", async () => { - const reply = vi.fn().mockResolvedValue(undefined); - const sendComposing = vi.fn().mockResolvedValue(undefined); - const sendMedia = vi.fn().mockResolvedValue(undefined); - - const replyResolver = vi.fn().mockResolvedValue({ text: "final reply" }); - - const mockConfig: OpenClawConfig = { - channels: { - whatsapp: { - allowFrom: ["*"], - }, - }, - }; - - setLoadConfigMock(mockConfig); - - await monitorWebChannel( - false, - async ({ onMessage }) => { - await onMessage({ - id: "m1", - from: "+1000", - conversationId: "+1000", - to: "+2000", - body: "hello", - timestamp: Date.now(), - chatType: "direct", - chatId: "direct:+1000", - senderJid: "", - senderE164: "+1000", - sendComposing, - reply, - sendMedia, - }); - return { close: vi.fn().mockResolvedValue(undefined) }; - }, - false, - replyResolver, - ); - - resetLoadConfigMock(); - - expect(replyResolver).toHaveBeenCalledTimes(1); - const ctx = replyResolver.mock.calls[0]?.[0] ?? {}; - expect(ctx.SenderE164).toBe("+1000"); - expect(ctx.SenderId).toBe("+1000"); - }); - it("updates last-route for direct chats without senderE164", async () => { - const now = Date.now(); - const mainSessionKey = "agent:main:main"; - const store = await makeSessionStore({ - [mainSessionKey]: { sessionId: "sid", updatedAt: now - 1 }, - }); - - const replyResolver = vi.fn().mockResolvedValue(undefined); - - const mockConfig: OpenClawConfig = { - channels: { whatsapp: { allowFrom: ["*"] } }, - session: { store: store.storePath }, - }; - - setLoadConfigMock(mockConfig); - - await monitorWebChannel( - false, - async ({ onMessage }) => { - await onMessage({ - id: "m1", - from: "+1000", - conversationId: "+1000", - to: "+2000", - body: "hello", - timestamp: now, - chatType: "direct", - chatId: "direct:+1000", - sendComposing: vi.fn().mockResolvedValue(undefined), - reply: vi.fn().mockResolvedValue(undefined), - sendMedia: vi.fn().mockResolvedValue(undefined), - }); - return { close: vi.fn().mockResolvedValue(undefined) }; - }, - false, - replyResolver, - ); - - // `monitorWebChannel(..., keepAlive=false)` waits for background tasks, including last-route writes. - const stored = JSON.parse(await fs.readFile(store.storePath, "utf8")) as Record< - string, - { lastChannel?: string; lastTo?: string } - >; - expect(stored[mainSessionKey]?.lastChannel).toBe("whatsapp"); - expect(stored[mainSessionKey]?.lastTo).toBe("+1000"); - - resetLoadConfigMock(); - await store.cleanup(); - }); - it("updates last-route for group chats with account id", async () => { - const now = Date.now(); - const groupSessionKey = "agent:main:whatsapp:group:123@g.us"; - const store = await makeSessionStore({ - [groupSessionKey]: { sessionId: "sid", updatedAt: now - 1 }, - }); - - const replyResolver = vi.fn().mockResolvedValue(undefined); - - const mockConfig: OpenClawConfig = { - channels: { whatsapp: { allowFrom: ["*"] } }, - session: { store: store.storePath }, - }; - - setLoadConfigMock(mockConfig); - - await monitorWebChannel( - false, - async ({ onMessage }) => { - await onMessage({ - id: "g1", - from: "123@g.us", - conversationId: "123@g.us", - to: "+2000", - body: "hello", - timestamp: now, - chatType: "group", - chatId: "123@g.us", - accountId: "work", - senderE164: "+1000", - senderName: "Alice", - selfE164: "+2000", - sendComposing: vi.fn().mockResolvedValue(undefined), - reply: vi.fn().mockResolvedValue(undefined), - sendMedia: vi.fn().mockResolvedValue(undefined), - }); - return { close: vi.fn().mockResolvedValue(undefined) }; - }, - false, - replyResolver, - ); - - const stored = JSON.parse(await fs.readFile(store.storePath, "utf8")) as Record< - string, - { lastChannel?: string; lastTo?: string; lastAccountId?: string } - >; - expect(stored[groupSessionKey]?.lastChannel).toBe("whatsapp"); - expect(stored[groupSessionKey]?.lastTo).toBe("123@g.us"); - expect(stored[groupSessionKey]?.lastAccountId).toBe("work"); - - resetLoadConfigMock(); - await store.cleanup(); - }); - it("defaults to self-only when no config is present", async () => { - vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { - durationMs: 1, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); - - // Not self: should be blocked - const blocked = await getReplyFromConfig( - { - Body: "hi", - From: "whatsapp:+999", - To: "whatsapp:+123", - }, - undefined, - {}, - ); - expect(blocked).toBeUndefined(); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - - // Self: should be allowed - const allowed = await getReplyFromConfig( - { - Body: "hi", - From: "whatsapp:+123", - To: "whatsapp:+123", - }, - undefined, - {}, - ); - expect(allowed).toMatchObject({ text: "ok", audioAsVoice: false }); - expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); - }); -}); diff --git a/src/web/auto-reply.web-auto-reply.prefixes-and-partial-gating.test.ts b/src/web/auto-reply.web-auto-reply.prefixes-and-partial-gating.test.ts new file mode 100644 index 000000000..59d67d0ae --- /dev/null +++ b/src/web/auto-reply.web-auto-reply.prefixes-and-partial-gating.test.ts @@ -0,0 +1,651 @@ +import "./test-helpers.js"; +import fs from "node:fs/promises"; +import { beforeAll, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { + installWebAutoReplyTestHomeHooks, + installWebAutoReplyUnitTestHooks, + makeSessionStore, + resetLoadConfigMock, + setLoadConfigMock, +} from "./auto-reply.test-harness.js"; + +installWebAutoReplyTestHomeHooks(); + +let monitorWebChannel: typeof import("./auto-reply.js").monitorWebChannel; +let HEARTBEAT_TOKEN: typeof import("./auto-reply.js").HEARTBEAT_TOKEN; +let getReplyFromConfig: typeof import("../auto-reply/reply.js").getReplyFromConfig; +let runEmbeddedPiAgent: typeof import("../agents/pi-embedded.js").runEmbeddedPiAgent; + +beforeAll(async () => { + ({ monitorWebChannel, HEARTBEAT_TOKEN } = await import("./auto-reply.js")); + ({ getReplyFromConfig } = await import("../auto-reply/reply.js")); + ({ runEmbeddedPiAgent } = await import("../agents/pi-embedded.js")); +}); + +function createCapturedListener() { + let capturedOnMessage: + | ((msg: import("./inbound.js").WebInboundMessage) => Promise) + | undefined; + const listenerFactory = async (opts: { + onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise; + }) => { + capturedOnMessage = opts.onMessage; + return { close: vi.fn() }; + }; + return { listenerFactory, getCapturedOnMessage: () => capturedOnMessage }; +} + +describe("web auto-reply", () => { + installWebAutoReplyUnitTestHooks(); + + it("prefixes body with same-phone marker when from === to", async () => { + setLoadConfigMock(() => ({ + channels: { whatsapp: { allowFrom: ["*"] } }, + messages: { + messagePrefix: "[same-phone]", + responsePrefix: undefined, + }, + })); + + const { listenerFactory, getCapturedOnMessage } = createCapturedListener(); + const resolver = vi.fn().mockResolvedValue({ text: "reply" }); + + await monitorWebChannel(false, listenerFactory, false, resolver); + expect(getCapturedOnMessage()).toBeDefined(); + + await getCapturedOnMessage()?.({ + body: "hello", + from: "+1555", + to: "+1555", + id: "msg1", + sendComposing: vi.fn(), + reply: vi.fn(), + sendMedia: vi.fn(), + }); + + const callArg = resolver.mock.calls[0]?.[0] as { Body?: string }; + expect(callArg?.Body).toBeDefined(); + expect(callArg?.Body).toContain("[WhatsApp +1555"); + expect(callArg?.Body).toContain("[same-phone] hello"); + resetLoadConfigMock(); + }); + + it("does not prefix body when from !== to", async () => { + const { listenerFactory, getCapturedOnMessage } = createCapturedListener(); + const resolver = vi.fn().mockResolvedValue({ text: "reply" }); + + await monitorWebChannel(false, listenerFactory, false, resolver); + expect(getCapturedOnMessage()).toBeDefined(); + + await getCapturedOnMessage()?.({ + body: "hello", + from: "+1555", + to: "+2666", + id: "msg1", + sendComposing: vi.fn(), + reply: vi.fn(), + sendMedia: vi.fn(), + }); + + const callArg = resolver.mock.calls[0]?.[0] as { Body?: string }; + expect(callArg?.Body).toContain("[WhatsApp +1555"); + expect(callArg?.Body).toContain("hello"); + }); + + it("forwards reply-to context to resolver", async () => { + const { listenerFactory, getCapturedOnMessage } = createCapturedListener(); + const resolver = vi.fn().mockResolvedValue({ text: "reply" }); + + await monitorWebChannel(false, listenerFactory, false, resolver); + expect(getCapturedOnMessage()).toBeDefined(); + + await getCapturedOnMessage()?.({ + body: "hello", + from: "+1555", + to: "+2666", + id: "msg1", + replyToId: "q1", + replyToBody: "original", + replyToSender: "+1999", + sendComposing: vi.fn(), + reply: vi.fn(), + sendMedia: vi.fn(), + }); + + const callArg = resolver.mock.calls[0]?.[0] as { + ReplyToId?: string; + ReplyToBody?: string; + ReplyToSender?: string; + Body?: string; + }; + expect(callArg.ReplyToId).toBe("q1"); + expect(callArg.ReplyToBody).toBe("original"); + expect(callArg.ReplyToSender).toBe("+1999"); + expect(callArg.Body).toContain("[Replying to +1999 id:q1]"); + expect(callArg.Body).toContain("original"); + }); + + it("applies responsePrefix to regular replies", async () => { + setLoadConfigMock(() => ({ + channels: { whatsapp: { allowFrom: ["*"] } }, + messages: { + messagePrefix: undefined, + responsePrefix: "🦞", + }, + })); + + const { listenerFactory, getCapturedOnMessage } = createCapturedListener(); + const reply = vi.fn(); + const resolver = vi.fn().mockResolvedValue({ text: "hello there" }); + + await monitorWebChannel(false, listenerFactory, false, resolver); + expect(getCapturedOnMessage()).toBeDefined(); + + await getCapturedOnMessage()?.({ + body: "hi", + from: "+1555", + to: "+2666", + id: "msg1", + sendComposing: vi.fn(), + reply, + sendMedia: vi.fn(), + }); + + expect(reply).toHaveBeenCalledWith("🦞 hello there"); + resetLoadConfigMock(); + }); + + it("applies channel responsePrefix override to replies", async () => { + setLoadConfigMock(() => ({ + channels: { whatsapp: { allowFrom: ["*"], responsePrefix: "[WA]" } }, + messages: { + messagePrefix: undefined, + responsePrefix: "[Global]", + }, + })); + + const { listenerFactory, getCapturedOnMessage } = createCapturedListener(); + const reply = vi.fn(); + const resolver = vi.fn().mockResolvedValue({ text: "hello there" }); + + await monitorWebChannel(false, listenerFactory, false, resolver); + expect(getCapturedOnMessage()).toBeDefined(); + + await getCapturedOnMessage()?.({ + body: "hi", + from: "+1555", + to: "+2666", + id: "msg1", + sendComposing: vi.fn(), + reply, + sendMedia: vi.fn(), + }); + + expect(reply).toHaveBeenCalledWith("[WA] hello there"); + resetLoadConfigMock(); + }); + + it("defaults responsePrefix for self-chat replies when unset", async () => { + setLoadConfigMock(() => ({ + agents: { + list: [ + { + id: "main", + default: true, + identity: { name: "Mainbot", emoji: "🦞", theme: "space lobster" }, + }, + ], + }, + channels: { whatsapp: { allowFrom: ["+1555"] } }, + messages: { + messagePrefix: undefined, + responsePrefix: undefined, + }, + })); + + const { listenerFactory, getCapturedOnMessage } = createCapturedListener(); + const reply = vi.fn(); + const resolver = vi.fn().mockResolvedValue({ text: "hello there" }); + + await monitorWebChannel(false, listenerFactory, false, resolver); + expect(getCapturedOnMessage()).toBeDefined(); + + await getCapturedOnMessage()?.({ + body: "hi", + from: "+1555", + to: "+1555", + selfE164: "+1555", + chatType: "direct", + id: "msg1", + sendComposing: vi.fn(), + reply, + sendMedia: vi.fn(), + }); + + expect(reply).toHaveBeenCalledWith("[Mainbot] hello there"); + resetLoadConfigMock(); + }); + + it("does not deliver HEARTBEAT_OK responses", async () => { + setLoadConfigMock(() => ({ + channels: { whatsapp: { allowFrom: ["*"] } }, + messages: { + messagePrefix: undefined, + responsePrefix: "🦞", + }, + })); + + const { listenerFactory, getCapturedOnMessage } = createCapturedListener(); + const reply = vi.fn(); + const resolver = vi.fn().mockResolvedValue({ text: HEARTBEAT_TOKEN }); + + await monitorWebChannel(false, listenerFactory, false, resolver); + expect(getCapturedOnMessage()).toBeDefined(); + + await getCapturedOnMessage()?.({ + body: "test", + from: "+1555", + to: "+2666", + id: "msg1", + sendComposing: vi.fn(), + reply, + sendMedia: vi.fn(), + }); + + expect(reply).not.toHaveBeenCalled(); + resetLoadConfigMock(); + }); + + it("does not double-prefix if responsePrefix already present", async () => { + setLoadConfigMock(() => ({ + channels: { whatsapp: { allowFrom: ["*"] } }, + messages: { + messagePrefix: undefined, + responsePrefix: "🦞", + }, + })); + + const { listenerFactory, getCapturedOnMessage } = createCapturedListener(); + const reply = vi.fn(); + const resolver = vi.fn().mockResolvedValue({ text: "🦞 already prefixed" }); + + await monitorWebChannel(false, listenerFactory, false, resolver); + expect(getCapturedOnMessage()).toBeDefined(); + + await getCapturedOnMessage()?.({ + body: "test", + from: "+1555", + to: "+2666", + id: "msg1", + sendComposing: vi.fn(), + reply, + sendMedia: vi.fn(), + }); + + expect(reply).toHaveBeenCalledWith("🦞 already prefixed"); + resetLoadConfigMock(); + }); + + it("skips tool summaries and sends final reply with responsePrefix", async () => { + setLoadConfigMock(() => ({ + channels: { whatsapp: { allowFrom: ["*"] } }, + messages: { + messagePrefix: undefined, + responsePrefix: "🦞", + }, + })); + + const { listenerFactory, getCapturedOnMessage } = createCapturedListener(); + const reply = vi.fn(); + const resolver = vi.fn().mockResolvedValue({ text: "final" }); + + await monitorWebChannel(false, listenerFactory, false, resolver); + expect(getCapturedOnMessage()).toBeDefined(); + + await getCapturedOnMessage()?.({ + body: "hi", + from: "+1555", + to: "+2666", + id: "msg1", + sendComposing: vi.fn(), + reply, + sendMedia: vi.fn(), + }); + + const replies = reply.mock.calls.map((call) => call[0]); + expect(replies).toEqual(["🦞 final"]); + resetLoadConfigMock(); + }); + + it("uses identity.name for messagePrefix when set", async () => { + setLoadConfigMock(() => ({ + agents: { + list: [ + { + id: "main", + default: true, + identity: { name: "Mainbot", emoji: "🦞", theme: "space lobster" }, + }, + { + id: "rich", + identity: { name: "Richbot", emoji: "🦁", theme: "lion bot" }, + }, + ], + }, + bindings: [ + { + agentId: "rich", + match: { + channel: "whatsapp", + peer: { kind: "direct", id: "+1555" }, + }, + }, + ], + })); + + const { listenerFactory, getCapturedOnMessage } = createCapturedListener(); + const reply = vi.fn(); + const resolver = vi.fn().mockResolvedValue({ text: "hello" }); + + await monitorWebChannel(false, listenerFactory, false, resolver); + expect(getCapturedOnMessage()).toBeDefined(); + + await getCapturedOnMessage()?.({ + body: "hi", + from: "+1555", + to: "+2666", + id: "msg1", + sendComposing: vi.fn(), + reply, + sendMedia: vi.fn(), + }); + + expect(resolver).toHaveBeenCalled(); + const resolverArg = resolver.mock.calls[0][0]; + expect(resolverArg.Body).toContain("[Richbot]"); + expect(resolverArg.Body).not.toContain("[openclaw]"); + resetLoadConfigMock(); + }); + + it("does not derive responsePrefix from identity.name when unset", async () => { + setLoadConfigMock(() => ({ + agents: { + list: [ + { + id: "main", + default: true, + identity: { name: "Mainbot", emoji: "🦞", theme: "space lobster" }, + }, + { + id: "rich", + identity: { name: "Richbot", emoji: "🦁", theme: "lion bot" }, + }, + ], + }, + bindings: [ + { + agentId: "rich", + match: { + channel: "whatsapp", + peer: { kind: "direct", id: "+1555" }, + }, + }, + ], + })); + + const { listenerFactory, getCapturedOnMessage } = createCapturedListener(); + const reply = vi.fn(); + const resolver = vi.fn().mockResolvedValue({ text: "hello there" }); + + await monitorWebChannel(false, listenerFactory, false, resolver); + expect(getCapturedOnMessage()).toBeDefined(); + + await getCapturedOnMessage()?.({ + body: "hi", + from: "+1555", + to: "+2666", + id: "msg1", + sendComposing: vi.fn(), + reply, + sendMedia: vi.fn(), + }); + + expect(reply).toHaveBeenCalledWith("hello there"); + resetLoadConfigMock(); + }); +}); + +describe("partial reply gating", () => { + installWebAutoReplyUnitTestHooks(); + + it("does not send partial replies for WhatsApp provider", async () => { + const reply = vi.fn().mockResolvedValue(undefined); + const sendComposing = vi.fn().mockResolvedValue(undefined); + const sendMedia = vi.fn().mockResolvedValue(undefined); + + const replyResolver = vi.fn().mockResolvedValue({ text: "final reply" }); + + const mockConfig: OpenClawConfig = { + channels: { whatsapp: { allowFrom: ["*"] } }, + }; + + setLoadConfigMock(mockConfig); + + await monitorWebChannel( + false, + async ({ onMessage }) => { + await onMessage({ + id: "m1", + from: "+1000", + conversationId: "+1000", + to: "+2000", + body: "hello", + timestamp: Date.now(), + chatType: "direct", + chatId: "direct:+1000", + sendComposing, + reply, + sendMedia, + }); + return { close: vi.fn().mockResolvedValue(undefined) }; + }, + false, + replyResolver, + ); + + resetLoadConfigMock(); + + expect(replyResolver).toHaveBeenCalledTimes(1); + const resolverOptions = replyResolver.mock.calls[0]?.[1] ?? {}; + expect("onPartialReply" in resolverOptions).toBe(false); + expect(reply).toHaveBeenCalledTimes(1); + expect(reply).toHaveBeenCalledWith("final reply"); + }); + + it("falls back from empty senderJid to senderE164 for SenderId", async () => { + const reply = vi.fn().mockResolvedValue(undefined); + const sendComposing = vi.fn().mockResolvedValue(undefined); + const sendMedia = vi.fn().mockResolvedValue(undefined); + + const replyResolver = vi.fn().mockResolvedValue({ text: "final reply" }); + + const mockConfig: OpenClawConfig = { + channels: { + whatsapp: { + allowFrom: ["*"], + }, + }, + }; + + setLoadConfigMock(mockConfig); + + await monitorWebChannel( + false, + async ({ onMessage }) => { + await onMessage({ + id: "m1", + from: "+1000", + conversationId: "+1000", + to: "+2000", + body: "hello", + timestamp: Date.now(), + chatType: "direct", + chatId: "direct:+1000", + senderJid: "", + senderE164: "+1000", + sendComposing, + reply, + sendMedia, + }); + return { close: vi.fn().mockResolvedValue(undefined) }; + }, + false, + replyResolver, + ); + + resetLoadConfigMock(); + + expect(replyResolver).toHaveBeenCalledTimes(1); + const ctx = replyResolver.mock.calls[0]?.[0] ?? {}; + expect(ctx.SenderE164).toBe("+1000"); + expect(ctx.SenderId).toBe("+1000"); + }); + + it("updates last-route for direct chats without senderE164", async () => { + const now = Date.now(); + const mainSessionKey = "agent:main:main"; + const store = await makeSessionStore({ + [mainSessionKey]: { sessionId: "sid", updatedAt: now - 1 }, + }); + + const replyResolver = vi.fn().mockResolvedValue(undefined); + + const mockConfig: OpenClawConfig = { + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: store.storePath }, + }; + + setLoadConfigMock(mockConfig); + + await monitorWebChannel( + false, + async ({ onMessage }) => { + await onMessage({ + id: "m1", + from: "+1000", + conversationId: "+1000", + to: "+2000", + body: "hello", + timestamp: now, + chatType: "direct", + chatId: "direct:+1000", + sendComposing: vi.fn().mockResolvedValue(undefined), + reply: vi.fn().mockResolvedValue(undefined), + sendMedia: vi.fn().mockResolvedValue(undefined), + }); + return { close: vi.fn().mockResolvedValue(undefined) }; + }, + false, + replyResolver, + ); + + const stored = JSON.parse(await fs.readFile(store.storePath, "utf8")) as Record< + string, + { lastChannel?: string; lastTo?: string } + >; + expect(stored[mainSessionKey]?.lastChannel).toBe("whatsapp"); + expect(stored[mainSessionKey]?.lastTo).toBe("+1000"); + + resetLoadConfigMock(); + await store.cleanup(); + }); + + it("updates last-route for group chats with account id", async () => { + const now = Date.now(); + const groupSessionKey = "agent:main:whatsapp:group:123@g.us"; + const store = await makeSessionStore({ + [groupSessionKey]: { sessionId: "sid", updatedAt: now - 1 }, + }); + + const replyResolver = vi.fn().mockResolvedValue(undefined); + + const mockConfig: OpenClawConfig = { + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: store.storePath }, + }; + + setLoadConfigMock(mockConfig); + + await monitorWebChannel( + false, + async ({ onMessage }) => { + await onMessage({ + id: "g1", + from: "123@g.us", + conversationId: "123@g.us", + to: "+2000", + body: "hello", + timestamp: now, + chatType: "group", + chatId: "123@g.us", + accountId: "work", + senderE164: "+1000", + senderName: "Alice", + selfE164: "+2000", + sendComposing: vi.fn().mockResolvedValue(undefined), + reply: vi.fn().mockResolvedValue(undefined), + sendMedia: vi.fn().mockResolvedValue(undefined), + }); + return { close: vi.fn().mockResolvedValue(undefined) }; + }, + false, + replyResolver, + ); + + const stored = JSON.parse(await fs.readFile(store.storePath, "utf8")) as Record< + string, + { lastChannel?: string; lastTo?: string; lastAccountId?: string } + >; + expect(stored[groupSessionKey]?.lastChannel).toBe("whatsapp"); + expect(stored[groupSessionKey]?.lastTo).toBe("123@g.us"); + expect(stored[groupSessionKey]?.lastAccountId).toBe("work"); + + resetLoadConfigMock(); + await store.cleanup(); + }); + + it("defaults to self-only when no config is present", async () => { + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 1, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + + const blocked = await getReplyFromConfig( + { + Body: "hi", + From: "whatsapp:+999", + To: "whatsapp:+123", + }, + undefined, + {}, + ); + expect(blocked).toBeUndefined(); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + + const allowed = await getReplyFromConfig( + { + Body: "hi", + From: "whatsapp:+123", + To: "whatsapp:+123", + }, + undefined, + {}, + ); + expect(allowed).toMatchObject({ text: "ok", audioAsVoice: false }); + expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/web/auto-reply.web-auto-reply.prefixes-body-same-phone-marker-from.test.ts b/src/web/auto-reply.web-auto-reply.prefixes-body-same-phone-marker-from.test.ts deleted file mode 100644 index b93cda149..000000000 --- a/src/web/auto-reply.web-auto-reply.prefixes-body-same-phone-marker-from.test.ts +++ /dev/null @@ -1,335 +0,0 @@ -import "./test-helpers.js"; -import { describe, expect, it, vi } from "vitest"; -import { HEARTBEAT_TOKEN, monitorWebChannel } from "./auto-reply.js"; -import { - installWebAutoReplyTestHomeHooks, - installWebAutoReplyUnitTestHooks, - resetLoadConfigMock, - setLoadConfigMock, -} from "./auto-reply.test-harness.js"; - -installWebAutoReplyTestHomeHooks(); - -describe("web auto-reply", () => { - installWebAutoReplyUnitTestHooks(); - - it("prefixes body with same-phone marker when from === to", async () => { - // Enable messagePrefix for same-phone mode testing - setLoadConfigMock(() => ({ - channels: { whatsapp: { allowFrom: ["*"] } }, - messages: { - messagePrefix: "[same-phone]", - responsePrefix: undefined, - }, - })); - - let capturedOnMessage: - | ((msg: import("./inbound.js").WebInboundMessage) => Promise) - | undefined; - const listenerFactory = async (opts: { - onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - return { close: vi.fn() }; - }; - - const resolver = vi.fn().mockResolvedValue({ text: "reply" }); - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(capturedOnMessage).toBeDefined(); - - await capturedOnMessage?.({ - body: "hello", - from: "+1555", - to: "+1555", // Same phone! - id: "msg1", - sendComposing: vi.fn(), - reply: vi.fn(), - sendMedia: vi.fn(), - }); - - // The resolver should receive a prefixed body with the configured marker - const callArg = resolver.mock.calls[0]?.[0] as { Body?: string }; - expect(callArg?.Body).toBeDefined(); - expect(callArg?.Body).toContain("[WhatsApp +1555"); - expect(callArg?.Body).toContain("[same-phone] hello"); - resetLoadConfigMock(); - }); - it("does not prefix body when from !== to", async () => { - let capturedOnMessage: - | ((msg: import("./inbound.js").WebInboundMessage) => Promise) - | undefined; - const listenerFactory = async (opts: { - onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - return { close: vi.fn() }; - }; - - const resolver = vi.fn().mockResolvedValue({ text: "reply" }); - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(capturedOnMessage).toBeDefined(); - - await capturedOnMessage?.({ - body: "hello", - from: "+1555", - to: "+2666", // Different phones - id: "msg1", - sendComposing: vi.fn(), - reply: vi.fn(), - sendMedia: vi.fn(), - }); - - // Body should include envelope but not the same-phone prefix - const callArg = resolver.mock.calls[0]?.[0] as { Body?: string }; - expect(callArg?.Body).toContain("[WhatsApp +1555"); - expect(callArg?.Body).toContain("hello"); - }); - it("forwards reply-to context to resolver", async () => { - let capturedOnMessage: - | ((msg: import("./inbound.js").WebInboundMessage) => Promise) - | undefined; - const listenerFactory = async (opts: { - onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - return { close: vi.fn() }; - }; - - const resolver = vi.fn().mockResolvedValue({ text: "reply" }); - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(capturedOnMessage).toBeDefined(); - - await capturedOnMessage?.({ - body: "hello", - from: "+1555", - to: "+2666", - id: "msg1", - replyToId: "q1", - replyToBody: "original", - replyToSender: "+1999", - sendComposing: vi.fn(), - reply: vi.fn(), - sendMedia: vi.fn(), - }); - - const callArg = resolver.mock.calls[0]?.[0] as { - ReplyToId?: string; - ReplyToBody?: string; - ReplyToSender?: string; - Body?: string; - }; - expect(callArg.ReplyToId).toBe("q1"); - expect(callArg.ReplyToBody).toBe("original"); - expect(callArg.ReplyToSender).toBe("+1999"); - expect(callArg.Body).toContain("[Replying to +1999 id:q1]"); - expect(callArg.Body).toContain("original"); - }); - it("applies responsePrefix to regular replies", async () => { - setLoadConfigMock(() => ({ - channels: { whatsapp: { allowFrom: ["*"] } }, - messages: { - messagePrefix: undefined, - responsePrefix: "🦞", - }, - })); - - let capturedOnMessage: - | ((msg: import("./inbound.js").WebInboundMessage) => Promise) - | undefined; - const reply = vi.fn(); - const listenerFactory = async (opts: { - onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - return { close: vi.fn() }; - }; - - const resolver = vi.fn().mockResolvedValue({ text: "hello there" }); - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(capturedOnMessage).toBeDefined(); - - await capturedOnMessage?.({ - body: "hi", - from: "+1555", - to: "+2666", - id: "msg1", - sendComposing: vi.fn(), - reply, - sendMedia: vi.fn(), - }); - - // Reply should have responsePrefix prepended - expect(reply).toHaveBeenCalledWith("🦞 hello there"); - resetLoadConfigMock(); - }); - it("applies channel responsePrefix override to replies", async () => { - setLoadConfigMock(() => ({ - channels: { whatsapp: { allowFrom: ["*"], responsePrefix: "[WA]" } }, - messages: { - messagePrefix: undefined, - responsePrefix: "[Global]", - }, - })); - - let capturedOnMessage: - | ((msg: import("./inbound.js").WebInboundMessage) => Promise) - | undefined; - const reply = vi.fn(); - const listenerFactory = async (opts: { - onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - return { close: vi.fn() }; - }; - - const resolver = vi.fn().mockResolvedValue({ text: "hello there" }); - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(capturedOnMessage).toBeDefined(); - - await capturedOnMessage?.({ - body: "hi", - from: "+1555", - to: "+2666", - id: "msg1", - sendComposing: vi.fn(), - reply, - sendMedia: vi.fn(), - }); - - expect(reply).toHaveBeenCalledWith("[WA] hello there"); - resetLoadConfigMock(); - }); - it("defaults responsePrefix for self-chat replies when unset", async () => { - setLoadConfigMock(() => ({ - agents: { - list: [ - { - id: "main", - default: true, - identity: { name: "Mainbot", emoji: "🦞", theme: "space lobster" }, - }, - ], - }, - channels: { whatsapp: { allowFrom: ["+1555"] } }, - messages: { - messagePrefix: undefined, - responsePrefix: undefined, - }, - })); - - let capturedOnMessage: - | ((msg: import("./inbound.js").WebInboundMessage) => Promise) - | undefined; - const reply = vi.fn(); - const listenerFactory = async (opts: { - onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - return { close: vi.fn() }; - }; - - const resolver = vi.fn().mockResolvedValue({ text: "hello there" }); - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(capturedOnMessage).toBeDefined(); - - await capturedOnMessage?.({ - body: "hi", - from: "+1555", - to: "+1555", - selfE164: "+1555", - chatType: "direct", - id: "msg1", - sendComposing: vi.fn(), - reply, - sendMedia: vi.fn(), - }); - - expect(reply).toHaveBeenCalledWith("[Mainbot] hello there"); - resetLoadConfigMock(); - }); - it("does not deliver HEARTBEAT_OK responses", async () => { - setLoadConfigMock(() => ({ - channels: { whatsapp: { allowFrom: ["*"] } }, - messages: { - messagePrefix: undefined, - responsePrefix: "🦞", - }, - })); - - let capturedOnMessage: - | ((msg: import("./inbound.js").WebInboundMessage) => Promise) - | undefined; - const reply = vi.fn(); - const listenerFactory = async (opts: { - onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - return { close: vi.fn() }; - }; - - // Resolver returns exact HEARTBEAT_OK - const resolver = vi.fn().mockResolvedValue({ text: HEARTBEAT_TOKEN }); - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(capturedOnMessage).toBeDefined(); - - await capturedOnMessage?.({ - body: "test", - from: "+1555", - to: "+2666", - id: "msg1", - sendComposing: vi.fn(), - reply, - sendMedia: vi.fn(), - }); - - expect(reply).not.toHaveBeenCalled(); - resetLoadConfigMock(); - }); - it("does not double-prefix if responsePrefix already present", async () => { - setLoadConfigMock(() => ({ - channels: { whatsapp: { allowFrom: ["*"] } }, - messages: { - messagePrefix: undefined, - responsePrefix: "🦞", - }, - })); - - let capturedOnMessage: - | ((msg: import("./inbound.js").WebInboundMessage) => Promise) - | undefined; - const reply = vi.fn(); - const listenerFactory = async (opts: { - onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - return { close: vi.fn() }; - }; - - // Resolver returns text that already has prefix - const resolver = vi.fn().mockResolvedValue({ text: "🦞 already prefixed" }); - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(capturedOnMessage).toBeDefined(); - - await capturedOnMessage?.({ - body: "test", - from: "+1555", - to: "+2666", - id: "msg1", - sendComposing: vi.fn(), - reply, - sendMedia: vi.fn(), - }); - - // Should not double-prefix - expect(reply).toHaveBeenCalledWith("🦞 already prefixed"); - resetLoadConfigMock(); - }); -}); diff --git a/src/web/auto-reply.web-auto-reply.sends-tool-summaries-immediately-responseprefix.test.ts b/src/web/auto-reply.web-auto-reply.sends-tool-summaries-immediately-responseprefix.test.ts deleted file mode 100644 index 062bc8186..000000000 --- a/src/web/auto-reply.web-auto-reply.sends-tool-summaries-immediately-responseprefix.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import "./test-helpers.js"; -import { monitorWebChannel } from "./auto-reply.js"; -import { - installWebAutoReplyTestHomeHooks, - installWebAutoReplyUnitTestHooks, - resetLoadConfigMock, - setLoadConfigMock, -} from "./auto-reply.test-harness.js"; - -installWebAutoReplyTestHomeHooks(); - -describe("web auto-reply", () => { - installWebAutoReplyUnitTestHooks(); - - it("skips tool summaries and sends final reply with responsePrefix", async () => { - setLoadConfigMock(() => ({ - channels: { whatsapp: { allowFrom: ["*"] } }, - messages: { - messagePrefix: undefined, - responsePrefix: "🦞", - }, - })); - - let capturedOnMessage: - | ((msg: import("./inbound.js").WebInboundMessage) => Promise) - | undefined; - const reply = vi.fn(); - const listenerFactory = async (opts: { - onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - return { close: vi.fn() }; - }; - - const resolver = vi.fn().mockResolvedValue({ text: "final" }); - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(capturedOnMessage).toBeDefined(); - - await capturedOnMessage?.({ - body: "hi", - from: "+1555", - to: "+2666", - id: "msg1", - sendComposing: vi.fn(), - reply, - sendMedia: vi.fn(), - }); - - const replies = reply.mock.calls.map((call) => call[0]); - expect(replies).toEqual(["🦞 final"]); - resetLoadConfigMock(); - }); - it("uses identity.name for messagePrefix when set", async () => { - setLoadConfigMock(() => ({ - agents: { - list: [ - { - id: "main", - default: true, - identity: { name: "Mainbot", emoji: "🦞", theme: "space lobster" }, - }, - { - id: "rich", - identity: { name: "Richbot", emoji: "🦁", theme: "lion bot" }, - }, - ], - }, - bindings: [ - { - agentId: "rich", - match: { - channel: "whatsapp", - peer: { kind: "direct", id: "+1555" }, - }, - }, - ], - })); - - let capturedOnMessage: - | ((msg: import("./inbound.js").WebInboundMessage) => Promise) - | undefined; - const reply = vi.fn(); - const listenerFactory = async (opts: { - onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - return { close: vi.fn() }; - }; - - const resolver = vi.fn().mockResolvedValue({ text: "hello" }); - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(capturedOnMessage).toBeDefined(); - - await capturedOnMessage?.({ - body: "hi", - from: "+1555", - to: "+2666", - id: "msg1", - sendComposing: vi.fn(), - reply, - sendMedia: vi.fn(), - }); - - // Check that resolver received the message with identity-based prefix - expect(resolver).toHaveBeenCalled(); - const resolverArg = resolver.mock.calls[0][0]; - expect(resolverArg.Body).toContain("[Richbot]"); - expect(resolverArg.Body).not.toContain("[openclaw]"); - resetLoadConfigMock(); - }); - it("does not derive responsePrefix from identity.name when unset", async () => { - setLoadConfigMock(() => ({ - agents: { - list: [ - { - id: "main", - default: true, - identity: { name: "Mainbot", emoji: "🦞", theme: "space lobster" }, - }, - { - id: "rich", - identity: { name: "Richbot", emoji: "🦁", theme: "lion bot" }, - }, - ], - }, - bindings: [ - { - agentId: "rich", - match: { - channel: "whatsapp", - peer: { kind: "direct", id: "+1555" }, - }, - }, - ], - })); - - let capturedOnMessage: - | ((msg: import("./inbound.js").WebInboundMessage) => Promise) - | undefined; - const reply = vi.fn(); - const listenerFactory = async (opts: { - onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - return { close: vi.fn() }; - }; - - const resolver = vi.fn().mockResolvedValue({ text: "hello there" }); - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(capturedOnMessage).toBeDefined(); - - await capturedOnMessage?.({ - body: "hi", - from: "+1555", - to: "+2666", - id: "msg1", - sendComposing: vi.fn(), - reply, - sendMedia: vi.fn(), - }); - - // No implicit responsePrefix. - expect(reply).toHaveBeenCalledWith("hello there"); - resetLoadConfigMock(); - }); -});