fix: harden embedded text normalization (#58555)

Co-authored-by: Morrow <271612559+agent-morrow@users.noreply.github.com>
This commit is contained in:
Morrow 2026-04-01 04:10:49 +03:00 committed by GitHub
parent 50cc28c559
commit be5a035d97
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 74 additions and 21 deletions

View File

@ -178,6 +178,11 @@ describe("sanitizeUserFacingText", () => {
it.each(["\n\n", " \n "])("returns empty for whitespace-only input: %j", (input) => {
expect(sanitizeUserFacingText(input)).toBe("");
});
it("tolerates non-string input without throwing", () => {
expect(sanitizeUserFacingText(undefined as unknown as string)).toBe("");
expect(sanitizeUserFacingText(42 as unknown as string)).toBe("42");
});
});
describe("stripThoughtSignatures", () => {

View File

@ -544,11 +544,16 @@ export function classifyFailoverReasonFromHttpStatus(
return null;
}
function stripFinalTagsFromText(text: string): string {
if (!text) {
return text;
function coerceText(value: unknown): string {
return typeof value === "string" ? value : value == null ? "" : String(value);
}
function stripFinalTagsFromText(text: unknown): string {
const normalized = coerceText(text);
if (!normalized) {
return normalized;
}
return text.replace(FINAL_TAG_RE, "");
return normalized.replace(FINAL_TAG_RE, "");
}
function collapseConsecutiveDuplicateBlocks(text: string): string {
@ -755,12 +760,13 @@ export function formatAssistantErrorText(
return raw.length > 600 ? `${raw.slice(0, 600)}` : raw;
}
export function sanitizeUserFacingText(text: string, opts?: { errorContext?: boolean }): string {
if (!text) {
return text;
export function sanitizeUserFacingText(text: unknown, opts?: { errorContext?: boolean }): string {
const raw = coerceText(text);
if (!raw) {
return raw;
}
const errorContext = opts?.errorContext ?? false;
const stripped = stripFinalTagsFromText(text);
const stripped = stripFinalTagsFromText(raw);
const trimmed = stripped.trim();
if (!trimmed) {
return "";

View File

@ -34,6 +34,21 @@ describe("resolveSilentReplyFallbackText", () => {
}),
).toBe("NO_REPLY");
});
it("tolerates malformed text payloads without throwing", () => {
expect(
resolveSilentReplyFallbackText({
text: undefined,
messagingToolSentTexts: ["final delivered text"],
}),
).toBe("");
expect(
resolveSilentReplyFallbackText({
text: "NO_REPLY",
messagingToolSentTexts: [42 as unknown as string],
}),
).toBe("42");
});
});
describe("hasAssistantVisibleReply", () => {

View File

@ -38,6 +38,9 @@ const stripTrailingDirective = (text: string): string => {
return text.slice(0, openIndex);
};
const coerceText = (value: unknown): string =>
typeof value === "string" ? value : value == null ? "" : String(value);
function isTranscriptOnlyOpenClawAssistantMessage(message: AgentMessage | undefined): boolean {
if (!message || message.role !== "assistant") {
return false;
@ -56,16 +59,17 @@ function emitReasoningEnd(ctx: EmbeddedPiSubscribeContext) {
}
export function resolveSilentReplyFallbackText(params: {
text: string;
text: unknown;
messagingToolSentTexts: string[];
}): string {
const trimmed = params.text.trim();
const text = coerceText(params.text);
const trimmed = text.trim();
if (trimmed !== SILENT_REPLY_TOKEN) {
return params.text;
return text;
}
const fallback = params.messagingToolSentTexts.at(-1)?.trim();
const fallback = coerceText(params.messagingToolSentTexts.at(-1)).trim();
if (!fallback) {
return params.text;
return text;
}
return fallback;
}
@ -357,7 +361,7 @@ export function handleMessageEnd(
}
promoteThinkingTagsToBlocks(assistantMessage);
const rawText = extractAssistantText(assistantMessage);
const rawText = coerceText(extractAssistantText(assistantMessage));
appendRawStream({
ts: Date.now(),
event: "assistant_message_end",
@ -382,7 +386,7 @@ export function handleMessageEnd(
let { mediaUrls, hasMedia } = resolveSendableOutboundReplyParts(parsedText ?? {});
if (!cleanedText && !hasMedia && !ctx.params.enforceFinalTag) {
const rawTrimmed = rawText.trim();
const rawTrimmed = coerceText(rawText).trim();
const rawStrippedFinal = rawTrimmed.replace(/<\s*\/?\s*final\s*>/gi, "").trim();
const rawCandidate = rawStrippedFinal || rawTrimmed;
if (rawCandidate) {

View File

@ -61,4 +61,22 @@ describe("shared/chat-content", () => {
}),
).toBeNull();
});
it("tolerates sanitize and normalize hooks that return non-string values", () => {
expect(
extractTextFromChatContent("hello", {
sanitizeText: () => undefined as unknown as string,
}),
).toBeNull();
expect(
extractTextFromChatContent([{ type: "text", text: "hello" }], {
sanitizeText: () => 42 as unknown as string,
}),
).toBe("42");
expect(
extractTextFromChatContent("hello", {
normalizeText: () => undefined as unknown as string,
}),
).toBeNull();
});
});

View File

@ -6,11 +6,19 @@ export function extractTextFromChatContent(
normalizeText?: (text: string) => string;
},
): string | null {
const normalize = opts?.normalizeText ?? ((text: string) => text.replace(/\s+/g, " ").trim());
const normalizeText = opts?.normalizeText ?? ((text: string) => text.replace(/\s+/g, " ").trim());
const joinWith = opts?.joinWith ?? " ";
const coerceText = (value: unknown): string =>
typeof value === "string" ? value : value == null ? "" : String(value);
const sanitize = (text: unknown): string => {
const raw = coerceText(text);
const sanitized = opts?.sanitizeText ? opts.sanitizeText(raw) : raw;
return coerceText(sanitized);
};
const normalize = (text: unknown): string => coerceText(normalizeText(coerceText(text)));
if (typeof content === "string") {
const value = opts?.sanitizeText ? opts.sanitizeText(content) : content;
const value = sanitize(content);
const normalized = normalize(value);
return normalized ? normalized : null;
}
@ -28,10 +36,7 @@ export function extractTextFromChatContent(
continue;
}
const text = (block as { text?: unknown }).text;
if (typeof text !== "string") {
continue;
}
const value = opts?.sanitizeText ? opts.sanitizeText(text) : text;
const value = sanitize(text);
if (value.trim()) {
chunks.push(value);
}