fix: CJK replies are silently dropped instead of recovered when the model skips message(action=send) (#115556)

* fix(auto-reply): count CJK sentence terminators in stranded private-final detection

* fix(auto-reply): compare private-final substance thresholds with the CJK-aware estimator

* fix(auto-reply): detect CJK sentence boundaries

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
ACD_RD4駱俊馳
2026-07-29 19:23:33 +08:00
committed by GitHub
parent f6e874835c
commit e7f0fdfd9b
7 changed files with 128 additions and 14 deletions
@@ -1,5 +1,6 @@
// Tests private message-tool final delivery and visibility suppression.
import { describe, expect, it } from "vitest";
import { estimateStringChars } from "../../utils/cjk-chars.js";
import { shouldWarnAboutPrivateMessageToolFinal } from "./private-message-tool-final.js";
const base = {
@@ -64,6 +65,75 @@ describe("shouldWarnAboutPrivateMessageToolFinal", () => {
).toBe(false);
});
// Raw UTF-16 length under-counts CJK about 4x, so these all used to fall below
// both thresholds and skip stranded recovery entirely (#115555).
it.each([
{
label: "multi-sentence CJK report",
finalText:
"近 7 日營收較前期增加 5.09%,已連續兩週回升。最大風險是集中:前五大站台占正營收 86.5%,已超過 85% 觀察門檻。" +
"建議先維持成長節奏並優先降低集中風險,不建議只看總額就全面加碼。",
expected: true,
},
{
label: "single-sentence CJK paragraph (length alone is substantive)",
finalText: `${"字".repeat(150)}`,
expected: true,
},
{
label: "CJK using full-width period U+FF0E",
finalText: `${"項".repeat(150)}`,
expected: true,
},
{
label: "CJK using halfwidth ideographic period U+FF61",
finalText: `${"項".repeat(150)}`,
expected: true,
},
{ label: "short CJK acknowledgement", finalText: "沒有需要補充的。已完成。", expected: false },
{ label: "short CJK single clause", finalText: "已完成", expected: false },
])("$label -> $expected", ({ finalText, expected }) => {
expect(shouldWarnAboutPrivateMessageToolFinal({ ...base, finalText })).toBe(expected);
});
it.each([
{ label: "ideographic full stop", terminator: "。" },
{ label: "full-width exclamation mark", terminator: "" },
{ label: "full-width question mark", terminator: "" },
{ label: "full-width full stop", terminator: "" },
{ label: "half-width ideographic full stop", terminator: "。" },
])("flags a medium-length CJK reply with $label", ({ terminator }) => {
const finalText =
`第一項設定已完成,請檢查通知狀態${terminator}` +
`第二項資料已同步,稍後即可收到訊息${terminator}`;
const estimatedChars = estimateStringChars(finalText);
expect(estimatedChars).toBeGreaterThanOrEqual(120);
expect(estimatedChars).toBeLessThan(280);
expect(shouldWarnAboutPrivateMessageToolFinal({ ...base, finalText })).toBe(true);
});
it.each([
{
label: "accented Latin stays on raw length",
finalText: `Le café est prêt. ${"x".repeat(100)}`,
expected: false,
},
{
label: "Cyrillic stays on raw length",
finalText: `Готово. ${"x".repeat(100)}`,
expected: false,
},
{
label: "emoji stays on raw length",
finalText: `Done ✅ Shipped 🚀 ${"x".repeat(100)}`,
expected: false,
},
])("non-CJK non-ASCII is unaffected: $label", ({ finalText, expected }) => {
expect(finalText.length).toBeLessThan(280);
expect(shouldWarnAboutPrivateMessageToolFinal({ ...base, finalText })).toBe(expected);
});
it("does not flag empty or whitespace-only final text", () => {
expect(shouldWarnAboutPrivateMessageToolFinal({ ...base, finalText: "" })).toBe(false);
expect(shouldWarnAboutPrivateMessageToolFinal({ ...base, finalText: " \n " })).toBe(false);
@@ -1,5 +1,6 @@
/** Detects and logs long private finals when message-tool-only delivery was expected. */
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { estimateStringChars } from "../../utils/cjk-chars.js";
import type { SourceReplyDeliveryMode } from "../get-reply-options.types.js";
import { isSilentReplyText } from "../tokens.js";
@@ -8,7 +9,8 @@ const privateFinalReplyLogger = createSubsystemLogger("source-reply/private-fina
const LONG_PRIVATE_FINAL_MIN_CHARS = 280;
const MULTI_SENTENCE_PRIVATE_FINAL_MIN_CHARS = 120;
const MULTI_SENTENCE_TERMINATOR_MIN_COUNT = 2;
const SENTENCE_TERMINATOR_REGEX = /[.!?]+(?:\s|$)/g;
// CJK sentence marks do not require following whitespace; keep ASCII's boundary rule.
const SENTENCE_TERMINATOR_REGEX = /[.!?]+(?:\s|$)|[]+/gu;
/**
* `message_tool_only` allows the model to stay silent by simply not calling the
@@ -34,12 +36,16 @@ export function shouldWarnAboutPrivateMessageToolFinal(params: {
if (!trimmed || isSilentReplyText(trimmed)) {
return false;
}
if (trimmed.length >= LONG_PRIVATE_FINAL_MIN_CHARS) {
// Both thresholds are substance proxies, so they must be compared against the
// shared CJK-aware estimate: raw UTF-16 length under-counts CJK about 4x, which
// kept substantive CJK finals below both branches and skipped stranded recovery.
const estimatedChars = estimateStringChars(trimmed);
if (estimatedChars >= LONG_PRIVATE_FINAL_MIN_CHARS) {
return true;
}
const sentenceTerminatorCount = countSentenceLikeTerminators(trimmed);
return (
trimmed.length >= MULTI_SENTENCE_PRIVATE_FINAL_MIN_CHARS &&
estimatedChars >= MULTI_SENTENCE_PRIVATE_FINAL_MIN_CHARS &&
sentenceTerminatorCount >= MULTI_SENTENCE_TERMINATOR_MIN_COUNT
);
}
@@ -85,6 +85,34 @@ describe("resolveStrandedReplyRecovery", () => {
}
});
it("creates the same retry for a substantive CJK private final", () => {
// Full-width terminators carry no trailing whitespace, so a CJK reply of the
// same shape used to score zero sentence terminators and skip recovery entirely.
const base = createMockFollowupRun({ prompt: "question" });
const substantiveCjkFinal =
"近 7 日營收較前期增加 5.09%,已連續兩週回升。最大風險是集中:前五大站台占正營收 86.5%,已超過 85% 觀察門檻。" +
"近 30 日最大單一產品占 44.2%,亦超過 40% 門檻。建議先維持成長節奏並優先降低集中風險,不建議只看總額就全面加碼。" +
"成長主因仍待業務確認,我尚未取得該線的回覆。";
const recovery = resolveStrandedReplyRecovery({
base,
finalText: substantiveCjkFinal,
sourceReplyDeliveryMode: "message_tool_only",
sendPolicyDenied: false,
successfulSourceReplyDelivery: false,
isHeartbeat: false,
isRoomEvent: false,
});
expect(recovery.kind).toBe("retry");
if (recovery.kind === "retry") {
expect(recovery.run.strandedReplyRetry).toBe(true);
expect(recovery.run.disableCollectBatching).toBe(true);
expect(recovery.run.prompt).toContain(substantiveCjkFinal);
expect(recovery.run.prompt).toContain("message(action=send)");
}
});
it("returns a diagnostic rather than a second retry", () => {
const base = createMockFollowupRun({ prompt: "question", strandedReplyRetry: true });