openclaw - 💡(How to fix) Fix Anthropic thinking blocks rejected after sanitizeTransportPayloadText() mutates signed content

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

provider-stream-DRIaVJMo.js applies sanitizeTransportPayloadText() to the content of Anthropic-signed thinking blocks while preserving the original thinkingSignature. The sanitizer strips orphan UTF-16 surrogate halves, mutating the text. Anthropic's API rejects the next turn because the mutated text no longer matches the cryptographic signature it issued in the prior response.

Error Message

LLM error invalid_request_error: messages.N.content.M: thinking or redacted_thinking blocks in the latest assistant message cannot be modified. These blocks must remain as they were in the original response.

Root Cause

File: src/agents/provider-stream.ts (compiled to dist/provider-stream-DRIaVJMo.js) Lines: ~301–322 (compiled)

if (block.type === "thinking") {
    if (block.redacted) { /* ... */ }
    if (block.thinking.trim().length === 0) continue;
    if (!block.thinkingSignature || block.thinkingSignature.trim().length === 0) {
        blocks.push({
            type: "text",
            text: sanitizeTransportPayloadText(block.thinking)
        });
    } else {
        const thinking = sanitizeTransportPayloadText(block.thinking);  // ← BUG
        if (block.thinkingSignature === "reasoning_content") {
            // ... allowReasoningContentReplay path ...
        }
        blocks.push({
            type: "thinking",
            thinking,                              // ← mutated text
            signature: block.thinkingSignature     // ← original signature for unmutated text
        });
    }
}

sanitizeTransportPayloadText (in openai-transport-stream-DNhzccm-.js):

function sanitizeTransportPayloadText(text) {
    return text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "");
}

This regex strips lone surrogates. It also strips characters during multi-codepoint normalization edge cases if any prior stage has touched the text.

The signature attached by Anthropic is computed against the EXACT byte representation Anthropic returned. ANY mutation to the text breaks the signature, even removing characters that "look" invalid to OpenAI-style transports.

Fix Action

Fix

                if (block.type === "thinking") {
                    if (block.redacted) { /* ... */ }
                    if (block.thinking.trim().length === 0) continue;
                    if (!block.thinkingSignature || block.thinkingSignature.trim().length === 0) {
                        blocks.push({
                            type: "text",
                            text: sanitizeTransportPayloadText(block.thinking)
                        });
                    } else {
-                       const thinking = sanitizeTransportPayloadText(block.thinking);
+                       // Do NOT sanitize Anthropic-signed thinking blocks. Sanitization mutates
+                       // the text and invalidates the cryptographic signature attached by Anthropic.
+                       // Only sanitize the synthetic "reasoning_content" signature variant.
+                       const thinking = block.thinkingSignature === "reasoning_content"
+                           ? sanitizeTransportPayloadText(block.thinking)
+                           : block.thinking;
                        if (block.thinkingSignature === "reasoning_content") {
                            // ...
                        }
                        blocks.push({
                            type: "thinking",
                            thinking,
                            signature: block.thinkingSignature
                        });
                    }
                }

Code Example

LLM error invalid_request_error: messages.N.content.M: thinking or
redacted_thinking blocks in the latest assistant message cannot be
modified. These blocks must remain as they were in the original response.

---

if (block.type === "thinking") {
    if (block.redacted) { /* ... */ }
    if (block.thinking.trim().length === 0) continue;
    if (!block.thinkingSignature || block.thinkingSignature.trim().length === 0) {
        blocks.push({
            type: "text",
            text: sanitizeTransportPayloadText(block.thinking)
        });
    } else {
        const thinking = sanitizeTransportPayloadText(block.thinking);  // ← BUG
        if (block.thinkingSignature === "reasoning_content") {
            // ... allowReasoningContentReplay path ...
        }
        blocks.push({
            type: "thinking",
            thinking,                              // ← mutated text
            signature: block.thinkingSignature     // ← original signature for unmutated text
        });
    }
}

---

function sanitizeTransportPayloadText(text) {
    return text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "");
}

---

