From 44b0bfecd7538ddfcf2646b3b9699497fdbf61f4 Mon Sep 17 00:00:00 2001 From: Alexxigang <37231458+Alexxigang@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:45:54 +0800 Subject: [PATCH] fix(anthropic): tolerate proxy responses without message ids --- .../anthropic/AnthropicResponseParser.java | 453 ++++---- .../anthropic/AnthropicChatFormatterTest.java | 981 +++++++++--------- .../AnthropicResponseParserTest.java | 888 +++++++++------- 3 files changed, 1264 insertions(+), 1058 deletions(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/AnthropicResponseParser.java b/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/AnthropicResponseParser.java index e9f921a90..052094434 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/AnthropicResponseParser.java +++ b/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/AnthropicResponseParser.java @@ -1,210 +1,243 @@ -/* - * Copyright 2024-2026 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.agentscope.core.formatter.anthropic; - -import com.anthropic.core.JsonValue; -import com.anthropic.core.ObjectMappers; -import com.anthropic.models.messages.Message; -import com.anthropic.models.messages.RawMessageStreamEvent; -import io.agentscope.core.message.ContentBlock; -import io.agentscope.core.message.TextBlock; -import io.agentscope.core.message.ThinkingBlock; -import io.agentscope.core.message.ToolUseBlock; -import io.agentscope.core.model.ChatResponse; -import io.agentscope.core.model.ChatUsage; -import io.agentscope.core.util.JsonUtils; -import java.time.Duration; -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import reactor.core.publisher.Flux; - -/** - * Parses Anthropic API responses (both streaming and non-streaming) into AgentScope ChatResponse - * objects. - */ -public class AnthropicResponseParser { - - private static final Logger log = LoggerFactory.getLogger(AnthropicResponseParser.class); - - /** - * Parse non-streaming Anthropic Message to ChatResponse. - */ - public static ChatResponse parseMessage(Message message, Instant startTime) { - List contentBlocks = new ArrayList<>(); - - // Process content blocks - for (var block : message.content()) { - // Text block - block.text() - .ifPresent( - textBlock -> - contentBlocks.add( - TextBlock.builder().text(textBlock.text()).build())); - - // Tool use block - block.toolUse() - .ifPresent( - toolUse -> { - Map input = - parseJsonInput(toolUse._input(), toolUse.name()); - contentBlocks.add( - ToolUseBlock.builder() - .id(toolUse.id()) - .name(toolUse.name()) - .input(input) - .content( - toolUse._input() != null - ? toolUse._input().toString() - : "") - .build()); - }); - - // Thinking block (extended thinking) - block.thinking() - .ifPresent( - thinking -> - contentBlocks.add( - ThinkingBlock.builder() - .thinking(thinking.thinking()) - .build())); - } - - // Parse usage - ChatUsage usage = - ChatUsage.builder() - .inputTokens((int) message.usage().inputTokens()) - .outputTokens((int) message.usage().outputTokens()) - .time(Duration.between(startTime, Instant.now()).toMillis() / 1000.0) - .build(); - - return ChatResponse.builder().id(message.id()).content(contentBlocks).usage(usage).build(); - } - - /** - * Parse streaming Anthropic events to ChatResponse Flux. - */ - public static Flux parseStreamEvents( - Flux eventFlux, Instant startTime) { - return eventFlux - .flatMap( - event -> { - try { - return Flux.just(parseStreamEvent(event, startTime)); - } catch (Exception e) { - log.warn("Error parsing stream event: {}", e.getMessage()); - return Flux.empty(); - } - }) - .filter(response -> response != null && !response.getContent().isEmpty()); - } - - /** - * Parse single stream event. - */ - private static ChatResponse parseStreamEvent(RawMessageStreamEvent event, Instant startTime) { - List contentBlocks = new ArrayList<>(); - ChatUsage usage = null; - String messageId = null; - - // Message start - if (event.isMessageStart()) { - messageId = event.asMessageStart().message().id(); - } - - // Content block delta - text - if (event.isContentBlockDelta()) { - var deltaEvent = event.asContentBlockDelta(); - - deltaEvent - .delta() - .text() - .ifPresent( - textDelta -> - contentBlocks.add( - TextBlock.builder().text(textDelta.text()).build())); - - // Input JSON delta (tool calling) - deltaEvent - .delta() - .inputJson() - .ifPresent( - jsonDelta -> { - // Create fragment ToolUseBlock for accumulation - contentBlocks.add( - ToolUseBlock.builder() - .id("") // Empty ID indicates fragment - .name("__fragment__") // Fragment marker - .content(jsonDelta.partialJson()) - .input(Map.of()) - .build()); - }); - } - - // Content block start - tool use - if (event.isContentBlockStart()) { - var startEvent = event.asContentBlockStart(); - - startEvent - .contentBlock() - .toolUse() - .ifPresent( - toolUse -> { - contentBlocks.add( - ToolUseBlock.builder() - .id(toolUse.id()) - .name(toolUse.name()) - .input(Map.of()) - .content("") - .build()); - }); - } - - // Message delta - usage information - if (event.isMessageDelta()) { - var messageDelta = event.asMessageDelta(); - usage = - ChatUsage.builder() - .outputTokens((int) messageDelta.usage().outputTokens()) - .time(Duration.between(startTime, Instant.now()).toMillis() / 1000.0) - .build(); - } - - return ChatResponse.builder().id(messageId).content(contentBlocks).usage(usage).build(); - } - - /** - * Parse JsonValue to Map for tool input. - */ - private static Map parseJsonInput(JsonValue jsonValue, String toolName) { - if (jsonValue == null) { - return Map.of(); - } - - try { - String jsonString = ObjectMappers.jsonMapper().writeValueAsString(jsonValue); - @SuppressWarnings("unchecked") - Map result = JsonUtils.getJsonCodec().fromJson(jsonString, Map.class); - return result != null ? result : Map.of(); - } catch (Exception e) { - log.warn("Failed to parse tool input JSON for tool {}: {}", toolName, e.getMessage()); - return Map.of(); - } - } -} +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.formatter.anthropic; + +import com.anthropic.core.JsonValue; +import com.anthropic.core.ObjectMappers; +import com.anthropic.models.messages.Message; +import com.anthropic.models.messages.RawMessageStreamEvent; +import io.agentscope.core.message.ContentBlock; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ThinkingBlock; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.ChatUsage; +import io.agentscope.core.util.JsonUtils; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; + +/** + * Parses Anthropic API responses (both streaming and non-streaming) into AgentScope ChatResponse + * objects. + */ +public class AnthropicResponseParser { + + private static final Logger log = LoggerFactory.getLogger(AnthropicResponseParser.class); + + /** + * Parse non-streaming Anthropic Message to ChatResponse. + */ + public static ChatResponse parseMessage(Message message, Instant startTime) { + List contentBlocks = new ArrayList<>(); + + // Process content blocks + for (var block : message.content()) { + // Text block + block.text() + .ifPresent( + textBlock -> + contentBlocks.add( + TextBlock.builder().text(textBlock.text()).build())); + + // Tool use block + block.toolUse() + .ifPresent( + toolUse -> { + Map input = + parseJsonInput(toolUse._input(), toolUse.name()); + contentBlocks.add( + ToolUseBlock.builder() + .id(toolUse.id()) + .name(toolUse.name()) + .input(input) + .content( + toolUse._input() != null + ? toolUse._input().toString() + : "") + .build()); + }); + + // Thinking block (extended thinking) + block.thinking() + .ifPresent( + thinking -> + contentBlocks.add( + ThinkingBlock.builder() + .thinking(thinking.thinking()) + .build())); + } + + // Parse usage + ChatUsage usage = + ChatUsage.builder() + .inputTokens((int) message.usage().inputTokens()) + .outputTokens((int) message.usage().outputTokens()) + .time(Duration.between(startTime, Instant.now()).toMillis() / 1000.0) + .build(); + + return ChatResponse.builder() + .id(safeMessageId(message)) + .content(contentBlocks) + .usage(usage) + .build(); + } + + /** + * Parse streaming Anthropic events to ChatResponse Flux. + */ + public static Flux parseStreamEvents( + Flux eventFlux, Instant startTime) { + return eventFlux + .flatMap( + event -> { + try { + return Flux.just(parseStreamEvent(event, startTime)); + } catch (Exception e) { + log.warn("Error parsing stream event: {}", e.getMessage()); + return Flux.empty(); + } + }) + .filter(response -> response != null && !response.getContent().isEmpty()); + } + + /** + * Parse single stream event. + */ + private static ChatResponse parseStreamEvent(RawMessageStreamEvent event, Instant startTime) { + List contentBlocks = new ArrayList<>(); + ChatUsage usage = null; + String messageId = null; + + // Message start + if (event.isMessageStart()) { + messageId = safeMessageId(event.asMessageStart().message()); + } + + // Content block delta - text + if (event.isContentBlockDelta()) { + var deltaEvent = event.asContentBlockDelta(); + + deltaEvent + .delta() + .text() + .ifPresent( + textDelta -> + contentBlocks.add( + TextBlock.builder().text(textDelta.text()).build())); + + // Input JSON delta (tool calling) + deltaEvent + .delta() + .inputJson() + .ifPresent( + jsonDelta -> { + // Create fragment ToolUseBlock for accumulation + contentBlocks.add( + ToolUseBlock.builder() + .id("") // Empty ID indicates fragment + .name("__fragment__") // Fragment marker + .content(jsonDelta.partialJson()) + .input(Map.of()) + .build()); + }); + } + + // Content block start - tool use + if (event.isContentBlockStart()) { + var startEvent = event.asContentBlockStart(); + + startEvent + .contentBlock() + .toolUse() + .ifPresent( + toolUse -> { + contentBlocks.add( + ToolUseBlock.builder() + .id(toolUse.id()) + .name(toolUse.name()) + .input(Map.of()) + .content("") + .build()); + }); + } + + // Message delta - usage information + if (event.isMessageDelta()) { + var messageDelta = event.asMessageDelta(); + usage = + ChatUsage.builder() + .outputTokens((int) messageDelta.usage().outputTokens()) + .time(Duration.between(startTime, Instant.now()).toMillis() / 1000.0) + .build(); + } + + return ChatResponse.builder().id(messageId).content(contentBlocks).usage(usage).build(); + } + + /** + * Read Anthropic message id without requiring the strict SDK accessor to succeed. + * + *

Some proxy services strip the `id` field from the payload. In that case the SDK's + * `message.id()` accessor throws, but the parser should still be able to produce a + * ChatResponse and let the ChatResponse builder generate a fallback id. + */ + private static String safeMessageId(Message message) { + if (message == null) { + return null; + } + + try { + var rawId = message._id(); + if (rawId != null && !rawId.isMissing()) { + return rawId.asString().orElse(null); + } + } catch (Exception e) { + log.debug("Failed to read Anthropic raw message id: {}", e.getMessage()); + } + + try { + return message.id(); + } catch (Exception e) { + log.debug("Anthropic message id is missing, falling back to generated ChatResponse id"); + return null; + } + } + + /** + * Parse JsonValue to Map for tool input. + */ + private static Map parseJsonInput(JsonValue jsonValue, String toolName) { + if (jsonValue == null) { + return Map.of(); + } + + try { + String jsonString = ObjectMappers.jsonMapper().writeValueAsString(jsonValue); + @SuppressWarnings("unchecked") + Map result = JsonUtils.getJsonCodec().fromJson(jsonString, Map.class); + return result != null ? result : Map.of(); + } catch (Exception e) { + log.warn("Failed to parse tool input JSON for tool {}: {}", toolName, e.getMessage()); + return Map.of(); + } + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicChatFormatterTest.java b/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicChatFormatterTest.java index 588c6b2b7..fdf6e8e06 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicChatFormatterTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicChatFormatterTest.java @@ -1,463 +1,518 @@ -/* - * Copyright 2024-2026 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.agentscope.core.formatter.anthropic; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import com.anthropic.models.messages.ContentBlock; -import com.anthropic.models.messages.ContentBlockParam; -import com.anthropic.models.messages.Message; -import com.anthropic.models.messages.MessageCreateParams; -import com.anthropic.models.messages.MessageParam; -import com.anthropic.models.messages.TextBlockParam; -import com.anthropic.models.messages.Usage; -import io.agentscope.core.message.Msg; -import io.agentscope.core.message.MsgRole; -import io.agentscope.core.message.TextBlock; -import io.agentscope.core.message.ToolResultBlock; -import io.agentscope.core.message.ToolUseBlock; -import io.agentscope.core.model.ChatResponse; -import io.agentscope.core.model.GenerateOptions; -import io.agentscope.core.model.ToolChoice; -import io.agentscope.core.model.ToolSchema; -import java.time.Instant; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -/** Unit tests for AnthropicChatFormatter. */ -class AnthropicChatFormatterTest extends AnthropicFormatterTestBase { - - private AnthropicChatFormatter formatter; - - private static com.anthropic.models.messages.TextBlock mockTextBlock() { - return mock(com.anthropic.models.messages.TextBlock.class); - } - - @BeforeEach - void setUp() { - formatter = new AnthropicChatFormatter(); - } - - @Test - void testFormatSimpleUserMessage() { - Msg msg = - Msg.builder() - .name("User") - .role(MsgRole.USER) - .content(List.of(TextBlock.builder().text("Hello").build())) - .build(); - - List result = formatter.format(List.of(msg)); - - assertNotNull(result); - assertEquals(1, result.size()); - assertEquals(MessageParam.Role.USER, result.get(0).role()); - } - - @Test - void testFormatSystemMessage() { - Msg msg = - Msg.builder() - .name("System") - .role(MsgRole.SYSTEM) - .content(List.of(TextBlock.builder().text("You are helpful").build())) - .build(); - - List result = formatter.format(List.of(msg)); - - assertNotNull(result); - assertEquals(1, result.size()); - // First system message converted to USER - assertEquals(MessageParam.Role.USER, result.get(0).role()); - } - - @Test - void testFormatMultipleMessages() { - Msg userMsg = - Msg.builder() - .name("User") - .role(MsgRole.USER) - .content(List.of(TextBlock.builder().text("Hello").build())) - .build(); - - Msg assistantMsg = - Msg.builder() - .name("Assistant") - .role(MsgRole.ASSISTANT) - .content(List.of(TextBlock.builder().text("Hi").build())) - .build(); - - List result = formatter.format(List.of(userMsg, assistantMsg)); - - assertEquals(2, result.size()); - assertEquals(MessageParam.Role.USER, result.get(0).role()); - assertEquals(MessageParam.Role.ASSISTANT, result.get(1).role()); - } - - @Test - void testFormatEmptyMessageList() { - List result = formatter.format(List.of()); - - assertNotNull(result); - assertTrue(result.isEmpty()); - } - - @Test - void testParseResponseWithMessage() { - // Create mock Message - Message message = mock(Message.class); - Usage usage = mock(Usage.class); - ContentBlock contentBlock = mock(ContentBlock.class); - var textBlock = mockTextBlock(); - - when(message.id()).thenReturn("msg_test"); - when(message.content()).thenReturn(List.of(contentBlock)); - when(message.usage()).thenReturn(usage); - when(usage.inputTokens()).thenReturn(100L); - when(usage.outputTokens()).thenReturn(50L); - - when(contentBlock.text()).thenReturn(Optional.of(textBlock)); - when(contentBlock.toolUse()).thenReturn(Optional.empty()); - when(contentBlock.thinking()).thenReturn(Optional.empty()); - when(textBlock.text()).thenReturn("Response"); - - Instant startTime = Instant.now(); - ChatResponse response = formatter.parseResponse(message, startTime); - - assertNotNull(response); - assertEquals("msg_test", response.getId()); - assertEquals(1, response.getContent().size()); - assertNotNull(response.getUsage()); - } - - @Test - void testParseResponseWithInvalidType() { - // Pass non-Message object should throw exception - String invalidResponse = "not a message"; - Instant startTime = Instant.now(); - - assertThrows( - IllegalArgumentException.class, - () -> formatter.parseResponse(invalidResponse, startTime)); - } - - @Test - void testApplySystemMessage() { - MessageCreateParams.Builder paramsBuilder = MessageCreateParams.builder(); - - Msg systemMsg = - Msg.builder() - .name("System") - .role(MsgRole.SYSTEM) - .content(List.of(TextBlock.builder().text("You are helpful").build())) - .build(); - - formatter.applySystemMessage(paramsBuilder, List.of(systemMsg)); - - // Build and verify system message was set - // Note: Anthropic requires at least one message, add a dummy message - MessageCreateParams params = - paramsBuilder - .model("claude-3-5-sonnet-20241022") - .maxTokens(1024) - .addMessage( - MessageParam.builder() - .role(MessageParam.Role.USER) - .content( - MessageParam.Content.ofBlockParams( - List.of( - ContentBlockParam.ofText( - TextBlockParam.builder() - .text("test") - .build())))) - .build()) - .build(); - - // System message should be present in params - // Note: We can't directly access the system field without building, - // but we can verify no exception was thrown - assertNotNull(params); - } - - @Test - void testApplySystemMessageWithNoSystemMessage() { - MessageCreateParams.Builder paramsBuilder = MessageCreateParams.builder(); - - Msg userMsg = - Msg.builder() - .name("User") - .role(MsgRole.USER) - .content(List.of(TextBlock.builder().text("Hello").build())) - .build(); - - formatter.applySystemMessage(paramsBuilder, List.of(userMsg)); - - // Should handle gracefully with no system message - MessageCreateParams params = - paramsBuilder - .model("claude-3-5-sonnet-20241022") - .maxTokens(1024) - .addMessage( - MessageParam.builder() - .role(MessageParam.Role.USER) - .content( - MessageParam.Content.ofBlockParams( - List.of( - ContentBlockParam.ofText( - TextBlockParam.builder() - .text("test") - .build())))) - .build()) - .build(); - - assertNotNull(params); - } - - @Test - void testApplySystemMessageWithEmptyMessages() { - MessageCreateParams.Builder paramsBuilder = MessageCreateParams.builder(); - - formatter.applySystemMessage(paramsBuilder, List.of()); - - // Should handle empty list gracefully - MessageCreateParams params = - paramsBuilder - .model("claude-3-5-sonnet-20241022") - .maxTokens(1024) - .addMessage( - MessageParam.builder() - .role(MessageParam.Role.USER) - .content( - MessageParam.Content.ofBlockParams( - List.of( - ContentBlockParam.ofText( - TextBlockParam.builder() - .text("test") - .build())))) - .build()) - .build(); - - assertNotNull(params); - } - - @Test - void testApplyOptions() { - MessageCreateParams.Builder paramsBuilder = MessageCreateParams.builder(); - - GenerateOptions options = - GenerateOptions.builder().temperature(0.7).maxTokens(2000).topP(0.9).build(); - - GenerateOptions defaultOptions = GenerateOptions.builder().build(); - - formatter.applyOptions(paramsBuilder, options, defaultOptions); - - // Build params and verify no exception - MessageCreateParams params = - paramsBuilder - .model("claude-3-5-sonnet-20241022") - .addMessage( - MessageParam.builder() - .role(MessageParam.Role.USER) - .content( - MessageParam.Content.ofBlockParams( - List.of( - ContentBlockParam.ofText( - TextBlockParam.builder() - .text("test") - .build())))) - .build()) - .build(); - - assertNotNull(params); - } - - @Test - void testApplyOptionsWithNullOptions() { - MessageCreateParams.Builder paramsBuilder = MessageCreateParams.builder(); - - GenerateOptions defaultOptions = - GenerateOptions.builder().temperature(0.5).maxTokens(1024).build(); - - formatter.applyOptions(paramsBuilder, null, defaultOptions); - - // Should use default options - MessageCreateParams params = - paramsBuilder - .model("claude-3-5-sonnet-20241022") - .addMessage( - MessageParam.builder() - .role(MessageParam.Role.USER) - .content( - MessageParam.Content.ofBlockParams( - List.of( - ContentBlockParam.ofText( - TextBlockParam.builder() - .text("test") - .build())))) - .build()) - .build(); - - assertNotNull(params); - } - - @Test - void testApplyTools() { - MessageCreateParams.Builder paramsBuilder = MessageCreateParams.builder(); - - ToolSchema searchTool = - ToolSchema.builder() - .name("search") - .description("Search the web") - .parameters(Map.of("type", "object", "properties", Map.of())) - .build(); - - // First set options, then apply tools (tools need options for tool_choice) - GenerateOptions options = - GenerateOptions.builder().toolChoice(new ToolChoice.Auto()).build(); - - formatter.applyOptions(paramsBuilder, options, GenerateOptions.builder().build()); - formatter.applyTools(paramsBuilder, List.of(searchTool)); - - // Build params and verify no exception - MessageCreateParams params = - paramsBuilder - .model("claude-3-5-sonnet-20241022") - .maxTokens(1024) - .addMessage( - MessageParam.builder() - .role(MessageParam.Role.USER) - .content( - MessageParam.Content.ofBlockParams( - List.of( - ContentBlockParam.ofText( - TextBlockParam.builder() - .text("test") - .build())))) - .build()) - .build(); - - assertNotNull(params); - } - - @Test - void testApplyToolsWithEmptyList() { - MessageCreateParams.Builder paramsBuilder = MessageCreateParams.builder(); - - GenerateOptions options = GenerateOptions.builder().build(); - formatter.applyOptions(paramsBuilder, options, GenerateOptions.builder().build()); - formatter.applyTools(paramsBuilder, List.of()); - - // Should handle empty tools gracefully - MessageCreateParams params = - paramsBuilder - .model("claude-3-5-sonnet-20241022") - .maxTokens(1024) - .addMessage( - MessageParam.builder() - .role(MessageParam.Role.USER) - .content( - MessageParam.Content.ofBlockParams( - List.of( - ContentBlockParam.ofText( - TextBlockParam.builder() - .text("test") - .build())))) - .build()) - .build(); - - assertNotNull(params); - } - - @Test - void testApplyToolsWithNullList() { - MessageCreateParams.Builder paramsBuilder = MessageCreateParams.builder(); - - GenerateOptions options = GenerateOptions.builder().build(); - formatter.applyOptions(paramsBuilder, options, GenerateOptions.builder().build()); - formatter.applyTools(paramsBuilder, null); - - // Should handle null tools gracefully - MessageCreateParams params = - paramsBuilder - .model("claude-3-5-sonnet-20241022") - .maxTokens(1024) - .addMessage( - MessageParam.builder() - .role(MessageParam.Role.USER) - .content( - MessageParam.Content.ofBlockParams( - List.of( - ContentBlockParam.ofText( - TextBlockParam.builder() - .text("test") - .build())))) - .build()) - .build(); - - assertNotNull(params); - } - - @Test - void testFormatWithToolUseMessage() { - Msg msg = - Msg.builder() - .name("Assistant") - .role(MsgRole.ASSISTANT) - .content( - List.of( - ToolUseBlock.builder() - .id("tool_123") - .name("search") - .input(Map.of("query", "test")) - .build())) - .build(); - - List result = formatter.format(List.of(msg)); - - assertEquals(1, result.size()); - assertEquals(MessageParam.Role.ASSISTANT, result.get(0).role()); - } - - @Test - void testFormatWithToolResultMessage() { - Msg msg = - Msg.builder() - .name("Tool") - .role(MsgRole.TOOL) - .content( - List.of( - ToolResultBlock.builder() - .id("tool_123") - .name("search") - .output(TextBlock.builder().text("Result").build()) - .build())) - .build(); - - List result = formatter.format(List.of(msg)); - - assertEquals(1, result.size()); - // Tool results are converted to USER messages - assertEquals(MessageParam.Role.USER, result.get(0).role()); - } -} +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.formatter.anthropic; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.anthropic.models.messages.ContentBlock; +import com.anthropic.models.messages.ContentBlockParam; +import com.anthropic.models.messages.Message; +import com.anthropic.models.messages.MessageCreateParams; +import com.anthropic.models.messages.MessageParam; +import com.anthropic.models.messages.TextBlockParam; +import com.anthropic.models.messages.Usage; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.ToolChoice; +import io.agentscope.core.model.ToolSchema; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** Unit tests for AnthropicChatFormatter. */ +class AnthropicChatFormatterTest extends AnthropicFormatterTestBase { + + private AnthropicChatFormatter formatter; + + private static com.anthropic.models.messages.TextBlock mockTextBlock() { + return mock(com.anthropic.models.messages.TextBlock.class); + } + + @BeforeEach + void setUp() { + formatter = new AnthropicChatFormatter(); + } + + @Test + void testFormatSimpleUserMessage() { + Msg msg = + Msg.builder() + .name("User") + .role(MsgRole.USER) + .content(List.of(TextBlock.builder().text("Hello").build())) + .build(); + + List result = formatter.format(List.of(msg)); + + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(MessageParam.Role.USER, result.get(0).role()); + } + + @Test + void testFormatSystemMessage() { + Msg msg = + Msg.builder() + .name("System") + .role(MsgRole.SYSTEM) + .content(List.of(TextBlock.builder().text("You are helpful").build())) + .build(); + + List result = formatter.format(List.of(msg)); + + assertNotNull(result); + assertEquals(1, result.size()); + // First system message converted to USER + assertEquals(MessageParam.Role.USER, result.get(0).role()); + } + + @Test + void testFormatMultipleMessages() { + Msg userMsg = + Msg.builder() + .name("User") + .role(MsgRole.USER) + .content(List.of(TextBlock.builder().text("Hello").build())) + .build(); + + Msg assistantMsg = + Msg.builder() + .name("Assistant") + .role(MsgRole.ASSISTANT) + .content(List.of(TextBlock.builder().text("Hi").build())) + .build(); + + List result = formatter.format(List.of(userMsg, assistantMsg)); + + assertEquals(2, result.size()); + assertEquals(MessageParam.Role.USER, result.get(0).role()); + assertEquals(MessageParam.Role.ASSISTANT, result.get(1).role()); + } + + @Test + void testFormatEmptyMessageList() { + List result = formatter.format(List.of()); + + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void testParseResponseWithMessage() { + // Create mock Message + Message message = mock(Message.class); + Usage usage = mock(Usage.class); + ContentBlock contentBlock = mock(ContentBlock.class); + var textBlock = mockTextBlock(); + + when(message.id()).thenReturn("msg_test"); + when(message.content()).thenReturn(List.of(contentBlock)); + when(message.usage()).thenReturn(usage); + when(usage.inputTokens()).thenReturn(100L); + when(usage.outputTokens()).thenReturn(50L); + + when(contentBlock.text()).thenReturn(Optional.of(textBlock)); + when(contentBlock.toolUse()).thenReturn(Optional.empty()); + when(contentBlock.thinking()).thenReturn(Optional.empty()); + when(textBlock.text()).thenReturn("Response"); + + Instant startTime = Instant.now(); + ChatResponse response = formatter.parseResponse(message, startTime); + + assertNotNull(response); + assertEquals("msg_test", response.getId()); + assertEquals(1, response.getContent().size()); + assertNotNull(response.getUsage()); + } + + @Test + void testParseResponseUsesRawJsonFieldIdWhenStrictAccessorThrows() { + Message message = mock(Message.class); + Usage usage = mock(Usage.class); + ContentBlock contentBlock = mock(ContentBlock.class); + var textBlock = mockTextBlock(); + + when(message.id()).thenThrow(new IllegalStateException("id is not set")); + when(message._id()).thenReturn(com.anthropic.core.JsonField.of("msg_formatter_raw_only")); + when(message.content()).thenReturn(List.of(contentBlock)); + when(message.usage()).thenReturn(usage); + when(usage.inputTokens()).thenReturn(12L); + when(usage.outputTokens()).thenReturn(6L); + when(contentBlock.text()).thenReturn(Optional.of(textBlock)); + when(contentBlock.toolUse()).thenReturn(Optional.empty()); + when(contentBlock.thinking()).thenReturn(Optional.empty()); + when(textBlock.text()).thenReturn("Response through formatter"); + + ChatResponse response = formatter.parseResponse(message, Instant.now()); + + assertNotNull(response); + assertEquals("msg_formatter_raw_only", response.getId()); + assertEquals(1, response.getContent().size()); + assertEquals(12, response.getUsage().getInputTokens()); + assertEquals(6, response.getUsage().getOutputTokens()); + } + + @Test + void testParseResponseWithoutIdFallsBackToGeneratedId() { + Message message = mock(Message.class); + Usage usage = mock(Usage.class); + ContentBlock contentBlock = mock(ContentBlock.class); + var textBlock = mockTextBlock(); + + when(message.id()).thenThrow(new IllegalStateException("id is not set")); + when(message._id()).thenReturn(com.anthropic.core.JsonField.ofNullable(null)); + when(message.content()).thenReturn(List.of(contentBlock)); + when(message.usage()).thenReturn(usage); + when(usage.inputTokens()).thenReturn(8L); + when(usage.outputTokens()).thenReturn(4L); + when(contentBlock.text()).thenReturn(Optional.of(textBlock)); + when(contentBlock.toolUse()).thenReturn(Optional.empty()); + when(contentBlock.thinking()).thenReturn(Optional.empty()); + when(textBlock.text()).thenReturn("Response without id"); + + ChatResponse response = formatter.parseResponse(message, Instant.now()); + + assertNotNull(response); + assertNotNull(response.getId()); + assertTrue(!response.getId().isEmpty()); + assertEquals(1, response.getContent().size()); + assertEquals(8, response.getUsage().getInputTokens()); + assertEquals(4, response.getUsage().getOutputTokens()); + } + + @Test + void testParseResponseWithInvalidType() { + // Pass non-Message object should throw exception + String invalidResponse = "not a message"; + Instant startTime = Instant.now(); + + assertThrows( + IllegalArgumentException.class, + () -> formatter.parseResponse(invalidResponse, startTime)); + } + + @Test + void testApplySystemMessage() { + MessageCreateParams.Builder paramsBuilder = MessageCreateParams.builder(); + + Msg systemMsg = + Msg.builder() + .name("System") + .role(MsgRole.SYSTEM) + .content(List.of(TextBlock.builder().text("You are helpful").build())) + .build(); + + formatter.applySystemMessage(paramsBuilder, List.of(systemMsg)); + + // Build and verify system message was set + // Note: Anthropic requires at least one message, add a dummy message + MessageCreateParams params = + paramsBuilder + .model("claude-3-5-sonnet-20241022") + .maxTokens(1024) + .addMessage( + MessageParam.builder() + .role(MessageParam.Role.USER) + .content( + MessageParam.Content.ofBlockParams( + List.of( + ContentBlockParam.ofText( + TextBlockParam.builder() + .text("test") + .build())))) + .build()) + .build(); + + // System message should be present in params + // Note: We can't directly access the system field without building, + // but we can verify no exception was thrown + assertNotNull(params); + } + + @Test + void testApplySystemMessageWithNoSystemMessage() { + MessageCreateParams.Builder paramsBuilder = MessageCreateParams.builder(); + + Msg userMsg = + Msg.builder() + .name("User") + .role(MsgRole.USER) + .content(List.of(TextBlock.builder().text("Hello").build())) + .build(); + + formatter.applySystemMessage(paramsBuilder, List.of(userMsg)); + + // Should handle gracefully with no system message + MessageCreateParams params = + paramsBuilder + .model("claude-3-5-sonnet-20241022") + .maxTokens(1024) + .addMessage( + MessageParam.builder() + .role(MessageParam.Role.USER) + .content( + MessageParam.Content.ofBlockParams( + List.of( + ContentBlockParam.ofText( + TextBlockParam.builder() + .text("test") + .build())))) + .build()) + .build(); + + assertNotNull(params); + } + + @Test + void testApplySystemMessageWithEmptyMessages() { + MessageCreateParams.Builder paramsBuilder = MessageCreateParams.builder(); + + formatter.applySystemMessage(paramsBuilder, List.of()); + + // Should handle empty list gracefully + MessageCreateParams params = + paramsBuilder + .model("claude-3-5-sonnet-20241022") + .maxTokens(1024) + .addMessage( + MessageParam.builder() + .role(MessageParam.Role.USER) + .content( + MessageParam.Content.ofBlockParams( + List.of( + ContentBlockParam.ofText( + TextBlockParam.builder() + .text("test") + .build())))) + .build()) + .build(); + + assertNotNull(params); + } + + @Test + void testApplyOptions() { + MessageCreateParams.Builder paramsBuilder = MessageCreateParams.builder(); + + GenerateOptions options = + GenerateOptions.builder().temperature(0.7).maxTokens(2000).topP(0.9).build(); + + GenerateOptions defaultOptions = GenerateOptions.builder().build(); + + formatter.applyOptions(paramsBuilder, options, defaultOptions); + + // Build params and verify no exception + MessageCreateParams params = + paramsBuilder + .model("claude-3-5-sonnet-20241022") + .addMessage( + MessageParam.builder() + .role(MessageParam.Role.USER) + .content( + MessageParam.Content.ofBlockParams( + List.of( + ContentBlockParam.ofText( + TextBlockParam.builder() + .text("test") + .build())))) + .build()) + .build(); + + assertNotNull(params); + } + + @Test + void testApplyOptionsWithNullOptions() { + MessageCreateParams.Builder paramsBuilder = MessageCreateParams.builder(); + + GenerateOptions defaultOptions = + GenerateOptions.builder().temperature(0.5).maxTokens(1024).build(); + + formatter.applyOptions(paramsBuilder, null, defaultOptions); + + // Should use default options + MessageCreateParams params = + paramsBuilder + .model("claude-3-5-sonnet-20241022") + .addMessage( + MessageParam.builder() + .role(MessageParam.Role.USER) + .content( + MessageParam.Content.ofBlockParams( + List.of( + ContentBlockParam.ofText( + TextBlockParam.builder() + .text("test") + .build())))) + .build()) + .build(); + + assertNotNull(params); + } + + @Test + void testApplyTools() { + MessageCreateParams.Builder paramsBuilder = MessageCreateParams.builder(); + + ToolSchema searchTool = + ToolSchema.builder() + .name("search") + .description("Search the web") + .parameters(Map.of("type", "object", "properties", Map.of())) + .build(); + + // First set options, then apply tools (tools need options for tool_choice) + GenerateOptions options = + GenerateOptions.builder().toolChoice(new ToolChoice.Auto()).build(); + + formatter.applyOptions(paramsBuilder, options, GenerateOptions.builder().build()); + formatter.applyTools(paramsBuilder, List.of(searchTool)); + + // Build params and verify no exception + MessageCreateParams params = + paramsBuilder + .model("claude-3-5-sonnet-20241022") + .maxTokens(1024) + .addMessage( + MessageParam.builder() + .role(MessageParam.Role.USER) + .content( + MessageParam.Content.ofBlockParams( + List.of( + ContentBlockParam.ofText( + TextBlockParam.builder() + .text("test") + .build())))) + .build()) + .build(); + + assertNotNull(params); + } + + @Test + void testApplyToolsWithEmptyList() { + MessageCreateParams.Builder paramsBuilder = MessageCreateParams.builder(); + + GenerateOptions options = GenerateOptions.builder().build(); + formatter.applyOptions(paramsBuilder, options, GenerateOptions.builder().build()); + formatter.applyTools(paramsBuilder, List.of()); + + // Should handle empty tools gracefully + MessageCreateParams params = + paramsBuilder + .model("claude-3-5-sonnet-20241022") + .maxTokens(1024) + .addMessage( + MessageParam.builder() + .role(MessageParam.Role.USER) + .content( + MessageParam.Content.ofBlockParams( + List.of( + ContentBlockParam.ofText( + TextBlockParam.builder() + .text("test") + .build())))) + .build()) + .build(); + + assertNotNull(params); + } + + @Test + void testApplyToolsWithNullList() { + MessageCreateParams.Builder paramsBuilder = MessageCreateParams.builder(); + + GenerateOptions options = GenerateOptions.builder().build(); + formatter.applyOptions(paramsBuilder, options, GenerateOptions.builder().build()); + formatter.applyTools(paramsBuilder, null); + + // Should handle null tools gracefully + MessageCreateParams params = + paramsBuilder + .model("claude-3-5-sonnet-20241022") + .maxTokens(1024) + .addMessage( + MessageParam.builder() + .role(MessageParam.Role.USER) + .content( + MessageParam.Content.ofBlockParams( + List.of( + ContentBlockParam.ofText( + TextBlockParam.builder() + .text("test") + .build())))) + .build()) + .build(); + + assertNotNull(params); + } + + @Test + void testFormatWithToolUseMessage() { + Msg msg = + Msg.builder() + .name("Assistant") + .role(MsgRole.ASSISTANT) + .content( + List.of( + ToolUseBlock.builder() + .id("tool_123") + .name("search") + .input(Map.of("query", "test")) + .build())) + .build(); + + List result = formatter.format(List.of(msg)); + + assertEquals(1, result.size()); + assertEquals(MessageParam.Role.ASSISTANT, result.get(0).role()); + } + + @Test + void testFormatWithToolResultMessage() { + Msg msg = + Msg.builder() + .name("Tool") + .role(MsgRole.TOOL) + .content( + List.of( + ToolResultBlock.builder() + .id("tool_123") + .name("search") + .output(TextBlock.builder().text("Result").build()) + .build())) + .build(); + + List result = formatter.format(List.of(msg)); + + assertEquals(1, result.size()); + // Tool results are converted to USER messages + assertEquals(MessageParam.Role.USER, result.get(0).role()); + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicResponseParserTest.java b/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicResponseParserTest.java index bcb9c05dc..a5d8ad3c4 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicResponseParserTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicResponseParserTest.java @@ -1,385 +1,503 @@ -/* - * Copyright 2024-2026 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.agentscope.core.formatter.anthropic; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import com.anthropic.models.messages.ContentBlock; -import com.anthropic.models.messages.Message; -import com.anthropic.models.messages.RawMessageStartEvent; -import com.anthropic.models.messages.RawMessageStreamEvent; -import com.anthropic.models.messages.Usage; -import io.agentscope.core.message.TextBlock; -import io.agentscope.core.message.ThinkingBlock; -import io.agentscope.core.message.ToolUseBlock; -import io.agentscope.core.model.ChatResponse; -import io.agentscope.core.model.ChatUsage; -import java.lang.reflect.Method; -import java.time.Instant; -import java.util.List; -import java.util.Optional; -import org.junit.jupiter.api.Test; -import reactor.core.publisher.Flux; -import reactor.test.StepVerifier; - -/** Unit tests for AnthropicResponseParser. */ -class AnthropicResponseParserTest extends AnthropicFormatterTestBase { - - private static com.anthropic.models.messages.TextBlock mockTextBlock() { - return mock(com.anthropic.models.messages.TextBlock.class); - } - - private static com.anthropic.models.messages.ThinkingBlock mockThinkingBlock() { - return mock(com.anthropic.models.messages.ThinkingBlock.class); - } - - private static com.anthropic.models.messages.ToolUseBlock mockToolUseBlock() { - return mock(com.anthropic.models.messages.ToolUseBlock.class); - } - - /** - * Use reflection to call private parseStreamEvent method for unit testing individual event - * types. - */ - private ChatResponse invokeParseStreamEvent(RawMessageStreamEvent event, Instant startTime) - throws Exception { - Method method = - AnthropicResponseParser.class.getDeclaredMethod( - "parseStreamEvent", RawMessageStreamEvent.class, Instant.class); - method.setAccessible(true); - return (ChatResponse) method.invoke(null, event, startTime); - } - - @Test - void testParseMessageWithTextBlock() { - // Create mock Message with text content - Message message = mock(Message.class); - Usage usage = mock(Usage.class); - ContentBlock contentBlock = mock(ContentBlock.class); - var textBlock = mockTextBlock(); - - when(message.id()).thenReturn("msg_123"); - when(message.content()).thenReturn(List.of(contentBlock)); - when(message.usage()).thenReturn(usage); - when(usage.inputTokens()).thenReturn(100L); - when(usage.outputTokens()).thenReturn(50L); - - when(contentBlock.text()).thenReturn(Optional.of(textBlock)); - when(contentBlock.toolUse()).thenReturn(Optional.empty()); - when(contentBlock.thinking()).thenReturn(Optional.empty()); - when(textBlock.text()).thenReturn("Hello, world!"); - - Instant startTime = Instant.now(); - ChatResponse response = AnthropicResponseParser.parseMessage(message, startTime); - - assertNotNull(response); - assertEquals("msg_123", response.getId()); - assertEquals(1, response.getContent().size()); - TextBlock parsedText = assertInstanceOf(TextBlock.class, response.getContent().get(0)); - assertEquals("Hello, world!", parsedText.getText()); - - ChatUsage responseUsage = response.getUsage(); - assertNotNull(responseUsage); - assertEquals(100, responseUsage.getInputTokens()); - assertEquals(50, responseUsage.getOutputTokens()); - } - - @Test - void testParseMessageWithToolUseBlock() { - // Create mock Message with tool use content - // Note: We use null input to avoid Kotlin reflection issues with JsonValue mocking - Message message = mock(Message.class); - Usage usage = mock(Usage.class); - ContentBlock contentBlock = mock(ContentBlock.class); - var toolUseBlock = mockToolUseBlock(); - - when(message.id()).thenReturn("msg_456"); - when(message.content()).thenReturn(List.of(contentBlock)); - when(message.usage()).thenReturn(usage); - when(usage.inputTokens()).thenReturn(200L); - when(usage.outputTokens()).thenReturn(100L); - - when(contentBlock.text()).thenReturn(Optional.empty()); - when(contentBlock.toolUse()).thenReturn(Optional.of(toolUseBlock)); - when(contentBlock.thinking()).thenReturn(Optional.empty()); - - when(toolUseBlock.id()).thenReturn("tool_call_123"); - when(toolUseBlock.name()).thenReturn("search"); - when(toolUseBlock._input()).thenReturn(null); // Avoid Kotlin reflection issues - - Instant startTime = Instant.now(); - ChatResponse response = AnthropicResponseParser.parseMessage(message, startTime); - - assertNotNull(response); - assertEquals("msg_456", response.getId()); - assertEquals(1, response.getContent().size()); - ToolUseBlock parsedToolUse = - assertInstanceOf(ToolUseBlock.class, response.getContent().get(0)); - assertEquals("tool_call_123", parsedToolUse.getId()); - assertEquals("search", parsedToolUse.getName()); - assertNotNull(parsedToolUse.getInput()); - // Null input should result in empty map - assertTrue(parsedToolUse.getInput().isEmpty()); - } - - @Test - void testParseMessageWithThinkingBlock() { - // Create mock Message with thinking content - Message message = mock(Message.class); - Usage usage = mock(Usage.class); - ContentBlock contentBlock = mock(ContentBlock.class); - var thinkingBlock = mockThinkingBlock(); - - when(message.id()).thenReturn("msg_789"); - when(message.content()).thenReturn(List.of(contentBlock)); - when(message.usage()).thenReturn(usage); - when(usage.inputTokens()).thenReturn(150L); - when(usage.outputTokens()).thenReturn(75L); - - when(contentBlock.text()).thenReturn(Optional.empty()); - when(contentBlock.toolUse()).thenReturn(Optional.empty()); - when(contentBlock.thinking()).thenReturn(Optional.of(thinkingBlock)); - when(thinkingBlock.thinking()).thenReturn("Let me think about this..."); - - Instant startTime = Instant.now(); - ChatResponse response = AnthropicResponseParser.parseMessage(message, startTime); - - assertNotNull(response); - assertEquals("msg_789", response.getId()); - assertEquals(1, response.getContent().size()); - ThinkingBlock parsedThinking = - assertInstanceOf(ThinkingBlock.class, response.getContent().get(0)); - assertEquals("Let me think about this...", parsedThinking.getThinking()); - } - - @Test - void testParseMessageWithMixedContent() { - // Create mock Message with multiple content blocks - Message message = mock(Message.class); - Usage usage = mock(Usage.class); - - ContentBlock textContentBlock = mock(ContentBlock.class); - var textBlock = mockTextBlock(); - - ContentBlock toolContentBlock = mock(ContentBlock.class); - var toolUseBlock = mockToolUseBlock(); - - when(message.id()).thenReturn("msg_mixed"); - when(message.content()).thenReturn(List.of(textContentBlock, toolContentBlock)); - when(message.usage()).thenReturn(usage); - when(usage.inputTokens()).thenReturn(300L); - when(usage.outputTokens()).thenReturn(150L); - - // Text block - when(textContentBlock.text()).thenReturn(Optional.of(textBlock)); - when(textContentBlock.toolUse()).thenReturn(Optional.empty()); - when(textContentBlock.thinking()).thenReturn(Optional.empty()); - when(textBlock.text()).thenReturn("Let me search for that."); - - // Tool use block - use null input to avoid Kotlin reflection issues - when(toolContentBlock.text()).thenReturn(Optional.empty()); - when(toolContentBlock.toolUse()).thenReturn(Optional.of(toolUseBlock)); - when(toolContentBlock.thinking()).thenReturn(Optional.empty()); - when(toolUseBlock.id()).thenReturn("tool_xyz"); - when(toolUseBlock.name()).thenReturn("web_search"); - when(toolUseBlock._input()).thenReturn(null); // Avoid Kotlin reflection issues - - Instant startTime = Instant.now(); - ChatResponse response = AnthropicResponseParser.parseMessage(message, startTime); - - assertNotNull(response); - assertEquals("msg_mixed", response.getId()); - assertEquals(2, response.getContent().size()); - - assertInstanceOf(TextBlock.class, response.getContent().get(0)); - assertInstanceOf(ToolUseBlock.class, response.getContent().get(1)); - } - - @Test - void testParseMessageWithEmptyContent() { - // Create mock Message with no content - Message message = mock(Message.class); - Usage usage = mock(Usage.class); - - when(message.id()).thenReturn("msg_empty"); - when(message.content()).thenReturn(List.of()); - when(message.usage()).thenReturn(usage); - when(usage.inputTokens()).thenReturn(50L); - when(usage.outputTokens()).thenReturn(0L); - - Instant startTime = Instant.now(); - ChatResponse response = AnthropicResponseParser.parseMessage(message, startTime); - - assertNotNull(response); - assertEquals("msg_empty", response.getId()); - assertTrue(response.getContent().isEmpty()); - } - - @Test - void testParseMessageWithNullToolInput() { - // Create mock Message with null tool input - Message message = mock(Message.class); - Usage usage = mock(Usage.class); - ContentBlock contentBlock = mock(ContentBlock.class); - var toolUseBlock = mockToolUseBlock(); - - when(message.id()).thenReturn("msg_null_input"); - when(message.content()).thenReturn(List.of(contentBlock)); - when(message.usage()).thenReturn(usage); - when(usage.inputTokens()).thenReturn(100L); - when(usage.outputTokens()).thenReturn(50L); - - when(contentBlock.text()).thenReturn(Optional.empty()); - when(contentBlock.toolUse()).thenReturn(Optional.of(toolUseBlock)); - when(contentBlock.thinking()).thenReturn(Optional.empty()); - - when(toolUseBlock.id()).thenReturn("tool_null"); - when(toolUseBlock.name()).thenReturn("test_tool"); - when(toolUseBlock._input()).thenReturn(null); - - Instant startTime = Instant.now(); - ChatResponse response = AnthropicResponseParser.parseMessage(message, startTime); - - assertNotNull(response); - assertEquals(1, response.getContent().size()); - - ToolUseBlock parsedToolUse = - assertInstanceOf(ToolUseBlock.class, response.getContent().get(0)); - assertEquals("tool_null", parsedToolUse.getId()); - assertEquals("test_tool", parsedToolUse.getName()); - // Null input should result in empty map - assertNotNull(parsedToolUse.getInput()); - assertTrue(parsedToolUse.getInput().isEmpty()); - } - - @Test - void testParseStreamEventsMessageStart() { - // Create mock MessageStart event - RawMessageStreamEvent event = mock(RawMessageStreamEvent.class); - RawMessageStartEvent messageStartEvent = mock(RawMessageStartEvent.class); - Message message = mock(Message.class); - - when(event.isMessageStart()).thenReturn(true); - when(event.asMessageStart()).thenReturn(messageStartEvent); - when(messageStartEvent.message()).thenReturn(message); - when(message.id()).thenReturn("msg_stream_123"); - - Instant startTime = Instant.now(); - Flux responseFlux = - AnthropicResponseParser.parseStreamEvents(Flux.just(event), startTime); - - // MessageStart events should be filtered out (empty content) - StepVerifier.create(responseFlux).verifyComplete(); - } - - @Test - void testParseStreamEventMessageStart() throws Exception { - // Test MessageStart event - should set message ID but have empty content - RawMessageStreamEvent event = mock(RawMessageStreamEvent.class); - RawMessageStartEvent messageStart = mock(RawMessageStartEvent.class); - Message message = mock(Message.class); - - when(event.isMessageStart()).thenReturn(true); - when(event.asMessageStart()).thenReturn(messageStart); - when(messageStart.message()).thenReturn(message); - when(message.id()).thenReturn("msg_stream_123"); - - when(event.isContentBlockDelta()).thenReturn(false); - when(event.isContentBlockStart()).thenReturn(false); - when(event.isMessageDelta()).thenReturn(false); - - Instant startTime = Instant.now(); - ChatResponse response = invokeParseStreamEvent(event, startTime); - - assertNotNull(response); - assertEquals("msg_stream_123", response.getId()); - assertTrue(response.getContent().isEmpty()); // MessageStart has no content - } - - @Test - void testParseStreamEventUnknownType() throws Exception { - // Test unknown event type - should return empty response - RawMessageStreamEvent event = mock(RawMessageStreamEvent.class); - - when(event.isMessageStart()).thenReturn(false); - when(event.isContentBlockDelta()).thenReturn(false); - when(event.isContentBlockStart()).thenReturn(false); - when(event.isMessageDelta()).thenReturn(false); - - Instant startTime = Instant.now(); - ChatResponse response = invokeParseStreamEvent(event, startTime); - - assertNotNull(response); - assertNotNull(response.getId()); // Builder auto-generates UUID when id is null - assertFalse(response.getId().isEmpty()); - assertTrue(response.getContent().isEmpty()); - assertNull(response.getUsage()); - } - - @Test - void testParseStreamEventsFiltersEmptyContent() { - // Test that parseStreamEvents filters out responses with empty content - RawMessageStreamEvent event = mock(RawMessageStreamEvent.class); - - when(event.isMessageStart()).thenReturn(false); - when(event.isContentBlockDelta()).thenReturn(false); - when(event.isContentBlockStart()).thenReturn(false); - when(event.isMessageDelta()).thenReturn(false); - - Instant startTime = Instant.now(); - Flux responseFlux = - AnthropicResponseParser.parseStreamEvents(Flux.just(event), startTime); - - // Empty content responses should be filtered out - StepVerifier.create(responseFlux).verifyComplete(); - } - - @Test - void testParseStreamEventsHandlesExceptions() { - // Test that exceptions in parsing are caught and logged - RawMessageStreamEvent event = mock(RawMessageStreamEvent.class); - - // Make the event throw an exception - when(event.isMessageStart()).thenThrow(new RuntimeException("Test exception")); - - Instant startTime = Instant.now(); - Flux responseFlux = - AnthropicResponseParser.parseStreamEvents(Flux.just(event), startTime); - - // Exception should be caught and result in empty flux - StepVerifier.create(responseFlux).verifyComplete(); - } - - @Test - void testParseStreamEventsErrorHandling() { - // Create a Flux that emits an error - Flux errorFlux = Flux.error(new RuntimeException("Stream error")); - - Instant startTime = Instant.now(); - - // parseStreamEvents should propagate errors - StepVerifier.create(AnthropicResponseParser.parseStreamEvents(errorFlux, startTime)) - .expectError(RuntimeException.class) - .verify(); - } -} +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.formatter.anthropic; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.anthropic.models.messages.ContentBlock; +import com.anthropic.models.messages.Message; +import com.anthropic.models.messages.RawMessageStartEvent; +import com.anthropic.models.messages.RawMessageStreamEvent; +import com.anthropic.models.messages.Usage; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ThinkingBlock; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.ChatUsage; +import java.lang.reflect.Method; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +/** Unit tests for AnthropicResponseParser. */ +class AnthropicResponseParserTest extends AnthropicFormatterTestBase { + + private static com.anthropic.models.messages.TextBlock mockTextBlock() { + return mock(com.anthropic.models.messages.TextBlock.class); + } + + private static com.anthropic.models.messages.ThinkingBlock mockThinkingBlock() { + return mock(com.anthropic.models.messages.ThinkingBlock.class); + } + + private static com.anthropic.models.messages.ToolUseBlock mockToolUseBlock() { + return mock(com.anthropic.models.messages.ToolUseBlock.class); + } + + /** + * Use reflection to call private parseStreamEvent method for unit testing individual event + * types. + */ + private ChatResponse invokeParseStreamEvent(RawMessageStreamEvent event, Instant startTime) + throws Exception { + Method method = + AnthropicResponseParser.class.getDeclaredMethod( + "parseStreamEvent", RawMessageStreamEvent.class, Instant.class); + method.setAccessible(true); + return (ChatResponse) method.invoke(null, event, startTime); + } + + @Test + void testParseMessageWithTextBlock() { + // Create mock Message with text content + Message message = mock(Message.class); + Usage usage = mock(Usage.class); + ContentBlock contentBlock = mock(ContentBlock.class); + var textBlock = mockTextBlock(); + + when(message.id()).thenReturn("msg_123"); + when(message.content()).thenReturn(List.of(contentBlock)); + when(message.usage()).thenReturn(usage); + when(usage.inputTokens()).thenReturn(100L); + when(usage.outputTokens()).thenReturn(50L); + + when(contentBlock.text()).thenReturn(Optional.of(textBlock)); + when(contentBlock.toolUse()).thenReturn(Optional.empty()); + when(contentBlock.thinking()).thenReturn(Optional.empty()); + when(textBlock.text()).thenReturn("Hello, world!"); + + Instant startTime = Instant.now(); + ChatResponse response = AnthropicResponseParser.parseMessage(message, startTime); + + assertNotNull(response); + assertEquals("msg_123", response.getId()); + assertEquals(1, response.getContent().size()); + TextBlock parsedText = assertInstanceOf(TextBlock.class, response.getContent().get(0)); + assertEquals("Hello, world!", parsedText.getText()); + + ChatUsage responseUsage = response.getUsage(); + assertNotNull(responseUsage); + assertEquals(100, responseUsage.getInputTokens()); + assertEquals(50, responseUsage.getOutputTokens()); + } + + @Test + void testParseMessageUsesRawJsonFieldIdWhenStrictAccessorThrows() { + Message message = mock(Message.class); + Usage usage = mock(Usage.class); + ContentBlock contentBlock = mock(ContentBlock.class); + var textBlock = mockTextBlock(); + + when(message.id()).thenThrow(new IllegalStateException("id is not set")); + when(message._id()).thenReturn(com.anthropic.core.JsonField.of("msg_raw_only")); + when(message.content()).thenReturn(List.of(contentBlock)); + when(message.usage()).thenReturn(usage); + when(usage.inputTokens()).thenReturn(10L); + when(usage.outputTokens()).thenReturn(5L); + when(contentBlock.text()).thenReturn(Optional.of(textBlock)); + when(contentBlock.toolUse()).thenReturn(Optional.empty()); + when(contentBlock.thinking()).thenReturn(Optional.empty()); + when(textBlock.text()).thenReturn("proxy-safe"); + + ChatResponse response = AnthropicResponseParser.parseMessage(message, Instant.now()); + + assertNotNull(response); + assertEquals("msg_raw_only", response.getId()); + assertEquals(1, response.getContent().size()); + } + + @Test + void testParseMessageWithoutIdFallsBackToGeneratedId() { + Message message = mock(Message.class); + Usage usage = mock(Usage.class); + ContentBlock contentBlock = mock(ContentBlock.class); + var textBlock = mockTextBlock(); + + when(message.id()).thenThrow(new IllegalStateException("id is not set")); + when(message._id()).thenReturn(com.anthropic.core.JsonField.ofNullable(null)); + when(message.content()).thenReturn(List.of(contentBlock)); + when(message.usage()).thenReturn(usage); + when(usage.inputTokens()).thenReturn(10L); + when(usage.outputTokens()).thenReturn(5L); + when(contentBlock.text()).thenReturn(Optional.of(textBlock)); + when(contentBlock.toolUse()).thenReturn(Optional.empty()); + when(contentBlock.thinking()).thenReturn(Optional.empty()); + when(textBlock.text()).thenReturn("proxy-without-id"); + + ChatResponse response = AnthropicResponseParser.parseMessage(message, Instant.now()); + + assertNotNull(response); + assertNotNull(response.getId()); + assertFalse(response.getId().isEmpty()); + assertEquals(1, response.getContent().size()); + } + + @Test + void testParseMessageWithToolUseBlock() { + // Create mock Message with tool use content + // Note: We use null input to avoid Kotlin reflection issues with JsonValue mocking + Message message = mock(Message.class); + Usage usage = mock(Usage.class); + ContentBlock contentBlock = mock(ContentBlock.class); + var toolUseBlock = mockToolUseBlock(); + + when(message.id()).thenReturn("msg_456"); + when(message.content()).thenReturn(List.of(contentBlock)); + when(message.usage()).thenReturn(usage); + when(usage.inputTokens()).thenReturn(200L); + when(usage.outputTokens()).thenReturn(100L); + + when(contentBlock.text()).thenReturn(Optional.empty()); + when(contentBlock.toolUse()).thenReturn(Optional.of(toolUseBlock)); + when(contentBlock.thinking()).thenReturn(Optional.empty()); + + when(toolUseBlock.id()).thenReturn("tool_call_123"); + when(toolUseBlock.name()).thenReturn("search"); + when(toolUseBlock._input()).thenReturn(null); // Avoid Kotlin reflection issues + + Instant startTime = Instant.now(); + ChatResponse response = AnthropicResponseParser.parseMessage(message, startTime); + + assertNotNull(response); + assertEquals("msg_456", response.getId()); + assertEquals(1, response.getContent().size()); + ToolUseBlock parsedToolUse = + assertInstanceOf(ToolUseBlock.class, response.getContent().get(0)); + assertEquals("tool_call_123", parsedToolUse.getId()); + assertEquals("search", parsedToolUse.getName()); + assertNotNull(parsedToolUse.getInput()); + // Null input should result in empty map + assertTrue(parsedToolUse.getInput().isEmpty()); + } + + @Test + void testParseMessageWithThinkingBlock() { + // Create mock Message with thinking content + Message message = mock(Message.class); + Usage usage = mock(Usage.class); + ContentBlock contentBlock = mock(ContentBlock.class); + var thinkingBlock = mockThinkingBlock(); + + when(message.id()).thenReturn("msg_789"); + when(message.content()).thenReturn(List.of(contentBlock)); + when(message.usage()).thenReturn(usage); + when(usage.inputTokens()).thenReturn(150L); + when(usage.outputTokens()).thenReturn(75L); + + when(contentBlock.text()).thenReturn(Optional.empty()); + when(contentBlock.toolUse()).thenReturn(Optional.empty()); + when(contentBlock.thinking()).thenReturn(Optional.of(thinkingBlock)); + when(thinkingBlock.thinking()).thenReturn("Let me think about this..."); + + Instant startTime = Instant.now(); + ChatResponse response = AnthropicResponseParser.parseMessage(message, startTime); + + assertNotNull(response); + assertEquals("msg_789", response.getId()); + assertEquals(1, response.getContent().size()); + ThinkingBlock parsedThinking = + assertInstanceOf(ThinkingBlock.class, response.getContent().get(0)); + assertEquals("Let me think about this...", parsedThinking.getThinking()); + } + + @Test + void testParseMessageWithMixedContent() { + // Create mock Message with multiple content blocks + Message message = mock(Message.class); + Usage usage = mock(Usage.class); + + ContentBlock textContentBlock = mock(ContentBlock.class); + var textBlock = mockTextBlock(); + + ContentBlock toolContentBlock = mock(ContentBlock.class); + var toolUseBlock = mockToolUseBlock(); + + when(message.id()).thenReturn("msg_mixed"); + when(message.content()).thenReturn(List.of(textContentBlock, toolContentBlock)); + when(message.usage()).thenReturn(usage); + when(usage.inputTokens()).thenReturn(300L); + when(usage.outputTokens()).thenReturn(150L); + + // Text block + when(textContentBlock.text()).thenReturn(Optional.of(textBlock)); + when(textContentBlock.toolUse()).thenReturn(Optional.empty()); + when(textContentBlock.thinking()).thenReturn(Optional.empty()); + when(textBlock.text()).thenReturn("Let me search for that."); + + // Tool use block - use null input to avoid Kotlin reflection issues + when(toolContentBlock.text()).thenReturn(Optional.empty()); + when(toolContentBlock.toolUse()).thenReturn(Optional.of(toolUseBlock)); + when(toolContentBlock.thinking()).thenReturn(Optional.empty()); + when(toolUseBlock.id()).thenReturn("tool_xyz"); + when(toolUseBlock.name()).thenReturn("web_search"); + when(toolUseBlock._input()).thenReturn(null); // Avoid Kotlin reflection issues + + Instant startTime = Instant.now(); + ChatResponse response = AnthropicResponseParser.parseMessage(message, startTime); + + assertNotNull(response); + assertEquals("msg_mixed", response.getId()); + assertEquals(2, response.getContent().size()); + + assertInstanceOf(TextBlock.class, response.getContent().get(0)); + assertInstanceOf(ToolUseBlock.class, response.getContent().get(1)); + } + + @Test + void testParseMessageWithEmptyContent() { + // Create mock Message with no content + Message message = mock(Message.class); + Usage usage = mock(Usage.class); + + when(message.id()).thenReturn("msg_empty"); + when(message.content()).thenReturn(List.of()); + when(message.usage()).thenReturn(usage); + when(usage.inputTokens()).thenReturn(50L); + when(usage.outputTokens()).thenReturn(0L); + + Instant startTime = Instant.now(); + ChatResponse response = AnthropicResponseParser.parseMessage(message, startTime); + + assertNotNull(response); + assertEquals("msg_empty", response.getId()); + assertTrue(response.getContent().isEmpty()); + } + + @Test + void testParseMessageWithNullToolInput() { + // Create mock Message with null tool input + Message message = mock(Message.class); + Usage usage = mock(Usage.class); + ContentBlock contentBlock = mock(ContentBlock.class); + var toolUseBlock = mockToolUseBlock(); + + when(message.id()).thenReturn("msg_null_input"); + when(message.content()).thenReturn(List.of(contentBlock)); + when(message.usage()).thenReturn(usage); + when(usage.inputTokens()).thenReturn(100L); + when(usage.outputTokens()).thenReturn(50L); + + when(contentBlock.text()).thenReturn(Optional.empty()); + when(contentBlock.toolUse()).thenReturn(Optional.of(toolUseBlock)); + when(contentBlock.thinking()).thenReturn(Optional.empty()); + + when(toolUseBlock.id()).thenReturn("tool_null"); + when(toolUseBlock.name()).thenReturn("test_tool"); + when(toolUseBlock._input()).thenReturn(null); + + Instant startTime = Instant.now(); + ChatResponse response = AnthropicResponseParser.parseMessage(message, startTime); + + assertNotNull(response); + assertEquals(1, response.getContent().size()); + + ToolUseBlock parsedToolUse = + assertInstanceOf(ToolUseBlock.class, response.getContent().get(0)); + assertEquals("tool_null", parsedToolUse.getId()); + assertEquals("test_tool", parsedToolUse.getName()); + // Null input should result in empty map + assertNotNull(parsedToolUse.getInput()); + assertTrue(parsedToolUse.getInput().isEmpty()); + } + + @Test + void testParseStreamEventsMessageStart() { + // Create mock MessageStart event + RawMessageStreamEvent event = mock(RawMessageStreamEvent.class); + RawMessageStartEvent messageStartEvent = mock(RawMessageStartEvent.class); + Message message = mock(Message.class); + + when(event.isMessageStart()).thenReturn(true); + when(event.asMessageStart()).thenReturn(messageStartEvent); + when(messageStartEvent.message()).thenReturn(message); + when(message.id()).thenReturn("msg_stream_123"); + + Instant startTime = Instant.now(); + Flux responseFlux = + AnthropicResponseParser.parseStreamEvents(Flux.just(event), startTime); + + // MessageStart events should be filtered out (empty content) + StepVerifier.create(responseFlux).verifyComplete(); + } + + @Test + void testParseStreamEventsMessageStartWithoutIdStillCompletes() { + RawMessageStreamEvent event = mock(RawMessageStreamEvent.class); + RawMessageStartEvent messageStartEvent = mock(RawMessageStartEvent.class); + Message message = mock(Message.class); + + when(event.isMessageStart()).thenReturn(true); + when(event.asMessageStart()).thenReturn(messageStartEvent); + when(messageStartEvent.message()).thenReturn(message); + when(message.id()).thenThrow(new IllegalStateException("id is not set")); + when(message._id()).thenReturn(com.anthropic.core.JsonField.ofNullable(null)); + when(event.isContentBlockDelta()).thenReturn(false); + when(event.isContentBlockStart()).thenReturn(false); + when(event.isMessageDelta()).thenReturn(false); + + Flux responseFlux = + AnthropicResponseParser.parseStreamEvents(Flux.just(event), Instant.now()); + + StepVerifier.create(responseFlux).verifyComplete(); + } + + @Test + void testParseStreamEventMessageStart() throws Exception { + // Test MessageStart event - should set message ID but have empty content + RawMessageStreamEvent event = mock(RawMessageStreamEvent.class); + RawMessageStartEvent messageStart = mock(RawMessageStartEvent.class); + Message message = mock(Message.class); + + when(event.isMessageStart()).thenReturn(true); + when(event.asMessageStart()).thenReturn(messageStart); + when(messageStart.message()).thenReturn(message); + when(message.id()).thenReturn("msg_stream_123"); + + when(event.isContentBlockDelta()).thenReturn(false); + when(event.isContentBlockStart()).thenReturn(false); + when(event.isMessageDelta()).thenReturn(false); + + Instant startTime = Instant.now(); + ChatResponse response = invokeParseStreamEvent(event, startTime); + + assertNotNull(response); + assertEquals("msg_stream_123", response.getId()); + assertTrue(response.getContent().isEmpty()); // MessageStart has no content + } + + @Test + void testParseStreamEventMessageStartUsesRawJsonFieldIdWhenStrictAccessorThrows() + throws Exception { + RawMessageStreamEvent event = mock(RawMessageStreamEvent.class); + RawMessageStartEvent messageStart = mock(RawMessageStartEvent.class); + Message message = mock(Message.class); + + when(event.isMessageStart()).thenReturn(true); + when(event.asMessageStart()).thenReturn(messageStart); + when(messageStart.message()).thenReturn(message); + when(message.id()).thenThrow(new IllegalStateException("id is not set")); + when(message._id()).thenReturn(com.anthropic.core.JsonField.of("msg_stream_raw_only")); + when(event.isContentBlockDelta()).thenReturn(false); + when(event.isContentBlockStart()).thenReturn(false); + when(event.isMessageDelta()).thenReturn(false); + + ChatResponse response = invokeParseStreamEvent(event, Instant.now()); + + assertNotNull(response); + assertEquals("msg_stream_raw_only", response.getId()); + assertTrue(response.getContent().isEmpty()); + } + + @Test + void testParseStreamEventMessageStartWithoutIdFallsBackToGeneratedId() throws Exception { + RawMessageStreamEvent event = mock(RawMessageStreamEvent.class); + RawMessageStartEvent messageStart = mock(RawMessageStartEvent.class); + Message message = mock(Message.class); + + when(event.isMessageStart()).thenReturn(true); + when(event.asMessageStart()).thenReturn(messageStart); + when(messageStart.message()).thenReturn(message); + when(message.id()).thenThrow(new IllegalStateException("id is not set")); + when(message._id()).thenReturn(com.anthropic.core.JsonField.ofNullable(null)); + when(event.isContentBlockDelta()).thenReturn(false); + when(event.isContentBlockStart()).thenReturn(false); + when(event.isMessageDelta()).thenReturn(false); + + ChatResponse response = invokeParseStreamEvent(event, Instant.now()); + + assertNotNull(response); + assertNotNull(response.getId()); + assertFalse(response.getId().isEmpty()); + assertTrue(response.getContent().isEmpty()); + } + + @Test + void testParseStreamEventUnknownType() throws Exception { + // Test unknown event type - should return empty response + RawMessageStreamEvent event = mock(RawMessageStreamEvent.class); + + when(event.isMessageStart()).thenReturn(false); + when(event.isContentBlockDelta()).thenReturn(false); + when(event.isContentBlockStart()).thenReturn(false); + when(event.isMessageDelta()).thenReturn(false); + + Instant startTime = Instant.now(); + ChatResponse response = invokeParseStreamEvent(event, startTime); + + assertNotNull(response); + assertNotNull(response.getId()); // Builder auto-generates UUID when id is null + assertFalse(response.getId().isEmpty()); + assertTrue(response.getContent().isEmpty()); + assertNull(response.getUsage()); + } + + @Test + void testParseStreamEventsFiltersEmptyContent() { + // Test that parseStreamEvents filters out responses with empty content + RawMessageStreamEvent event = mock(RawMessageStreamEvent.class); + + when(event.isMessageStart()).thenReturn(false); + when(event.isContentBlockDelta()).thenReturn(false); + when(event.isContentBlockStart()).thenReturn(false); + when(event.isMessageDelta()).thenReturn(false); + + Instant startTime = Instant.now(); + Flux responseFlux = + AnthropicResponseParser.parseStreamEvents(Flux.just(event), startTime); + + // Empty content responses should be filtered out + StepVerifier.create(responseFlux).verifyComplete(); + } + + @Test + void testParseStreamEventsHandlesExceptions() { + // Test that exceptions in parsing are caught and logged + RawMessageStreamEvent event = mock(RawMessageStreamEvent.class); + + // Make the event throw an exception + when(event.isMessageStart()).thenThrow(new RuntimeException("Test exception")); + + Instant startTime = Instant.now(); + Flux responseFlux = + AnthropicResponseParser.parseStreamEvents(Flux.just(event), startTime); + + // Exception should be caught and result in empty flux + StepVerifier.create(responseFlux).verifyComplete(); + } + + @Test + void testParseStreamEventsErrorHandling() { + // Create a Flux that emits an error + Flux errorFlux = Flux.error(new RuntimeException("Stream error")); + + Instant startTime = Instant.now(); + + // parseStreamEvents should propagate errors + StepVerifier.create(AnthropicResponseParser.parseStreamEvents(errorFlux, startTime)) + .expectError(RuntimeException.class) + .verify(); + } +}