diff --git a/extensions/discord/src/outbound-adapter.test.ts b/extensions/discord/src/outbound-adapter.test.ts
index f4dfca39357..6abe33663ad 100644
--- a/extensions/discord/src/outbound-adapter.test.ts
+++ b/extensions/discord/src/outbound-adapter.test.ts
@@ -72,6 +72,26 @@ describe("discordOutbound", () => {
});
});
+ it("sanitizes internal runtime scaffolding before Discord delivery", () => {
+ expect(
+ discordOutbound.sanitizeText?.({
+ text: "nullvisible",
+ payload: { text: "nullvisible" },
+ }),
+ ).toBe("visible");
+ });
+
+ it("preserves Discord-native angle markup while stripping internal scaffolding", () => {
+ expect(
+ discordOutbound.sanitizeText?.({
+ text: "soon run null",
+ payload: {
+ text: "soon run null",
+ },
+ }),
+ ).toBe("soon run ");
+ });
+
it("forwards explicit formatting options to Discord text sends", async () => {
await discordOutbound.sendText?.({
cfg: {},
diff --git a/extensions/discord/src/outbound-adapter.ts b/extensions/discord/src/outbound-adapter.ts
index d790b141c42..f4ceb58ccea 100644
--- a/extensions/discord/src/outbound-adapter.ts
+++ b/extensions/discord/src/outbound-adapter.ts
@@ -26,6 +26,19 @@ import {
} from "./outbound-send-context.js";
export const DISCORD_TEXT_CHUNK_LIMIT = 2000;
+const DISCORD_INTERNAL_RUNTIME_SCAFFOLDING_BLOCK_RE =
+ /<\s*(system-reminder|previous_response)\b[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi;
+const DISCORD_INTERNAL_RUNTIME_SCAFFOLDING_SELF_CLOSING_RE =
+ /<\s*(?:system-reminder|previous_response)\b[^>]*\/\s*>/gi;
+const DISCORD_INTERNAL_RUNTIME_SCAFFOLDING_TAG_RE =
+ /<\s*\/?\s*(?:system-reminder|previous_response)\b[^>]*>/gi;
+
+function stripDiscordInternalRuntimeScaffolding(text: string): string {
+ return text
+ .replace(DISCORD_INTERNAL_RUNTIME_SCAFFOLDING_BLOCK_RE, "")
+ .replace(DISCORD_INTERNAL_RUNTIME_SCAFFOLDING_SELF_CLOSING_RE, "")
+ .replace(DISCORD_INTERNAL_RUNTIME_SCAFFOLDING_TAG_RE, "");
+}
type DiscordThreadBindingsModule = typeof import("./monitor/thread-bindings.js");
@@ -97,6 +110,7 @@ export const discordOutbound: ChannelOutboundAdapter = {
maxLines: ctx?.formatting?.maxLinesPerMessage,
}),
textChunkLimit: DISCORD_TEXT_CHUNK_LIMIT,
+ sanitizeText: ({ text }) => stripDiscordInternalRuntimeScaffolding(text),
pollMaxOptions: 10,
normalizePayload: ({ payload }) => normalizeDiscordApprovalPayload(payload),
presentationCapabilities: {
diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts
index 26110e9a482..0f7c3af55d8 100644
--- a/src/infra/outbound/deliver.test.ts
+++ b/src/infra/outbound/deliver.test.ts
@@ -706,6 +706,175 @@ describe("deliverOutboundPayloads", () => {
expect(sendText).not.toHaveBeenCalled();
});
+ it("strips internal runtime scaffolding added by message_sending hooks before delivery", async () => {
+ hookMocks.runner.hasHooks.mockImplementation(
+ (hookName?: string) => hookName === "message_sending",
+ );
+ hookMocks.runner.runMessageSending.mockResolvedValue({
+ content:
+ "nullhiddenvisible",
+ });
+ const sendText = vi.fn().mockResolvedValue({
+ channel: "matrix" as const,
+ messageId: "clean",
+ roomId: "!room",
+ });
+ setActivePluginRegistry(
+ createTestRegistry([
+ {
+ pluginId: "matrix",
+ source: "test",
+ plugin: createOutboundTestPlugin({
+ id: "matrix",
+ outbound: {
+ deliveryMode: "direct",
+ sendText,
+ },
+ }),
+ },
+ ]),
+ );
+
+ await deliverOutboundPayloads({
+ cfg: {},
+ channel: "matrix",
+ to: "!room",
+ payloads: [{ text: "original" }],
+ });
+
+ expect(sendText).toHaveBeenCalledWith(expect.objectContaining({ text: "visible" }));
+ });
+
+ it("strips internal runtime scaffolding before adapter payload normalization copies text", async () => {
+ hookMocks.runner.hasHooks.mockImplementation(
+ (hookName?: string) => hookName === "message_sending",
+ );
+ hookMocks.runner.runMessageSending.mockResolvedValue({
+ content: "nullvisible",
+ });
+ const sendPayload = vi.fn().mockResolvedValue({
+ channel: "matrix" as const,
+ messageId: "clean",
+ roomId: "!room",
+ });
+ setActivePluginRegistry(
+ createTestRegistry([
+ {
+ pluginId: "matrix",
+ source: "test",
+ plugin: createOutboundTestPlugin({
+ id: "matrix",
+ outbound: {
+ deliveryMode: "direct",
+ normalizePayload: ({ payload }) => ({
+ ...payload,
+ channelData: { copiedText: payload.text },
+ }),
+ sendText: vi.fn(),
+ sendMedia: vi.fn(),
+ sendPayload,
+ },
+ }),
+ },
+ ]),
+ );
+
+ await deliverOutboundPayloads({
+ cfg: {},
+ channel: "matrix",
+ to: "!room",
+ payloads: [{ text: "original" }],
+ });
+
+ expect(sendPayload).toHaveBeenCalledWith(
+ expect.objectContaining({
+ payload: expect.objectContaining({
+ text: "visible",
+ channelData: { copiedText: "visible" },
+ }),
+ }),
+ );
+ });
+
+ it("strips internal runtime scaffolding copied into rendered and normalized nested payloads", async () => {
+ const sendPayload = vi.fn().mockResolvedValue({
+ channel: "matrix" as const,
+ messageId: "clean-nested",
+ roomId: "!room",
+ });
+ setActivePluginRegistry(
+ createTestRegistry([
+ {
+ pluginId: "matrix",
+ source: "test",
+ plugin: createOutboundTestPlugin({
+ id: "matrix",
+ outbound: {
+ deliveryMode: "direct",
+ renderPresentation: ({ payload }) => ({
+ ...payload,
+ channelData: {
+ renderedText: payload.text,
+ renderedBlocks: [{ text: payload.text }],
+ },
+ }),
+ normalizePayload: ({ payload }) => {
+ const text = payload.text ?? "";
+ return {
+ ...payload,
+ channelData: {
+ ...payload.channelData,
+ normalizedText: text,
+ },
+ interactive: {
+ blocks: [{ type: "text", text }],
+ },
+ };
+ },
+ sendText: vi.fn(),
+ sendMedia: vi.fn(),
+ sendPayload,
+ },
+ }),
+ },
+ ]),
+ );
+
+ await deliverOutboundPayloads({
+ cfg: {},
+ channel: "matrix",
+ to: "!room",
+ payloads: [
+ {
+ text: "nullvisible",
+ presentation: {
+ title: "Title",
+ blocks: [],
+ },
+ },
+ ],
+ });
+
+ expect(JSON.stringify(sendPayload.mock.calls[0]?.[0]?.payload)).not.toContain(
+ "previous_response",
+ );
+ expect(sendPayload).toHaveBeenCalledWith(
+ expect.objectContaining({
+ payload: expect.objectContaining({
+ text: "visible",
+ channelData: {
+ renderedText: "visible",
+ renderedBlocks: [{ text: "visible" }],
+ normalizedText: "visible",
+ },
+ interactive: {
+ blocks: [{ type: "text", text: "visible" }],
+ },
+ }),
+ }),
+ );
+ });
+
it("runs adapter after-delivery hooks with the payload delivery results", async () => {
const afterDeliverPayload = vi.fn();
setActivePluginRegistry(
diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts
index 4ea06eac070..8cab39103e9 100644
--- a/src/infra/outbound/deliver.ts
+++ b/src/infra/outbound/deliver.ts
@@ -55,6 +55,7 @@ import {
type OutboundPayloadPlan,
} from "./payloads.js";
import { createReplyToDeliveryPolicy } from "./reply-policy.js";
+import { stripInternalRuntimeScaffolding } from "./sanitize-text.js";
import { resolveOutboundSendDep, type OutboundSendDeps } from "./send-deps.js";
import type { OutboundSessionContext } from "./session-context.js";
import type { OutboundChannel } from "./targets.js";
@@ -478,7 +479,7 @@ function normalizePayloadsForChannelDelivery(
): ReplyPayload[] {
const normalizedPayloads: ReplyPayload[] = [];
for (const payload of projectOutboundPayloadPlanForDelivery(plan)) {
- let sanitizedPayload = payload;
+ let sanitizedPayload = stripInternalRuntimeScaffoldingFromPayload(payload);
if (handler.sanitizeText && sanitizedPayload.text) {
if (!handler.shouldSkipPlainTextSanitization?.(sanitizedPayload)) {
sanitizedPayload = {
@@ -491,7 +492,9 @@ function normalizePayloadsForChannelDelivery(
? handler.normalizePayload(sanitizedPayload)
: sanitizedPayload;
const normalized = normalizedPayload
- ? normalizeEmptyPayloadForDelivery(normalizedPayload)
+ ? normalizeEmptyPayloadForDelivery(
+ stripInternalRuntimeScaffoldingFromPayload(normalizedPayload),
+ )
: null;
if (normalized) {
normalizedPayloads.push(normalized);
@@ -500,6 +503,43 @@ function normalizePayloadsForChannelDelivery(
return normalizedPayloads;
}
+function stripInternalRuntimeScaffoldingFromValue(value: unknown): unknown {
+ if (typeof value === "string") {
+ return stripInternalRuntimeScaffolding(value);
+ }
+ if (Array.isArray(value)) {
+ let changed = false;
+ const next = value.map((entry) => {
+ const stripped = stripInternalRuntimeScaffoldingFromValue(entry);
+ changed ||= stripped !== entry;
+ return stripped;
+ });
+ return changed ? next : value;
+ }
+ if (!value || typeof value !== "object") {
+ return value;
+ }
+ const proto = Object.getPrototypeOf(value);
+ if (proto !== Object.prototype && proto !== null) {
+ return value;
+ }
+ let changed = false;
+ const next: Record = {};
+ for (const [key, entry] of Object.entries(value)) {
+ const stripped = stripInternalRuntimeScaffoldingFromValue(entry);
+ changed ||= stripped !== entry;
+ next[key] = stripped;
+ }
+ return changed ? next : value;
+}
+
+function stripInternalRuntimeScaffoldingFromPayload(payload: ReplyPayload): ReplyPayload {
+ const stripped = stripInternalRuntimeScaffoldingFromValue(payload);
+ return stripped && typeof stripped === "object" && !Array.isArray(stripped)
+ ? (stripped as ReplyPayload)
+ : payload;
+}
+
function buildPayloadSummary(payload: ReplyPayload): NormalizedOutboundPayload {
return summarizeOutboundPayloadForTransport(payload);
}
@@ -1032,12 +1072,16 @@ async function deliverOutboundPayloadsCore(
if (hookResult.cancelled) {
continue;
}
- const renderedPayload = await renderPresentationForDelivery(handler, hookResult.payload);
+ const renderedPayload = stripInternalRuntimeScaffoldingFromPayload(
+ await renderPresentationForDelivery(handler, hookResult.payload),
+ );
const normalizedEffectivePayload = handler.normalizePayload
? handler.normalizePayload(renderedPayload)
: renderedPayload;
const effectivePayload = normalizedEffectivePayload
- ? normalizeEmptyPayloadForDelivery(normalizedEffectivePayload)
+ ? normalizeEmptyPayloadForDelivery(
+ stripInternalRuntimeScaffoldingFromPayload(normalizedEffectivePayload),
+ )
: null;
if (!effectivePayload) {
continue;
diff --git a/src/infra/outbound/sanitize-text.test.ts b/src/infra/outbound/sanitize-text.test.ts
index f43ae615246..8842088b761 100644
--- a/src/infra/outbound/sanitize-text.test.ts
+++ b/src/infra/outbound/sanitize-text.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { sanitizeForPlainText } from "./sanitize-text.js";
+import { sanitizeForPlainText, stripInternalRuntimeScaffolding } from "./sanitize-text.js";
// ---------------------------------------------------------------------------
// sanitizeForPlainText
@@ -63,6 +63,15 @@ describe("sanitizeForPlainText", () => {
expect(sanitizeForPlainText('link')).toBe("link");
});
+ it("strips known internal runtime scaffolding tags including underscore names", () => {
+ expect(sanitizeForPlainText("ok null done")).toBe(
+ "ok done",
+ );
+ expect(sanitizeForPlainText("ok use todos done")).toBe(
+ "ok done",
+ );
+ });
+
it("preserves angle-bracket autolinks", () => {
expect(sanitizeForPlainText("See now")).toBe(
"See https://example.com/path?q=1 now",
@@ -92,3 +101,26 @@ describe("sanitizeForPlainText", () => {
expect(sanitizeForPlainText("a
b")).toBe("a\n\nb");
});
});
+
+describe("stripInternalRuntimeScaffolding", () => {
+ it("removes closed, self-closing, and stray internal runtime tags", () => {
+ expect(
+ stripInternalRuntimeScaffolding(
+ [
+ "before",
+ "internal hint",
+ "null",
+ "",
+ "",
+ "visible",
+ ].join("\n"),
+ ),
+ ).toBe(["before", "", "", "", "", "visible"].join("\n"));
+ });
+
+ it("does not strip arbitrary XML-like user content", () => {
+ expect(stripInternalRuntimeScaffolding("keep this")).toBe(
+ "keep this",
+ );
+ });
+});
diff --git a/src/infra/outbound/sanitize-text.ts b/src/infra/outbound/sanitize-text.ts
index 36b0bd3e254..9bb68b76051 100644
--- a/src/infra/outbound/sanitize-text.ts
+++ b/src/infra/outbound/sanitize-text.ts
@@ -11,6 +11,28 @@
* @see https://github.com/openclaw/openclaw/issues/18558
*/
+const INTERNAL_RUNTIME_SCAFFOLDING_TAGS = ["system-reminder", "previous_response"] as const;
+const INTERNAL_RUNTIME_SCAFFOLDING_TAG_PATTERN = INTERNAL_RUNTIME_SCAFFOLDING_TAGS.join("|");
+const INTERNAL_RUNTIME_SCAFFOLDING_BLOCK_RE = new RegExp(
+ `<\\s*(${INTERNAL_RUNTIME_SCAFFOLDING_TAG_PATTERN})\\b[^>]*>[\\s\\S]*?<\\s*\\/\\s*\\1\\s*>`,
+ "gi",
+);
+const INTERNAL_RUNTIME_SCAFFOLDING_SELF_CLOSING_RE = new RegExp(
+ `<\\s*(?:${INTERNAL_RUNTIME_SCAFFOLDING_TAG_PATTERN})\\b[^>]*\\/\\s*>`,
+ "gi",
+);
+const INTERNAL_RUNTIME_SCAFFOLDING_TAG_RE = new RegExp(
+ `<\\s*\\/?\\s*(?:${INTERNAL_RUNTIME_SCAFFOLDING_TAG_PATTERN})\\b[^>]*>`,
+ "gi",
+);
+
+export function stripInternalRuntimeScaffolding(text: string): string {
+ return text
+ .replace(INTERNAL_RUNTIME_SCAFFOLDING_BLOCK_RE, "")
+ .replace(INTERNAL_RUNTIME_SCAFFOLDING_SELF_CLOSING_RE, "")
+ .replace(INTERNAL_RUNTIME_SCAFFOLDING_TAG_RE, "");
+}
+
/**
* Convert common HTML tags to their plain-text/lightweight-markup equivalents
* and strip anything that remains.
@@ -21,7 +43,7 @@
*/
export function sanitizeForPlainText(text: string): string {
return (
- text
+ stripInternalRuntimeScaffolding(text)
// Preserve angle-bracket autolinks as plain URLs before tag stripping.
.replace(/<((?:https?:\/\/|mailto:)[^<>\s]+)>/gi, "$1")
// Line breaks
@@ -41,7 +63,7 @@ export function sanitizeForPlainText(text: string): string {
// List items → bullet points
.replace(/]*>(.*?)<\/li>/gi, "• $1\n")
// Strip remaining HTML tags (require tag-like structure: )
- .replace(/<\/?[a-z][a-z0-9]*\b[^>]*>/gi, "")
+ .replace(/<\/?[a-z][a-z0-9_-]*\b[^>]*>/gi, "")
// Collapse 3+ consecutive newlines into 2
.replace(/\n{3,}/g, "\n\n")
);