fix(anthropic): cascade-strip stale thinking siblings when their tool_use is orphaned (#8166)

* fix(anthropic): don't strip a trailing tool_use with no subsequent message

Fixes #8159

cleanOrphanedToolCalls treated any assistant tool_use with no matching
tool_result as an orphan and stripped it -- including a tool_use in the
very last message, where there is no subsequent message to have found a
result in yet. "No result yet" is not the same as "no result ever": the
tool may simply not have finished executing, or the conversion may be
happening for a reason other than sending the completed turn to
Anthropic (token counting, a resumed/replayed session snapshot, a retry
issued before tool execution completes, ...).

Silently deleting a currently-active tool_use corrupts the assistant's
most recent turn, and the damage compounds when that turn also carries a
signed extended-thinking block: the block's signature is computed over
the full sibling content of the turn, so removing the tool_use next to
it invalidates the signature and replaying the mutated turn produces
Anthropic 400 "thinking blocks in the latest assistant message cannot be
modified" -- a genuinely confusing error for something the client did to
its own outgoing request.

Fix: when an assistant tool_use is in the last message of the array (no
message follows it at all), treat its tool_use blocks as valid/unresolved
rather than scanning for a match that can't exist yet. A tool_use is only
condemned as orphaned when a subsequent message was actually scanned and
found to lack a matching tool_result.

Also fixed one existing test that unintentionally pinned the buggy
behavior as expected output ("cleans orphaned tool_use blocks without
matching tool_result" used a fixture with no subsequent message at all,
which is the trailing case, not a genuine orphan) -- added a real next
message with unrelated content so it now exercises an actual orphan.
Added a new regression test for the trailing case.

Verification:
- New/updated unit tests in converter.test.ts (73 tests, was 72).
- Full anthropicContentGenerator/ suite: 193 tests pass.
- tsc --noEmit -p packages/core/tsconfig.json and eslint clean for
  touched files.

* fix(anthropic): prune stale thinking signatures after a sibling tool_use is removed

Fixes #8162

Anthropic validates a thinking/redacted_thinking block's opaque
signature against the content it was originally computed over.
Removing a sibling tool_use from that same turn -- whether in this
request's own cleanup pass or in an earlier compaction cycle now baked
into stored history -- can leave a thinking block whose signature no
longer matches, producing:

  "thinking blocks in the latest assistant message cannot be modified"

We independently hit this exact error text this session from a related
cause (a different orphan-cleanup implementation stripping the *current*
turn's tool_use and leaving its thinking sibling stale -- see #8159/
#8163), which is what prompted auditing this converter for the same
class of gap.

Two patches, ported from a downstream fork's previously-tested fix
(closed a "residual class of 400 ... errors on Vertex-routed
claude-opus-4.x sessions with adaptive thinking" per that fix's own
commit message):

PATCH-A (same-turn cascade, in cleanOrphanedToolCalls): when a tool_use
is stripped from an assistant turn by this same cleanup pass, its
thinking/redacted_thinking siblings in that turn are now cascade-removed
too, since their signature was computed over content that included the
now-gone tool_use.

PATCH-B (cross-turn, new pruneUntrustworthyThinking pass): catches the
case PATCH-A can't -- a non-latest assistant turn whose thinking
survived earlier trims but whose tool_use was already gone by the time
it entered this request's history. Only ever touches turns that are NOT
the most recent assistant turn (Anthropic's contract requires the latest
turn's signatures to replay byte-exact regardless). A strictly
thinking-only older turn (no surviving tool_use, no other text) has its
thinking downgraded to plain text so the model still sees the historical
reasoning without an unreplayable signature; redacted_thinking has no
plaintext fallback and is dropped instead. A turn that already carries
real text alongside the untrustworthy thinking is left as-is (narrower
than a blanket rewrite, matching the tested downstream fix).

pruneUntrustworthyThinking is skipped when injectThinkingOnToolUseTurns
is set (DeepSeek compatibility path): DeepSeek requires a synthetic
empty thinking placeholder structurally on every tool-use turn and
doesn't validate a signature the way Anthropic does, so this pass'
"empty/untrustworthy thinking" concept doesn't apply there and would
strip the very placeholder DeepSeek needs.

IMPORTANT calibration note on live verification: I fully live-verified
PATCH-A's mechanism this session via a related bug (#8159/#8163) and via
code-reading of this exact cascade. For PATCH-B (the cross-turn case), I
attempted a live reproduction against the real Anthropic Messages API
(via our corporate proxy, Vertex-routed claude-sonnet-4-6, extended
thinking enabled): obtained a genuine signed thinking+tool_use turn,
then replayed it as a non-latest turn with the tool_use stripped but
thinking intact, followed by a new user turn. This did NOT reproduce a
400 -- the API returned 200, with the model noticing the missing tool
call itself and self-correcting in its response text rather than the
server rejecting the malformed signature context. So the cross-turn
mechanism, while structurally sound and matching an already-tested
downstream fix's own historical diagnosis, is not independently
live-confirmed via this proxy path in my environment. PATCH-B is still
included as a defensive, non-regressive improvement (it can only ever
remove content that's already been flagged as untrustworthy, never add
risk), but I want reviewers to weigh this transparently rather than
overclaim a live 400 I couldn't reproduce.

Verification:
- New tests: same-turn cascade (thinking dropped alongside its orphaned
  tool_use sibling), cross-turn thinking-only downgrade, cross-turn
  redacted_thinking-derived drop, latest-turn exemption, and
  narrower-scope-preserved (real text alongside stale thinking left
  untouched).
- Full anthropicContentGenerator/ suite: 198 tests pass (was 193; net +5
  tests). One pre-existing DeepSeek test required the
  injectThinkingOnToolUseTurns gate described above to keep passing.
- tsc --noEmit -p packages/core/tsconfig.json and eslint clean for
  touched files.
- Live proxy verification for PATCH-A's mechanism per above; PATCH-B's
  cross-turn case did not reproduce a 400 in my environment (see note
  above and the corresponding comment on #8162).

* docs(anthropic): document pruneUntrustworthyThinking's false-positive heuristic

Per review feedback on PR #8166: the function cannot distinguish 'this
turn's tool_use was removed by an earlier trim' from 'this turn was
always thinking-only' -- both are structurally identical by the time
this pass runs (no surviving tool_use, no other text). Make that
imprecision explicit in the doc comment rather than implying this only
touches genuinely-stale turns, and note the live-verification gap
(the specific 400 this guards against did not reproduce against a
Vertex-routed proxy).

* refactor(anthropic): narrow to the same-turn cascade, drop the cross-turn heuristic

Per wenshao's live-verified review on #8166: split PATCH-A and PATCH-B,
keep PATCH-A plus the one genuine fix inside PATCH-B, drop the rest.

Kept and refined (PATCH-A, cleanOrphanedToolCalls's same-turn cascade):
scoped the thinking/redacted_thinking cascade to only fire when NO
tool_use survives the same turn, not merely "any tool_use removed" (S2).
A turn shaped [thinking, tool_use A, tool_use B] where only B is a
genuine orphan now keeps both the surviving tool_use A and its thinking
sibling -- previously the thinking was stripped even though A still
needed it to satisfy Anthropic's manual-mode "final turn must begin
with thinking when a tool_use is present" rule, trading one 400 for
another.

Kept (S6, extracted into its own function, dropEmptyTextThinkingBlocks):
an unconditional guard dropping any thinking block with empty text on a
non-latest assistant turn. This is unconditionally correct regardless
of tool_use presence and isn't redundant with the existing
dropUnsignedThinkingFromAssistantMessages pass, which is itself gated
to non-native-baseURL + adaptive-thinking + non-DeepSeek configs -- this
guard also covers native Anthropic API sessions that pass never touches.

Must run AFTER dropUnsignedThinkingFromAssistantMessages, not before:
that pass has a deliberate fail-loud design -- a thinking block with a
missing/empty signature on a turn inside the still-active tool-use
chain throws rather than silently drops, since Claude requires all of
an active loop's thinking blocks to be passed back complete and
unmodified. An empty-text, unsigned redacted_thinking-derived block is
unsigned by that same definition; if dropEmptyTextThinkingBlocks ran
first it deleted the block before the fail-loud check ever saw it,
silently swallowing exactly the proxy bug that throw exists to surface.
This is the identical pass-ordering hazard the removed PATCH-B
heuristic was rejected for, reintroduced by this refactor's own
extraction -- caught in review and fixed here by reordering, with a
regression test.

Dropped entirely (the broader pruneUntrustworthyThinking heuristic):
"a non-latest, thinking-only turn with no surviving tool_use is
structurally untrustworthy, downgrade its thinking to text." Removed
because:
- Pass ordering: it ran before dropUnsignedThinkingFromAssistantMessages
  for the same reason described above -- re-typing untrustworthy
  thinking to plain text first made the drop pass no longer recognize
  it as thinking at all, so genuinely unsigned reasoning that should
  have been removed was instead sent to the model as assistant text.
- Its DeepSeek exclusion only covered thinking-on mode
  (!injectThinkingOnToolUseTurns); DeepSeek-with-thinking-off went
  through a different, deliberately-permissive strip pass that this
  heuristic wasn't gated against, rewriting a shape that pass
  intentionally leaves alone.
- Live A/B verification against a real session showed a thinking-only
  turn (that never carried a tool_use, so its signature was never
  actually invalidated) getting re-typed to text one request later,
  purely because a newer assistant turn had displaced it as "latest" --
  changing the serialized prefix between two consecutive requests
  (invalidating a cache breakpoint) and costing extra tokens on
  last-turn-only models that would otherwise strip prior-turn thinking
  for free.
- The state this heuristic exists to clean up -- a non-latest turn whose
  tool_use went stale in an earlier trim -- did not reproduce against
  this codebase's actual machinery: chatCompressionService compresses
  the full history rather than partially trimming it (confirmed the
  post-compaction attachment path preserves a trailing functionCall
  turn's thinking sibling intact rather than splitting it), and
  repairOrphanedToolUseTurns already synthesizes an error tool_result
  for a genuine cross-turn orphan on every send, upstream of the
  converter, before this pass would ever see it. truncateHistory and
  stripThoughtsFromHistory were also checked and don't produce the
  target state either.

Also fixes two bot review Suggestions on the current diff: a duplicated
JSDoc paragraph in cleanOrphanedToolCalls's doc comment, and missing
test coverage for the branch where a cascade empties a turn out entirely
(the message must be dropped and its surrounding user turns merged).

---------

Co-authored-by: Palanisamy, Dinesh <Dinesh.Palanisamy@netapp.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
This commit is contained in:
Dinesh Palanisamy 2026-08-01 01:07:02 -04:00 committed by GitHub
parent 196549dc81
commit c1539df4d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 413 additions and 5 deletions

View file

@ -1148,6 +1148,113 @@ describe('AnthropicContentConverter', () => {
]);
});
it('cascade-strips a signed thinking block when its sibling tool_use is orphaned in the same pass', () => {
// A thinking block's signature is computed over the full sibling
// content of its turn. If a sibling tool_use is stripped as an
// orphan, the signature no longer matches and replaying it 400s:
// "thinking blocks in the latest assistant message cannot be
// modified". So the thinking block must go with it.
const { messages } = converter.convertGeminiRequestToAnthropic({
model: 'models/test',
contents: [
{ role: 'user', parts: [{ text: 'Hi' }] },
{
role: 'model',
parts: [
{ text: 'reasoning', thought: true, thoughtSignature: 'sig' },
{ text: 'Let me help' },
{ functionCall: { id: 'orphan', name: 'tool', args: {} } },
],
},
{ role: 'user', parts: [{ text: 'never mind' }] },
],
});
const assistantMsg = messages.find((m) => m.role === 'assistant');
expect(assistantMsg).toBeDefined();
expect(assistantMsg!.content).toEqual([
{ type: 'text', text: 'Let me help' },
]);
});
it('does not cascade-strip thinking when a sibling tool_use survives alongside an orphaned one', () => {
// Partial-orphan case: turn = [thinking, tool_use A, tool_use B],
// only A's result comes back -- B is a genuine orphan and is
// stripped, but A survives. The thinking sibling must stay too: it's
// still needed to satisfy Anthropic's manual-mode "final turn must
// begin with thinking when a tool_use is present" rule, and
// cascading here would trade one 400 for another.
const { messages } = converter.convertGeminiRequestToAnthropic({
model: 'models/test',
contents: [
{ role: 'user', parts: [{ text: 'Hi' }] },
{
role: 'model',
parts: [
{ text: 'reasoning', thought: true, thoughtSignature: 'sig' },
{ functionCall: { id: 'a', name: 'tool', args: {} } },
{ functionCall: { id: 'b', name: 'tool', args: {} } },
],
},
{
role: 'user',
parts: [
{
functionResponse: {
id: 'a',
name: 'tool',
response: { output: 'ok' },
},
},
],
},
],
});
const assistantMsg = messages.find((m) => m.role === 'assistant');
expect(assistantMsg).toBeDefined();
const blocks = assistantMsg!.content as Array<{ type: string }>;
expect(blocks[0]?.type).toBe('thinking');
expect(blocks.some((b) => b.type === 'tool_use')).toBe(true);
expect(blocks).toHaveLength(2);
});
it('drops the whole message and merges surrounding user turns when a cascade empties out the turn entirely', () => {
// The bot review's flagged coverage gap: the only existing cascade
// test leaves a surviving `text` block, so `finalBlocks` is never
// empty and the `else` drop branch in cleanOrphanedToolCalls is
// never exercised. Here the turn's only blocks are a signed thinking
// part and an orphaned tool_use, so after the cascade strips both,
// finalBlocks is empty and the whole assistant message must be
// dropped -- and the surrounding user messages must merge.
const { messages } = converter.convertGeminiRequestToAnthropic({
model: 'models/test',
contents: [
{ role: 'user', parts: [{ text: 'before' }] },
{
role: 'model',
parts: [
{ text: 'reasoning', thought: true, thoughtSignature: 'sig' },
{ functionCall: { id: 'orphan', name: 'tool', args: {} } },
],
},
{ role: 'user', parts: [{ text: 'after' }] },
],
});
expect(messages.some((m) => m.role === 'assistant')).toBe(false);
expect(messages).toHaveLength(1);
expect(messages[0]!.role).toBe('user');
expect(messages[0]!.content).toEqual([
{ type: 'text', text: 'before' },
{
type: 'text',
text: 'after',
cache_control: { type: 'ephemeral' },
},
]);
});
it('cleans orphaned tool_result blocks without matching tool_use', () => {
const { messages } = converter.convertGeminiRequestToAnthropic({
model: 'models/test',
@ -1944,6 +2051,77 @@ describe('AnthropicContentConverter', () => {
).toThrow('proxy omitted the thinking signature');
});
it('fails locally, rather than silently dropping the block, when an EMPTY-text unsigned thinking block belongs to a non-latest step of an active tool-use loop', () => {
// Regression guard for pipeline ordering: dropEmptyTextThinkingBlocks
// must run AFTER this check, not before. An empty-text thinking
// block with no signature is unsigned by the same definition this
// active-loop check uses -- if the empty-text guard ran first it
// would delete the block before this check ever saw it, silently
// swallowing exactly the proxy bug this throw exists to surface
// (the same pass-ordering hazard identified against the removed
// PATCH-B heuristic).
//
// Needs a two-step loop: a single assistant turn is always "the
// latest", and dropEmptyTextThinkingBlocks unconditionally exempts
// the latest turn regardless of ordering, so a one-step fixture
// can't distinguish the two orderings. Step 1's empty-text thinking
// must be on a NON-latest turn that is still part of the unbroken
// tool_use/tool_result chain reaching the end of history.
expect(() =>
converter.convertGeminiRequestToAnthropic(
{
model: 'models/test',
contents: [
{ role: 'user', parts: [{ text: 'Run tool' }] },
{
role: 'model',
parts: [
{ text: '', thought: true },
{ functionCall: { id: 't1', name: 'tool', args: {} } },
],
},
{
role: 'user',
parts: [
{
functionResponse: {
id: 't1',
name: 'tool',
response: { output: 'ok' },
},
},
],
},
{
role: 'model',
parts: [
{
text: 'signed reasoning',
thought: true,
thoughtSignature: 'sig',
},
{ functionCall: { id: 't2', name: 'tool', args: {} } },
],
},
{
role: 'user',
parts: [
{
functionResponse: {
id: 't2',
name: 'tool',
response: { output: 'ok' },
},
},
],
},
],
},
{ dropUnsignedAssistantThinking: true },
),
).toThrow('proxy omitted the thinking signature');
});
it('drops unsigned thinking from a completed tool-use turn', () => {
const { messages } = converter.convertGeminiRequestToAnthropic(
{
@ -2039,6 +2217,95 @@ describe('AnthropicContentConverter', () => {
});
});
describe('dropEmptyTextThinkingBlocks', () => {
it('leaves a signed, non-empty thinking block on a non-latest turn untouched', () => {
// A broader cross-turn heuristic here (detecting "this turn's
// tool_use went stale in an earlier trim" and downgrading its
// thinking to text) was removed after review: it couldn't
// distinguish that state from "this turn was always thinking-only",
// and live verification showed it rewriting turns that were never
// actually invalid. Only an empty-text thinking block is
// unconditionally invalid regardless of tool_use presence; a
// populated, signed thinking block is left exactly as-is.
const { messages } = converter.convertGeminiRequestToAnthropic({
model: 'models/test',
contents: [
{ role: 'user', parts: [{ text: 'Hi' }] },
{
role: 'model',
parts: [
{
text: 'stale reasoning',
thought: true,
thoughtSignature: 'sig',
},
],
},
{ role: 'user', parts: [{ text: 'anything else?' }] },
{ role: 'model', parts: [{ text: 'Sure, here you go.' }] },
],
});
const olderAssistant = messages[1];
expect(olderAssistant.role).toBe('assistant');
expect(olderAssistant.content).toEqual([
{ type: 'thinking', thinking: 'stale reasoning', signature: 'sig' },
]);
});
it('drops an empty redacted_thinking-derived turn entirely (defensive, no plaintext fallback)', () => {
// convertAnthropicResponseToGemini represents a redacted_thinking
// block as `{ text: '', thought: true }` (its opaque `data` doesn't
// survive the Gemini-Part round trip -- see that method's doc). When
// this round-trips back through processContent it becomes an
// empty-text `thinking` block on the wire, which this defensive
// guard drops outright, dropping the whole message since nothing
// else survives.
const { messages } = converter.convertGeminiRequestToAnthropic({
model: 'models/test',
contents: [
{ role: 'user', parts: [{ text: 'Hi' }] },
{
role: 'model',
parts: [{ text: '', thought: true }],
},
{ role: 'user', parts: [{ text: 'anything else?' }] },
{ role: 'model', parts: [{ text: 'Sure, here you go.' }] },
],
});
const assistantMessages = messages.filter((m) => m.role === 'assistant');
expect(assistantMessages).toHaveLength(1);
expect(assistantMessages[0].content).toEqual([
{ type: 'text', text: 'Sure, here you go.' },
]);
});
it('leaves the latest assistant turn untouched even with empty-text thinking', () => {
// The latestAssistantIdx short-circuit fires before the empty-text
// filter runs at all, so this must hold regardless of content -- use
// an actually-empty-text block (matching the title) rather than a
// populated one, so this test would fail if the exemption were ever
// narrowed to "non-empty-text latest turns only".
const { messages } = converter.convertGeminiRequestToAnthropic({
model: 'models/test',
contents: [
{ role: 'user', parts: [{ text: 'Hi' }] },
{
role: 'model',
parts: [{ text: '', thought: true, thoughtSignature: 'sig' }],
},
],
});
const lastMsg = messages[messages.length - 1];
expect(lastMsg.role).toBe('assistant');
expect(lastMsg.content).toEqual([
{ type: 'thinking', thinking: '', signature: 'sig' },
]);
});
});
// https://github.com/QwenLM/qwen-code/issues/3786 — DeepSeek's
// anthropic-compatible API rejects requests in thinking mode when a prior
// assistant turn carrying `tool_use` omits a thinking block. Plain-text

View file

@ -276,9 +276,33 @@ export class AnthropicContentConverter {
messages = mergeConsecutiveAssistantMessages(messages);
messages = cleanOrphanedToolCalls(messages);
messages = mergeConsecutiveAssistantMessages(messages);
// Must run BEFORE dropEmptyTextThinkingBlocks: dropUnsignedThinking...
// throws when an unsigned thinking block belongs to a turn that's part
// of an unbroken, still-active tool_use/tool_result chain reaching the
// end of history -- a real proxy bug that should fail loudly rather
// than silently continue. An empty-text thinking block with no
// signature is unsigned by this same definition; if the empty-text
// guard ran first it would delete the block outright before this
// check ever saw it, silently swallowing exactly the proxy bug this
// throw exists to surface (the same pass-ordering hazard raised
// against the removed PATCH-B heuristic, which retyped instead of
// deleted but had the identical effect of hiding the block from this
// check).
if (options.dropUnsignedAssistantThinking) {
messages = this.dropUnsignedThinkingFromAssistantMessages(messages);
}
// Defense-in-depth against an empty-text thinking block surviving into
// a non-latest turn (see dropEmptyTextThinkingBlocks's doc) -- e.g. one
// that DOES carry a signature, so dropUnsignedThinkingFromAssistant...
// above leaves it alone. Skipped for DeepSeek's injectThinkingOnToolUseTurns
// path: DeepSeek's synthetic thinking placeholder (injected above) is
// deliberately `{type:'thinking', thinking:'', signature:''}` on every
// tool-use turn, and DeepSeek doesn't validate a signature the way
// Anthropic does, so this guard would strip the very placeholder
// DeepSeek needs.
if (!options.injectThinkingOnToolUseTurns) {
messages = dropEmptyTextThinkingBlocks(messages);
}
if (options.stripAssistantThinking) {
this.stripThinkingFromAssistantMessages(messages);
}
@ -1427,6 +1451,19 @@ function makeToolResultDeduper(): (id: string | undefined) => boolean {
* Remove tool_use blocks that have no matching tool_result in the
* immediately following user message, and remove tool_result blocks that
* have no matching tool_use in the immediately preceding assistant message.
* Also cascade-strips `thinking`/`redacted_thinking` blocks from an
* assistant turn whenever a `tool_use` is removed from that same turn by
* this pass AND no other `tool_use` survives in it -- the signature on
* those blocks was computed over content that included the now-removed
* `tool_use`, so replaying it produces Anthropic 400 "thinking blocks in
* the latest assistant message cannot be modified". The model regenerates
* thinking on its next turn regardless. Scoped to "no surviving tool_use"
* rather than "any tool_use removed": a turn with `[thinking, tool_use A,
* tool_use B]` where only B is a genuine orphan still sends A on the wire,
* and per Anthropic's manual-mode extended-thinking contract the final
* assistant turn of a thinking-enabled request must begin with a thinking
* block when any `tool_use` remains in it -- stripping the thinking here
* would trade one 400 for another.
*
* A `tool_use` in the very last message (no message follows it at all) is
* never condemned as orphaned here -- "no result yet" isn't the same as
@ -1438,8 +1475,10 @@ function makeToolResultDeduper(): (id: string | undefined) => boolean {
* matching `tool_result` is a genuine orphan.
*
* Empty messages produced by the cleanup are dropped entirely. A subsequent
* mergeConsecutiveAssistantMessages call fixes any alternation issues
* created by dropped messages.
* mergeConsecutiveAssistantMessages call fixes alternation issues created
* by a dropped assistant message sandwiched between two other assistant
* messages; mergeConsecutiveUserMessages (later in the pipeline) does the
* same when the sandwiching messages are user turns instead.
*
* Mirrors the same-name function in the OpenAI converter.
*/
@ -1525,13 +1564,16 @@ function cleanOrphanedToolCalls(
continue;
}
let toolUseRemoved = false;
const keepToolResult = makeToolResultDeduper();
const filtered = blocks.filter((b) => {
const t = (b as { type?: string }).type;
if (t === 'tool_use') {
const id = (b as { id?: string }).id;
return !id || validToolUseBlocks.has(b as object);
const keep = !id || validToolUseBlocks.has(b as object);
if (!keep) toolUseRemoved = true;
return keep;
}
if (t === 'tool_result') {
const id = (b as { tool_use_id?: string }).tool_use_id;
@ -1542,8 +1584,28 @@ function cleanOrphanedToolCalls(
return true;
});
if (filtered.length > 0) {
cleaned.push({ ...message, content: filtered });
// A tool_use was stripped from this turn and none survives -- any
// thinking/redacted_thinking sibling in the same turn is now
// untrustworthy (see function doc). If a tool_use survives, the
// thinking sibling is left in place: it's still needed to satisfy
// Anthropic's manual-mode "final turn must begin with thinking when a
// tool_use is present" rule, and only cascading on total removal keeps
// this narrower than a blanket "any removal" rule. tool_use/thinking
// only ever co-occur on assistant messages, but the role check is
// defensive.
const survivingToolUse = filtered.some(
(b) => (b as { type?: string }).type === 'tool_use',
);
const finalBlocks =
toolUseRemoved && !survivingToolUse && message.role === 'assistant'
? filtered.filter((b) => {
const t = (b as { type?: string }).type;
return t !== 'thinking' && t !== 'redacted_thinking';
})
: filtered;
if (finalBlocks.length > 0) {
cleaned.push({ ...message, content: finalBlocks });
} else {
debugLogger.debug(
'cleanOrphanedToolCalls: dropping message with only orphaned tool blocks',
@ -1554,6 +1616,85 @@ function cleanOrphanedToolCalls(
return cleaned;
}
/**
* Drops any `thinking` block with empty text from a non-latest assistant
* turn (dropping the whole message if that empties it out). An Anthropic
* `thinking` block's signature is computed over its own text content; a
* block with no text at all cannot represent valid signed reasoning
* regardless of whether a signature is present. This arises when a
* `redacted_thinking` block -- whose opaque `data` doesn't survive the
* Gemini-`Part` round trip, see
* {@link AnthropicContentConverter.convertAnthropicResponseToGemini} --
* is replayed back through history construction as an empty-text
* `thinking` block.
*
* Scoped to non-latest assistant turns, matching Anthropic's contract that
* the latest assistant turn's signatures must replay byte-exact.
*
* This was originally one guard inside a larger `pruneUntrustworthyThinking`
* pass that also tried to detect and downgrade a non-latest, thinking-only
* turn whose `tool_use` had gone stale in an earlier trim (a cross-turn
* complement to {@link cleanOrphanedToolCalls}'s same-turn cascade). That
* broader heuristic was removed after review: it could not distinguish "this
* turn's tool_use was removed by an earlier trim" from "this turn was
* always thinking-only" (both are structurally identical by the time it
* ran), it ran before the passes that already handle unsigned thinking
* correctly (reordering caused them to stop recognizing thinking it had
* already re-typed as text), its DeepSeek exclusion only covered one of
* DeepSeek's two thinking modes, and live A/B verification against a real
* session showed it re-typing a thinking-only turn on the very next
* request just because a newer assistant turn had been appended --
* invalidating a cache breakpoint and adding token cost for content that
* was never actually invalid. Investigation into this codebase's actual
* compaction (`chatCompressionService` is full-history, not a partial
* trim that could strand a `tool_use`) and orphan-repair
* (`repairOrphanedToolUseTurns` already synthesizes an error
* `tool_result` for a genuine cross-turn orphan before it would reach this
* pass) did not reproduce the state the broader heuristic existed to
* clean up. This guard is the one part of that pass that is unconditionally
* correct regardless of that heuristic's premise, so it's kept on its own.
*/
function dropEmptyTextThinkingBlocks(
messages: AnthropicMessageParam[],
): AnthropicMessageParam[] {
let latestAssistantIdx = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i]!.role === 'assistant') {
latestAssistantIdx = i;
break;
}
}
const out: AnthropicMessageParam[] = [];
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]!;
if (msg.role !== 'assistant' || !Array.isArray(msg.content)) {
out.push(msg);
continue;
}
if (i === latestAssistantIdx) {
out.push(msg);
continue;
}
const blocks = msg.content as AnthropicContentBlockParam[];
const filtered = blocks.filter((raw) => {
const bType = (raw as { type?: string }).type;
const bThinkingRaw = (raw as { thinking?: unknown }).thinking;
const bThinking =
typeof bThinkingRaw === 'string' ? bThinkingRaw : undefined;
return !(
bType === 'thinking' &&
(bThinking === undefined || bThinking.length === 0)
);
});
if (filtered.length === 0) continue;
out.push({ role: msg.role, content: filtered });
}
return out;
}
function mergeConsecutiveUserMessages(
messages: AnthropicMessageParam[],
): AnthropicMessageParam[] {