mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:50:43 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
199
extensions/whatsapp/src/system-prompt.test.ts
Normal file
199
extensions/whatsapp/src/system-prompt.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user