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>
This commit is contained in:
Ron Cohen
2026-04-24 19:45:58 +03:00
committed by GitHub
parent 11ad1919ed
commit 3de44fe593
5 changed files with 439 additions and 16 deletions

View File

@@ -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.<id>` or `direct.<peerId>` 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

View File

@@ -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["<groupId>"].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["<groupId>"].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["<peerId>"].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["<peerId>"].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.<id>.historyLimit`); prompt overrides live under `direct`.

View File

@@ -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<typeof import("../../inbound-policy.js")>();
return {
...actual,
resolveWhatsAppCommandAuthorized: async () => true,
resolveWhatsAppInboundPolicy: resolvePolicyMock,
};
});
vi.mock("./inbound-dispatch.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./inbound-dispatch.js")>();
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<typeof import("../../identity.js")>();
return {
...actual,
getPrimaryIdentityId: () => null,
getSelfIdentity: () => ({ e164: "+15550001111" }),
getSenderIdentity: () => ({ name: "Alice", e164: "+15550002222" }),
};
});
vi.mock("../../reconnect.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../reconnect.js")>();
return { ...actual, newConnectionId: () => "test-conn-id" };
});
vi.mock("../../session.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../session.js")>();
return { ...actual, formatError: (e: unknown) => String(e) };
});
vi.mock("../deliver-reply.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../deliver-reply.js")>();
return { ...actual, deliverWebReply: async () => {} };
});
vi.mock("../loggers.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../loggers.js")>();
return {
...actual,
whatsappInboundLog: { info: () => {}, debug: () => {} },
};
});
vi.mock("./ack-reaction.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./ack-reaction.js")>();
return { ...actual, maybeSendAckReaction: async () => {} };
});
vi.mock("./inbound-context.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./inbound-context.js")>();
return {
...actual,
resolveVisibleWhatsAppGroupHistory: () => [],
resolveVisibleWhatsAppReplyContext: () => null,
};
});
vi.mock("./last-route.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./last-route.js")>();
return {
...actual,
trackBackgroundTask: () => {},
updateLastRouteInBackground: () => {},
};
});
vi.mock("./message-line.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./message-line.js")>();
return { ...actual, buildInboundLine: () => "hi" };
});
vi.mock("./runtime-api.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./runtime-api.js")>();
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<string, { systemPrompt?: string }> = {}): {
accountId: string;
authDir: string;
groups: Record<string, { systemPrompt?: string }>;
} {
return { accountId: "default", authDir: "/tmp/wa-test-auth", groups };
}
function makePolicy(account: ReturnType<typeof makeAccount>) {
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");
});
});

View File

@@ -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");
});
});

View File

@@ -1,29 +1,31 @@
export function resolveWhatsAppGroupSystemPrompt(params: {
accountConfig?: { groups?: Record<string, { systemPrompt?: string }> } | null;
accountConfig?: { groups?: Record<string, { systemPrompt?: string | null }> } | 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<string, { systemPrompt?: string }> } | null;
accountConfig?: { direct?: Record<string, { systemPrompt?: string | null }> } | 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;
}