fix(imessage): match WhatsApp's per-group systemPrompt resolution semantic

Earlier draft used a simple normalize-and-fall-through resolver: any
empty/whitespace per-chat_id systemPrompt fell through to the
groups['*'] wildcard. That doesn't match the WhatsApp resolver
(extensions/whatsapp/src/system-prompt.ts), where defining the
systemPrompt key on the specific group (even as '') means 'this group
has no prompt' and suppresses the wildcard.

Make iMessage resolution byte-identical:
- specific != null && specific.systemPrompt != null -> use the trimmed
  specific (empty trim -> undefined, wildcard suppressed).
- otherwise -> trimmed wildcard.

Add the resolver as a small exported helper resolveIMessageGroupSystemPrompt
so the unit tests cover it directly. Update the per-group systemPrompt
doc section in docs/channels/imessage.md to copy WhatsApp's resolution
hierarchy language and add an explicit-suppression example.

Refs #78285.
This commit is contained in:
Omar Shahine
2026-05-08 13:14:36 +00:00
parent b188d292bc
commit 1f70394e7b
3 changed files with 74 additions and 12 deletions

View File

@@ -273,7 +273,10 @@ If SIP-disabled isn't acceptable for your threat model:
Per-group `systemPrompt`:
Each entry under `channels.imessage.groups.*` accepts an optional `systemPrompt` string. The value is injected into the agent's system prompt on every turn that handles a message in that group. The wildcard `groups["*"]` entry is honored as a fallback when the per-`chat_id` entry has no `systemPrompt`.
Each entry under `channels.imessage.groups.*` accepts an optional `systemPrompt` string. The value is injected into the agent's system prompt on every turn that handles a message in that group. Resolution mirrors the per-group prompt resolution used by `channels.whatsapp.groups`:
1. **Group-specific system prompt** (`groups["<chat_id>"].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 to that group.
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.
```json5
{
@@ -287,6 +290,10 @@ If SIP-disabled isn't acceptable for your threat model:
requireMention: true,
systemPrompt: "This is the on-call rotation chat. Keep replies under 3 sentences.",
},
"9907": {
// explicit suppression: the wildcard "Use British spelling." does not apply here
systemPrompt: "",
},
},
},
},

View File

@@ -92,10 +92,14 @@ describe("resolveIMessageInboundDecision per-group systemPrompt", () => {
expect(decision.groupSystemPrompt).toBe("Specific group voice.");
});
it("treats whitespace-only systemPrompt as undefined", () => {
it("treats whitespace-only per-chat_id systemPrompt as suppression of the wildcard", () => {
// Mirrors WhatsApp semantic: defining the systemPrompt key on a specific
// group entry (even as whitespace) means "this group has no prompt" and
// suppresses the groups["*"] fallback.
const decision = resolveIMessageInboundDecision(
buildDecisionParams({
cfg: buildCfgWithGroups({
"*": { systemPrompt: "Wildcard." },
"7": { systemPrompt: " " },
}),
}),
@@ -105,6 +109,34 @@ describe("resolveIMessageInboundDecision per-group systemPrompt", () => {
expect(decision.groupSystemPrompt).toBeUndefined();
});
it("treats explicit empty-string per-chat_id systemPrompt as suppression of the wildcard", () => {
const decision = resolveIMessageInboundDecision(
buildDecisionParams({
cfg: buildCfgWithGroups({
"*": { systemPrompt: "Wildcard." },
"7": { systemPrompt: "" },
}),
}),
);
expect(decision.kind).toBe("dispatch");
if (decision.kind !== "dispatch") return;
expect(decision.groupSystemPrompt).toBeUndefined();
});
it("falls back to the wildcard when the per-chat_id entry has no systemPrompt key at all", () => {
const decision = resolveIMessageInboundDecision(
buildDecisionParams({
cfg: buildCfgWithGroups({
"*": { systemPrompt: "Wildcard." },
"7": { requireMention: true },
}),
}),
);
expect(decision.kind).toBe("dispatch");
if (decision.kind !== "dispatch") return;
expect(decision.groupSystemPrompt).toBe("Wildcard.");
});
it("does not set groupSystemPrompt on true DM decisions", () => {
// Use a chat_id that does NOT match any configured group entry, and
// route through the DM-shaped message (is_group=false, no chat_id key

View File

@@ -28,7 +28,6 @@ import {
resolveDmGroupAccessWithLists,
evaluateSupplementalContextVisibility,
} from "openclaw/plugin-sdk/security-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { sanitizeTerminalText } from "openclaw/plugin-sdk/text-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-runtime";
import { resolveIMessageConversationRoute } from "../conversation-route.js";
@@ -132,6 +131,31 @@ function hasIMessageEchoMatch(params: {
return false;
}
/**
* Per-group `systemPrompt` resolution. Mirrors `resolveWhatsAppGroupSystemPrompt`
* in `extensions/whatsapp/src/system-prompt.ts`:
*
* 1. If the matched per-`chat_id` entry exists AND defines `systemPrompt` (key
* is present, value is non-null), use it. Trim whitespace; if the trim
* leaves an empty string, return `undefined` and DO NOT fall through to the
* wildcard. This is how operators say "this specific group has no prompt"
* without inheriting from `groups["*"]`.
* 2. Otherwise, return the wildcard `groups["*"].systemPrompt` (trimmed; empty
* after trim → `undefined`).
*/
export function resolveIMessageGroupSystemPrompt(params: {
groupConfig: unknown;
defaultConfig: unknown;
}): string | undefined {
const specific = params.groupConfig as { systemPrompt?: string | null } | undefined;
if (specific != null && specific.systemPrompt != null) {
return specific.systemPrompt.trim() || undefined;
}
const wildcard = (params.defaultConfig as { systemPrompt?: string | null } | undefined)
?.systemPrompt;
return wildcard != null ? wildcard.trim() || undefined : undefined;
}
type IMessageInboundDispatchDecision = {
kind: "dispatch";
isGroup: boolean;
@@ -532,16 +556,15 @@ export function resolveIMessageInboundDecision(params: {
}
// Per-chat_id `systemPrompt` wins; fall back to the `groups["*"]` wildcard
// entry when the matched group has no explicit prompt. Mirrors the per-group
// systemPrompt pattern already supported by Telegram, IRC, Discord, Slack,
// GoogleChat, and the retired BlueBubbles channel.
// ONLY when the matched group does not define the key at all. If the matched
// group sets `systemPrompt: ""` the wildcard is suppressed (no prompt is
// applied to that specific group). Mirrors the resolution semantic in
// `extensions/whatsapp/src/system-prompt.ts`.
const groupSystemPrompt = isGroup
? (normalizeOptionalString(
(groupListPolicy.groupConfig as { systemPrompt?: unknown } | undefined)?.systemPrompt,
) ??
normalizeOptionalString(
(groupListPolicy.defaultConfig as { systemPrompt?: unknown } | undefined)?.systemPrompt,
))
? resolveIMessageGroupSystemPrompt({
groupConfig: groupListPolicy.groupConfig,
defaultConfig: groupListPolicy.defaultConfig,
})
: undefined;
return {