Skip to content

Commit c86870d

Browse files
neeravmakwanasteipete
authored andcommitted
fix(messages): strip unsupported citation markers
1 parent 23961fe commit c86870d

9 files changed

Lines changed: 154 additions & 7 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai
2727
### Fixes
2828

2929
- Gateway CLI: surface local post-challenge connect assembly failures immediately instead of waiting for the wrapper timeout. Fixes #68944. (#85253) Thanks @samzong.
30+
- Messages: strip unsupported web-search citation control markers from outbound replies before they reach WebChat or external channels. Fixes #85193. (#85204) Thanks @neeravmakwana.
3031
- Agents/exec: treat denied exec approvals as terminal instead of feeding them back into agent follow-up work, and recognize Chinese stop phrases in abort handling. Fixes #69386. (#85194) Thanks @samzong.
3132
- CLI/agents: abort accepted Gateway-backed `openclaw agent` runs on SIGINT/SIGTERM so cron and supervisor timeouts do not leave remote agent work alive. Fixes #71710. (#84381) Thanks @Kaspre.
3233
- Codex app-server: retry replay-safe stdio client-close turns once using structured failure metadata, while surfacing idle `turn/completed` timeouts instead of blindly replaying active shared-server turns. Thanks @VACInc.

src/infra/outbound/message-action-runner.send-validation.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,31 @@ describe("runMessageAction send validation", () => {
118118
});
119119
});
120120

