fix(imessage): keep fenced role-marker mapping keys out of the outbound stripper (#117159)

* 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 <noreply@anthropic.com>

* test(imessage): cover code-aware marker stripping

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
Harjoth Khara
2026-08-01 01:52:47 -07:00
committed by GitHub
parent 75830f027a
commit 41051fe37c
2 changed files with 54 additions and 4 deletions
@@ -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 = "<thinking>step 1</thinking>NO_REPLY +#+#+#+# assistant to=final\n\nActual reply";
const result = sanitizeOutboundText(text);
@@ -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();