From 41051fe37c7f06c5416ae8ee929ad4e18ec99a67 Mon Sep 17 00:00:00 2001 From: Harjoth Khara Date: Sat, 1 Aug 2026 01:52:47 -0700 Subject: [PATCH] fix(imessage): keep fenced role-marker mapping keys out of the outbound stripper (#117159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(imessage): stop the outbound sanitizer deleting fenced role keys sanitizeOutboundText strips standalone `user:`/`system:`/`assistant:` lines (leaked turn boundaries), `#+#` separators and `assistant to=` markers, but the three regexes had no code-fence awareness. A bare YAML mapping key like `user:` on its own line inside a ```yaml block matched ROLE_TURN_MARKER_RE and was silently deleted before iMessage delivery, reparenting its children under the wrong node — still-valid YAML, wrong meaning, and nothing signalling a line was removed. Skip matches whose offset falls inside a markdown code region for all three patterns, reusing findCodeRegions/isInsideCode already applied by the sibling reflection-guard on this channel. Regions are recomputed per pass because each strip shifts later offsets. Leaked markers in prose are still stripped. Refs: #116942 Co-Authored-By: Claude Opus 4.8 * test(imessage): cover code-aware marker stripping --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: Vincent Koc --- .../monitor/sanitize-outbound.test-support.ts | 33 +++++++++++++++++++ .../imessage/src/monitor/sanitize-outbound.ts | 25 +++++++++++--- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/extensions/imessage/src/monitor/sanitize-outbound.test-support.ts b/extensions/imessage/src/monitor/sanitize-outbound.test-support.ts index 977c3feb2ed8..c0aa6176f9c7 100644 --- a/extensions/imessage/src/monitor/sanitize-outbound.test-support.ts +++ b/extensions/imessage/src/monitor/sanitize-outbound.test-support.ts @@ -63,6 +63,39 @@ describe("sanitizeOutboundText", () => { expect(sanitizeOutboundText(text)).toBe("Hello\n\nWorld"); }); + it.each([ + { + name: "backtick fence preserves user while stripping prose user", + input: "user:\n```yaml\nuser:\n name: alice\n```", + expected: "```yaml\nuser:\n name: alice\n```", + }, + { + name: "tilde fence preserves system while stripping prose system", + input: "system:\n~~~yaml\nsystem:\n enabled: true\n~~~", + expected: "~~~yaml\nsystem:\n enabled: true\n~~~", + }, + { + name: "indented code preserves assistant while stripping prose assistant", + input: "keep\n\nassistant:\n\n assistant:\n enabled: true", + expected: "keep\n\n assistant:\n enabled: true", + }, + { + name: "unterminated fence preserves every marker family after offset shifts", + input: [ + "#+#+#", + "assistant to=final", + "user:", + "```text", + "#+#+#", + "assistant to=tool", + "user:", + ].join("\n"), + expected: ["```text", "#+#+#", "assistant to=tool", "user:"].join("\n"), + }, + ] as const)("$name", ({ input, expected }) => { + expect(sanitizeOutboundText(input)).toBe(expected); + }); + it("handles combined internal markers in one message", () => { const text = "step 1NO_REPLY +#+#+#+# assistant to=final\n\nActual reply"; const result = sanitizeOutboundText(text); diff --git a/extensions/imessage/src/monitor/sanitize-outbound.ts b/extensions/imessage/src/monitor/sanitize-outbound.ts index 8d58c7efdf7f..a22468fff180 100644 --- a/extensions/imessage/src/monitor/sanitize-outbound.ts +++ b/extensions/imessage/src/monitor/sanitize-outbound.ts @@ -1,5 +1,9 @@ // Imessage plugin module implements sanitize outbound behavior. -import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking"; +import { + findCodeRegions, + isInsideCode, + sanitizeAssistantVisibleText, +} from "openclaw/plugin-sdk/text-chunking"; /** * Patterns that indicate assistant-internal metadata leaked into text. @@ -11,6 +15,19 @@ const ASSISTANT_ROLE_MARKER_RE = /\bassistant\s+to\s*=\s*\w+/gi; // any line that merely ends with the word "user/system/assistant:" in prose. const ROLE_TURN_MARKER_RE = /^[ \t]*(?:user|system|assistant)\s*:\s*$/gm; +/** + * Strip an internal-marker pattern from prose while leaving matches that fall + * inside a fenced/indented/inline code region untouched: there a bare `user:` + * mapping key or `#+#` line is the user's own content, not leaked scaffolding. + * Regions are recomputed per call because the prior strip shifted later offsets. + */ +function stripMarkerOutsideCode(text: string, marker: RegExp): string { + const codeRegions = findCodeRegions(text); + return text.replace(marker, (match, offset: number) => + isInsideCode(offset, codeRegions) ? match : "", + ); +} + /** * Strip all assistant-internal scaffolding from outbound text before delivery. * Applies reasoning/thinking tag removal, memory tag removal, and @@ -23,9 +40,9 @@ export function sanitizeOutboundText(text: string): string { let cleaned = sanitizeAssistantVisibleText(text); - cleaned = cleaned.replace(INTERNAL_SEPARATOR_RE, ""); - cleaned = cleaned.replace(ASSISTANT_ROLE_MARKER_RE, ""); - cleaned = cleaned.replace(ROLE_TURN_MARKER_RE, ""); + cleaned = stripMarkerOutsideCode(cleaned, INTERNAL_SEPARATOR_RE); + cleaned = stripMarkerOutsideCode(cleaned, ASSISTANT_ROLE_MARKER_RE); + cleaned = stripMarkerOutsideCode(cleaned, ROLE_TURN_MARKER_RE); // Collapse excessive blank lines left after stripping. cleaned = cleaned.replace(/\n{3,}/g, "\n\n").trim();