121+
it("strips unsupported citation control markers from internal UI source replies", async () => {
122+
const result = await runMessageAction({
123+
cfg: emptyConfig,
124+
action: "send",
125+
params: {
126+
message: "v2026.5.20 release note citeturn2view0",
127+
},
128+
toolContext: {
129+
currentChannelProvider: "webchat",
130+
},
131+
sessionKey: "agent:main",
132+
sourceReplyDeliveryMode: "message_tool_only",
133+
});
134+
135+
expect(result).toMatchObject({
136+
kind: "send",
137+
payload: {
138+
sourceReply: {
139+
text: "v2026.5.20 release note",
140+
},
141+
},
142+
});
143+
expect(JSON.stringify(result.payload)).not.toContain("turn2view0");
144+
});
145+
121146
it("does not infer an internal UI sink outside message-tool-only source delivery", async () => {
122147
await expect(
123148
runMessageAction({
@@ -160,6 +185,45 @@ describe("runMessageAction send validation", () => {
160185
});
161186
});
162187

188+
it("strips unsupported citation control markers from normal channel sends", async () => {
189+
const sentText: string[] = [];
190+
setActivePluginRegistry(
191+
createTestRegistry([
192+
{
193+
pluginId: "workspace",
194+
source: "test",
195+
plugin: {
196+
...workspaceTestPlugin,
197+
outbound: {
198+
...workspaceTestPlugin.outbound,
199+
sendText: async (ctx) => {
200+
sentText.push(ctx.text);
201+
return { messageId: "workspace-test-message" };
202+
},
203+
},
204+
},
205+
},
206+
]),
207+
);
208+
209+
const result = await runMessageAction({
210+
cfg: workspaceConfig,
211+
action: "send",
212+
params: {
213+
channel: "workspace",
214+
target: "#C12345678",
215+
message: "v2026.5.20 release note citeturn2view0",
216+
},
217+
});
218+
219+
expect(result).toMatchObject({
220+
kind: "send",
221+
channel: "workspace",
222+
});
223+
expect(sentText).toEqual(["v2026.5.20 release note"]);
224+
expect(JSON.stringify(result.payload)).not.toContain("turn2view0");
225+
});
226+
163227
it.each([
164228
{
165229
name: "structured poll params",

src/infra/outbound/message-action-runner.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
normalizeOptionalLowercaseString,
3838
normalizeOptionalString,
3939
} from "../../shared/string-coerce.js";
40+
import { stripUnsupportedCitationControlMarkers } from "../../shared/text/citation-control-markers.js";
4041
import {
4142
GATEWAY_CLIENT_MODES,
4243
GATEWAY_CLIENT_NAMES,
@@ -798,7 +799,7 @@ async function buildSendPayloadParts(params: {
798799
mergedMediaUrls.length = 0;
799800
mergedMediaUrls.push(...normalizedMediaUrls);
800801

801-
message = parsed.text;
802+
message = stripUnsupportedCitationControlMarkers(parsed.text);
802803
actionParams.message = message;
803804
if (!actionParams.replyTo && parsed.replyToId) {
804805
actionParams.replyTo = parsed.replyToId;

src/infra/outbound/payloads.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,27 @@ describe("normalizeReplyPayloadsForDelivery", () => {
5252
]);
5353
});
5454

55+
it("strips unsupported citation control markers from reply payload text", () => {
56+
const payloads: ReplyPayload[] = [{ text: "v2026.5.20 release note citeturn2view0" }];
57+
58+
expect(normalizeReplyPayloadsForDelivery(payloads)).toMatchObject([
59+
{ text: "v2026.5.20 release note" },
60+
]);
61+
expect(resolveMirrorProjection(payloads).text).toBe("v2026.5.20 release note");
62+
expect(normalizeOutboundPayloadsForJson(payloads)).toMatchObject([
63+
{ text: "v2026.5.20 release note" },
64+
]);
65+
});
66+
67+
it("suppresses silent replies after removing citation control markers", () => {
68+
expect(
69+
normalizeReplyPayloadsForDelivery([
70+
{ text: "NO_REPLY citeturn2view0" },
71+
{ text: '{"action":"NO_REPLY"} citeturn2view0' },
72+
]),
73+
).toStrictEqual([]);
74+
});
75+
5576
it("drops silent payloads without media and suppresses reasoning payloads", () => {
5677
expect(
5778
normalizeReplyPayloadsForDelivery([
@@ -602,6 +623,14 @@ describe("summarizeOutboundPayloadForTransport", () => {
602623
expect(summary.hookContent).toBeUndefined();
603624
});
604625

626+
it("strips unsupported citation control markers from transport text", () => {
627+
const summary = summarizeOutboundPayloadForTransport({
628+
text: "v2026.5.20 release note citeturn2view0",
629+
});
630+
631+
expect(summary.text).toBe("v2026.5.20 release note");
632+
});
633+
605634
it("surfaces spokenText only as hook content for audio-only payloads", () => {
606635
const summary = summarizeOutboundPayloadForTransport({
607636
mediaUrl: "/tmp/reply.opus",
@@ -615,6 +644,17 @@ describe("summarizeOutboundPayloadForTransport", () => {
615644
expect(summary.audioAsVoice).toBe(true);
616645
});
617646

647+
it("strips unsupported citation control markers from hook-only spoken text", () => {
648+
const summary = summarizeOutboundPayloadForTransport({
649+
mediaUrl: "/tmp/reply.opus",
650+
audioAsVoice: true,
651+
spokenText: "Hi Ivy citeturn2view0",
652+
});
653+
654+
expect(summary.text).toBe("");
655+
expect(summary.hookContent).toBe("Hi Ivy");
656+
});
657+
618658
it("ignores blank spokenText", () => {
619659
const summary = summarizeOutboundPayloadForTransport({
620660
mediaUrl: "/tmp/reply.opus",

src/infra/outbound/payloads.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
isRenderablePayload,
66
shouldSuppressReasoningPayload,
77
} from "../../auto-reply/reply/reply-payloads.js";
8+
import { isSilentReplyPayloadText } from "../../auto-reply/tokens.js";
89
import type { ReplyPayload } from "../../auto-reply/types.js";
910
import type { OpenClawConfig } from "../../config/types.openclaw.js";
1011
import {
@@ -19,6 +20,7 @@ import {
1920
type ReplyPayloadDelivery,
2021
} from "../../interactive/payload.js";
2122
import { type SilentReplyConversationType } from "../../shared/silent-reply-policy.js";
23+
import { stripUnsupportedCitationControlMarkers } from "../../shared/text/citation-control-markers.js";
2224

2325
export type NormalizedOutboundPayload = {
2426
text: string;
@@ -220,11 +222,12 @@ function createOutboundPayloadPlanEntry(
220222
explicitMediaUrls,
221223
explicitMediaUrl ? [explicitMediaUrl] : undefined,
222224
);
223-
const parsedText = parsed.text ?? "";
225+
const parsedText = stripUnsupportedCitationControlMarkers(parsed.text ?? "");
224226
if (isSuppressedRelayStatusText(parsedText) && mergedMedia.length === 0) {
225227
return null;
226228
}
227-
const isSilent = parsed.isSilent && mergedMedia.length === 0;
229+
const isSilent =
230+
(parsed.isSilent || isSilentReplyPayloadText(parsedText)) && mergedMedia.length === 0;
228231
const hasMultipleMedia = (explicitMediaUrls?.length ?? 0) > 1;
229232
const resolvedMediaUrl = hasMultipleMedia ? undefined : explicitMediaUrl;
230233
const normalizedPayload: ReplyPayload = {
@@ -358,16 +361,21 @@ export function summarizeOutboundPayloadForTransport(
358361
payload: ReplyPayload,
359362
): NormalizedOutboundPayload {
360363
const parts = resolveSendableOutboundReplyParts(payload);
361-
const spokenText = payload.spokenText?.trim() ? payload.spokenText : undefined;
364+
const text = stripUnsupportedCitationControlMarkers(parts.text);
365+
const strippedSpokenText =
366+
typeof payload.spokenText === "string"
367+
? stripUnsupportedCitationControlMarkers(payload.spokenText)
368+
: undefined;
369+
const spokenText = strippedSpokenText?.trim() ? strippedSpokenText : undefined;
362370
return {
363-
text: parts.text,
371+
text,
364372
mediaUrls: parts.mediaUrls,
365373
audioAsVoice: payload.audioAsVoice === true ? true : undefined,
366374
presentation: payload.presentation,
367375
delivery: payload.delivery,
368376
interactive: payload.interactive,
369377
channelData: payload.channelData,
370-
...(parts.text || !spokenText ? {} : { hookContent: spokenText }),
378+
...(text || !spokenText ? {} : { hookContent: spokenText }),
371379
};
372380
}
373381

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { describe, expect, it } from "vitest";
2+
import { stripUnsupportedCitationControlMarkers } from "./citation-control-markers.js";
3+
4+
describe("stripUnsupportedCitationControlMarkers", () => {
5+
it("removes citation control markers and line-end spacing they leave behind", () => {
6+
expect(stripUnsupportedCitationControlMarkers("v2026.5.20 citeturn2view0")).toBe(
7+
"v2026.5.20",
8+
);
9+
});
10+
11+
it("preserves unrelated trailing whitespace", () => {
12+
expect(stripUnsupportedCitationControlMarkers("hard break \nnext")).toBe("hard break \nnext");
13+
});
14+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
const UNSUPPORTED_CITATION_CONTROL_MARKER_RE = /cite(?:[^]*)?/g;
2+
const TRAILING_UNSUPPORTED_CITATION_CONTROL_MARKER_RE = /[ \t]*cite(?:[^]*)?(?=\r?\n|$)/g;
3+
4+
export function stripUnsupportedCitationControlMarkers(text: string): string {
5+
return text
6+
.replace(TRAILING_UNSUPPORTED_CITATION_CONTROL_MARKER_RE, "")
7+
.replace(UNSUPPORTED_CITATION_CONTROL_MARKER_RE, "");
8+
}

ui/src/ui/markdown.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,16 @@ describe("toSanitizedMarkdownHtml", () => {
2727
);
2828
});
2929

30+
it("strips unsupported citation control markers before display", () => {
31+
const html = toSanitizedMarkdownHtml(
32+
"v2026.5.20 release note citeturn2view0\n\nStill readable.",
33+
);
34+
35+
expect(html).toBe("<p>v2026.5.20 release note</p>\n<p>Still readable.</p>\n");
36+
expect(html).not.toContain("cite");
37+
expect(html).not.toContain("turn2view0");
38+
});
39+
3040
// ── Additional tests for markdown-it migration ──
3141
describe("www autolinks", () => {
3242
it("links www.example.com", () => {

ui/src/ui/markdown.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import xml from "highlight.js/lib/languages/xml";
1616
import yaml from "highlight.js/lib/languages/yaml";
1717
import MarkdownIt from "markdown-it";
1818
import markdownItTaskLists from "markdown-it-task-lists";
19+
import { stripUnsupportedCitationControlMarkers } from "../../../src/shared/text/citation-control-markers.js";
1920
import { i18n, t } from "../i18n/index.ts";
2021
import { truncateText } from "./format.ts";
2122
import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts";
@@ -576,7 +577,7 @@ md.renderer.rules.code_block = (tokens, idx) => {
576577
};
577578

578579
export function toSanitizedMarkdownHtml(markdown: string): string {
579-
const input = markdown.trim();
580+
const input = stripUnsupportedCitationControlMarkers(markdown).trim();
580581
if (!input) {
581582
return "";
582583
}

0 commit comments

Comments
 (0)