if (block.type === "thinking") {
                    if (block.redacted) { /* ... */ }
                    if (block.thinking.trim().length === 0) continue;
                    if (!block.thinkingSignature || block.thinkingSignature.trim().length === 0) {
                        blocks.push({
                            type: "text",
                            text: sanitizeTransportPayloadText(block.thinking)
                        });
                    } else {
-                       const thinking = sanitizeTransportPayloadText(block.thinking);
+                       // Do NOT sanitize Anthropic-signed thinking blocks. Sanitization mutates
+                       // the text and invalidates the cryptographic signature attached by Anthropic.
+                       // Only sanitize the synthetic "reasoning_content" signature variant.
+                       const thinking = block.thinkingSignature === "reasoning_content"
+                           ? sanitizeTransportPayloadText(block.thinking)
+                           : block.thinking;
                        if (block.thinkingSignature === "reasoning_content") {
                            // ...
                        }
                        blocks.push({
                            type: "thinking",
                            thinking,
                            signature: block.thinkingSignature
                        });
                    }
                }

---

// tests/provider-stream-thinking-signature.test.ts
import { describe, it, expect } from "vitest";
import { transformTransportMessages } from "../src/agents/provider-stream";

describe("thinking block signature preservation", () => {
    it("does not mutate Anthropic-signed thinking content", () => {
        const input = {
            role: "assistant",
            content: [{
                type: "thinking",
                thinking: "Let me think about 🤔 this problem",  // emoji is a surrogate pair
                thinkingSignature: "ANTHROPIC_REAL_SIGNATURE_BASE64",
            }]
        };
        const output = transformTransportMessages([input], { provider: "anthropic" });
        expect(output[0].content[0].thinking).toBe("Let me think about 🤔 this problem");
    });

    it("does sanitize reasoning_content variant (non-Anthropic)", () => {
        const input = {
            role: "assistant",
            content: [{
                type: "thinking",
                thinking: "broken\uD800surrogate",  // lone surrogate
                thinkingSignature: "reasoning_content",
            }]
        };
        const output = transformTransportMessages([input], { provider: "xai" });
        expect(output[0].content[0].thinking).toBe("brokensurrogate");
    });
});
RAW_BUFFERClick to expand / collapse

Bug: Anthropic thinking blocks rejected after sanitizeTransportPayloadText() mutation

Reporter: Bryan Baer (cerebral.baerautotech.com) Date: 2026-05-27 OpenClaw version: 2026.5.26 Severity: High — blocks all long-running sessions on Anthropic models when thinking is enabled


Summary

provider-stream-DRIaVJMo.js applies sanitizeTransportPayloadText() to the content of Anthropic-signed thinking blocks while preserving the original thinkingSignature. The sanitizer strips orphan UTF-16 surrogate halves, mutating the text. Anthropic's API rejects the next turn because the mutated text no longer matches the cryptographic signature it issued in the prior response.

Symptoms

API error from Anthropic:

LLM error invalid_request_error: messages.N.content.M: thinking or
redacted_thinking blocks in the latest assistant message cannot be
modified. These blocks must remain as they were in the original response.

Where N is the offending message index (typically 100+ in long sessions) and M is the offending content block index.

Reproduction

  1. Start a session against any Anthropic Claude model with extended thinking enabled (e.g. claude-sonnet-4-6, claude-opus-4-7)
  2. Reach a point where the assistant's thinking contains a character represented as a UTF-16 surrogate pair (any emoji like 🤔, mathematical symbols like 𝑎, certain code blocks containing non-BMP characters)
  3. Continue the conversation past that turn
  4. Next API call fails with the error above

Affects:

  • Multi-turn sessions where thinking blocks accumulate in history
  • Subagent runs that span more than a few turns
  • Any session compaction that replays the assistant message stream
  • Mixed-content reasoning that touches non-BMP Unicode

Root Cause

File: src/agents/provider-stream.ts (compiled to dist/provider-stream-DRIaVJMo.js) Lines: ~301–322 (compiled)

if (block.type === "thinking") {
    if (block.redacted) { /* ... */ }
    if (block.thinking.trim().length === 0) continue;
    if (!block.thinkingSignature || block.thinkingSignature.trim().length === 0) {
        blocks.push({
            type: "text",
            text: sanitizeTransportPayloadText(block.thinking)
        });
    } else {
        const thinking = sanitizeTransportPayloadText(block.thinking);  // ← BUG
        if (block.thinkingSignature === "reasoning_content") {
            // ... allowReasoningContentReplay path ...
        }
        blocks.push({
            type: "thinking",
            thinking,                              // ← mutated text
            signature: block.thinkingSignature     // ← original signature for unmutated text
        });
    }
}

