Skip to content

fix: round-trip thinking blocks in the Claude model - #1634

Open
innoprej wants to merge 1 commit into
google:mainfrom
innoprej:fix/claude-thinking-blocks
Open

innoprej wants to merge 1 commit into
google:mainfrom
innoprej:fix/claude-thinking-blocks

Conversation

@innoprej

@innoprej innoprej commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Please ensure you have read the contribution guide before creating a pull request.

Link to Issue or Description of Change

1. Link to an existing issue (if applicable):

2. Or, if no issue exists, describe the change:

Problem:

Claude.anthropicContentBlockToPart converts only text and tool_use blocks and throws UnsupportedOperationException("Not supported yet.") for any other block. The Claude 5 models use adaptive thinking when the request has no thinking field, which ADK Java never sets, so a response can contain a thinking block without being asked, and the agent run ends with that exception. The request side has the matching gap: partToAnthropicMessageBlock sends a thought Part with text as a plain text block, dropping its signature, and throws for one that has only a signature. A non-thought Part that carries only a signature, which Gemini leaves in the history, fails as well: it throws when it has no text, and with text="" (the shape the Gemini API sends) it goes out as an empty text block, which Anthropic rejects. The part stays in the session, so every later turn fails the same way.

Solution:

Use the same mapping as ADK Python (google/adk-python@16952bd39, src/google/adk/models/anthropic_llm.py):

  • thinking → Part(text, thought=true, thoughtSignature=signature).
  • redacted_thinking → Part(thought=true, thoughtSignature=data).
  • On the way back, a thought with a signature becomes a thinking block when it has text and a redacted_thinking block when it has none. A tool-use turn therefore sends the blocks back unchanged and in order, which the Anthropic thinking docs ("Preserving thinking blocks") require.
  • Only signatures stored this way, as UTF-8 text, are sent back. A thought that another model left with a binary signature (Gemini's are binary) is not Claude's, and Anthropic always returns a 400 for a signature it cannot decode.
  • As in google/adk-python@a5d6238cb, a non-thought part that carries only a signature is dropped with a warning. As in its _is_content_free_signature, that means the text is absent or empty and none of the content fields that function checks is set; metadata such as partMetadata does not count. Gemini leaves such parts in the history (GeminiTest.processRawResponses_contentFreeSignaturePart_isKept and processRawResponses_emptyTextSignaturePart_isKept). Claude cannot verify that signature, and the session keeps the part for a later Gemini turn.
  • As in the same commit, a message whose parts were all dropped is left out instead of being sent with empty content, which Anthropic rejects. Otherwise an earlier turn holding only a thought with a binary signature would go out as an empty assistant message.

Three deliberate differences from ADK Python, each marked with a one-line comment:

  • A thought with empty text (what display: "omitted", the Claude 5 default, returns) goes back as the thinking block it came from. ADK Python's if part.thought and part.text: treats the empty text as absent and sends a redacted_thinking block whose data is the signature. In Java, Part.text() is an Optional, so empty and absent text can be told apart.
  • Any other thought is never sent as a thinking block. It is still sent as text, as before, or dropped if it has no text, since there is nothing to send back and Anthropic rejects empty text blocks; ADK Python's _is_empty_text_part (google/adk-python@6e8546c88) drops an empty-text thought without a signature the same way. ADK Python sends an unsigned thought with text as a thinking block with signature="".
  • A part with a function call or response is always sent as tool_use or tool_result, even if it is a thought or also has text, so the call and its tool_result stay paired; Contents.isPartInvisible already treats thought-flagged calls and responses as calls and responses. ADK Python checks non-empty text and a thought's signature before the call, so the call is lost in those cases.

Testing Plan

Unit Tests:

  • I have added or updated unit tests for my change.
  • All unit tests pass locally.

./mvnw -pl core test -Dtest=ClaudeTest: 18 tests, 0 failures (Microsoft Build of OpenJDK 17.0.19, Windows 11). The ten new tests go through generateContent with a mocked AnthropicClient:

Test On main With this change
generateContent_thinkingBlocks_becomeThoughtParts throws UnsupportedOperationException passes
generateContent_toolTurn_sendsThinkingBlocksBackUnchanged: [thinking, redacted_thinking, text, thinking, tool_use], then the next request throws UnsupportedOperationException passes
generateContent_emptyThoughtWithoutSignature_isNotSent fails: the empty thoughts are sent as empty text blocks passes
generateContent_thoughtWithBinarySignature_isSentAsText passes (main sends every thought with text as text) passes; guards the UTF-8 check
generateContent_thoughtWithoutTextOrUsableSignature_isNotSent: Part.builder().thought(true).build() and a thought with only a binary signature throws UnsupportedOperationException passes
generateContent_thoughtFlaggedCallsAndResponses_areSentAsToolUseAndToolResult: thought-flagged calls and responses with a signature and no text, with text="", and with text and a signature fails: the ones with text, even empty, go out as text blocks, so their calls and responses are lost passes
generateContent_contentFreeSignature_isNotSent: a part with text="" and a binary signature and one with only a signature, next to text throws UnsupportedOperationException passes
generateContent_signatureBesideContent_isKept: a call with a signature, a thought with text and one without, and a signature with only partMetadata throws UnsupportedOperationException passes
generateContent_turnOfOnlyDroppedThoughts_isNotSent: an earlier turn holding only Part.builder().thought(true).build() and a thought with a binary signature throws UnsupportedOperationException passes
generateContent_turnOfOnlyContentFreeSignature_isNotSent: an earlier turn holding only a signature throws UnsupportedOperationException passes

Partial implementations fail them too: ignoring redacted_thinking fails the first two; sending a zero-length signature fails the third; ADK Python's "text is non-empty" rule, or sending the thinking text back empty, fails the round-trip test; and dropping the UTF-8 check fails the fourth. The fifth and sixth catch each part of the first follow-up to review: leaving out the call/response condition or its functionResponse half, keeping .orElse(false), or checking text before the call or the response each fails one of them. The last four catch the second: treating empty text as content, ignoring the thought flag or the call, counting partMetadata as content, checking for such a part only where the method used to throw, dropping every part it cannot map, or leaving out the empty-message filter each fails one of them.

Full ./mvnw -pl core test: 2130 run, 0 failures (24 skipped).

Manual End-to-End (E2E) Tests:

Run against the live Messages API on 2026-10-12 (anthropic-java 2.15.0). A throwaway JUnit test, not part of this PR, runs an LlmAgent with FunctionTools through InMemoryRunner for two user turns, with a recording wrapper around a real AnthropicClient that logs each request and response.

These runs used an earlier version of this commit (e47cf7cf). The follow-ups to review only change what happens to parts that Claude's own responses never produce (thought-flagged calls and responses, thoughts with neither text nor a usable signature, parts with both text and a call or response, and non-thought parts that carry only a signature) and to messages left with no blocks, which these runs never had, so every request in these runs is the same with the current commit.

Requests as ADK sends them (no thinking field), on claude-opus-5-5, claude-sonnet-5-5 and claude-haiku-5-5. The one-tool scenario ran once with Claude.java from main and once with this change; the two-tool scenario ran with this change only:

Model response main With this change
[thinking, text] after a tool result, with empty thinking text and a signature UnsupportedOperationException at Claude.java:391; the run ends The run completes. The next user turn sends the block back unchanged, and the API accepts it
[thinking, tool_use] between two tool calls Not run; any thinking block fails as above The next request in that tool-use turn sends [thinking (same signature), tool_use] with the tool_result; the API accepts it, and the run completes

All three models behaved the same. Because thinking is adaptive, the test reruns a model when the response a scenario needs doesn't appear; no rerun was needed.

In these runs the thinking text was always empty (display defaults to "omitted"), and no redacted_thinking block came back. To cover those cases too, two more runs with this change had the wrapper add a thinking setting to every request (ADK itself never sends one):

Setting added by the test Model response With this change
{type: "adaptive", display: "summarized"}, two-tool scenario, same three models [thinking, tool_use] with non-empty thinking text The next request in that tool-use turn sends the block back with the same text and signature; the API accepts it, and the run completes
{type: "enabled", budget_tokens: 2000} on claude-sonnet-4-6, with the test string from Anthropic's extended thinking cookbook that triggers redacted thinking [redacted_thinking, text], then [redacted_thinking, text, tool_use] The next turn sends the first block back, and the next request in the tool-use turn sends both, each with the same data; the API accepts them, and the run completes

With requests as ADK sends them, the three Claude 5.5 models answered the same test string with a regular thinking block, not redacted_thinking.

The instruction was a fixed string in all runs, so none of them exercise the known limitation below.

No message content is logged: only block types and lengths, tool names, stop reasons and token usage. A signature (or redacted_thinking data) is shown as its length and the first 12 hex digits of its SHA-256, and non-empty thinking text as its length and SHA-256 prefix (textsha), so the same block can be matched across requests.

Logs: main, one-tool scenario
LIVE main/claude-opus-5-5/attempt1 request 1: model=claude-opus-5-5, messages=[user[text(160)]]
LIVE main/claude-opus-5-5/attempt1 response 1: stop=tool_use, blocks=[tool_use(get_hourly_forecast)], usage(in/out)=501/54
LIVE main/claude-opus-5-5/attempt1 request 2: model=claude-opus-5-5, messages=[user[text(160)], assistant[tool_use(get_hourly_forecast)], user[tool_result]]
LIVE main/claude-opus-5-5/attempt1 response 2: stop=end_turn, blocks=[thinking(text=0, sig=1220/eaf85ceab5d4), text(240)], usage(in/out)=648/247
LIVE main/claude-opus-5-5/attempt1 turn 1: FAILED java.lang.UnsupportedOperationException: Not supported yet. at anthropicContentBlockToPart(Claude.java:391)
LIVE main/claude-sonnet-5-5/attempt1 request 1: model=claude-sonnet-5-5, messages=[user[text(160)]]
LIVE main/claude-sonnet-5-5/attempt1 response 1: stop=tool_use, blocks=[tool_use(get_hourly_forecast)], usage(in/out)=501/54
LIVE main/claude-sonnet-5-5/attempt1 request 2: model=claude-sonnet-5-5, messages=[user[text(160)], assistant[tool_use(get_hourly_forecast)], user[tool_result]]
LIVE main/claude-sonnet-5-5/attempt1 response 2: stop=end_turn, blocks=[thinking(text=0, sig=1160/4a921bdd0aaf), text(237)], usage(in/out)=648/225
LIVE main/claude-sonnet-5-5/attempt1 turn 1: FAILED java.lang.UnsupportedOperationException: Not supported yet. at anthropicContentBlockToPart(Claude.java:391)
LIVE main/claude-haiku-5-5/attempt1 request 1: model=claude-haiku-5-5, messages=[user[text(160)]]
LIVE main/claude-haiku-5-5/attempt1 response 1: stop=tool_use, blocks=[tool_use(get_hourly_forecast)], usage(in/out)=501/54
LIVE main/claude-haiku-5-5/attempt1 request 2: model=claude-haiku-5-5, messages=[user[text(160)], assistant[tool_use(get_hourly_forecast)], user[tool_result]]
LIVE main/claude-haiku-5-5/attempt1 response 2: stop=end_turn, blocks=[thinking(text=0, sig=1188/dee59a032599), text(155)], usage(in/out)=648/200
LIVE main/claude-haiku-5-5/attempt1 turn 1: FAILED java.lang.UnsupportedOperationException: Not supported yet. at anthropicContentBlockToPart(Claude.java:391)
Logs: this change, one-tool scenario
LIVE pr/claude-opus-5-5/attempt1 request 1: model=claude-opus-5-5, messages=[user[text(160)]]
LIVE pr/claude-opus-5-5/attempt1 response 1: stop=tool_use, blocks=[tool_use(get_hourly_forecast)], usage(in/out)=501/54
LIVE pr/claude-opus-5-5/attempt1 request 2: model=claude-opus-5-5, messages=[user[text(160)], assistant[tool_use(get_hourly_forecast)], user[tool_result]]
LIVE pr/claude-opus-5-5/attempt1 response 2: stop=end_turn, blocks=[thinking(text=0, sig=1204/4561cd606e94), text(240)], usage(in/out)=648/245
LIVE pr/claude-opus-5-5/attempt1 turn 1: completed, events=3, thoughtParts=1, lastTextLength=240
LIVE pr/claude-opus-5-5/attempt1 request 3: model=claude-opus-5-5, messages=[user[text(160)], assistant[tool_use(get_hourly_forecast)], user[tool_result], assistant[thinking(text=0, sig=1204/4561cd606e94), text(240)], user[text(105)]]
LIVE pr/claude-opus-5-5/attempt1 response 3: stop=end_turn, blocks=[thinking(text=0, sig=1448/c99149782366), text(266)], usage(in/out)=929/270
LIVE pr/claude-opus-5-5/attempt1 turn 2: completed, events=1, thoughtParts=1, lastTextLength=266
LIVE pr/claude-sonnet-5-5/attempt1 request 1: model=claude-sonnet-5-5, messages=[user[text(160)]]
LIVE pr/claude-sonnet-5-5/attempt1 response 1: stop=tool_use, blocks=[tool_use(get_hourly_forecast)], usage(in/out)=501/54
LIVE pr/claude-sonnet-5-5/attempt1 request 2: model=claude-sonnet-5-5, messages=[user[text(160)], assistant[tool_use(get_hourly_forecast)], user[tool_result]]
LIVE pr/claude-sonnet-5-5/attempt1 response 2: stop=end_turn, blocks=[thinking(text=0, sig=1236/80224aa2d22d), text(334)], usage(in/out)=648/286
LIVE pr/claude-sonnet-5-5/attempt1 turn 1: completed, events=3, thoughtParts=1, lastTextLength=334
LIVE pr/claude-sonnet-5-5/attempt1 request 3: model=claude-sonnet-5-5, messages=[user[text(160)], assistant[tool_use(get_hourly_forecast)], user[tool_result], assistant[thinking(text=0, sig=1236/80224aa2d22d), text(334)], user[text(105)]]
LIVE pr/claude-sonnet-5-5/attempt1 response 3: stop=end_turn, blocks=[thinking(text=0, sig=1044/cb2e24b35c12), text(194)], usage(in/out)=970/131
LIVE pr/claude-sonnet-5-5/attempt1 turn 2: completed, events=1, thoughtParts=1, lastTextLength=194
LIVE pr/claude-haiku-5-5/attempt1 request 1: model=claude-haiku-5-5, messages=[user[text(160)]]
LIVE pr/claude-haiku-5-5/attempt1 response 1: stop=tool_use, blocks=[tool_use(get_hourly_forecast)], usage(in/out)=501/54
LIVE pr/claude-haiku-5-5/attempt1 request 2: model=claude-haiku-5-5, messages=[user[text(160)], assistant[tool_use(get_hourly_forecast)], user[tool_result]]
LIVE pr/claude-haiku-5-5/attempt1 response 2: stop=end_turn, blocks=[thinking(text=0, sig=1212/730443ec8dd1), text(163)], usage(in/out)=648/208
LIVE pr/claude-haiku-5-5/attempt1 turn 1: completed, events=3, thoughtParts=1, lastTextLength=163
LIVE pr/claude-haiku-5-5/attempt1 request 3: model=claude-haiku-5-5, messages=[user[text(160)], assistant[tool_use(get_hourly_forecast)], user[tool_result], assistant[thinking(text=0, sig=1212/730443ec8dd1), text(163)], user[text(105)]]
LIVE pr/claude-haiku-5-5/attempt1 response 3: stop=end_turn, blocks=[thinking(text=0, sig=1460/42a56c7b1a51), text(220)], usage(in/out)=892/272
LIVE pr/claude-haiku-5-5/attempt1 turn 2: completed, events=1, thoughtParts=1, lastTextLength=220
Logs: this change, two-tool scenario
LIVE pr-booking/claude-opus-5-5/attempt1 request 1: model=claude-opus-5-5, messages=[user[text(98)]]
LIVE pr-booking/claude-opus-5-5/attempt1 response 1: stop=tool_use, blocks=[tool_use(get_hourly_forecast)], usage(in/out)=608/54
LIVE pr-booking/claude-opus-5-5/attempt1 request 2: model=claude-opus-5-5, messages=[user[text(98)], assistant[tool_use(get_hourly_forecast)], user[tool_result]]
LIVE pr-booking/claude-opus-5-5/attempt1 response 2: stop=tool_use, blocks=[thinking(text=0, sig=1152/96debef4c027), tool_use(book_photo_slot)], usage(in/out)=755/185
LIVE pr-booking/claude-opus-5-5/attempt1 request 3: model=claude-opus-5-5, messages=[user[text(98)], assistant[tool_use(get_hourly_forecast)], user[tool_result], assistant[thinking(text=0, sig=1152/96debef4c027), tool_use(book_photo_slot)], user[tool_result]]
LIVE pr-booking/claude-opus-5-5/attempt1 response 3: stop=end_turn, blocks=[text(151)], usage(in/out)=965/59
LIVE pr-booking/claude-opus-5-5/attempt1 turn 1: completed, events=5, thoughtParts=1, lastTextLength=151
LIVE pr-booking/claude-opus-5-5/attempt1 request 4: model=claude-opus-5-5, messages=[user[text(98)], assistant[tool_use(get_hourly_forecast)], user[tool_result], assistant[thinking(text=0, sig=1152/96debef4c027), tool_use(book_photo_slot)], user[tool_result], assistant[text(151)], user[text(123)]]
LIVE pr-booking/claude-opus-5-5/attempt1 response 4: stop=end_turn, blocks=[thinking(text=0, sig=1484/de30d01352f0), text(220)], usage(in/out)=1066/260
LIVE pr-booking/claude-opus-5-5/attempt1 turn 2: completed, events=1, thoughtParts=1, lastTextLength=220
LIVE pr-booking/claude-sonnet-5-5/attempt1 request 1: model=claude-sonnet-5-5, messages=[user[text(98)]]
LIVE pr-booking/claude-sonnet-5-5/attempt1 response 1: stop=tool_use, blocks=[tool_use(get_hourly_forecast)], usage(in/out)=608/54
LIVE pr-booking/claude-sonnet-5-5/attempt1 request 2: model=claude-sonnet-5-5, messages=[user[text(98)], assistant[tool_use(get_hourly_forecast)], user[tool_result]]
LIVE pr-booking/claude-sonnet-5-5/attempt1 response 2: stop=tool_use, blocks=[thinking(text=0, sig=1460/2e53ea4318af), tool_use(book_photo_slot)], usage(in/out)=755/290
LIVE pr-booking/claude-sonnet-5-5/attempt1 request 3: model=claude-sonnet-5-5, messages=[user[text(98)], assistant[tool_use(get_hourly_forecast)], user[tool_result], assistant[thinking(text=0, sig=1460/2e53ea4318af), tool_use(book_photo_slot)], user[tool_result]]
LIVE pr-booking/claude-sonnet-5-5/attempt1 response 3: stop=end_turn, blocks=[text(143)], usage(in/out)=1070/57
LIVE pr-booking/claude-sonnet-5-5/attempt1 turn 1: completed, events=5, thoughtParts=1, lastTextLength=143
LIVE pr-booking/claude-sonnet-5-5/attempt1 request 4: model=claude-sonnet-5-5, messages=[user[text(98)], assistant[tool_use(get_hourly_forecast)], user[tool_result], assistant[thinking(text=0, sig=1460/2e53ea4318af), tool_use(book_photo_slot)], user[tool_result], assistant[text(143)], user[text(123)]]
LIVE pr-booking/claude-sonnet-5-5/attempt1 response 4: stop=end_turn, blocks=[thinking(text=0, sig=2392/7b9d2584f352), text(200)], usage(in/out)=1169/490
LIVE pr-booking/claude-sonnet-5-5/attempt1 turn 2: completed, events=1, thoughtParts=1, lastTextLength=200
LIVE pr-booking/claude-haiku-5-5/attempt1 request 1: model=claude-haiku-5-5, messages=[user[text(98)]]
LIVE pr-booking/claude-haiku-5-5/attempt1 response 1: stop=tool_use, blocks=[tool_use(get_hourly_forecast)], usage(in/out)=608/54
LIVE pr-booking/claude-haiku-5-5/attempt1 request 2: model=claude-haiku-5-5, messages=[user[text(98)], assistant[tool_use(get_hourly_forecast)], user[tool_result]]
LIVE pr-booking/claude-haiku-5-5/attempt1 response 2: stop=tool_use, blocks=[thinking(text=0, sig=1056/de8c65aaa4c6), tool_use(book_photo_slot)], usage(in/out)=755/147
LIVE pr-booking/claude-haiku-5-5/attempt1 request 3: model=claude-haiku-5-5, messages=[user[text(98)], assistant[tool_use(get_hourly_forecast)], user[tool_result], assistant[thinking(text=0, sig=1056/de8c65aaa4c6), tool_use(book_photo_slot)], user[tool_result]]
LIVE pr-booking/claude-haiku-5-5/attempt1 response 3: stop=end_turn, blocks=[text(139)], usage(in/out)=927/61
LIVE pr-booking/claude-haiku-5-5/attempt1 turn 1: completed, events=5, thoughtParts=1, lastTextLength=139
LIVE pr-booking/claude-haiku-5-5/attempt1 request 4: model=claude-haiku-5-5, messages=[user[text(98)], assistant[tool_use(get_hourly_forecast)], user[tool_result], assistant[thinking(text=0, sig=1056/de8c65aaa4c6), tool_use(book_photo_slot)], user[tool_result], assistant[text(139)], user[text(123)]]
LIVE pr-booking/claude-haiku-5-5/attempt1 response 4: stop=end_turn, blocks=[thinking(text=0, sig=1744/b175e7905172), text(200)], usage(in/out)=1030/337
LIVE pr-booking/claude-haiku-5-5/attempt1 turn 2: completed, events=1, thoughtParts=1, lastTextLength=200
Logs: this change, two-tool scenario, display: "summarized" added
LIVE pr-booking-summarized/claude-opus-5-5/attempt1 request 1: model=claude-opus-5-5, thinkingConfigAddedByTest=summarized, messages=[user[text(98)]]
LIVE pr-booking-summarized/claude-opus-5-5/attempt1 response 1: stop=tool_use, blocks=[tool_use(get_hourly_forecast)], usage(in/out)=608/54
LIVE pr-booking-summarized/claude-opus-5-5/attempt1 request 2: model=claude-opus-5-5, thinkingConfigAddedByTest=summarized, messages=[user[text(98)], assistant[tool_use(get_hourly_forecast)], user[tool_result]]
LIVE pr-booking-summarized/claude-opus-5-5/attempt1 response 2: stop=tool_use, blocks=[thinking(text=206, textsha=65520a56c159, sig=980/48ffb017315b), tool_use(book_photo_slot)], usage(in/out)=755/130
LIVE pr-booking-summarized/claude-opus-5-5/attempt1 request 3: model=claude-opus-5-5, thinkingConfigAddedByTest=summarized, messages=[user[text(98)], assistant[tool_use(get_hourly_forecast)], user[tool_result], assistant[thinking(text=206, textsha=65520a56c159, sig=980/48ffb017315b), tool_use(book_photo_slot)], user[tool_result]]
LIVE pr-booking-summarized/claude-opus-5-5/attempt1 response 3: stop=end_turn, blocks=[text(153)], usage(in/out)=910/61
LIVE pr-booking-summarized/claude-opus-5-5/attempt1 turn 1: completed, events=5, thoughtParts=1, lastTextLength=153
LIVE pr-booking-summarized/claude-opus-5-5/attempt1 request 4: model=claude-opus-5-5, thinkingConfigAddedByTest=summarized, messages=[user[text(98)], assistant[tool_use(get_hourly_forecast)], user[tool_result], assistant[thinking(text=206, textsha=65520a56c159, sig=980/48ffb017315b), tool_use(book_photo_slot)], user[tool_result], assistant[text(153)], user[text(123)]]
LIVE pr-booking-summarized/claude-opus-5-5/attempt1 response 4: stop=end_turn, blocks=[thinking(text=298, textsha=fed4f0b01bff, sig=1552/621e571151a1), text(287)], usage(in/out)=1013/300
LIVE pr-booking-summarized/claude-opus-5-5/attempt1 turn 2: completed, events=1, thoughtParts=1, lastTextLength=287
LIVE pr-booking-summarized/claude-sonnet-5-5/attempt1 request 1: model=claude-sonnet-5-5, thinkingConfigAddedByTest=summarized, messages=[user[text(98)]]
LIVE pr-booking-summarized/claude-sonnet-5-5/attempt1 response 1: stop=tool_use, blocks=[tool_use(get_hourly_forecast)], usage(in/out)=608/54
LIVE pr-booking-summarized/claude-sonnet-5-5/attempt1 request 2: model=claude-sonnet-5-5, thinkingConfigAddedByTest=summarized, messages=[user[text(98)], assistant[tool_use(get_hourly_forecast)], user[tool_result]]
LIVE pr-booking-summarized/claude-sonnet-5-5/attempt1 response 2: stop=tool_use, blocks=[thinking(text=169, textsha=bb7240498234, sig=1184/1d6351ce0345), tool_use(book_photo_slot)], usage(in/out)=755/205
LIVE pr-booking-summarized/claude-sonnet-5-5/attempt1 request 3: model=claude-sonnet-5-5, thinkingConfigAddedByTest=summarized, messages=[user[text(98)], assistant[tool_use(get_hourly_forecast)], user[tool_result], assistant[thinking(text=169, textsha=bb7240498234, sig=1184/1d6351ce0345), tool_use(book_photo_slot)], user[tool_result]]
LIVE pr-booking-summarized/claude-sonnet-5-5/attempt1 response 3: stop=end_turn, blocks=[text(165)], usage(in/out)=985/64
LIVE pr-booking-summarized/claude-sonnet-5-5/attempt1 turn 1: completed, events=5, thoughtParts=1, lastTextLength=165
LIVE pr-booking-summarized/claude-sonnet-5-5/attempt1 request 4: model=claude-sonnet-5-5, thinkingConfigAddedByTest=summarized, messages=[user[text(98)], assistant[tool_use(get_hourly_forecast)], user[tool_result], assistant[thinking(text=169, textsha=bb7240498234, sig=1184/1d6351ce0345), tool_use(book_photo_slot)], user[tool_result], assistant[text(165)], user[text(123)]]
LIVE pr-booking-summarized/claude-sonnet-5-5/attempt1 response 4: stop=end_turn, blocks=[thinking(text=268, textsha=3ed9c06f9559, sig=1764/c56aa1ec74f9), text(202)], usage(in/out)=1091/314
LIVE pr-booking-summarized/claude-sonnet-5-5/attempt1 turn 2: completed, events=1, thoughtParts=1, lastTextLength=202
LIVE pr-booking-summarized/claude-haiku-5-5/attempt1 request 1: model=claude-haiku-5-5, thinkingConfigAddedByTest=summarized, messages=[user[text(98)]]
LIVE pr-booking-summarized/claude-haiku-5-5/attempt1 response 1: stop=tool_use, blocks=[tool_use(get_hourly_forecast)], usage(in/out)=608/54
LIVE pr-booking-summarized/claude-haiku-5-5/attempt1 request 2: model=claude-haiku-5-5, thinkingConfigAddedByTest=summarized, messages=[user[text(98)], assistant[tool_use(get_hourly_forecast)], user[tool_result]]
LIVE pr-booking-summarized/claude-haiku-5-5/attempt1 response 2: stop=tool_use, blocks=[thinking(text=216, textsha=76f93e1a3a33, sig=1076/4902020b1168), tool_use(book_photo_slot)], usage(in/out)=755/155
LIVE pr-booking-summarized/claude-haiku-5-5/attempt1 request 3: model=claude-haiku-5-5, thinkingConfigAddedByTest=summarized, messages=[user[text(98)], assistant[tool_use(get_hourly_forecast)], user[tool_result], assistant[thinking(text=216, textsha=76f93e1a3a33, sig=1076/4902020b1168), tool_use(book_photo_slot)], user[tool_result]]
LIVE pr-booking-summarized/claude-haiku-5-5/attempt1 response 3: stop=end_turn, blocks=[text(157)], usage(in/out)=935/63
LIVE pr-booking-summarized/claude-haiku-5-5/attempt1 turn 1: completed, events=5, thoughtParts=1, lastTextLength=157
LIVE pr-booking-summarized/claude-haiku-5-5/attempt1 request 4: model=claude-haiku-5-5, thinkingConfigAddedByTest=summarized, messages=[user[text(98)], assistant[tool_use(get_hourly_forecast)], user[tool_result], assistant[thinking(text=216, textsha=76f93e1a3a33, sig=1076/4902020b1168), tool_use(book_photo_slot)], user[tool_result], assistant[text(157)], user[text(123)]]
LIVE pr-booking-summarized/claude-haiku-5-5/attempt1 response 4: stop=end_turn, blocks=[thinking(text=566, textsha=8204276e8d42, sig=2172/bd871f177bde), text(264)], usage(in/out)=1040/495
LIVE pr-booking-summarized/claude-haiku-5-5/attempt1 turn 2: completed, events=1, thoughtParts=1, lastTextLength=264
Logs: this change, redacted-thinking test string, claude-sonnet-4-6 with budget_tokens: 2000 added
LIVE pr-redacted-enabled2000/claude-sonnet-4-6/attempt1 request 1: model=claude-sonnet-4-6, thinkingConfigAddedByTest=enabled2000, messages=[user[text(113)]]
LIVE pr-redacted-enabled2000/claude-sonnet-4-6/attempt1 response 1: stop=end_turn, blocks=[redacted_thinking(sig=532/a30790865307), text(327)], usage(in/out)=492/127
LIVE pr-redacted-enabled2000/claude-sonnet-4-6/attempt1 turn 1: completed, events=1, thoughtParts=1, lastTextLength=327
LIVE pr-redacted-enabled2000/claude-sonnet-4-6/attempt1 request 2: model=claude-sonnet-4-6, thinkingConfigAddedByTest=enabled2000, messages=[user[text(113)], assistant[redacted_thinking(sig=532/a30790865307), text(327)], user[text(100)]]
LIVE pr-redacted-enabled2000/claude-sonnet-4-6/attempt1 response 2: stop=tool_use, blocks=[redacted_thinking(sig=548/da06608aa797), text(52), tool_use(get_hourly_forecast)], usage(in/out)=657/105
LIVE pr-redacted-enabled2000/claude-sonnet-4-6/attempt1 request 3: model=claude-sonnet-4-6, thinkingConfigAddedByTest=enabled2000, messages=[user[text(113)], assistant[redacted_thinking(sig=532/a30790865307), text(327)], user[text(100)], assistant[redacted_thinking(sig=548/da06608aa797), text(52), tool_use(get_hourly_forecast)], user[tool_result]]
LIVE pr-redacted-enabled2000/claude-sonnet-4-6/attempt1 response 3: stop=end_turn, blocks=[text(239)], usage(in/out)=844/80
LIVE pr-redacted-enabled2000/claude-sonnet-4-6/attempt1 turn 2: completed, events=3, thoughtParts=1, lastTextLength=239
Logs: this change, redacted-thinking test string, requests as ADK sends them
LIVE pr-redacted-none/claude-opus-5-5/attempt1 request 1: model=claude-opus-5-5, thinkingConfigAddedByTest=none, messages=[user[text(113)]]
LIVE pr-redacted-none/claude-opus-5-5/attempt1 response 1: stop=end_turn, blocks=[thinking(text=0, sig=1072/93dd5d743bd3), text(117)], usage(in/out)=526/98
LIVE pr-redacted-none/claude-opus-5-5/attempt1 turn 1: completed, events=1, thoughtParts=1, lastTextLength=117
LIVE pr-redacted-none/claude-opus-5-5/attempt1 request 2: model=claude-opus-5-5, thinkingConfigAddedByTest=none, messages=[user[text(113)], assistant[thinking(text=0, sig=1072/93dd5d743bd3), text(117)], user[text(100)]]
LIVE pr-redacted-none/claude-opus-5-5/attempt1 response 2: stop=tool_use, blocks=[tool_use(get_hourly_forecast)], usage(in/out)=671/54
LIVE pr-redacted-none/claude-opus-5-5/attempt1 request 3: model=claude-opus-5-5, thinkingConfigAddedByTest=none, messages=[user[text(113)], assistant[thinking(text=0, sig=1072/93dd5d743bd3), text(117)], user[text(100)], assistant[tool_use(get_hourly_forecast)], user[tool_result]]
LIVE pr-redacted-none/claude-opus-5-5/attempt1 response 3: stop=end_turn, blocks=[thinking(text=0, sig=1096/6d2e782c1a1a), text(235)], usage(in/out)=818/167
LIVE pr-redacted-none/claude-opus-5-5/attempt1 turn 2: completed, events=3, thoughtParts=1, lastTextLength=235
LIVE pr-redacted-none/claude-sonnet-5-5/attempt1 request 1: model=claude-sonnet-5-5, thinkingConfigAddedByTest=none, messages=[user[text(113)]]
LIVE pr-redacted-none/claude-sonnet-5-5/attempt1 response 1: stop=end_turn, blocks=[thinking(text=0, sig=1164/e0c2e7850d5a), text(173)], usage(in/out)=526/135
LIVE pr-redacted-none/claude-sonnet-5-5/attempt1 turn 1: completed, events=1, thoughtParts=1, lastTextLength=173
LIVE pr-redacted-none/claude-sonnet-5-5/attempt1 request 2: model=claude-sonnet-5-5, thinkingConfigAddedByTest=none, messages=[user[text(113)], assistant[thinking(text=0, sig=1164/e0c2e7850d5a), text(173)], user[text(100)]]
LIVE pr-redacted-none/claude-sonnet-5-5/attempt1 response 2: stop=tool_use, blocks=[tool_use(get_hourly_forecast)], usage(in/out)=708/54
LIVE pr-redacted-none/claude-sonnet-5-5/attempt1 request 3: model=claude-sonnet-5-5, thinkingConfigAddedByTest=none, messages=[user[text(113)], assistant[thinking(text=0, sig=1164/e0c2e7850d5a), text(173)], user[text(100)], assistant[tool_use(get_hourly_forecast)], user[tool_result]]
LIVE pr-redacted-none/claude-sonnet-5-5/attempt1 response 3: stop=end_turn, blocks=[text(233)], usage(in/out)=855/108
LIVE pr-redacted-none/claude-sonnet-5-5/attempt1 turn 2: completed, events=3, thoughtParts=0, lastTextLength=233
LIVE pr-redacted-none/claude-haiku-5-5/attempt1 request 1: model=claude-haiku-5-5, thinkingConfigAddedByTest=none, messages=[user[text(113)]]
LIVE pr-redacted-none/claude-haiku-5-5/attempt1 response 1: stop=end_turn, blocks=[thinking(text=0, sig=1404/fafda5d262d9), text(160)], usage(in/out)=526/194
LIVE pr-redacted-none/claude-haiku-5-5/attempt1 turn 1: completed, events=1, thoughtParts=1, lastTextLength=160
LIVE pr-redacted-none/claude-haiku-5-5/attempt1 request 2: model=claude-haiku-5-5, thinkingConfigAddedByTest=none, messages=[user[text(113)], assistant[thinking(text=0, sig=1404/fafda5d262d9), text(160)], user[text(100)]]
LIVE pr-redacted-none/claude-haiku-5-5/attempt1 response 2: stop=tool_use, blocks=[tool_use(get_hourly_forecast)], usage(in/out)=767/54
LIVE pr-redacted-none/claude-haiku-5-5/attempt1 request 3: model=claude-haiku-5-5, thinkingConfigAddedByTest=none, messages=[user[text(113)], assistant[thinking(text=0, sig=1404/fafda5d262d9), text(160)], user[text(100)], assistant[tool_use(get_hourly_forecast)], user[tool_result]]
LIVE pr-redacted-none/claude-haiku-5-5/attempt1 response 3: stop=end_turn, blocks=[thinking(text=0, sig=1088/f3c48d0bf1e1), text(251)], usage(in/out)=914/168
LIVE pr-redacted-none/claude-haiku-5-5/attempt1 turn 2: completed, events=3, thoughtParts=1, lastTextLength=251

Checklist

  • I have read the CONTRIBUTING.md document.
  • My pull request contains a single commit.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • I have manually tested my changes end-to-end.
  • Any dependent changes have been merged and published in downstream modules.

Additional context

  • Thought Parts produced by another model in the same agent's history (the question in Claude model throws UnsupportedOperationException("Not supported yet.") on thinking blocks from Claude 5 models #1630): with this change they go out as text, as before, or are dropped if they have no text, which matches the preserved thinking FAQ ("Send the other model's output as text and tool_use content"). If you would rather drop them, it is a small change.
  • Known limitation, same as ADK Python: on Claude Fable 5.1, Opus 5.5, Sonnet 5.5 and Haiku 5.5, a thinking block that is sent back is valid only while system, tools and the earlier messages are unchanged, and for accounts created on or after 2026-08-31 the API returns a 400 when they change. ADK rebuilds the system instruction on every request (for example {state} placeholders), and Contents keeps every earlier turn's signed thoughts, so once the instruction changes, later requests in that session fail. Sending back only the current turn's thinking blocks would make this much rarer, and thinking.block_binding.prefix_mismatch_behavior: "drop_block" (beta header thinking-binding-controls-2026-08-01) makes the API drop such blocks instead. Both change behavior beyond this fix, so I left them for a follow-up.
  • Not changed here: the thinking, effort and display request settings, the maxTokens default, and streaming (feat: implement streaming support for Claude model #1221, which also needs thinking deltas and signatures).

@hemasekhar-p hemasekhar-p self-assigned this Oct 8, 2026
@hemasekhar-p
hemasekhar-p force-pushed the fix/claude-thinking-blocks branch from 105f79e to e47cf7c Compare October 8, 2026 10:54
@hemasekhar-p

Copy link
Copy Markdown
Contributor

Hi @innoprej, We appreciate your contribution and the effort you put into this pull request. It is currently under review by our team. We will update you if any additional details are needed.

@krwc

krwc commented Oct 12, 2026

Copy link
Copy Markdown

Not run against the live API. With a mocked AnthropicClient, an LlmAgent with a FunctionTool run through InMemoryRunner receives [thinking, tool_use] and then text: the second request sends [thinking (same signature), tool_use], and the run ends with the text.

Why not? How do we know that this change fixes the issue?

@krwc krwc left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please test e2e and add some logs / test results to the PR. Thanks!

@innoprej

Copy link
Copy Markdown
Contributor Author

Fair question. When I opened the PR I didn't have an Anthropic API key set up, so I had only run the mocked tests. I've now run it end to end against the live API with claude-opus-5-5, claude-sonnet-5-5 and claude-haiku-5-5, and added the results and logs under "Manual End-to-End (E2E) Tests" in the description.

In short: on main, every model answered [thinking, text] after a tool result, with empty thinking text and a signature, and each run ended with UnsupportedOperationException: Not supported yet. at Claude.java:391, the failure reported in #1630. With this change the runs completed, including a turn where the model answered [thinking, tool_use] and the next request sent those blocks back unchanged with the tool_result, which the API accepted.

ADK doesn't set thinking, so display stayed at its "omitted" default and the thinking text was always empty in those runs; no redacted_thinking block came back either. Two more runs had the test add a thinking setting to cover these cases: with display: "summarized", the non-empty thinking text went back unchanged, and on claude-sonnet-4-6 with the redacted-thinking test string from Anthropic's extended thinking cookbook, the redacted_thinking blocks went back unchanged, including within a tool-use turn. The API accepted both.

@MiloszSobczyk
MiloszSobczyk self-requested a review October 14, 2026 07:59
RedactedThinkingBlockParam.builder().data(signature.get()).build());
}
// Other thoughts go out as text (ADK Python sends thinking), or not at all if empty.
if (part.text().map(String::isEmpty).orElse(false)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A thought with no text and no usable signature still falls through to UnsupportedOperationException("Not supported yet."), for example a thought part that carries only a binary signature, or Part.builder().thought(true).build(). The description says these are dropped. Could you please use .orElse(true) here (together with the change above) and add a small test for it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. generateContent_thoughtWithoutTextOrUsableSignature_isNotSent sends Part.builder().thought(true).build() and a thought carrying only a binary signature next to a text part; both threw before, and now only the text block goes out.

One case is left: if a thought carrying only a binary signature is the only part of an earlier turn, that message is now empty, which the API rejects (before this change the same turn threw). ADK Python filters out empty messages in google/adk-python@a5d6238cb, the same change as the signature-only part under "Additional context", so I'd cover both there.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please cover both of those cases.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done here, as asked. Following google/adk-python@a5d6238cb, partToAnthropicMessageBlock now drops a non-thought part that carries only a signature (text absent or empty, none of the content fields _is_content_free_signature checks; metadata such as partMetadata doesn't count) and logs the same warning, and generateContent leaves out a message whose blocks were all dropped.

  • generateContent_turnOfOnlyDroppedThoughts_isNotSent: an earlier turn holding only the two thoughts from my test above, which the previous commit sent as an empty message.
  • generateContent_turnOfOnlyContentFreeSignature_isNotSent and generateContent_contentFreeSignature_isNotSent: a signature-only part alone in a turn and next to text, including the text="" shape the Gemini API sends.
  • generateContent_signatureBesideContent_isKept: a signed call and Claude's thinking and redacted thinking still go out.

