Repository navigation
[BUG] clearOldReasoning mutates thinking blocks in place, then stripClearedReasoning sentinelizes them — both survive to the API request #162
Description
Summary
clearOldReasoning mutates thinking and text properties of reasoning parts directly on the message object (setting them to the string "[cleared]"). stripClearedReasoning then converts those mutated parts to sentinels ({ type: "text", text: "" }). If either step touches a block that was returned by the most recent API response, Anthropic rejects the subsequent request with:
400: messages.1.content.80: `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
// dist/index.js ~line 184913
function clearOldReasoning(messages, reasoningByMessage, messageTagNumbers, clearReasoningAge) {
const maxTag = findMaxTag(messageTagNumbers);
const ageCutoff = maxTag - clearReasoningAge; // can be ≤ 0 on short sessions
for (const message of messages) {
const msgTag = messageTagNumbers.get(message) ?? 0;
if (msgTag === 0 || msgTag > ageCutoff) continue; // skips untagged + recent
for (const tp of parts) {
tp.thinking = '[cleared]'; // ← in-place mutation of the original part object
tp.text = '[cleared]';
}
}
}// dist/index.js ~line 184947
function stripClearedReasoning(messages) {
// converts any part whose thinking/text === '[cleared]' to makeSentinel()
// → { type: 'text', text: '' }
message.parts[i] = makeSentinel(part);
}The ageCutoff guard is designed to protect recent messages, but two edge-cases defeat it:
- Small
clearReasoningAgerelative tomaxTag— ifclearReasoningAgeis, say, 2 andmaxTagis 3, thenageCutoff = 1. The message with tag 2 (second-most-recent) gets its thinking cleared, then sentinelized. If that message is the last assistant turn, Anthropic rejects it. - Tag assignment races on slash-command execution — when a slash command fires, the transform pipeline may run before the current assistant turn has been assigned a tag (
msgTag === 0). Untagged messages are skipped correctly today, but downstream mutations to already-tagged messages from the same turn boundary are not protected.
Additionally, the in-place mutation of the part object (rather than producing a new object) means any other reference to the same part object also sees the corrupted value.
Reproduction
- Use an Anthropic model with extended thinking.
- Have a conversation long enough that
maxTag > clearReasoningAge(depends on configured value). - Trigger a slash command so the transform pipeline runs.
- The second-to-last (or earlier) assistant message's thinking blocks are cleared and sentinelized.
- If any of those blocks are referenced as part of the "latest assistant message" by Anthropic's validator, the API returns 400.
Expected behaviour
clearOldReasoningmust never mutate parts from the last assistant message, regardless of tag number — add an explicit last-assistant-message guard mirroring the fix proposed in issue [BUG] stripReasoningFromMergedAssistants mutates thinking/redacted_thinking blocks in the last assistant message, causing Anthropic 400 #161.- The in-place mutation (
tp.thinking = '[cleared]') should be replaced with a new object or a sentinel substitution at themessage.parts[i]level, not a property mutation on the shared part reference. stripClearedReasoningshould be a no-op if invoked on the last assistant message.
Severity
P1 — triggers on longer sessions where tag numbers exceed clearReasoningAge, or on any session where the tag-assignment race occurs.
Activity
fix(reasoning): gate typed reasoning clearing to canonical Anthropic (#…
fix(pi/reasoning): empty cleared reasoning instead of "[cleared]" (#162…
fix(pi/reasoning): empty cleared reasoning instead of "[cleared]" (#162…
alfonso-magic-context commentedon Jun 19, 2026
Fixed in v0.26.0.
clearOldReasoningandreplayClearedReasoningare now both gated onmodelAcceptsEmptyContent(providerID)— they only run for providers whose adapter strips empty/sentinel content before the wire (canonical Anthropic). On non-canonical or proxy providers, reasoning blocks are now left intact rather than cleared-in-place and then sentinelized, so neither a literal[cleared]nor a surviving empty thinking block reaches the API request.Closing as fixed — please reopen if you still see it on 0.26.0+.
fix(reasoning): gate typed reasoning clearing to canonical Anthropic (c…
fix(pi/reasoning): empty cleared reasoning instead of "[cleared]" (co…
fix(pi/reasoning): empty cleared reasoning instead of "[cleared]" (co…