sanitizeTransportPayloadText (in openai-transport-stream-DNhzccm-.js):

function sanitizeTransportPayloadText(text) {
    return text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "");
}

This regex strips lone surrogates. It also strips characters during multi-codepoint normalization edge cases if any prior stage has touched the text.

The signature attached by Anthropic is computed against the EXACT byte representation Anthropic returned. ANY mutation to the text breaks the signature, even removing characters that "look" invalid to OpenAI-style transports.

Why this is wrong for Anthropic specifically

The OpenAI/Azure-OpenAI transport requires sanitizeTransportPayloadText because their Responses API rejects messages containing orphan surrogates. Anthropic does not have this constraint — they happily round-trip their own response data byte-for-byte. The sanitizer should only run for transports that need it, and never for content whose integrity is signed by the provider.

The reasoning_content signature variant is a synthetic value OpenClaw assigns to non-Anthropic reasoning (e.g. xAI Grok, DeepSeek). Those signatures are not cryptographic — they're just markers. Sanitizing those is fine.

Fix

                if (block.type === "thinking") {
                    if (block.redacted) { /* ... */ }
                    if (block.thinking.trim().length === 0) continue;
                    if (!block.thinkingSignature || block.thinkingSignature.trim().length === 0) {
                        blocks.push({
                            type: "text",
                            text: sanitizeTransportPayloadText(block.thinking)
                        });
                    } else {
-                       const thinking = sanitizeTransportPayloadText(block.thinking);
+                       // Do NOT sanitize Anthropic-signed thinking blocks. Sanitization mutates
+                       // the text and invalidates the cryptographic signature attached by Anthropic.
+                       // Only sanitize the synthetic "reasoning_content" signature variant.
+                       const thinking = block.thinkingSignature === "reasoning_content"
+                           ? sanitizeTransportPayloadText(block.thinking)
+                           : block.thinking;
                        if (block.thinkingSignature === "reasoning_content") {
                            // ...
                        }
                        blocks.push({
                            type: "thinking",
                            thinking,
                            signature: block.thinkingSignature
                        });
                    }
                }

Impact (in our deployment)

Today's session (4-hour registry migration with parallel Sonnet subagents):

  • 4 subagent crashes with this error
  • ~2 hours of work lost across crashed sessions
  • Required falling back to background shell processes (no thinking) for some workloads
  • Conversation history corruption: once a session hits this state, every subsequent turn fails until the corrupted thinking block is removed from history

This is blocking real production work on platforms relying on OpenClaw + Anthropic.

Suggested test case

// tests/provider-stream-thinking-signature.test.ts
import { describe, it, expect } from "vitest";
import { transformTransportMessages } from "../src/agents/provider-stream";

describe("thinking block signature preservation", () => {
    it("does not mutate Anthropic-signed thinking content", () => {
        const input = {
            role: "assistant",
            content: [{
                type: "thinking",
                thinking: "Let me think about 🤔 this problem",  // emoji is a surrogate pair
                thinkingSignature: "ANTHROPIC_REAL_SIGNATURE_BASE64",
            }]
        };
        const output = transformTransportMessages([input], { provider: "anthropic" });
        expect(output[0].content[0].thinking).toBe("Let me think about 🤔 this problem");
    });

    it("does sanitize reasoning_content variant (non-Anthropic)", () => {
        const input = {
            role: "assistant",
            content: [{
                type: "thinking",
                thinking: "broken\uD800surrogate",  // lone surrogate
                thinkingSignature: "reasoning_content",
            }]
        };
        const output = transformTransportMessages([input], { provider: "xai" });
        expect(output[0].content[0].thinking).toBe("brokensurrogate");
    });
});

Workaround (applied locally)

Patched dist/provider-stream-DRIaVJMo.js directly with the fix above. Backup of original at dist/provider-stream-DRIaVJMo.js.bak-pre-thinking-sig-fix.


I'm happy to submit this as a PR to the upstream repo. Let me know if you want any additional diagnostic data (sample failing message, stack trace, etc.) attached.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

openclaw - 💡(How to fix) Fix Anthropic thinking blocks rejected after sanitizeTransportPayloadText() mutates signed content