fix: strip leading whitespace from sanitizeUserFacingText output (#16158)

* fix: strip leading whitespace from sanitizeUserFacingText output

LLM responses frequently begin with \n\n, which survives through
sanitizeUserFacingText and reaches the channel as visible blank lines.

Root cause: the function used trimmed text for empty-checks but returned
the untrimmed 'stripped' variable. Two one-line fixes:
1. Return empty string (not whitespace-only 'stripped') for blank input
2. Apply trimStart() to the final return value

Fixes the same issue as #8052 and #10612 but at the root cause
(sanitizeUserFacingText) rather than scattering trimStart across
multiple delivery paths.

* Changelog: note sanitizeUserFacingText whitespace normalization

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
Jake
2026-02-15 04:23:05 +13:00
committed by GitHub
parent e3b432e481
commit 3881af5b37
3 changed files with 24 additions and 2 deletions

View File

@@ -56,6 +56,7 @@ Docs: https://docs.openclaw.ai
- Security/Audit: warn when `gateway.tools.allow` re-enables default-denied tools over HTTP `POST /tools/invoke`, since this can increase RCE blast radius if the gateway is reachable.
- Security/Plugins/Hooks: harden npm-based installs by restricting specs to registry packages only, passing `--ignore-scripts` to `npm pack`, and cleaning up temp install directories.
- Feishu: stop persistent Typing reaction on NO_REPLY/suppressed runs by wiring reply-dispatcher cleanup to remove typing indicators. (#15464) Thanks @arosstale.
- Agents: strip leading whitespace/newlines from `sanitizeUserFacingText` output and normalize whitespace-only outputs to empty text. (#16158) Thanks @mcinteerj.
- BlueBubbles: gracefully degrade when Private API is disabled by filtering private-only actions, skipping private-only reactions/reply effects, and avoiding private reply markers so non-private flows remain usable. (#16002) Thanks @L-U-C-K-Y.
- Outbound: add a write-ahead delivery queue with crash-recovery retries to prevent lost outbound messages after gateway restarts. (#15636) Thanks @nabbilkhan, @thewilloftheshadow.
- Auto-reply/Threading: auto-inject implicit reply threading so `replyToMode` works without requiring model-emitted `[[reply_to_current]]`, while preserving `replyToMode: "off"` behavior for implicit Slack replies and keeping block-streaming chunk coalescing stable under `replyToMode: "first"`. (#14976) Thanks @Diaspar4u.

View File

@@ -69,4 +69,25 @@ describe("sanitizeUserFacingText", () => {
const text = "Hello there!\n\nDifferent line.";
expect(sanitizeUserFacingText(text)).toBe(text);
});
it("strips leading newlines from LLM output", () => {
expect(sanitizeUserFacingText("\n\nHello there!")).toBe("Hello there!");
expect(sanitizeUserFacingText("\nHello there!")).toBe("Hello there!");
expect(sanitizeUserFacingText("\n\n\nMultiple newlines")).toBe("Multiple newlines");
});
it("strips leading whitespace and newlines combined", () => {
expect(sanitizeUserFacingText("\n \n Hello")).toBe("Hello");
expect(sanitizeUserFacingText(" \n\nHello")).toBe("Hello");
});
it("preserves trailing whitespace and internal newlines", () => {
expect(sanitizeUserFacingText("Hello\n\nWorld\n")).toBe("Hello\n\nWorld\n");
expect(sanitizeUserFacingText("Line 1\nLine 2")).toBe("Line 1\nLine 2");
});
it("returns empty for whitespace-only input", () => {
expect(sanitizeUserFacingText("\n\n")).toBe("");
expect(sanitizeUserFacingText(" \n ")).toBe("");
});
});

View File

@@ -488,7 +488,7 @@ export function sanitizeUserFacingText(text: string, opts?: { errorContext?: boo
const stripped = stripFinalTagsFromText(text);
const trimmed = stripped.trim();
if (!trimmed) {
return stripped;
return "";
}
// Only apply error-pattern rewrites when the caller knows this text is an error payload.
@@ -527,7 +527,7 @@ export function sanitizeUserFacingText(text: string, opts?: { errorContext?: boo
}
}
return collapseConsecutiveDuplicateBlocks(stripped);
return collapseConsecutiveDuplicateBlocks(stripped).trimStart();
}
export function isRateLimitAssistantError(msg: AssistantMessage | undefined): boolean {