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();