From 1f70394e7bf166d89fdf7e39e96aa95aaffd771d Mon Sep 17 00:00:00 2001 From: Omar Shahine <10343873+omarshahine@users.noreply.github.com> Date: Fri, 8 May 2026 13:14:36 +0000 Subject: [PATCH] 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. --- docs/channels/imessage.md | 9 +++- .../inbound-processing.systemPrompt.test.ts | 34 ++++++++++++++- .../src/monitor/inbound-processing.ts | 43 ++++++++++++++----- 3 files changed, 74 insertions(+), 12 deletions(-) diff --git a/docs/channels/imessage.md b/docs/channels/imessage.md index 5729eaf13d5..27af9088d1e 100644 --- a/docs/channels/imessage.md +++ b/docs/channels/imessage.md @@ -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[""].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: "", + }, }, }, }, diff --git a/extensions/imessage/src/monitor/inbound-processing.systemPrompt.test.ts b/extensions/imessage/src/monitor/inbound-processing.systemPrompt.test.ts index a519b552649..e14f274ea85 100644 --- a/extensions/imessage/src/monitor/inbound-processing.systemPrompt.test.ts +++ b/extensions/imessage/src/monitor/inbound-processing.systemPrompt.test.ts @@ -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 diff --git a/extensions/imessage/src/monitor/inbound-processing.ts b/extensions/imessage/src/monitor/inbound-processing.ts index 27897d44473..432441c8e07 100644 --- a/extensions/imessage/src/monitor/inbound-processing.ts +++ b/extensions/imessage/src/monitor/inbound-processing.ts @@ -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 {