From d1d9dc2ba7841dd436ef4f446321779f9ea910bf Mon Sep 17 00:00:00 2001 From: Jaehee Shin Date: Thu, 8 Oct 2026 17:18:04 +0900 Subject: [PATCH] fix: round-trip thinking blocks in the Claude model 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. --- .../java/com/google/adk/models/Claude.java | 73 ++- .../com/google/adk/models/ClaudeTest.java | 423 ++++++++++++++++++ 2 files changed, 492 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/com/google/adk/models/Claude.java b/core/src/main/java/com/google/adk/models/Claude.java index fe8a4f2e5..72b7c8f3f 100644 --- a/core/src/main/java/com/google/adk/models/Claude.java +++ b/core/src/main/java/com/google/adk/models/Claude.java @@ -16,6 +16,8 @@ package com.google.adk.models; +import static java.nio.charset.StandardCharsets.UTF_8; + import com.anthropic.client.AnthropicClient; import com.anthropic.models.messages.ContentBlock; import com.anthropic.models.messages.ContentBlockParam; @@ -23,7 +25,9 @@ import com.anthropic.models.messages.MessageCreateParams; import com.anthropic.models.messages.MessageParam; import com.anthropic.models.messages.MessageParam.Role; +import com.anthropic.models.messages.RedactedThinkingBlockParam; import com.anthropic.models.messages.TextBlockParam; +import com.anthropic.models.messages.ThinkingBlockParam; import com.anthropic.models.messages.Tool; import com.anthropic.models.messages.ToolChoice; import com.anthropic.models.messages.ToolChoiceAuto; @@ -32,6 +36,7 @@ import com.anthropic.models.messages.ToolUseBlockParam; import com.fasterxml.jackson.core.type.TypeReference; import com.google.adk.JsonBaseModel; +import com.google.common.base.Utf8; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.genai.types.*; @@ -44,6 +49,7 @@ import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -104,6 +110,8 @@ public Flowable generateContent(LlmRequest llmRequest, boolean stre List messages = llmRequest.contents().stream() .map(this::contentToAnthropicMessageParam) + // Anthropic rejects empty messages, e.g. a turn whose parts were all dropped. + .filter(message -> !message.content().asBlockParams().isEmpty()) .collect(Collectors.toList()); List tools = ImmutableList.of(); @@ -173,10 +181,53 @@ private MessageParam contentToAnthropicMessageParam(Content content) { .build(); } - private ContentBlockParam partToAnthropicMessageBlock(Part part) { - if (part.text().isPresent()) { - return ContentBlockParam.ofText(TextBlockParam.builder().text(part.text().get()).build()); - } else if (part.functionCall().isPresent()) { + /** Whether a non-thought part carries a signature and nothing to send, as in ADK Python. */ + private static boolean isContentFreeSignature(Part part) { + return part.thoughtSignature().map(signature -> signature.length > 0).orElse(false) + && !part.thought().orElse(false) + && part.text().map(String::isEmpty).orElse(true) + && part.functionCall().isEmpty() + && part.functionResponse().isEmpty() + && part.inlineData().isEmpty() + && part.fileData().isEmpty() + && part.executableCode().isEmpty() + && part.codeExecutionResult().isEmpty() + && part.toolCall().isEmpty() + && part.toolResponse().isEmpty() + && part.audioTranscription().isEmpty(); + } + + private @Nullable ContentBlockParam partToAnthropicMessageBlock(Part part) { + if (isContentFreeSignature(part)) { + logger.warn("Dropping a thought signature from another model."); + return null; + } + if (part.thought().orElse(false) + && part.functionCall().isEmpty() + && part.functionResponse().isEmpty()) { + // Signatures this class stores are UTF-8; a binary one (e.g. Gemini's) is not Claude's. + Optional signature = + part.thoughtSignature() + .filter(bytes -> bytes.length > 0 && Utf8.isWellFormed(bytes)) + .map(bytes -> new String(bytes, UTF_8)); + if (signature.isPresent()) { + // Unlike ADK Python, empty text (display "omitted") stays thinking, not redacted_thinking. + return part.text().isPresent() + ? ContentBlockParam.ofThinking( + ThinkingBlockParam.builder() + .thinking(part.text().get()) + .signature(signature.get()) + .build()) + : ContentBlockParam.ofRedactedThinking( + RedactedThinkingBlockParam.builder().data(signature.get()).build()); + } + // Other thoughts go out as text (ADK Python sends thinking), or are dropped without text. + if (part.text().map(String::isEmpty).orElse(true)) { + return null; + } + } + // Unlike ADK Python, a call or response is always sent, even on a thought or beside text. + if (part.functionCall().isPresent()) { return ContentBlockParam.ofToolUse( ToolUseBlockParam.builder() .id(part.functionCall().get().id().orElse("")) @@ -205,6 +256,8 @@ private ContentBlockParam partToAnthropicMessageBlock(Part part) { .content(content) .isError(false) .build()); + } else if (part.text().isPresent()) { + return ContentBlockParam.ofText(TextBlockParam.builder().text(part.text().get()).build()); } throw new UnsupportedOperationException("Not supported yet."); } @@ -387,6 +440,18 @@ private Part anthropicContentBlockToPart(ContentBlock block) { .convert(new TypeReference>() {})) .build()) .build(); + } else if (block.isThinking()) { + return Part.builder() + .text(block.asThinking().thinking()) + .thought(true) + .thoughtSignature(block.asThinking().signature().getBytes(UTF_8)) + .build(); + } else if (block.isRedactedThinking()) { + // Keep the encrypted data in thoughtSignature so the block can be sent back unchanged. + return Part.builder() + .thought(true) + .thoughtSignature(block.asRedactedThinking().data().getBytes(UTF_8)) + .build(); } throw new UnsupportedOperationException("Not supported yet."); } diff --git a/core/src/test/java/com/google/adk/models/ClaudeTest.java b/core/src/test/java/com/google/adk/models/ClaudeTest.java index b2dd059fd..42241de11 100644 --- a/core/src/test/java/com/google/adk/models/ClaudeTest.java +++ b/core/src/test/java/com/google/adk/models/ClaudeTest.java @@ -17,44 +17,64 @@ package com.google.adk.models; import static com.google.common.truth.Truth.assertThat; +import static java.nio.charset.StandardCharsets.UTF_8; import static org.junit.Assert.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.anthropic.client.AnthropicClient; import com.anthropic.core.JsonValue; +import com.anthropic.models.messages.ContentBlock; import com.anthropic.models.messages.ContentBlockParam; +import com.anthropic.models.messages.DirectCaller; import com.anthropic.models.messages.Message; +import com.anthropic.models.messages.MessageCreateParams; +import com.anthropic.models.messages.MessageParam; +import com.anthropic.models.messages.RedactedThinkingBlock; +import com.anthropic.models.messages.TextBlock; +import com.anthropic.models.messages.ThinkingBlock; import com.anthropic.models.messages.Tool; import com.anthropic.models.messages.ToolResultBlockParam; +import com.anthropic.models.messages.ToolUseBlock; import com.anthropic.models.messages.Usage; +import com.anthropic.services.blocking.MessageService; import com.fasterxml.jackson.core.type.TypeReference; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.google.genai.types.Content; +import com.google.genai.types.FunctionCall; import com.google.genai.types.FunctionDeclaration; import com.google.genai.types.FunctionResponse; import com.google.genai.types.Part; import com.google.genai.types.Schema; import java.lang.reflect.Method; import java.util.Collections; +import java.util.List; import java.util.Map; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; +import org.mockito.ArgumentCaptor; import org.mockito.Mockito; @RunWith(JUnit4.class) public final class ClaudeTest { private Claude claude; + private MessageService messageService; private Method partToAnthropicMessageBlockMethod; private Method functionDeclarationToAnthropicToolMethod; @Before public void setUp() throws Exception { AnthropicClient mockClient = Mockito.mock(AnthropicClient.class); + messageService = Mockito.mock(MessageService.class); + when(mockClient.messages()).thenReturn(messageService); claude = new Claude("claude-3-opus", mockClient); // Access private method for testing the extraction logic @@ -74,6 +94,54 @@ private static Map inputSchemaProperties(Tool tool) { return properties.convert(new TypeReference>() {}); } + private static Message message(ContentBlock... blocks) { + Message message = mock(Message.class); + when(message.content()).thenReturn(ImmutableList.copyOf(blocks)); + return message; + } + + private static ContentBlock thinkingBlock(String thinking, String signature) { + return ContentBlock.ofThinking( + ThinkingBlock.builder().thinking(thinking).signature(signature).build()); + } + + private static ContentBlock redactedThinkingBlock(String data) { + return ContentBlock.ofRedactedThinking(RedactedThinkingBlock.builder().data(data).build()); + } + + private static ContentBlock textBlock(String text) { + return ContentBlock.ofText( + TextBlock.builder().text(text).citations(ImmutableList.of()).build()); + } + + private static Content userText(String text) { + return Content.builder().role("user").parts(Part.fromText(text)).build(); + } + + private static LlmRequest request(Content... contents) { + return LlmRequest.builder().contents(ImmutableList.copyOf(contents)).build(); + } + + private static String signatureOf(Part part) { + return new String(part.thoughtSignature().get(), UTF_8); + } + + private static FunctionCall weatherCall(String id, String city) { + return FunctionCall.builder() + .id(id) + .name("getWeather") + .args(ImmutableMap.of("city", city)) + .build(); + } + + private static FunctionResponse weatherResult(String id, String weather) { + return FunctionResponse.builder() + .id(id) + .name("getWeather") + .response(ImmutableMap.of("result", weather)) + .build(); + } + @Test public void testPartToAnthropicMessageBlock_mcpTool_legacyTextOutputKey() throws Exception { Map responseData = @@ -260,4 +328,359 @@ public void functionDeclarationToAnthropicTool_preservesRefsAndDefs() throws Exc Map defs = defsValue.convert(new TypeReference>() {}); assertThat(defs).containsKey("Pet"); } + + @Test + public void generateContent_thinkingBlocks_becomeThoughtParts() { + // With display "omitted" (the Claude 5 default) a thinking block has empty text. + Message reply = + message( + thinkingBlock("", "signature"), redactedThinkingBlock("redacted-data"), textBlock("4")); + when(messageService.create(any(MessageCreateParams.class))).thenReturn(reply); + + LlmResponse response = claude.generateContent(request(userText("2+2?")), false).blockingFirst(); + + List parts = response.content().get().parts().get(); + assertThat(parts).hasSize(3); + assertThat(parts.get(0).thought()).hasValue(true); + assertThat(parts.get(0).text()).hasValue(""); + assertThat(signatureOf(parts.get(0))).isEqualTo("signature"); + assertThat(parts.get(1).thought()).hasValue(true); + assertThat(parts.get(1).text()).isEmpty(); + assertThat(signatureOf(parts.get(1))).isEqualTo("redacted-data"); + assertThat(parts.get(2).thought()).isEmpty(); + assertThat(parts.get(2).text()).hasValue("4"); + } + + @Test + public void generateContent_toolTurn_sendsThinkingBlocksBackUnchanged() { + Message toolUseReply = + message( + thinkingBlock("", "signature-1"), + redactedThinkingBlock("redacted-data"), + textBlock("Let me check."), + thinkingBlock("Checking the weather.", "signature-2"), + ContentBlock.ofToolUse( + ToolUseBlock.builder() + .id("toolu_1") + .name("getWeather") + .input(JsonValue.from(ImmutableMap.of("city", "Seoul"))) + .caller(DirectCaller.builder().build()) + .build())); + Message finalReply = message(textBlock("It is sunny.")); + when(messageService.create(any(MessageCreateParams.class))) + .thenReturn(toolUseReply, finalReply); + Content modelTurn = + claude + .generateContent(request(userText("Weather in Seoul?")), false) + .blockingFirst() + .content() + .get(); + Content toolResult = + Content.builder() + .role("user") + .parts( + Part.builder() + .functionResponse( + FunctionResponse.builder() + .id("toolu_1") + .name("getWeather") + .response(ImmutableMap.of("result", "sunny")) + .build()) + .build()) + .build(); + + claude + .generateContent(request(userText("Weather in Seoul?"), modelTurn, toolResult), false) + .blockingFirst(); + + ArgumentCaptor params = ArgumentCaptor.forClass(MessageCreateParams.class); + verify(messageService, times(2)).create(params.capture()); + List blocks = + params.getAllValues().get(1).messages().get(1).content().asBlockParams(); + assertThat(blocks).hasSize(5); + assertThat(blocks.get(0).asThinking().thinking()).isEmpty(); + assertThat(blocks.get(0).asThinking().signature()).isEqualTo("signature-1"); + assertThat(blocks.get(1).asRedactedThinking().data()).isEqualTo("redacted-data"); + assertThat(blocks.get(2).asText().text()).isEqualTo("Let me check."); + assertThat(blocks.get(3).asThinking().thinking()).isEqualTo("Checking the weather."); + assertThat(blocks.get(3).asThinking().signature()).isEqualTo("signature-2"); + assertThat(blocks.get(4).asToolUse().id()).isEqualTo("toolu_1"); + } + + @Test + public void generateContent_emptyThoughtWithoutSignature_isNotSent() { + Message reply = message(textBlock("ok")); + when(messageService.create(any(MessageCreateParams.class))).thenReturn(reply); + Content modelTurn = + Content.builder() + .role("model") + .parts( + Part.builder().text("").thought(true).build(), + Part.builder().text("").thought(true).thoughtSignature(new byte[0]).build(), + Part.fromText("Hello.")) + .build(); + + claude + .generateContent(request(userText("Hi"), modelTurn, userText("Bye")), false) + .blockingFirst(); + + ArgumentCaptor params = ArgumentCaptor.forClass(MessageCreateParams.class); + verify(messageService).create(params.capture()); + // Neither thought has a signature to send back, and Anthropic rejects empty text blocks. + List blocks = params.getValue().messages().get(1).content().asBlockParams(); + assertThat(blocks).hasSize(1); + assertThat(blocks.get(0).asText().text()).isEqualTo("Hello."); + } + + @Test + public void generateContent_thoughtWithBinarySignature_isSentAsText() { + Message reply = message(textBlock("ok")); + when(messageService.create(any(MessageCreateParams.class))).thenReturn(reply); + // Another model (e.g. Gemini) stores a binary signature, which Anthropic could not decode. + Content modelTurn = + Content.builder() + .role("model") + .parts( + Part.builder() + .text("Looking up the weather.") + .thought(true) + .thoughtSignature(new byte[] {(byte) 0xC3, 0x28}) + .build(), + Part.fromText("It is sunny.")) + .build(); + + claude + .generateContent(request(userText("Hi"), modelTurn, userText("Thanks")), false) + .blockingFirst(); + + ArgumentCaptor params = ArgumentCaptor.forClass(MessageCreateParams.class); + verify(messageService).create(params.capture()); + List blocks = params.getValue().messages().get(1).content().asBlockParams(); + assertThat(blocks).hasSize(2); + assertThat(blocks.get(0).asText().text()).isEqualTo("Looking up the weather."); + assertThat(blocks.get(1).asText().text()).isEqualTo("It is sunny."); + } + + @Test + public void generateContent_thoughtWithoutTextOrUsableSignature_isNotSent() { + Message reply = message(textBlock("ok")); + when(messageService.create(any(MessageCreateParams.class))).thenReturn(reply); + Content modelTurn = + Content.builder() + .role("model") + .parts( + Part.builder().thought(true).build(), + Part.builder() + .thought(true) + .thoughtSignature(new byte[] {(byte) 0xC3, 0x28}) + .build(), + Part.fromText("Hello.")) + .build(); + + claude + .generateContent(request(userText("Hi"), modelTurn, userText("Bye")), false) + .blockingFirst(); + + ArgumentCaptor params = ArgumentCaptor.forClass(MessageCreateParams.class); + verify(messageService).create(params.capture()); + List blocks = params.getValue().messages().get(1).content().asBlockParams(); + assertThat(blocks).hasSize(1); + assertThat(blocks.get(0).asText().text()).isEqualTo("Hello."); + } + + @Test + public void generateContent_thoughtFlaggedCallsAndResponses_areSentAsToolUseAndToolResult() { + Message reply = message(textBlock("ok")); + when(messageService.create(any(MessageCreateParams.class))).thenReturn(reply); + byte[] signature = "signature".getBytes(UTF_8); + // A call or response flagged as a thought is still sent, whatever text or signature it has. + Content modelTurn = + Content.builder() + .role("model") + .parts( + Part.builder() + .thought(true) + .thoughtSignature(signature) + .functionCall(weatherCall("toolu_1", "Seoul")) + .build(), + Part.builder() + .text("") + .thought(true) + .functionCall(weatherCall("toolu_2", "Busan")) + .build(), + Part.builder() + .text("Checking Jeju.") + .thought(true) + .thoughtSignature(signature) + .functionCall(weatherCall("toolu_3", "Jeju")) + .build()) + .build(); + Content toolResults = + Content.builder() + .role("user") + .parts( + Part.builder() + .thought(true) + .thoughtSignature(signature) + .functionResponse(weatherResult("toolu_1", "sunny")) + .build(), + Part.builder() + .text("") + .thought(true) + .functionResponse(weatherResult("toolu_2", "rainy")) + .build(), + Part.builder() + .text("Got Jeju.") + .thought(true) + .thoughtSignature(signature) + .functionResponse(weatherResult("toolu_3", "windy")) + .build()) + .build(); + + claude + .generateContent(request(userText("Weather?"), modelTurn, toolResults), false) + .blockingFirst(); + + ArgumentCaptor params = ArgumentCaptor.forClass(MessageCreateParams.class); + verify(messageService).create(params.capture()); + List calls = params.getValue().messages().get(1).content().asBlockParams(); + assertThat(calls).hasSize(3); + assertThat(calls.get(0).asToolUse().id()).isEqualTo("toolu_1"); + assertThat(calls.get(0).asToolUse().name()).isEqualTo("getWeather"); + assertThat(calls.get(0).asToolUse()._input()) + .isEqualTo(JsonValue.from(ImmutableMap.of("city", "Seoul"))); + assertThat(calls.get(1).asToolUse().id()).isEqualTo("toolu_2"); + assertThat(calls.get(1).asToolUse().name()).isEqualTo("getWeather"); + assertThat(calls.get(1).asToolUse()._input()) + .isEqualTo(JsonValue.from(ImmutableMap.of("city", "Busan"))); + assertThat(calls.get(2).asToolUse().id()).isEqualTo("toolu_3"); + assertThat(calls.get(2).asToolUse().name()).isEqualTo("getWeather"); + assertThat(calls.get(2).asToolUse()._input()) + .isEqualTo(JsonValue.from(ImmutableMap.of("city", "Jeju"))); + List results = params.getValue().messages().get(2).content().asBlockParams(); + assertThat(results).hasSize(3); + assertThat(results.get(0).asToolResult().toolUseId()).isEqualTo("toolu_1"); + assertThat(results.get(0).asToolResult().content().get().asString()).isEqualTo("sunny"); + assertThat(results.get(1).asToolResult().toolUseId()).isEqualTo("toolu_2"); + assertThat(results.get(1).asToolResult().content().get().asString()).isEqualTo("rainy"); + assertThat(results.get(2).asToolResult().toolUseId()).isEqualTo("toolu_3"); + assertThat(results.get(2).asToolResult().content().get().asString()).isEqualTo("windy"); + } + + @Test + public void generateContent_contentFreeSignature_isNotSent() { + Message reply = message(textBlock("ok")); + when(messageService.create(any(MessageCreateParams.class))).thenReturn(reply); + // Another model (e.g. Gemini) leaves signatures on parts that carry nothing else. + Content modelTurn = + Content.builder() + .role("model") + .parts( + Part.builder().text("").thoughtSignature(new byte[] {(byte) 0xC3, 0x28}).build(), + Part.builder().thoughtSignature("signature".getBytes(UTF_8)).build(), + Part.fromText("Hello.")) + .build(); + + claude + .generateContent(request(userText("Hi"), modelTurn, userText("Bye")), false) + .blockingFirst(); + + ArgumentCaptor params = ArgumentCaptor.forClass(MessageCreateParams.class); + verify(messageService).create(params.capture()); + List blocks = params.getValue().messages().get(1).content().asBlockParams(); + assertThat(blocks).hasSize(1); + assertThat(blocks.get(0).asText().text()).isEqualTo("Hello."); + } + + @Test + public void generateContent_signatureBesideContent_isKept() { + Message reply = message(textBlock("ok")); + when(messageService.create(any(MessageCreateParams.class))).thenReturn(reply); + Content modelTurn = + Content.builder() + .role("model") + .parts( + Part.builder() + .functionCall(weatherCall("toolu_1", "Seoul")) + .thoughtSignature(new byte[] {(byte) 0xC3, 0x28}) + .build(), + Part.builder() + .text("Checking the weather.") + .thought(true) + .thoughtSignature("signature-1".getBytes(UTF_8)) + .build(), + Part.builder().thought(true).thoughtSignature("redacted-1".getBytes(UTF_8)).build(), + // Metadata is not content, so this part is still dropped. + Part.builder() + .thoughtSignature("signature-2".getBytes(UTF_8)) + .partMetadata(ImmutableMap.of("source", "test")) + .build()) + .build(); + Content toolResult = + Content.builder() + .role("user") + .parts(Part.builder().functionResponse(weatherResult("toolu_1", "sunny")).build()) + .build(); + + claude + .generateContent(request(userText("Weather?"), modelTurn, toolResult), false) + .blockingFirst(); + + ArgumentCaptor params = ArgumentCaptor.forClass(MessageCreateParams.class); + verify(messageService).create(params.capture()); + List blocks = params.getValue().messages().get(1).content().asBlockParams(); + assertThat(blocks).hasSize(3); + assertThat(blocks.get(0).asToolUse().id()).isEqualTo("toolu_1"); + assertThat(blocks.get(1).asThinking().signature()).isEqualTo("signature-1"); + assertThat(blocks.get(2).asRedactedThinking().data()).isEqualTo("redacted-1"); + } + + @Test + public void generateContent_turnOfOnlyDroppedThoughts_isNotSent() { + Message reply = message(textBlock("ok")); + when(messageService.create(any(MessageCreateParams.class))).thenReturn(reply); + Content modelTurn = + Content.builder() + .role("model") + .parts( + Part.builder().thought(true).build(), + Part.builder() + .thought(true) + .thoughtSignature(new byte[] {(byte) 0xC3, 0x28}) + .build()) + .build(); + + claude + .generateContent(request(userText("Hi"), modelTurn, userText("Bye")), false) + .blockingFirst(); + + ArgumentCaptor params = ArgumentCaptor.forClass(MessageCreateParams.class); + verify(messageService).create(params.capture()); + List messages = params.getValue().messages(); + assertThat(messages).hasSize(2); + assertThat(messages.get(0).content().asBlockParams().get(0).asText().text()).isEqualTo("Hi"); + assertThat(messages.get(1).content().asBlockParams().get(0).asText().text()).isEqualTo("Bye"); + } + + @Test + public void generateContent_turnOfOnlyContentFreeSignature_isNotSent() { + Message reply = message(textBlock("ok")); + when(messageService.create(any(MessageCreateParams.class))).thenReturn(reply); + Content modelTurn = + Content.builder() + .role("model") + .parts(Part.builder().thoughtSignature(new byte[] {(byte) 0xC3, 0x28}).build()) + .build(); + + claude + .generateContent(request(userText("Hi"), modelTurn, userText("Bye")), false) + .blockingFirst(); + + ArgumentCaptor params = ArgumentCaptor.forClass(MessageCreateParams.class); + verify(messageService).create(params.capture()); + List messages = params.getValue().messages(); + assertThat(messages).hasSize(2); + assertThat(messages.get(0).content().asBlockParams().get(0).asText().text()).isEqualTo("Hi"); + assertThat(messages.get(1).content().asBlockParams().get(0).asText().text()).isEqualTo("Bye"); + } }