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:
zenglingbiao
2026-06-07 19:45:00 +09:00
committed by GitHub
parent 9bafa2a2b6
commit 3753c5e2c8
2 changed files with 154 additions and 5 deletions
+108
View File
@@ -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",
+46 -5
View File
@@ -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(