mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(inbound-meta): preserve reply-context body tails
Preserve actionable tail content in long reply-context bodies before they enter prompt JSON or inline reply context formatting. - Apply UTF-16-safe head+tail truncation to ReplyChain JSON bodies and fallback ReplyToBody JSON blocks. - Use the same body-aware truncation for Telegram inline ReplyToBody fallback and chat_window message bodies, so those paths cannot suppress the JSON fallback and still lose the tail. - Adds regression coverage for ReplyChain, fallback ReplyToBody, Telegram inline ReplyToBody, chat_window reply targets, and emoji-heavy heads. Verification: - node scripts/run-vitest.mjs src/auto-reply/reply/inbound-meta.test.ts - node_modules/.bin/oxfmt --check --threads=1 src/auto-reply/reply/inbound-meta.ts src/auto-reply/reply/inbound-meta.test.ts - node scripts/run-oxlint.mjs src/auto-reply/reply/inbound-meta.ts src/auto-reply/reply/inbound-meta.test.ts - .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main - Testbox-through-Crabbox check:changed: provider=blacksmith-testbox id=tbx_01ktgthbb5xa9d5ap58h4134s0 exit=0 - PR CI: 158/158 completed, no failures, MERGEABLE/CLEAN Fixes #91042
This commit is contained in:
@@ -598,6 +598,29 @@ describe("buildInboundUserContextPrefix", () => {
|
||||
expect(text).not.toContain("Reply target of current user message");
|
||||
});
|
||||
|
||||
it("preserves Telegram inline ReplyToBody tail content", () => {
|
||||
const head = "BEGIN. ".repeat(300);
|
||||
const tail = " TELEGRAM_INLINE_TAIL";
|
||||
const longBody = head + tail;
|
||||
expect(longBody.length).toBeGreaterThan(2_000);
|
||||
|
||||
const text = buildInboundUserContextPrefix({
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
OriginatingChannel: "telegram",
|
||||
ChatType: "group",
|
||||
MessageSid: "34974",
|
||||
ReplyToId: "34971",
|
||||
ReplyToBody: longBody,
|
||||
SenderName: "obviyus",
|
||||
} as TemplateContext);
|
||||
|
||||
expect(text).toContain("TELEGRAM_INLINE_TAIL");
|
||||
expect(text).toContain("…[omitted]…");
|
||||
expect(text).not.toContain("…[truncated]");
|
||||
expect(text).not.toContain("Reply target of current user message");
|
||||
});
|
||||
|
||||
it("keeps Telegram current-message quote even when context already includes the target", () => {
|
||||
const text = buildInboundUserContextPrefix({
|
||||
Provider: "telegram",
|
||||
@@ -948,6 +971,91 @@ describe("buildInboundUserContextPrefix", () => {
|
||||
expect(text).toContain('"body": "');
|
||||
});
|
||||
|
||||
it("preserves tail content in ReplyChain body via head+tail truncation", () => {
|
||||
const head = "BEGIN. ".repeat(300);
|
||||
const tail = " IMPORTANT_TAIL_SENTINEL";
|
||||
const longBody = head + tail;
|
||||
expect(longBody.length).toBeGreaterThan(2_000);
|
||||
|
||||
const text = buildInboundUserContextPrefix({
|
||||
ChatType: "group",
|
||||
ReplyChain: [{ body: longBody, sender: "Alice" }],
|
||||
} as TemplateContext);
|
||||
|
||||
const [reply] = parseReplyChainPayload(text);
|
||||
expect(reply?.["body"]).toContain("IMPORTANT_TAIL_SENTINEL");
|
||||
expect(reply?.["body"]).toContain("…[omitted]…");
|
||||
expect(reply?.["body"]).not.toContain("…[truncated]");
|
||||
});
|
||||
|
||||
it("preserves tail content in fallback ReplyToBody via head+tail truncation", () => {
|
||||
const head = "BEGIN. ".repeat(300);
|
||||
const tail = " REPLY_TAIL_SENTINEL";
|
||||
const longBody = head + tail;
|
||||
expect(longBody.length).toBeGreaterThan(2_000);
|
||||
|
||||
const text = buildInboundUserContextPrefix({
|
||||
ChatType: "group",
|
||||
ReplyToBody: longBody,
|
||||
} as TemplateContext);
|
||||
|
||||
const reply = parseReplyPayload(text);
|
||||
expect(reply["body"]).toContain("REPLY_TAIL_SENTINEL");
|
||||
expect(reply["body"]).toContain("…[omitted]…");
|
||||
expect(reply["body"]).not.toContain("…[truncated]");
|
||||
});
|
||||
|
||||
it("preserves fallback ReplyToBody tail when the head is emoji-heavy", () => {
|
||||
const head = "😀".repeat(1_200);
|
||||
const tail = " TAIL_AFTER_EMOJI_HEAD";
|
||||
const longBody = head + tail;
|
||||
expect(longBody.length).toBeGreaterThan(2_000);
|
||||
|
||||
const text = buildInboundUserContextPrefix({
|
||||
ReplyToSender: "Quoter",
|
||||
ReplyToBody: longBody,
|
||||
} as TemplateContext);
|
||||
|
||||
const reply = parseReplyPayload(text);
|
||||
expect(reply["body"]).toContain("TAIL_AFTER_EMOJI_HEAD");
|
||||
expect(reply["body"]).toContain("…[omitted]…");
|
||||
expect(reply["body"]).not.toContain("…[truncated]");
|
||||
});
|
||||
|
||||
it("preserves chat window reply-target body tail content", () => {
|
||||
const head = "BEGIN. ".repeat(300);
|
||||
const tail = " CHAT_WINDOW_TAIL";
|
||||
const longBody = head + tail;
|
||||
expect(longBody.length).toBeGreaterThan(2_000);
|
||||
|
||||
const text = buildInboundUserContextPrefix({
|
||||
ChatType: "group",
|
||||
ReplyToId: "msg-1",
|
||||
UntrustedStructuredContext: [
|
||||
{
|
||||
label: "Conversation context",
|
||||
type: "chat_window",
|
||||
payload: {
|
||||
relation: "around_reply_target",
|
||||
messages: [
|
||||
{
|
||||
message_id: "msg-1",
|
||||
sender: "Avery",
|
||||
body: longBody,
|
||||
is_reply_target: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as TemplateContext);
|
||||
|
||||
expect(text).toContain("CHAT_WINDOW_TAIL");
|
||||
expect(text).toContain("…[omitted]…");
|
||||
expect(text).not.toContain("…[truncated]");
|
||||
expect(text).not.toContain("Reply target of current user message");
|
||||
});
|
||||
|
||||
it("caps serialized inbound history to the most recent bounded tail", () => {
|
||||
const text = buildInboundUserContextPrefix({
|
||||
ChatType: "group",
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getLoadedChannelPluginById } from "../../channels/plugins/registry-load
|
||||
import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
|
||||
import { normalizeAnyChannelId } from "../../channels/registry.js";
|
||||
import { resolveSenderLabel } from "../../channels/sender-label.js";
|
||||
import { truncateUtf16Safe } from "../../utils.js";
|
||||
import { sliceUtf16Safe, truncateUtf16Safe } from "../../utils.js";
|
||||
import type { EnvelopeFormatOptions } from "../envelope.js";
|
||||
import { formatEnvelopeTimestamp } from "../envelope.js";
|
||||
import type { SourceReplyDeliveryMode } from "../get-reply-options.types.js";
|
||||
@@ -65,6 +65,34 @@ function truncateUntrustedJsonString(value: string): string {
|
||||
return `${truncateUtf16Safe(value, Math.max(0, MAX_UNTRUSTED_JSON_STRING_CHARS - 14)).trimEnd()}…[truncated]`;
|
||||
}
|
||||
|
||||
const HEAD_TAIL_OMISSION_MARKER = "…[omitted]…";
|
||||
const HEAD_TAIL_MARKER_LENGTH = HEAD_TAIL_OMISSION_MARKER.length;
|
||||
const MIN_HEAD_TAIL_CHARS = 20;
|
||||
|
||||
/**
|
||||
* Applies head+tail truncation so the result is ≤ maxChars and the downstream
|
||||
* {@link truncateUntrustedJsonString} (prefix-only 2000-char cap) is a no-op.
|
||||
* Head and tail portions are sized to keep the body within
|
||||
* {@link MAX_UNTRUSTED_JSON_STRING_CHARS}, preserving actionable tail content
|
||||
* that prefix-only truncation would drop.
|
||||
*/
|
||||
function truncateBodyHeadTail(body: string, maxChars = MAX_UNTRUSTED_JSON_STRING_CHARS): string {
|
||||
if (body.length <= maxChars) {
|
||||
return body;
|
||||
}
|
||||
const available = maxChars - HEAD_TAIL_MARKER_LENGTH;
|
||||
if (available < MIN_HEAD_TAIL_CHARS * 2) {
|
||||
return `${truncateUtf16Safe(body, Math.max(0, maxChars - 14)).trimEnd()}…[truncated]`;
|
||||
}
|
||||
// Budget in UTF-16 code units because truncateUntrustedJsonString enforces
|
||||
// that same cap after JSON serialization.
|
||||
const headChars = Math.floor(available * 0.6);
|
||||
const tailChars = available - headChars;
|
||||
const head = truncateUtf16Safe(body, headChars);
|
||||
const tail = sliceUtf16Safe(body, -tailChars);
|
||||
return `${head}${HEAD_TAIL_OMISSION_MARKER}${tail}`;
|
||||
}
|
||||
|
||||
function sanitizeUntrustedJsonValue(value: unknown): unknown {
|
||||
if (typeof value === "string") {
|
||||
return neutralizeMarkdownFences(truncateUntrustedJsonString(value));
|
||||
@@ -100,6 +128,17 @@ function sanitizeTranscriptField(value: unknown): string | undefined {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function sanitizeTranscriptBody(value: unknown): string | undefined {
|
||||
const body = sanitizePromptBody(value);
|
||||
if (!body) {
|
||||
return undefined;
|
||||
}
|
||||
const sanitized = neutralizeMarkdownFences(truncateBodyHeadTail(body))
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return sanitized || undefined;
|
||||
}
|
||||
|
||||
function formatUntrustedStructuredContextLabel(label: unknown): string {
|
||||
const normalized = normalizePromptMetadataString(label);
|
||||
return normalized
|
||||
@@ -156,7 +195,7 @@ function formatChatWindowMessage(
|
||||
const replyToId = sanitizeTranscriptField(value["reply_to_id"]);
|
||||
const mediaType = sanitizeTranscriptField(value["media_type"]);
|
||||
const mediaRef = sanitizeTranscriptField(value["media_ref"]);
|
||||
const body = sanitizeTranscriptField(value["body"]);
|
||||
const body = sanitizeTranscriptBody(value["body"]);
|
||||
const details = [
|
||||
messageId ? `#${messageId}` : undefined,
|
||||
timestamp,
|
||||
@@ -274,7 +313,8 @@ function buildReplyChainPayload(ctx: TemplateContext): Array<Record<string, unkn
|
||||
return [];
|
||||
}
|
||||
return ctx.ReplyChain.flatMap((entry) => {
|
||||
const body = sanitizePromptBody(entry.body);
|
||||
const rawBody = sanitizePromptBody(entry.body);
|
||||
const body = rawBody ? truncateBodyHeadTail(rawBody) : rawBody;
|
||||
const mediaType = normalizePromptMetadataString(entry.mediaType);
|
||||
const mediaPath = normalizePromptMetadataString(entry.mediaPath);
|
||||
const mediaRef = normalizePromptMetadataString(entry.mediaRef);
|
||||
@@ -312,7 +352,7 @@ function isTelegramInboundContext(ctx: TemplateContext): boolean {
|
||||
}
|
||||
|
||||
function resolveInlineReplyQuote(ctx: TemplateContext): string | undefined {
|
||||
return sanitizeTranscriptField(ctx.ReplyToQuoteText) ?? sanitizeTranscriptField(ctx.ReplyToBody);
|
||||
return sanitizeTranscriptField(ctx.ReplyToQuoteText) ?? sanitizeTranscriptBody(ctx.ReplyToBody);
|
||||
}
|
||||
|
||||
function formatTelegramCurrentMessageContext(ctx: TemplateContext): string | undefined {
|
||||
@@ -552,7 +592,8 @@ export function buildInboundUserContextPrefix(
|
||||
);
|
||||
}
|
||||
|
||||
const replyToBody = sanitizePromptBody(ctx.ReplyToBody);
|
||||
const rawReplyToBody = sanitizePromptBody(ctx.ReplyToBody);
|
||||
const replyToBody = rawReplyToBody ? truncateBodyHeadTail(rawReplyToBody) : rawReplyToBody;
|
||||
if (replyChainPayload.length > 0 && !chatWindowCoversReplyContext && !currentMessageContext) {
|
||||
blocks.push(
|
||||
formatUntrustedJsonBlock(
|
||||
|
||||
Reference in New Issue
Block a user