From 3de44fe593057b508d76f65abb36d58e0936ef1d Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Fri, 24 Apr 2026 19:45:58 +0300 Subject: [PATCH] fix(whatsapp): setting systemPrompt to "" suppresses the wildcard prompt (#70381) * fix(whatsapp): setting systemPrompt to "" suppresses the wildcard instead of falling through to it * test(whatsapp): reset mocks instead of only clearing call history * docs(changelog): note WhatsApp empty systemPrompt suppresses wildcard * test(whatsapp): preserve real module exports in process-message mocks * test(whatsapp): whitespace-only systemPrompt also suppresses wildcard --------- Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/channels/whatsapp.md | 8 +- .../monitor/process-message.test.ts | 221 ++++++++++++++++++ extensions/whatsapp/src/system-prompt.test.ts | 199 ++++++++++++++++ extensions/whatsapp/src/system-prompt.ts | 26 ++- 5 files changed, 439 insertions(+), 16 deletions(-) create mode 100644 extensions/whatsapp/src/auto-reply/monitor/process-message.test.ts create mode 100644 extensions/whatsapp/src/system-prompt.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fabae899b88..f30f9e5922e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -319,6 +319,7 @@ Docs: https://docs.openclaw.ai - Gateway/restart: default session-scoped restart sentinels to a one-shot agent continuation, so chat-initiated Gateway restarts acknowledge successful boot automatically. (#70269) Thanks @obviyus. - Build/npm publish: fail postpublish verification when root `dist/*` files import bundled plugin runtime dependencies without mirroring them in the root package manifest, so Slack-style plugin deps cannot silently ship on the wrong module-resolution path again. (#60112) thanks @medns. - Gateway/sessions: extend the webchat session-mutation guard to `sessions.compact` and `sessions.compaction.restore`, so `WEBCHAT_UI` clients are rejected from compaction-side session mutations consistently with the existing patch/delete guards. (#70716) Thanks @drobison00. +- WhatsApp/groups+direct: setting `systemPrompt: ""` on a specific `groups.` or `direct.` entry now suppresses the wildcard system prompt instead of falling through to it, so users can silence the global prompt for a specific group or peer. (#70381) Thanks @Bluetegu. ## 2026.4.21 diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index 22034b1be29..e729b2bc79a 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -500,15 +500,15 @@ Resolution hierarchy for group messages: The effective `groups` map is determined first: if the account defines its own `groups`, it fully replaces the root `groups` map (no deep merge). Prompt lookup then runs on the resulting single map: -1. **Group-specific system prompt** (`groups[""].systemPrompt`): used if the specific group entry defines a `systemPrompt`. -2. **Group wildcard system prompt** (`groups["*"].systemPrompt`): used when the specific group entry is absent or defines no `systemPrompt`. +1. **Group-specific system prompt** (`groups[""].systemPrompt`): used when the specific group entry exists in the map **and** its `systemPrompt` key is defined. If `systemPrompt` is an empty string (`""`), the wildcard is suppressed and no system prompt is applied. +2. **Group wildcard system prompt** (`groups["*"].systemPrompt`): used when the specific group entry is absent from the map entirely, or when it exists but defines no `systemPrompt` key. Resolution hierarchy for direct messages: The effective `direct` map is determined first: if the account defines its own `direct`, it fully replaces the root `direct` map (no deep merge). Prompt lookup then runs on the resulting single map: -1. **Direct-specific system prompt** (`direct[""].systemPrompt`): used if the specific peer entry defines a `systemPrompt`. -2. **Direct wildcard system prompt** (`direct["*"].systemPrompt`): used when the specific peer entry is absent or defines no `systemPrompt`. +1. **Direct-specific system prompt** (`direct[""].systemPrompt`): used when the specific peer entry exists in the map **and** its `systemPrompt` key is defined. If `systemPrompt` is an empty string (`""`), the wildcard is suppressed and no system prompt is applied. +2. **Direct wildcard system prompt** (`direct["*"].systemPrompt`): used when the specific peer entry is absent from the map entirely, or when it exists but defines no `systemPrompt` key. Note: `dms` remains the lightweight per-DM history override bucket (`dms..historyLimit`); prompt overrides live under `direct`. diff --git a/extensions/whatsapp/src/auto-reply/monitor/process-message.test.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.test.ts new file mode 100644 index 00000000000..d795c55ffab --- /dev/null +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.test.ts @@ -0,0 +1,221 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Hoisted mocks used across tests so vi.mock factories can reference them. +const { resolvePolicyMock, buildContextMock } = vi.hoisted(() => ({ + resolvePolicyMock: vi.fn(), + buildContextMock: vi.fn(), +})); + +vi.mock("../../inbound-policy.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveWhatsAppCommandAuthorized: async () => true, + resolveWhatsAppInboundPolicy: resolvePolicyMock, + }; +}); + +vi.mock("./inbound-dispatch.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + buildWhatsAppInboundContext: buildContextMock, + dispatchWhatsAppBufferedReply: async () => ({ + queuedFinal: false, + counts: { tool: 0, block: 0, final: 0 }, + }), + resolveWhatsAppDmRouteTarget: () => null, + resolveWhatsAppResponsePrefix: () => undefined, + updateWhatsAppMainLastRoute: () => {}, + }; +}); + +vi.mock("../../identity.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getPrimaryIdentityId: () => null, + getSelfIdentity: () => ({ e164: "+15550001111" }), + getSenderIdentity: () => ({ name: "Alice", e164: "+15550002222" }), + }; +}); + +vi.mock("../../reconnect.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, newConnectionId: () => "test-conn-id" }; +}); + +vi.mock("../../session.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, formatError: (e: unknown) => String(e) }; +}); + +vi.mock("../deliver-reply.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, deliverWebReply: async () => {} }; +}); + +vi.mock("../loggers.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + whatsappInboundLog: { info: () => {}, debug: () => {} }, + }; +}); + +vi.mock("./ack-reaction.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, maybeSendAckReaction: async () => {} }; +}); + +vi.mock("./inbound-context.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveVisibleWhatsAppGroupHistory: () => [], + resolveVisibleWhatsAppReplyContext: () => null, + }; +}); + +vi.mock("./last-route.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + trackBackgroundTask: () => {}, + updateLastRouteInBackground: () => {}, + }; +}); + +vi.mock("./message-line.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, buildInboundLine: () => "hi" }; +}); + +vi.mock("./runtime-api.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + buildHistoryContextFromEntries: () => "hi", + createChannelReplyPipeline: () => ({ onModelSelected: () => {}, responsePrefix: undefined }), + formatInboundEnvelope: () => "hi", + logVerbose: () => {}, + normalizeE164: (v: string) => v, + recordSessionMetaFromInbound: async () => {}, + resolveChannelContextVisibilityMode: () => "off", + resolveInboundSessionEnvelopeContext: () => ({ + storePath: "/tmp", + envelopeOptions: {}, + previousTimestamp: undefined, + }), + resolvePinnedMainDmOwnerFromAllowlist: () => null, + shouldComputeCommandAuthorized: () => false, + shouldLogVerbose: () => false, + }; +}); + +import { processMessage } from "./process-message.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeAccount(groups: Record = {}): { + accountId: string; + authDir: string; + groups: Record; +} { + return { accountId: "default", authDir: "/tmp/wa-test-auth", groups }; +} + +function makePolicy(account: ReturnType) { + return { + account, + dmPolicy: "pairing", + groupPolicy: "allowlist", + configuredAllowFrom: [], + dmAllowFrom: [], + groupAllowFrom: [], + isSelfChat: false, + providerMissingFallbackApplied: false, + shouldReadStorePairingApprovals: true, + isSamePhone: () => false, + isDmSenderAllowed: () => false, + isGroupSenderAllowed: () => false, + resolveConversationGroupPolicy: () => "allowlist", + resolveConversationRequireMention: () => false, + }; +} + +const GROUP_JID = "123@g.us"; + +const baseMsg = { + id: "msg1", + from: GROUP_JID, + to: "+15550001111", + conversationId: GROUP_JID, + accountId: "default", + chatId: GROUP_JID, + chatType: "group" as const, + body: "hi", + sendComposing: async () => {}, + reply: async () => {}, + sendMedia: async () => {}, +}; + +const baseRoute = { + agentId: "main", + channel: "whatsapp", + accountId: "default", + sessionKey: "agent:main:whatsapp:group:123@g.us", + mainSessionKey: "agent:main:whatsapp:group:123@g.us", + lastRoutePolicy: "main", + matchedBy: "default", +}; + +function callProcessMessage() { + return processMessage({ + cfg: {} as never, + msg: baseMsg as never, + route: baseRoute as never, + groupHistoryKey: "whatsapp:default:group:123@g.us", + groupHistories: new Map(), + groupMemberNames: new Map(), + connectionId: "conn-1", + verbose: false, + maxMediaBytes: 1024, + replyResolver: (async () => undefined) as never, + replyLogger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as never, + backgroundTasks: new Set(), + rememberSentText: () => {}, + echoHas: () => false, + echoForget: () => {}, + buildCombinedEchoKey: ({ sessionKey }) => sessionKey, + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("processMessage group system prompt wiring", () => { + beforeEach(() => { + buildContextMock.mockReset(); + resolvePolicyMock.mockReset(); + buildContextMock.mockImplementation( + (params: { groupSystemPrompt?: string; combinedBody?: string }) => ({ + GroupSystemPrompt: params.groupSystemPrompt, + Body: params.combinedBody ?? "", + }), + ); + }); + + it("resolves group systemPrompt from account config and passes it into buildWhatsAppInboundContext", async () => { + resolvePolicyMock.mockReturnValue( + makePolicy(makeAccount({ [GROUP_JID]: { systemPrompt: "from config" } })), + ); + + await callProcessMessage(); + + expect(buildContextMock.mock.calls[0][0].groupSystemPrompt).toBe("from config"); + }); +}); diff --git a/extensions/whatsapp/src/system-prompt.test.ts b/extensions/whatsapp/src/system-prompt.test.ts new file mode 100644 index 00000000000..56db918725b --- /dev/null +++ b/extensions/whatsapp/src/system-prompt.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "vitest"; +import { + resolveWhatsAppDirectSystemPrompt, + resolveWhatsAppGroupSystemPrompt, +} from "./system-prompt.js"; + +describe("resolveWhatsAppGroupSystemPrompt", () => { + it("returns undefined when groupId is absent", () => { + expect(resolveWhatsAppGroupSystemPrompt({ groupId: null })).toBeUndefined(); + expect(resolveWhatsAppGroupSystemPrompt({ groupId: undefined })).toBeUndefined(); + expect(resolveWhatsAppGroupSystemPrompt({})).toBeUndefined(); + }); + + it("returns undefined when accountConfig is absent", () => { + expect( + resolveWhatsAppGroupSystemPrompt({ groupId: "g1", accountConfig: null }), + ).toBeUndefined(); + expect( + resolveWhatsAppGroupSystemPrompt({ groupId: "g1", accountConfig: undefined }), + ).toBeUndefined(); + }); + + it("returns the group-specific systemPrompt when defined", () => { + expect( + resolveWhatsAppGroupSystemPrompt({ + groupId: "g1", + accountConfig: { groups: { g1: { systemPrompt: "group prompt" } } }, + }), + ).toBe("group prompt"); + }); + + it("falls back to wildcard when specific group entry is absent", () => { + expect( + resolveWhatsAppGroupSystemPrompt({ + groupId: "g1", + accountConfig: { + groups: { "*": { systemPrompt: "wildcard prompt" } }, + }, + }), + ).toBe("wildcard prompt"); + }); + + it("suppresses wildcard when specific group entry sets systemPrompt to empty string", () => { + expect( + resolveWhatsAppGroupSystemPrompt({ + groupId: "g1", + accountConfig: { + groups: { + g1: { systemPrompt: "" }, + "*": { systemPrompt: "wildcard prompt" }, + }, + }, + }), + ).toBeUndefined(); + }); + + it("suppresses wildcard when specific group entry sets systemPrompt to whitespace-only string", () => { + expect( + resolveWhatsAppGroupSystemPrompt({ + groupId: "g1", + accountConfig: { + groups: { + g1: { systemPrompt: " " }, + "*": { systemPrompt: "wildcard prompt" }, + }, + }, + }), + ).toBeUndefined(); + }); + + it("trims whitespace from specific group systemPrompt", () => { + expect( + resolveWhatsAppGroupSystemPrompt({ + groupId: "g1", + accountConfig: { groups: { g1: { systemPrompt: " trimmed " } } }, + }), + ).toBe("trimmed"); + }); + + it("returns undefined when specific group entry has no systemPrompt key and no wildcard", () => { + expect( + resolveWhatsAppGroupSystemPrompt({ + groupId: "g1", + accountConfig: { groups: { g1: {} } }, + }), + ).toBeUndefined(); + }); + + it("falls back to wildcard when specific group entry has no systemPrompt key", () => { + expect( + resolveWhatsAppGroupSystemPrompt({ + groupId: "g1", + accountConfig: { + groups: { + g1: {}, + "*": { systemPrompt: "wildcard prompt" }, + }, + }, + }), + ).toBe("wildcard prompt"); + }); +}); + +describe("resolveWhatsAppDirectSystemPrompt", () => { + it("returns undefined when peerId is absent", () => { + expect(resolveWhatsAppDirectSystemPrompt({ peerId: null })).toBeUndefined(); + expect(resolveWhatsAppDirectSystemPrompt({ peerId: undefined })).toBeUndefined(); + expect(resolveWhatsAppDirectSystemPrompt({})).toBeUndefined(); + }); + + it("returns undefined when accountConfig is absent", () => { + expect( + resolveWhatsAppDirectSystemPrompt({ peerId: "p1", accountConfig: null }), + ).toBeUndefined(); + expect( + resolveWhatsAppDirectSystemPrompt({ peerId: "p1", accountConfig: undefined }), + ).toBeUndefined(); + }); + + it("returns the peer-specific systemPrompt when defined", () => { + expect( + resolveWhatsAppDirectSystemPrompt({ + peerId: "p1", + accountConfig: { direct: { p1: { systemPrompt: "direct prompt" } } }, + }), + ).toBe("direct prompt"); + }); + + it("falls back to wildcard when specific peer entry is absent", () => { + expect( + resolveWhatsAppDirectSystemPrompt({ + peerId: "p1", + accountConfig: { + direct: { "*": { systemPrompt: "wildcard prompt" } }, + }, + }), + ).toBe("wildcard prompt"); + }); + + it("suppresses wildcard when specific peer entry sets systemPrompt to empty string", () => { + expect( + resolveWhatsAppDirectSystemPrompt({ + peerId: "p1", + accountConfig: { + direct: { + p1: { systemPrompt: "" }, + "*": { systemPrompt: "wildcard prompt" }, + }, + }, + }), + ).toBeUndefined(); + }); + + it("suppresses wildcard when specific peer entry sets systemPrompt to whitespace-only string", () => { + expect( + resolveWhatsAppDirectSystemPrompt({ + peerId: "p1", + accountConfig: { + direct: { + p1: { systemPrompt: " " }, + "*": { systemPrompt: "wildcard prompt" }, + }, + }, + }), + ).toBeUndefined(); + }); + + it("trims whitespace from specific peer systemPrompt", () => { + expect( + resolveWhatsAppDirectSystemPrompt({ + peerId: "p1", + accountConfig: { direct: { p1: { systemPrompt: " trimmed " } } }, + }), + ).toBe("trimmed"); + }); + + it("returns undefined when specific peer entry has no systemPrompt key and no wildcard", () => { + expect( + resolveWhatsAppDirectSystemPrompt({ + peerId: "p1", + accountConfig: { direct: { p1: {} } }, + }), + ).toBeUndefined(); + }); + + it("falls back to wildcard when specific peer entry has no systemPrompt key", () => { + expect( + resolveWhatsAppDirectSystemPrompt({ + peerId: "p1", + accountConfig: { + direct: { + p1: {}, + "*": { systemPrompt: "wildcard prompt" }, + }, + }, + }), + ).toBe("wildcard prompt"); + }); +}); diff --git a/extensions/whatsapp/src/system-prompt.ts b/extensions/whatsapp/src/system-prompt.ts index ddc5450e8b9..da8584a3d96 100644 --- a/extensions/whatsapp/src/system-prompt.ts +++ b/extensions/whatsapp/src/system-prompt.ts @@ -1,29 +1,31 @@ export function resolveWhatsAppGroupSystemPrompt(params: { - accountConfig?: { groups?: Record } | null; + accountConfig?: { groups?: Record } | null; groupId?: string | null; }): string | undefined { if (!params.groupId) { return undefined; } const groups = params.accountConfig?.groups; - return ( - groups?.[params.groupId]?.systemPrompt?.trim() || - groups?.["*"]?.systemPrompt?.trim() || - undefined - ); + const specific = groups?.[params.groupId]; + if (specific != null && specific.systemPrompt != null) { + return specific.systemPrompt.trim() || undefined; + } + const wildcard = groups?.["*"]?.systemPrompt; + return wildcard != null ? wildcard.trim() || undefined : undefined; } export function resolveWhatsAppDirectSystemPrompt(params: { - accountConfig?: { direct?: Record } | null; + accountConfig?: { direct?: Record } | null; peerId?: string | null; }): string | undefined { if (!params.peerId) { return undefined; } const direct = params.accountConfig?.direct; - return ( - direct?.[params.peerId]?.systemPrompt?.trim() || - direct?.["*"]?.systemPrompt?.trim() || - undefined - ); + const specific = direct?.[params.peerId]; + if (specific != null && specific.systemPrompt != null) { + return specific.systemPrompt.trim() || undefined; + } + const wildcard = direct?.["*"]?.systemPrompt; + return wildcard != null ? wildcard.trim() || undefined : undefined; }