I've updated the description to match.

Comment thread core/src/main/java/com/google/adk/models/Claude.java Outdated
@innoprej
innoprej force-pushed the fix/claude-thinking-blocks branch from e47cf7c to 40b0a6c Compare October 14, 2026 23:31
@MiloszSobczyk
MiloszSobczyk self-requested a review October 15, 2026 08:18
Claude.anthropicContentBlockToPart converted only text and tool_use
blocks and threw UnsupportedOperationException("Not supported yet.")
for any other block. The Claude 5 models use adaptive thinking when the
request has no thinking field, which ADK Java never sets, so their
responses can contain thinking blocks without being asked, and each
such response ended the agent run.

Convert the blocks the same way as ADK Python:
- thinking -> Part(text, thought=true, thoughtSignature=signature)
- redacted_thinking -> Part(thought=true, thoughtSignature=data)

partToAnthropicMessageBlock sends them back unchanged, which Anthropic
requires within a tool-use turn: a thought with a signature becomes a
thinking block when it has text and a redacted_thinking block when it
has none. Only signatures stored this way, as UTF-8 text, are sent
back. A binary signature that another model such as Gemini left on a
thought is not Claude's, and Anthropic rejects signatures it cannot
decode.

It also ports google/adk-python@a5d6238cb. A non-thought part that
carries only a thought signature, which Gemini leaves in the history,
is dropped with a warning instead of throwing or going out as an empty
text block, and a message whose parts were all dropped is left out,
since Anthropic rejects empty messages. The part stays in the session,
so without this every later turn failed the same way.

Three deliberate differences from ADK Python:
- A thought with empty text (display "omitted", the Claude 5 default)
  goes back as the thinking block it came from. ADK Python treats the
  empty text as absent and sends a redacted_thinking block whose data
  is the signature.
- Any other thought is never sent as a thinking block. It is still
  sent as text, as before, or dropped if it has no text, since there
  is nothing to send back and Anthropic rejects empty text blocks.
  ADK Python sends an unsigned thought with text as a thinking block
  with an empty signature.
- A part with a function call or response is always sent as tool_use
  or tool_result, even if it is a thought or also has text, so that
  the call and its tool_result stay paired. ADK Python checks
  non-empty text and a thought's signature before the call, so the
  call is lost in those cases.
@innoprej
innoprej force-pushed the fix/claude-thinking-blocks branch from 40b0a6c to d1d9dc2 Compare October 15, 2026 13:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Claude model throws UnsupportedOperationException("Not supported yet.") on thinking blocks from Claude 5 models

4 participants