buildAllToolCalls() {
- return builders.values().stream().map(ToolCallBuilder::build).collect(Collectors.toList());
- }
-
- /**
- * Get accumulated tool call by ID.
- *
- * If the ID is null or empty, or if no builder is found for the given ID,
- * this method falls back to using the lastToolCallKey.
- *
- * @param id The tool call ID to look up
- * @return The accumulated ToolUseBlock, or null if not found
- */
- public ToolUseBlock getAccumulatedToolCall(String id) {
- if (id != null && !id.isEmpty()) {
- // First try to find by ID directly
- ToolCallBuilder builder = builders.get(id);
- if (builder != null) {
- return builder.build();
- }
- }
-
- // Fallback to lastToolCallKey if ID is empty or not found
- if (lastToolCallKey != null) {
- ToolCallBuilder builder = builders.get(lastToolCallKey);
- if (builder != null) {
- return builder.build();
- }
- }
-
- return null;
- }
-
- /**
- * Get all accumulated tool calls.
- *
- *
This is an alias for {@link #buildAllToolCalls()} for API consistency.
- *
- * @return List of all accumulated ToolUseBlocks
- */
- public List getAllAccumulatedToolCalls() {
- return buildAllToolCalls();
- }
-
- /**
- * Get the ID of the current (last) tool call being accumulated.
- *
- * This is useful for enriching fragment chunks with the correct tool call ID,
- * allowing users to properly concatenate streaming chunks.
- *
- * @return The current tool call ID, or null if no tool call is being accumulated
- */
- public String getCurrentToolCallId() {
- if (lastToolCallKey == null) {
- return null;
- }
-
- ToolCallBuilder builder = builders.get(lastToolCallKey);
- if (builder == null) {
- return null;
- }
-
- return builder.toolId;
- }
-
- /**
- * @hidden
- */
- @Override
- public void reset() {
- builders.clear();
- nextIndex = 0;
- lastToolCallKey = null;
- }
-}
+/*
+ * 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.agent.accumulator;
+
+import io.agentscope.core.message.ContentBlock;
+import io.agentscope.core.message.ToolUseBlock;
+import io.agentscope.core.util.ToolCallJsonUtils;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * Tool calls accumulator for accumulating streaming tool call chunks.
+ *
+ *
This accumulator supports multiple parallel tool calls and handles:
+ *
+ *
+ * - Tool name and ID accumulation
+ *
- Incremental parameter merging
+ *
- Raw JSON content accumulation and parsing
+ *
- Placeholder name handling (e.g., "__fragment__")
+ *
+ * @hidden
+ */
+public class ToolCallsAccumulator implements ContentAccumulator {
+
+ // Map to support multiple parallel tool calls
+ // Key: tool identifier (ID, name, or index)
+ private final Map builders = new LinkedHashMap<>();
+ private int nextIndex = 0;
+
+ // Track the last tool call key for streaming chunks without ID
+ // This is needed when models return fragments with placeholder names and empty IDs
+ private String lastToolCallKey = null;
+
+ /** Builder for a single tool call. */
+ private static class ToolCallBuilder {
+ String toolId;
+ String name;
+ Map args = new HashMap<>();
+ StringBuilder rawContent = new StringBuilder();
+ Map metadata = new HashMap<>();
+
+ void merge(ToolUseBlock block) {
+ // Update ID if present
+ if (this.toolId == null && block.getId() != null && !block.getId().isEmpty()) {
+ this.toolId = block.getId();
+ }
+
+ // Update name (ignore placeholders)
+ if (block.getName() != null && !isPlaceholder(block.getName())) {
+ this.name = block.getName();
+ }
+
+ // Merge parameters
+ if (block.getInput() != null) {
+ this.args.putAll(block.getInput());
+ }
+
+ // Accumulate raw content (for parsing complete JSON)
+ if (block.getContent() != null) {
+ this.rawContent.append(block.getContent());
+ }
+
+ // Merge metadata (e.g., thoughtSignature for Gemini 3 Pro)
+ if (block.getMetadata() != null && !block.getMetadata().isEmpty()) {
+ this.metadata.putAll(block.getMetadata());
+ }
+ }
+
+ ToolUseBlock build() {
+ Map finalArgs = new HashMap<>(args);
+ String rawContentStr = this.rawContent.toString();
+
+ if (finalArgs.isEmpty()) {
+ finalArgs.putAll(ToolCallJsonUtils.parseJsonObjectOrEmpty(rawContentStr));
+ }
+
+ String sanitizedContent =
+ ToolCallJsonUtils.sanitizeArgumentsJson(rawContentStr, finalArgs);
+
+ return ToolUseBlock.builder()
+ .id(toolId != null ? toolId : generateId())
+ .name(name)
+ .input(finalArgs)
+ .content(sanitizedContent)
+ .metadata(metadata.isEmpty() ? null : metadata)
+ .build();
+ }
+
+ private boolean isPlaceholder(String name) {
+ // Common placeholder names
+ return "__fragment__".equals(name)
+ || "__pending__".equals(name)
+ || (name != null && name.startsWith("__"));
+ }
+
+ private String generateId() {
+ return "tool_call_" + System.currentTimeMillis();
+ }
+ }
+
+ /**
+ * @hidden
+ */
+ @Override
+ public void add(ToolUseBlock block) {
+ if (block == null) {
+ return;
+ }
+
+ // Determine which tool call this block belongs to
+ String key = determineKey(block);
+
+ // Get or create the corresponding builder
+ ToolCallBuilder builder = builders.computeIfAbsent(key, k -> new ToolCallBuilder());
+
+ // Merge the block
+ builder.merge(block);
+ }
+
+ /**
+ * Determine the key for a tool call (to distinguish multiple parallel calls).
+ *
+ * Priority:
+ *
+ *
+ * - Use tool ID if available (non-empty)
+ *
- Use tool name if available (non-placeholder)
+ *
- If this is a fragment (placeholder name), reuse the last tool call key
+ *
- Otherwise, use index for chunks without any identifier
+ *
+ */
+ private String determineKey(ToolUseBlock block) {
+ // 1. Prefer tool ID if non-empty
+ if (block.getId() != null && !block.getId().isEmpty()) {
+ String key = block.getId();
+ // Remember this key if it's not a placeholder
+ if (block.getName() != null && !isPlaceholder(block.getName())) {
+ lastToolCallKey = key;
+ }
+ return key;
+ }
+
+ // 2. Use tool name (non-placeholder)
+ if (block.getName() != null && !isPlaceholder(block.getName())) {
+ String key = "name:" + block.getName();
+ lastToolCallKey = key;
+ return key;
+ }
+
+ // 3. If this is a fragment (placeholder name) and we have a last key, reuse it
+ if (isPlaceholder(block.getName()) && lastToolCallKey != null) {
+ return lastToolCallKey;
+ }
+
+ // 4. Use index (for chunks without any identifier)
+ String key = "index:" + nextIndex++;
+ lastToolCallKey = key;
+ return key;
+ }
+
+ private boolean isPlaceholder(String name) {
+ return name != null && name.startsWith("__");
+ }
+
+ /**
+ * @hidden
+ */
+ @Override
+ public boolean hasContent() {
+ return !builders.isEmpty();
+ }
+
+ /**
+ * @hidden
+ */
+ @Override
+ public ContentBlock buildAggregated() {
+ List toolCalls = buildAllToolCalls();
+
+ // If only one tool call, return it
+ // If multiple, return the last one (or could return a special multi-call block)
+ if (toolCalls.isEmpty()) {
+ return null;
+ }
+
+ return toolCalls.get(toolCalls.size() - 1);
+ }
+
+ /**
+ * Build all accumulated tool calls.
+ *
+ * @hidden
+ * @return List of tool calls
+ */
+ public List buildAllToolCalls() {
+ return builders.values().stream().map(ToolCallBuilder::build).collect(Collectors.toList());
+ }
+
+ /**
+ * Get accumulated tool call by ID.
+ *
+ * If the ID is null or empty, or if no builder is found for the given ID,
+ * this method falls back to using the lastToolCallKey.
+ *
+ * @param id The tool call ID to look up
+ * @return The accumulated ToolUseBlock, or null if not found
+ */
+ public ToolUseBlock getAccumulatedToolCall(String id) {
+ if (id != null && !id.isEmpty()) {
+ // First try to find by ID directly
+ ToolCallBuilder builder = builders.get(id);
+ if (builder != null) {
+ return builder.build();
+ }
+ }
+
+ // Fallback to lastToolCallKey if ID is empty or not found
+ if (lastToolCallKey != null) {
+ ToolCallBuilder builder = builders.get(lastToolCallKey);
+ if (builder != null) {
+ return builder.build();
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Get all accumulated tool calls.
+ *
+ *
This is an alias for {@link #buildAllToolCalls()} for API consistency.
+ *
+ * @return List of all accumulated ToolUseBlocks
+ */
+ public List getAllAccumulatedToolCalls() {
+ return buildAllToolCalls();
+ }
+
+ /**
+ * Get the ID of the current (last) tool call being accumulated.
+ *
+ * This is useful for enriching fragment chunks with the correct tool call ID,
+ * allowing users to properly concatenate streaming chunks.
+ *
+ * @return The current tool call ID, or null if no tool call is being accumulated
+ */
+ public String getCurrentToolCallId() {
+ if (lastToolCallKey == null) {
+ return null;
+ }
+
+ ToolCallBuilder builder = builders.get(lastToolCallKey);
+ if (builder == null) {
+ return null;
+ }
+
+ return builder.toolId;
+ }
+
+ /**
+ * @hidden
+ */
+ @Override
+ public void reset() {
+ builders.clear();
+ nextIndex = 0;
+ lastToolCallKey = null;
+ }
+}
diff --git a/agentscope-core/src/main/java/io/agentscope/core/formatter/dashscope/DashScopeToolsHelper.java b/agentscope-core/src/main/java/io/agentscope/core/formatter/dashscope/DashScopeToolsHelper.java
index 7a5a9eb57..7175f3088 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/formatter/dashscope/DashScopeToolsHelper.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/formatter/dashscope/DashScopeToolsHelper.java
@@ -1,335 +1,325 @@
-/*
- * 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.dashscope;
-
-import io.agentscope.core.formatter.dashscope.dto.DashScopeFunction;
-import io.agentscope.core.formatter.dashscope.dto.DashScopeParameters;
-import io.agentscope.core.formatter.dashscope.dto.DashScopeTool;
-import io.agentscope.core.formatter.dashscope.dto.DashScopeToolCall;
-import io.agentscope.core.formatter.dashscope.dto.DashScopeToolFunction;
-import io.agentscope.core.message.ToolUseBlock;
-import io.agentscope.core.model.GenerateOptions;
-import io.agentscope.core.model.ToolChoice;
-import io.agentscope.core.model.ToolSchema;
-import io.agentscope.core.util.JsonUtils;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.function.Function;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Handles tool registration and options application for DashScope API.
- *
- *
This class converts AgentScope tool schemas and options to DashScope DTO format.
- */
-public class DashScopeToolsHelper {
-
- private static final Logger log = LoggerFactory.getLogger(DashScopeToolsHelper.class);
-
- public DashScopeToolsHelper() {}
-
- /**
- * Apply GenerateOptions to DashScopeParameters.
- *
- * @param params DashScope parameters to modify
- * @param options Generation options to apply
- * @param defaultOptions Default options to use if options parameter is null
- */
- public void applyOptions(
- DashScopeParameters params, GenerateOptions options, GenerateOptions defaultOptions) {
- Double temperature = getOption(options, defaultOptions, GenerateOptions::getTemperature);
- if (temperature != null) {
- params.setTemperature(temperature);
- }
-
- Double topP = getOption(options, defaultOptions, GenerateOptions::getTopP);
- if (topP != null) {
- params.setTopP(topP);
- }
-
- Integer maxTokens = getOption(options, defaultOptions, GenerateOptions::getMaxTokens);
- if (maxTokens != null) {
- params.setMaxTokens(maxTokens);
- }
-
- Integer thinkingBudget =
- getOption(options, defaultOptions, GenerateOptions::getThinkingBudget);
- if (thinkingBudget != null) {
- params.setThinkingBudget(thinkingBudget);
- params.setEnableThinking(true);
- }
-
- Integer topK = getOption(options, defaultOptions, GenerateOptions::getTopK);
- if (topK != null) {
- params.setTopK(topK);
- }
-
- Long seed = getOption(options, defaultOptions, GenerateOptions::getSeed);
- if (seed != null) {
- params.setSeed(seed.intValue());
- }
-
- Double frequencyPenalty =
- getOption(options, defaultOptions, GenerateOptions::getFrequencyPenalty);
- if (frequencyPenalty != null) {
- params.setFrequencyPenalty(frequencyPenalty);
- }
-
- Double presencePenalty =
- getOption(options, defaultOptions, GenerateOptions::getPresencePenalty);
- if (presencePenalty != null) {
- params.setPresencePenalty(presencePenalty);
- }
- }
-
- /**
- * Helper method to get option value with fallback to default.
- */
- private T getOption(
- GenerateOptions options,
- GenerateOptions defaultOptions,
- Function getter) {
- if (options != null) {
- T value = getter.apply(options);
- if (value != null) {
- return value;
- }
- }
- if (defaultOptions != null) {
- return getter.apply(defaultOptions);
- }
- return null;
- }
-
- /**
- * Convert tool schemas to DashScope tool list.
- *
- * @param tools List of tool schemas to convert (may be null or empty)
- * @return List of DashScopeTool objects
- */
- public List convertTools(List tools) {
- if (tools == null || tools.isEmpty()) {
- return List.of();
- }
-
- List result = new ArrayList<>();
- for (ToolSchema t : tools) {
- Map parameters = new HashMap<>();
- if (t.getParameters() != null) {
- parameters.putAll(t.getParameters());
- }
-
- DashScopeToolFunction function =
- DashScopeToolFunction.builder()
- .name(t.getName())
- .description(t.getDescription())
- .parameters(parameters)
- .build();
-
- result.add(DashScopeTool.function(function));
- }
-
- log.debug("Converted {} tools to DashScope format", result.size());
- return result;
- }
-
- /**
- * Apply tools to DashScopeParameters.
- *
- * @param params DashScope parameters to modify
- * @param tools List of tool schemas to apply (may be null or empty)
- */
- public void applyTools(DashScopeParameters params, List tools) {
- if (tools == null || tools.isEmpty()) {
- return;
- }
- params.setTools(convertTools(tools));
- }
-
- /**
- * Convert tool choice to DashScope format.
- *
- * DashScope API supports:
- *
- * - String "auto": model decides whether to call tools (default)
- * - String "none": disable tool calling
- * - Object {"type": "function", "function": {"name": "tool_name"}}: force specific tool
- *
- *
- * @param toolChoice The tool choice configuration (null means auto/default)
- * @return The converted tool choice object
- */
- public Object convertToolChoice(ToolChoice toolChoice) {
- if (toolChoice == null) {
- return null;
- }
-
- if (toolChoice instanceof ToolChoice.Auto) {
- log.debug("ToolChoice.Auto: returning 'auto'");
- return "auto";
- } else if (toolChoice instanceof ToolChoice.None) {
- log.debug("ToolChoice.None: returning 'none'");
- return "none";
- } else if (toolChoice instanceof ToolChoice.Required) {
- log.warn(
- "ToolChoice.Required is not directly supported by DashScope API. Using 'auto'"
- + " instead.");
- return "auto";
- } else if (toolChoice instanceof ToolChoice.Specific specific) {
- log.debug("ToolChoice.Specific: forcing tool '{}'", specific.toolName());
- Map choice = new HashMap<>();
- choice.put("type", "function");
- Map function = new HashMap<>();
- function.put("name", specific.toolName());
- choice.put("function", function);
- return choice;
- }
-
- return null;
- }
-
- /**
- * Apply tool choice configuration to DashScopeParameters.
- *
- * @param params DashScope parameters to modify
- * @param toolChoice The tool choice configuration (null means auto/default)
- */
- public void applyToolChoice(DashScopeParameters params, ToolChoice toolChoice) {
- Object choice = convertToolChoice(toolChoice);
- if (choice != null) {
- params.setToolChoice(choice);
- }
- }
-
- /**
- * Convert ToolUseBlock list to DashScope ToolCall format.
- *
- * @param toolBlocks The tool use blocks to convert
- * @return List of DashScopeToolCall objects (empty list if input is null/empty)
- */
- public List convertToolCalls(List toolBlocks) {
- if (toolBlocks == null || toolBlocks.isEmpty()) {
- return List.of();
- }
-
- List result = new ArrayList<>();
-
- for (ToolUseBlock toolUse : toolBlocks) {
- if (toolUse == null) {
- log.warn("Skipping null ToolUseBlock in convertToolCalls");
- continue;
- }
-
- // Prioritize using content field (raw arguments string), fallback to input map
- // serialization
- String argsJson;
- if (toolUse.getContent() != null && !toolUse.getContent().isEmpty()) {
- argsJson = toolUse.getContent();
- } else {
- try {
- argsJson = JsonUtils.getJsonCodec().toJson(toolUse.getInput());
- } catch (Exception e) {
- log.warn("Failed to serialize tool call arguments: {}", e.getMessage());
- argsJson = "{}";
- }
- }
-
- DashScopeFunction function = DashScopeFunction.of(toolUse.getName(), argsJson);
- DashScopeToolCall toolCall =
- DashScopeToolCall.builder()
- .id(toolUse.getId())
- .type("function")
- .function(function)
- .build();
-
- result.add(toolCall);
- }
-
- return result;
- }
-
- /**
- * Merge additional headers from options and default options.
- *
- * Default options are applied first, then options override.
- *
- * @param options the primary options (higher priority)
- * @param defaultOptions the fallback options (lower priority)
- * @return merged headers map, or null if both are empty
- */
- public Map mergeAdditionalHeaders(
- GenerateOptions options, GenerateOptions defaultOptions) {
- Map result = new HashMap<>();
-
- if (defaultOptions != null && !defaultOptions.getAdditionalHeaders().isEmpty()) {
- result.putAll(defaultOptions.getAdditionalHeaders());
- }
- if (options != null && !options.getAdditionalHeaders().isEmpty()) {
- result.putAll(options.getAdditionalHeaders());
- }
-
- return result.isEmpty() ? null : result;
- }
-
- /**
- * Merge additional body parameters from options and default options.
- *
- * Default options are applied first, then options override.
- *
- * @param options the primary options (higher priority)
- * @param defaultOptions the fallback options (lower priority)
- * @return merged body params map, or null if both are empty
- */
- public Map mergeAdditionalBodyParams(
- GenerateOptions options, GenerateOptions defaultOptions) {
- Map result = new HashMap<>();
-
- if (defaultOptions != null && !defaultOptions.getAdditionalBodyParams().isEmpty()) {
- result.putAll(defaultOptions.getAdditionalBodyParams());
- }
- if (options != null && !options.getAdditionalBodyParams().isEmpty()) {
- result.putAll(options.getAdditionalBodyParams());
- }
-
- return result.isEmpty() ? null : result;
- }
-
- /**
- * Merge additional query parameters from options and default options.
- *
- * Default options are applied first, then options override.
- *
- * @param options the primary options (higher priority)
- * @param defaultOptions the fallback options (lower priority)
- * @return merged query params map, or null if both are empty
- */
- public Map mergeAdditionalQueryParams(
- GenerateOptions options, GenerateOptions defaultOptions) {
- Map result = new HashMap<>();
-
- if (defaultOptions != null && !defaultOptions.getAdditionalQueryParams().isEmpty()) {
- result.putAll(defaultOptions.getAdditionalQueryParams());
- }
- if (options != null && !options.getAdditionalQueryParams().isEmpty()) {
- result.putAll(options.getAdditionalQueryParams());
- }
-
- return result.isEmpty() ? null : result;
- }
-}
+/*
+ * 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.dashscope;
+
+import io.agentscope.core.formatter.dashscope.dto.DashScopeFunction;
+import io.agentscope.core.formatter.dashscope.dto.DashScopeParameters;
+import io.agentscope.core.formatter.dashscope.dto.DashScopeTool;
+import io.agentscope.core.formatter.dashscope.dto.DashScopeToolCall;
+import io.agentscope.core.formatter.dashscope.dto.DashScopeToolFunction;
+import io.agentscope.core.message.ToolUseBlock;
+import io.agentscope.core.model.GenerateOptions;
+import io.agentscope.core.model.ToolChoice;
+import io.agentscope.core.model.ToolSchema;
+import io.agentscope.core.util.ToolCallJsonUtils;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Handles tool registration and options application for DashScope API.
+ *
+ * This class converts AgentScope tool schemas and options to DashScope DTO format.
+ */
+public class DashScopeToolsHelper {
+
+ private static final Logger log = LoggerFactory.getLogger(DashScopeToolsHelper.class);
+
+ public DashScopeToolsHelper() {}
+
+ /**
+ * Apply GenerateOptions to DashScopeParameters.
+ *
+ * @param params DashScope parameters to modify
+ * @param options Generation options to apply
+ * @param defaultOptions Default options to use if options parameter is null
+ */
+ public void applyOptions(
+ DashScopeParameters params, GenerateOptions options, GenerateOptions defaultOptions) {
+ Double temperature = getOption(options, defaultOptions, GenerateOptions::getTemperature);
+ if (temperature != null) {
+ params.setTemperature(temperature);
+ }
+
+ Double topP = getOption(options, defaultOptions, GenerateOptions::getTopP);
+ if (topP != null) {
+ params.setTopP(topP);
+ }
+
+ Integer maxTokens = getOption(options, defaultOptions, GenerateOptions::getMaxTokens);
+ if (maxTokens != null) {
+ params.setMaxTokens(maxTokens);
+ }
+
+ Integer thinkingBudget =
+ getOption(options, defaultOptions, GenerateOptions::getThinkingBudget);
+ if (thinkingBudget != null) {
+ params.setThinkingBudget(thinkingBudget);
+ params.setEnableThinking(true);
+ }
+
+ Integer topK = getOption(options, defaultOptions, GenerateOptions::getTopK);
+ if (topK != null) {
+ params.setTopK(topK);
+ }
+
+ Long seed = getOption(options, defaultOptions, GenerateOptions::getSeed);
+ if (seed != null) {
+ params.setSeed(seed.intValue());
+ }
+
+ Double frequencyPenalty =
+ getOption(options, defaultOptions, GenerateOptions::getFrequencyPenalty);
+ if (frequencyPenalty != null) {
+ params.setFrequencyPenalty(frequencyPenalty);
+ }
+
+ Double presencePenalty =
+ getOption(options, defaultOptions, GenerateOptions::getPresencePenalty);
+ if (presencePenalty != null) {
+ params.setPresencePenalty(presencePenalty);
+ }
+ }
+
+ /**
+ * Helper method to get option value with fallback to default.
+ */
+ private T getOption(
+ GenerateOptions options,
+ GenerateOptions defaultOptions,
+ Function getter) {
+ if (options != null) {
+ T value = getter.apply(options);
+ if (value != null) {
+ return value;
+ }
+ }
+ if (defaultOptions != null) {
+ return getter.apply(defaultOptions);
+ }
+ return null;
+ }
+
+ /**
+ * Convert tool schemas to DashScope tool list.
+ *
+ * @param tools List of tool schemas to convert (may be null or empty)
+ * @return List of DashScopeTool objects
+ */
+ public List convertTools(List tools) {
+ if (tools == null || tools.isEmpty()) {
+ return List.of();
+ }
+
+ List result = new ArrayList<>();
+ for (ToolSchema t : tools) {
+ Map parameters = new HashMap<>();
+ if (t.getParameters() != null) {
+ parameters.putAll(t.getParameters());
+ }
+
+ DashScopeToolFunction function =
+ DashScopeToolFunction.builder()
+ .name(t.getName())
+ .description(t.getDescription())
+ .parameters(parameters)
+ .build();
+
+ result.add(DashScopeTool.function(function));
+ }
+
+ log.debug("Converted {} tools to DashScope format", result.size());
+ return result;
+ }
+
+ /**
+ * Apply tools to DashScopeParameters.
+ *
+ * @param params DashScope parameters to modify
+ * @param tools List of tool schemas to apply (may be null or empty)
+ */
+ public void applyTools(DashScopeParameters params, List tools) {
+ if (tools == null || tools.isEmpty()) {
+ return;
+ }
+ params.setTools(convertTools(tools));
+ }
+
+ /**
+ * Convert tool choice to DashScope format.
+ *
+ * DashScope API supports:
+ *
+ * - String "auto": model decides whether to call tools (default)
+ * - String "none": disable tool calling
+ * - Object {"type": "function", "function": {"name": "tool_name"}}: force specific tool
+ *
+ *
+ * @param toolChoice The tool choice configuration (null means auto/default)
+ * @return The converted tool choice object
+ */
+ public Object convertToolChoice(ToolChoice toolChoice) {
+ if (toolChoice == null) {
+ return null;
+ }
+
+ if (toolChoice instanceof ToolChoice.Auto) {
+ log.debug("ToolChoice.Auto: returning 'auto'");
+ return "auto";
+ } else if (toolChoice instanceof ToolChoice.None) {
+ log.debug("ToolChoice.None: returning 'none'");
+ return "none";
+ } else if (toolChoice instanceof ToolChoice.Required) {
+ log.warn(
+ "ToolChoice.Required is not directly supported by DashScope API. Using 'auto'"
+ + " instead.");
+ return "auto";
+ } else if (toolChoice instanceof ToolChoice.Specific specific) {
+ log.debug("ToolChoice.Specific: forcing tool '{}'", specific.toolName());
+ Map choice = new HashMap<>();
+ choice.put("type", "function");
+ Map function = new HashMap<>();
+ function.put("name", specific.toolName());
+ choice.put("function", function);
+ return choice;
+ }
+
+ return null;
+ }
+
+ /**
+ * Apply tool choice configuration to DashScopeParameters.
+ *
+ * @param params DashScope parameters to modify
+ * @param toolChoice The tool choice configuration (null means auto/default)
+ */
+ public void applyToolChoice(DashScopeParameters params, ToolChoice toolChoice) {
+ Object choice = convertToolChoice(toolChoice);
+ if (choice != null) {
+ params.setToolChoice(choice);
+ }
+ }
+
+ /**
+ * Convert ToolUseBlock list to DashScope ToolCall format.
+ *
+ * @param toolBlocks The tool use blocks to convert
+ * @return List of DashScopeToolCall objects (empty list if input is null/empty)
+ */
+ public List convertToolCalls(List toolBlocks) {
+ if (toolBlocks == null || toolBlocks.isEmpty()) {
+ return List.of();
+ }
+
+ List result = new ArrayList<>();
+
+ for (ToolUseBlock toolUse : toolBlocks) {
+ if (toolUse == null) {
+ log.warn("Skipping null ToolUseBlock in convertToolCalls");
+ continue;
+ }
+
+ String argsJson =
+ ToolCallJsonUtils.sanitizeArgumentsJson(
+ toolUse.getContent(), toolUse.getInput());
+
+ DashScopeFunction function = DashScopeFunction.of(toolUse.getName(), argsJson);
+ DashScopeToolCall toolCall =
+ DashScopeToolCall.builder()
+ .id(toolUse.getId())
+ .type("function")
+ .function(function)
+ .build();
+
+ result.add(toolCall);
+ }
+
+ return result;
+ }
+
+ /**
+ * Merge additional headers from options and default options.
+ *
+ * Default options are applied first, then options override.
+ *
+ * @param options the primary options (higher priority)
+ * @param defaultOptions the fallback options (lower priority)
+ * @return merged headers map, or null if both are empty
+ */
+ public Map mergeAdditionalHeaders(
+ GenerateOptions options, GenerateOptions defaultOptions) {
+ Map result = new HashMap<>();
+
+ if (defaultOptions != null && !defaultOptions.getAdditionalHeaders().isEmpty()) {
+ result.putAll(defaultOptions.getAdditionalHeaders());
+ }
+ if (options != null && !options.getAdditionalHeaders().isEmpty()) {
+ result.putAll(options.getAdditionalHeaders());
+ }
+
+ return result.isEmpty() ? null : result;
+ }
+
+ /**
+ * Merge additional body parameters from options and default options.
+ *
+ * Default options are applied first, then options override.
+ *
+ * @param options the primary options (higher priority)
+ * @param defaultOptions the fallback options (lower priority)
+ * @return merged body params map, or null if both are empty
+ */
+ public Map mergeAdditionalBodyParams(
+ GenerateOptions options, GenerateOptions defaultOptions) {
+ Map result = new HashMap<>();
+
+ if (defaultOptions != null && !defaultOptions.getAdditionalBodyParams().isEmpty()) {
+ result.putAll(defaultOptions.getAdditionalBodyParams());
+ }
+ if (options != null && !options.getAdditionalBodyParams().isEmpty()) {
+ result.putAll(options.getAdditionalBodyParams());
+ }
+
+ return result.isEmpty() ? null : result;
+ }
+
+ /**
+ * Merge additional query parameters from options and default options.
+ *
+ * Default options are applied first, then options override.
+ *
+ * @param options the primary options (higher priority)
+ * @param defaultOptions the fallback options (lower priority)
+ * @return merged query params map, or null if both are empty
+ */
+ public Map mergeAdditionalQueryParams(
+ GenerateOptions options, GenerateOptions defaultOptions) {
+ Map result = new HashMap<>();
+
+ if (defaultOptions != null && !defaultOptions.getAdditionalQueryParams().isEmpty()) {
+ result.putAll(defaultOptions.getAdditionalQueryParams());
+ }
+ if (options != null && !options.getAdditionalQueryParams().isEmpty()) {
+ result.putAll(options.getAdditionalQueryParams());
+ }
+
+ return result.isEmpty() ? null : result;
+ }
+}
diff --git a/agentscope-core/src/main/java/io/agentscope/core/formatter/openai/OpenAIMessageConverter.java b/agentscope-core/src/main/java/io/agentscope/core/formatter/openai/OpenAIMessageConverter.java
index 01a4c4f3d..032375b37 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/formatter/openai/OpenAIMessageConverter.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/formatter/openai/OpenAIMessageConverter.java
@@ -1,495 +1,481 @@
-/*
- * 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.openai;
-
-import io.agentscope.core.formatter.openai.dto.OpenAIContentPart;
-import io.agentscope.core.formatter.openai.dto.OpenAIFunction;
-import io.agentscope.core.formatter.openai.dto.OpenAIMessage;
-import io.agentscope.core.formatter.openai.dto.OpenAIReasoningDetail;
-import io.agentscope.core.formatter.openai.dto.OpenAIToolCall;
-import io.agentscope.core.message.AudioBlock;
-import io.agentscope.core.message.Base64Source;
-import io.agentscope.core.message.ContentBlock;
-import io.agentscope.core.message.ImageBlock;
-import io.agentscope.core.message.MessageMetadataKeys;
-import io.agentscope.core.message.Msg;
-import io.agentscope.core.message.MsgRole;
-import io.agentscope.core.message.Source;
-import io.agentscope.core.message.TextBlock;
-import io.agentscope.core.message.ThinkingBlock;
-import io.agentscope.core.message.ToolResultBlock;
-import io.agentscope.core.message.ToolUseBlock;
-import io.agentscope.core.message.URLSource;
-import io.agentscope.core.message.VideoBlock;
-import io.agentscope.core.util.JsonUtils;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.function.Function;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Converts AgentScope Msg objects to OpenAI DTO types (for HTTP API).
- *
- * This class handles all message role conversions including system, user, assistant, and tool
- * messages. It supports multimodal content (text, images, audio) and tool calling functionality.
- */
-public class OpenAIMessageConverter {
-
- private static final Logger log = LoggerFactory.getLogger(OpenAIMessageConverter.class);
-
- private final Function textExtractor;
- private final Function, String> toolResultConverter;
-
- /**
- * Create an OpenAIMessageConverter with required dependency functions.
- *
- * @param textExtractor Function to extract text content from Msg
- * @param toolResultConverter Function to convert tool result blocks to strings
- */
- public OpenAIMessageConverter(
- Function textExtractor,
- Function, String> toolResultConverter) {
- this.textExtractor = textExtractor;
- this.toolResultConverter = toolResultConverter;
- }
-
- /**
- * Convert single Msg to OpenAI DTO Message.
- *
- * @param msg The message to convert
- * @param hasMediaContent Whether the message contains media (images/audio)
- * @return OpenAIMessage for OpenAI API
- */
- public OpenAIMessage convertToMessage(Msg msg, boolean hasMediaContent) {
- // Check if SYSTEM message contains tool result - treat as TOOL role
- OpenAIMessage result;
- if (msg.getRole() == MsgRole.SYSTEM && msg.hasContentBlocks(ToolResultBlock.class)) {
- result = convertToolMessage(msg);
- } else {
- result =
- switch (msg.getRole()) {
- case SYSTEM -> convertSystemMessage(msg);
- case USER -> convertUserMessage(msg, hasMediaContent);
- case ASSISTANT -> convertAssistantMessage(msg);
- case TOOL -> convertToolMessage(msg);
- };
- }
-
- // Apply cache_control from message metadata if manually marked
- applyCacheControlFromMetadata(msg, result);
-
- return result;
- }
-
- /**
- * Convert system message.
- *
- * @param msg The system message
- * @return OpenAIMessage
- */
- private OpenAIMessage convertSystemMessage(Msg msg) {
- String content = textExtractor.apply(msg);
- return OpenAIMessage.builder()
- .role("system")
- .content(content != null ? content : "")
- .build();
- }
-
- /**
- * Convert user message with support for multimodal content.
- *
- * @param msg The user message
- * @param hasMediaContent Whether the message contains media
- * @return OpenAIMessage
- */
- private OpenAIMessage convertUserMessage(Msg msg, boolean hasMediaContent) {
- OpenAIMessage.Builder builder = OpenAIMessage.builder().role("user");
-
- if (msg.getName() != null) {
- builder.name(msg.getName());
- }
-
- List blocks = msg.getContent();
- if (blocks == null) {
- blocks = new ArrayList<>();
- }
-
- // Optimization: pure text fast path
- if (!hasMediaContent
- && !blocks.isEmpty()
- && blocks.size() == 1
- && blocks.get(0) instanceof TextBlock) {
- builder.content(((TextBlock) blocks.get(0)).getText());
- return builder.build();
- }
-
- // Multi-modal path: build ContentPart list
- List contentParts = convertContentBlocks(blocks);
-
- if (!contentParts.isEmpty()) {
- builder.content(contentParts);
- } else {
- // Avoid sending null content to OpenAI API
- builder.content("");
- }
-
- return builder.build();
- }
-
- /**
- * Convert content blocks to OpenAI content parts.
- *
- * @param blocks List of content blocks
- * @return List of OpenAI content parts
- */
- private List convertContentBlocks(List blocks) {
- List contentParts = new ArrayList<>();
-
- for (ContentBlock block : blocks) {
- if (block instanceof TextBlock tb) {
- contentParts.add(OpenAIContentPart.text(tb.getText()));
- } else if (block instanceof ImageBlock ib) {
- try {
- Source source = ib.getSource();
- if (source == null) {
- log.warn("ImageBlock has null source, skipping");
- continue;
- }
- String imageUrl = convertImageSourceToUrl(source);
- contentParts.add(OpenAIContentPart.imageUrl(imageUrl));
- } catch (Exception e) {
- String errorMsg =
- e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName();
- log.warn("Failed to process ImageBlock: {}", errorMsg);
- contentParts.add(
- OpenAIContentPart.text(
- "[Image - processing failed: " + errorMsg + "]"));
- }
- } else if (block instanceof AudioBlock ab) {
- try {
- // OpenAI expects base64 audio in input_audio format
- Source source = ab.getSource();
- if (source == null) {
- log.warn("AudioBlock has null source, using placeholder");
- contentParts.add(OpenAIContentPart.text("[Audio - source missing]"));
- continue;
- }
- if (source instanceof Base64Source b64) {
- String audioData = b64.getData();
- if (audioData == null || audioData.isEmpty()) {
- log.warn("Base64Source has null or empty data, using placeholder");
- contentParts.add(OpenAIContentPart.text("[Audio - data missing]"));
- continue;
- }
- String mediaType = b64.getMediaType();
- String format = mediaType != null ? detectAudioFormat(mediaType) : "wav";
- if (format == null) {
- log.debug("Audio format detection returned null, defaulting to wav");
- format = "wav";
- }
- contentParts.add(OpenAIContentPart.inputAudio(audioData, format));
- } else if (source instanceof URLSource urlSource) {
- // For URL-based audio, we need to add as text since OpenAI
- // input_audio requires base64
- String url = urlSource.getUrl();
- if (url == null || url.isEmpty()) {
- log.warn("URLSource has null or empty URL, using placeholder");
- contentParts.add(OpenAIContentPart.text("[Audio URL - missing]"));
- continue;
- }
- log.warn("URL-based audio not directly supported, using text reference");
- contentParts.add(OpenAIContentPart.text("[Audio URL: " + url + "]"));
- } else {
- log.warn(
- "Unknown audio source type: {}", source.getClass().getSimpleName());
- contentParts.add(
- OpenAIContentPart.text("[Audio - unsupported source type]"));
- }
- } catch (Exception e) {
- String errorMsg =
- e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName();
- log.warn("Failed to process AudioBlock: {}", errorMsg, e);
- contentParts.add(
- OpenAIContentPart.text(
- "[Audio - processing failed: " + errorMsg + "]"));
- }
- } else if (block instanceof ThinkingBlock) {
- log.debug("Skipping ThinkingBlock when formatting for OpenAI");
- } else if (block instanceof VideoBlock vb) {
- try {
- Source source = vb.getSource();
- if (source == null) {
- log.warn("VideoBlock has null source, skipping");
- continue;
- }
- String videoUrl = convertVideoSourceToUrl(source);
- contentParts.add(OpenAIContentPart.videoUrl(videoUrl));
- } catch (Exception e) {
- String errorMsg =
- e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName();
- log.warn("Failed to process VideoBlock: {}", errorMsg);
- contentParts.add(
- OpenAIContentPart.text(
- "[Video - processing failed: " + errorMsg + "]"));
- }
- } else if (block instanceof ToolUseBlock) {
- log.warn("ToolUseBlock is not supported in user messages");
- } else if (block instanceof ToolResultBlock) {
- log.warn("ToolResultBlock is not supported in user messages");
- }
- }
- return contentParts;
- }
-
- /**
- * Convert assistant message with support for tool calls.
- *
- * @param msg The assistant message
- * @return OpenAIMessage
- */
- private OpenAIMessage convertAssistantMessage(Msg msg) {
- OpenAIMessage.Builder builder = OpenAIMessage.builder().role("assistant");
-
- String textContent = textExtractor.apply(msg);
- if (textContent != null && !textContent.isEmpty()) {
- builder.content(textContent);
- }
-
- // Handle ThinkingBlock for reasoning models (e.g. Gemini via OpenRouter)
- // These models require reasoning content to be preserved in history
- ThinkingBlock thinkingBlock = msg.getFirstContentBlock(ThinkingBlock.class);
- if (thinkingBlock != null) {
- String thinking = thinkingBlock.getThinking();
- if (thinking != null && !thinking.isEmpty()) {
- builder.reasoningContent(thinking);
- }
-
- // Restore reasoning_details from ThinkingBlock metadata
- // This is needed for OpenRouter/Gemini models that use reasoning tokens
- if (thinkingBlock.getMetadata() != null) {
- Object detailsObj =
- thinkingBlock.getMetadata().get(ThinkingBlock.METADATA_REASONING_DETAILS);
- if (detailsObj instanceof List> list && !list.isEmpty()) {
- List details = new ArrayList<>();
- for (Object item : list) {
- if (item instanceof OpenAIReasoningDetail rd) {
- details.add(rd);
- }
- }
- if (!details.isEmpty()) {
- builder.reasoningDetails(details);
- }
- }
- }
- }
-
- if (msg.getName() != null) {
- builder.name(msg.getName());
- }
-
- // Handle tool calls
- List toolBlocks = msg.getContentBlocks(ToolUseBlock.class);
- if (!toolBlocks.isEmpty()) {
- List toolCalls = new ArrayList<>();
- List reasoningDetails = new ArrayList<>();
-
- // First pass: find any thought signature in the blocks
- String fallbackSignature = null;
- for (ToolUseBlock toolUse : toolBlocks) {
- if (toolUse.getMetadata() != null) {
- Object signatureObj =
- toolUse.getMetadata().get(ToolUseBlock.METADATA_THOUGHT_SIGNATURE);
- if (signatureObj instanceof String) {
- fallbackSignature = (String) signatureObj;
- if (fallbackSignature != null && !fallbackSignature.isEmpty()) {
- break;
- }
- }
- }
- }
-
- for (ToolUseBlock toolUse : toolBlocks) {
- String toolId = toolUse.getId();
- String toolName = toolUse.getName();
- if (toolId == null || toolName == null) {
- log.warn("ToolUseBlock has null id or name, skipping");
- continue;
- }
-
- // Prioritize using content field (raw arguments string), fallback to input map
- // serialization
- String argsJson;
- if (toolUse.getContent() != null && !toolUse.getContent().isEmpty()) {
- argsJson = toolUse.getContent();
- } else {
- try {
- argsJson = JsonUtils.getJsonCodec().toJson(toolUse.getInput());
- } catch (Exception e) {
- String errorMsg =
- e.getMessage() != null
- ? e.getMessage()
- : e.getClass().getSimpleName();
- log.warn("Failed to serialize tool call arguments: {}", errorMsg);
- argsJson = "{}";
- }
- }
-
- // Add thought signature if present in metadata (required for Gemini)
- String signature = null;
- if (toolUse.getMetadata() != null) {
- Object signatureObj =
- toolUse.getMetadata().get(ToolUseBlock.METADATA_THOUGHT_SIGNATURE);
- if (signatureObj instanceof String) {
- signature = (String) signatureObj;
- }
-
- // Add reasoning detail if present
- Object detailObj = toolUse.getMetadata().get("reasoningDetail");
- if (detailObj instanceof OpenAIReasoningDetail) {
- reasoningDetails.add((OpenAIReasoningDetail) detailObj);
- }
- }
-
- // Fallback to shared signature if missing
- if (signature == null) {
- signature = fallbackSignature;
- }
-
- OpenAIFunction function = OpenAIFunction.of(toolName, argsJson);
- if (signature != null) {
- function.setThoughtSignature(signature);
- }
-
- OpenAIToolCall.Builder toolCallBuilder =
- OpenAIToolCall.builder().id(toolId).type("function").function(function);
-
- toolCalls.add(toolCallBuilder.build());
-
- log.debug(
- "Formatted assistant tool call: id={}, name={}, hasSignature={}",
- toolId,
- toolName,
- signature != null);
- }
- builder.toolCalls(toolCalls);
-
- if (!reasoningDetails.isEmpty()) {
- builder.reasoningDetails(reasoningDetails);
- }
- }
-
- return builder.build();
- }
-
- /**
- * Convert tool result message.
- *
- * @param msg The tool result message
- * @return OpenAIMessage
- */
- private OpenAIMessage convertToolMessage(Msg msg) {
- ToolResultBlock result = msg.getFirstContentBlock(ToolResultBlock.class);
- String toolCallId =
- result != null && result.getId() != null
- ? result.getId()
- : "tool_call_" + System.currentTimeMillis();
-
- OpenAIMessage.Builder builder = OpenAIMessage.builder().role("tool").toolCallId(toolCallId);
-
- // Check for multimodal content in tool result
- if (result != null && hasMediaContent(result.getOutput())) {
- List parts = convertContentBlocks(result.getOutput());
- builder.content(parts);
- } else {
- // Use provided converter for text-only or fallback
- String content;
- if (result != null) {
- content = toolResultConverter.apply(result.getOutput());
- } else {
- content = textExtractor.apply(msg);
- }
- if (content == null) {
- content = "";
- }
- builder.content(content);
- }
-
- return builder.build();
- }
-
- private boolean hasMediaContent(List blocks) {
- if (blocks == null) {
- return false;
- }
- for (ContentBlock block : blocks) {
- if (block instanceof ImageBlock
- || block instanceof AudioBlock
- || block instanceof VideoBlock) {
- return true;
- }
- }
- return false;
- }
-
- /**
- * Convert image Source to URL string for OpenAI API.
- *
- * @param source The Source to convert
- * @return URL string (either a URL or base64 data URI)
- * @throws IllegalArgumentException if source is null or of unknown type
- */
- private String convertImageSourceToUrl(Source source) {
- return OpenAIConverterUtils.convertImageSourceToUrl(source);
- }
-
- /**
- * Convert video Source to URL string for OpenAI API.
- *
- * @param source The Source to convert
- * @return URL string (either a URL or base64 data URI)
- * @throws IllegalArgumentException if source is null or of unknown type
- */
- private String convertVideoSourceToUrl(Source source) {
- return OpenAIConverterUtils.convertVideoSourceToUrl(source);
- }
-
- /**
- * Detect audio format from media type.
- *
- * @param mediaType The media type (e.g., "audio/wav")
- * @return The format string (e.g., "wav")
- */
- private String detectAudioFormat(String mediaType) {
- return OpenAIConverterUtils.detectAudioFormat(mediaType);
- }
-
- /**
- * Apply cache_control from Msg metadata to the converted OpenAIMessage.
- *
- * @param msg the source message with metadata
- * @param result the converted OpenAI message
- */
- private void applyCacheControlFromMetadata(Msg msg, OpenAIMessage result) {
- if (msg.getMetadata() == null) {
- return;
- }
- Object cacheFlag = msg.getMetadata().get(MessageMetadataKeys.CACHE_CONTROL);
- if (Boolean.TRUE.equals(cacheFlag)) {
- result.setCacheControl(OpenAIBaseFormatter.getEphemeralCacheControl());
- }
- }
-}
+/*
+ * 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.openai;
+
+import io.agentscope.core.formatter.openai.dto.OpenAIContentPart;
+import io.agentscope.core.formatter.openai.dto.OpenAIFunction;
+import io.agentscope.core.formatter.openai.dto.OpenAIMessage;
+import io.agentscope.core.formatter.openai.dto.OpenAIReasoningDetail;
+import io.agentscope.core.formatter.openai.dto.OpenAIToolCall;
+import io.agentscope.core.message.AudioBlock;
+import io.agentscope.core.message.Base64Source;
+import io.agentscope.core.message.ContentBlock;
+import io.agentscope.core.message.ImageBlock;
+import io.agentscope.core.message.MessageMetadataKeys;
+import io.agentscope.core.message.Msg;
+import io.agentscope.core.message.MsgRole;
+import io.agentscope.core.message.Source;
+import io.agentscope.core.message.TextBlock;
+import io.agentscope.core.message.ThinkingBlock;
+import io.agentscope.core.message.ToolResultBlock;
+import io.agentscope.core.message.ToolUseBlock;
+import io.agentscope.core.message.URLSource;
+import io.agentscope.core.message.VideoBlock;
+import io.agentscope.core.util.ToolCallJsonUtils;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Function;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Converts AgentScope Msg objects to OpenAI DTO types (for HTTP API).
+ *
+ * This class handles all message role conversions including system, user, assistant, and tool
+ * messages. It supports multimodal content (text, images, audio) and tool calling functionality.
+ */
+public class OpenAIMessageConverter {
+
+ private static final Logger log = LoggerFactory.getLogger(OpenAIMessageConverter.class);
+
+ private final Function textExtractor;
+ private final Function, String> toolResultConverter;
+
+ /**
+ * Create an OpenAIMessageConverter with required dependency functions.
+ *
+ * @param textExtractor Function to extract text content from Msg
+ * @param toolResultConverter Function to convert tool result blocks to strings
+ */
+ public OpenAIMessageConverter(
+ Function textExtractor,
+ Function, String> toolResultConverter) {
+ this.textExtractor = textExtractor;
+ this.toolResultConverter = toolResultConverter;
+ }
+
+ /**
+ * Convert single Msg to OpenAI DTO Message.
+ *
+ * @param msg The message to convert
+ * @param hasMediaContent Whether the message contains media (images/audio)
+ * @return OpenAIMessage for OpenAI API
+ */
+ public OpenAIMessage convertToMessage(Msg msg, boolean hasMediaContent) {
+ // Check if SYSTEM message contains tool result - treat as TOOL role
+ OpenAIMessage result;
+ if (msg.getRole() == MsgRole.SYSTEM && msg.hasContentBlocks(ToolResultBlock.class)) {
+ result = convertToolMessage(msg);
+ } else {
+ result =
+ switch (msg.getRole()) {
+ case SYSTEM -> convertSystemMessage(msg);
+ case USER -> convertUserMessage(msg, hasMediaContent);
+ case ASSISTANT -> convertAssistantMessage(msg);
+ case TOOL -> convertToolMessage(msg);
+ };
+ }
+
+ // Apply cache_control from message metadata if manually marked
+ applyCacheControlFromMetadata(msg, result);
+
+ return result;
+ }
+
+ /**
+ * Convert system message.
+ *
+ * @param msg The system message
+ * @return OpenAIMessage
+ */
+ private OpenAIMessage convertSystemMessage(Msg msg) {
+ String content = textExtractor.apply(msg);
+ return OpenAIMessage.builder()
+ .role("system")
+ .content(content != null ? content : "")
+ .build();
+ }
+
+ /**
+ * Convert user message with support for multimodal content.
+ *
+ * @param msg The user message
+ * @param hasMediaContent Whether the message contains media
+ * @return OpenAIMessage
+ */
+ private OpenAIMessage convertUserMessage(Msg msg, boolean hasMediaContent) {
+ OpenAIMessage.Builder builder = OpenAIMessage.builder().role("user");
+
+ if (msg.getName() != null) {
+ builder.name(msg.getName());
+ }
+
+ List blocks = msg.getContent();
+ if (blocks == null) {
+ blocks = new ArrayList<>();
+ }
+
+ // Optimization: pure text fast path
+ if (!hasMediaContent
+ && !blocks.isEmpty()
+ && blocks.size() == 1
+ && blocks.get(0) instanceof TextBlock) {
+ builder.content(((TextBlock) blocks.get(0)).getText());
+ return builder.build();
+ }
+
+ // Multi-modal path: build ContentPart list
+ List contentParts = convertContentBlocks(blocks);
+
+ if (!contentParts.isEmpty()) {
+ builder.content(contentParts);
+ } else {
+ // Avoid sending null content to OpenAI API
+ builder.content("");
+ }
+
+ return builder.build();
+ }
+
+ /**
+ * Convert content blocks to OpenAI content parts.
+ *
+ * @param blocks List of content blocks
+ * @return List of OpenAI content parts
+ */
+ private List convertContentBlocks(List blocks) {
+ List contentParts = new ArrayList<>();
+
+ for (ContentBlock block : blocks) {
+ if (block instanceof TextBlock tb) {
+ contentParts.add(OpenAIContentPart.text(tb.getText()));
+ } else if (block instanceof ImageBlock ib) {
+ try {
+ Source source = ib.getSource();
+ if (source == null) {
+ log.warn("ImageBlock has null source, skipping");
+ continue;
+ }
+ String imageUrl = convertImageSourceToUrl(source);
+ contentParts.add(OpenAIContentPart.imageUrl(imageUrl));
+ } catch (Exception e) {
+ String errorMsg =
+ e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName();
+ log.warn("Failed to process ImageBlock: {}", errorMsg);
+ contentParts.add(
+ OpenAIContentPart.text(
+ "[Image - processing failed: " + errorMsg + "]"));
+ }
+ } else if (block instanceof AudioBlock ab) {
+ try {
+ // OpenAI expects base64 audio in input_audio format
+ Source source = ab.getSource();
+ if (source == null) {
+ log.warn("AudioBlock has null source, using placeholder");
+ contentParts.add(OpenAIContentPart.text("[Audio - source missing]"));
+ continue;
+ }
+ if (source instanceof Base64Source b64) {
+ String audioData = b64.getData();
+ if (audioData == null || audioData.isEmpty()) {
+ log.warn("Base64Source has null or empty data, using placeholder");
+ contentParts.add(OpenAIContentPart.text("[Audio - data missing]"));
+ continue;
+ }
+ String mediaType = b64.getMediaType();
+ String format = mediaType != null ? detectAudioFormat(mediaType) : "wav";
+ if (format == null) {
+ log.debug("Audio format detection returned null, defaulting to wav");
+ format = "wav";
+ }
+ contentParts.add(OpenAIContentPart.inputAudio(audioData, format));
+ } else if (source instanceof URLSource urlSource) {
+ // For URL-based audio, we need to add as text since OpenAI
+ // input_audio requires base64
+ String url = urlSource.getUrl();
+ if (url == null || url.isEmpty()) {
+ log.warn("URLSource has null or empty URL, using placeholder");
+ contentParts.add(OpenAIContentPart.text("[Audio URL - missing]"));
+ continue;
+ }
+ log.warn("URL-based audio not directly supported, using text reference");
+ contentParts.add(OpenAIContentPart.text("[Audio URL: " + url + "]"));
+ } else {
+ log.warn(
+ "Unknown audio source type: {}", source.getClass().getSimpleName());
+ contentParts.add(
+ OpenAIContentPart.text("[Audio - unsupported source type]"));
+ }
+ } catch (Exception e) {
+ String errorMsg =
+ e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName();
+ log.warn("Failed to process AudioBlock: {}", errorMsg, e);
+ contentParts.add(
+ OpenAIContentPart.text(
+ "[Audio - processing failed: " + errorMsg + "]"));
+ }
+ } else if (block instanceof ThinkingBlock) {
+ log.debug("Skipping ThinkingBlock when formatting for OpenAI");
+ } else if (block instanceof VideoBlock vb) {
+ try {
+ Source source = vb.getSource();
+ if (source == null) {
+ log.warn("VideoBlock has null source, skipping");
+ continue;
+ }
+ String videoUrl = convertVideoSourceToUrl(source);
+ contentParts.add(OpenAIContentPart.videoUrl(videoUrl));
+ } catch (Exception e) {
+ String errorMsg =
+ e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName();
+ log.warn("Failed to process VideoBlock: {}", errorMsg);
+ contentParts.add(
+ OpenAIContentPart.text(
+ "[Video - processing failed: " + errorMsg + "]"));
+ }
+ } else if (block instanceof ToolUseBlock) {
+ log.warn("ToolUseBlock is not supported in user messages");
+ } else if (block instanceof ToolResultBlock) {
+ log.warn("ToolResultBlock is not supported in user messages");
+ }
+ }
+ return contentParts;
+ }
+
+ /**
+ * Convert assistant message with support for tool calls.
+ *
+ * @param msg The assistant message
+ * @return OpenAIMessage
+ */
+ private OpenAIMessage convertAssistantMessage(Msg msg) {
+ OpenAIMessage.Builder builder = OpenAIMessage.builder().role("assistant");
+
+ String textContent = textExtractor.apply(msg);
+ if (textContent != null && !textContent.isEmpty()) {
+ builder.content(textContent);
+ }
+
+ // Handle ThinkingBlock for reasoning models (e.g. Gemini via OpenRouter)
+ // These models require reasoning content to be preserved in history
+ ThinkingBlock thinkingBlock = msg.getFirstContentBlock(ThinkingBlock.class);
+ if (thinkingBlock != null) {
+ String thinking = thinkingBlock.getThinking();
+ if (thinking != null && !thinking.isEmpty()) {
+ builder.reasoningContent(thinking);
+ }
+
+ // Restore reasoning_details from ThinkingBlock metadata
+ // This is needed for OpenRouter/Gemini models that use reasoning tokens
+ if (thinkingBlock.getMetadata() != null) {
+ Object detailsObj =
+ thinkingBlock.getMetadata().get(ThinkingBlock.METADATA_REASONING_DETAILS);
+ if (detailsObj instanceof List> list && !list.isEmpty()) {
+ List details = new ArrayList<>();
+ for (Object item : list) {
+ if (item instanceof OpenAIReasoningDetail rd) {
+ details.add(rd);
+ }
+ }
+ if (!details.isEmpty()) {
+ builder.reasoningDetails(details);
+ }
+ }
+ }
+ }
+
+ if (msg.getName() != null) {
+ builder.name(msg.getName());
+ }
+
+ // Handle tool calls
+ List toolBlocks = msg.getContentBlocks(ToolUseBlock.class);
+ if (!toolBlocks.isEmpty()) {
+ List toolCalls = new ArrayList<>();
+ List reasoningDetails = new ArrayList<>();
+
+ // First pass: find any thought signature in the blocks
+ String fallbackSignature = null;
+ for (ToolUseBlock toolUse : toolBlocks) {
+ if (toolUse.getMetadata() != null) {
+ Object signatureObj =
+ toolUse.getMetadata().get(ToolUseBlock.METADATA_THOUGHT_SIGNATURE);
+ if (signatureObj instanceof String) {
+ fallbackSignature = (String) signatureObj;
+ if (fallbackSignature != null && !fallbackSignature.isEmpty()) {
+ break;
+ }
+ }
+ }
+ }
+
+ for (ToolUseBlock toolUse : toolBlocks) {
+ String toolId = toolUse.getId();
+ String toolName = toolUse.getName();
+ if (toolId == null || toolName == null) {
+ log.warn("ToolUseBlock has null id or name, skipping");
+ continue;
+ }
+
+ String argsJson =
+ ToolCallJsonUtils.sanitizeArgumentsJson(
+ toolUse.getContent(), toolUse.getInput());
+
+ // Add thought signature if present in metadata (required for Gemini)
+ String signature = null;
+ if (toolUse.getMetadata() != null) {
+ Object signatureObj =
+ toolUse.getMetadata().get(ToolUseBlock.METADATA_THOUGHT_SIGNATURE);
+ if (signatureObj instanceof String) {
+ signature = (String) signatureObj;
+ }
+
+ // Add reasoning detail if present
+ Object detailObj = toolUse.getMetadata().get("reasoningDetail");
+ if (detailObj instanceof OpenAIReasoningDetail) {
+ reasoningDetails.add((OpenAIReasoningDetail) detailObj);
+ }
+ }
+
+ // Fallback to shared signature if missing
+ if (signature == null) {
+ signature = fallbackSignature;
+ }
+
+ OpenAIFunction function = OpenAIFunction.of(toolName, argsJson);
+ if (signature != null) {
+ function.setThoughtSignature(signature);
+ }
+
+ OpenAIToolCall.Builder toolCallBuilder =
+ OpenAIToolCall.builder().id(toolId).type("function").function(function);
+
+ toolCalls.add(toolCallBuilder.build());
+
+ log.debug(
+ "Formatted assistant tool call: id={}, name={}, hasSignature={}",
+ toolId,
+ toolName,
+ signature != null);
+ }
+ builder.toolCalls(toolCalls);
+
+ if (!reasoningDetails.isEmpty()) {
+ builder.reasoningDetails(reasoningDetails);
+ }
+ }
+
+ return builder.build();
+ }
+
+ /**
+ * Convert tool result message.
+ *
+ * @param msg The tool result message
+ * @return OpenAIMessage
+ */
+ private OpenAIMessage convertToolMessage(Msg msg) {
+ ToolResultBlock result = msg.getFirstContentBlock(ToolResultBlock.class);
+ String toolCallId =
+ result != null && result.getId() != null
+ ? result.getId()
+ : "tool_call_" + System.currentTimeMillis();
+
+ OpenAIMessage.Builder builder = OpenAIMessage.builder().role("tool").toolCallId(toolCallId);
+
+ // Check for multimodal content in tool result
+ if (result != null && hasMediaContent(result.getOutput())) {
+ List parts = convertContentBlocks(result.getOutput());
+ builder.content(parts);
+ } else {
+ // Use provided converter for text-only or fallback
+ String content;
+ if (result != null) {
+ content = toolResultConverter.apply(result.getOutput());
+ } else {
+ content = textExtractor.apply(msg);
+ }
+ if (content == null) {
+ content = "";
+ }
+ builder.content(content);
+ }
+
+ return builder.build();
+ }
+
+ private boolean hasMediaContent(List blocks) {
+ if (blocks == null) {
+ return false;
+ }
+ for (ContentBlock block : blocks) {
+ if (block instanceof ImageBlock
+ || block instanceof AudioBlock
+ || block instanceof VideoBlock) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Convert image Source to URL string for OpenAI API.
+ *
+ * @param source The Source to convert
+ * @return URL string (either a URL or base64 data URI)
+ * @throws IllegalArgumentException if source is null or of unknown type
+ */
+ private String convertImageSourceToUrl(Source source) {
+ return OpenAIConverterUtils.convertImageSourceToUrl(source);
+ }
+
+ /**
+ * Convert video Source to URL string for OpenAI API.
+ *
+ * @param source The Source to convert
+ * @return URL string (either a URL or base64 data URI)
+ * @throws IllegalArgumentException if source is null or of unknown type
+ */
+ private String convertVideoSourceToUrl(Source source) {
+ return OpenAIConverterUtils.convertVideoSourceToUrl(source);
+ }
+
+ /**
+ * Detect audio format from media type.
+ *
+ * @param mediaType The media type (e.g., "audio/wav")
+ * @return The format string (e.g., "wav")
+ */
+ private String detectAudioFormat(String mediaType) {
+ return OpenAIConverterUtils.detectAudioFormat(mediaType);
+ }
+
+ /**
+ * Apply cache_control from Msg metadata to the converted OpenAIMessage.
+ *
+ * @param msg the source message with metadata
+ * @param result the converted OpenAI message
+ */
+ private void applyCacheControlFromMetadata(Msg msg, OpenAIMessage result) {
+ if (msg.getMetadata() == null) {
+ return;
+ }
+ Object cacheFlag = msg.getMetadata().get(MessageMetadataKeys.CACHE_CONTROL);
+ if (Boolean.TRUE.equals(cacheFlag)) {
+ result.setCacheControl(OpenAIBaseFormatter.getEphemeralCacheControl());
+ }
+ }
+}
diff --git a/agentscope-core/src/main/java/io/agentscope/core/util/ToolCallJsonUtils.java b/agentscope-core/src/main/java/io/agentscope/core/util/ToolCallJsonUtils.java
new file mode 100644
index 000000000..55308977e
--- /dev/null
+++ b/agentscope-core/src/main/java/io/agentscope/core/util/ToolCallJsonUtils.java
@@ -0,0 +1,85 @@
+/*
+ * 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.util;
+
+import java.util.Collections;
+import java.util.Map;
+
+/** Utility helpers for normalizing tool-call arguments stored as JSON strings. */
+public final class ToolCallJsonUtils {
+
+ private ToolCallJsonUtils() {}
+
+ /**
+ * Parse a raw JSON object string into a map.
+ *
+ * @param rawJson raw JSON object string
+ * @return parsed map, or empty map when the payload is blank or invalid
+ */
+ public static Map parseJsonObjectOrEmpty(String rawJson) {
+ if (rawJson == null || rawJson.isBlank()) {
+ return Collections.emptyMap();
+ }
+
+ try {
+ @SuppressWarnings("unchecked")
+ Map parsed = JsonUtils.getJsonCodec().fromJson(rawJson, Map.class);
+ return parsed != null ? parsed : Collections.emptyMap();
+ } catch (Exception ignored) {
+ return Collections.emptyMap();
+ }
+ }
+
+ /**
+ * Return a provider-safe JSON object string for tool call arguments.
+ *
+ * Valid raw JSON is preserved as-is. Invalid or partial JSON falls back to serializing the
+ * structured input map, and ultimately to an empty JSON object.
+ *
+ * @param rawJson raw tool-call content accumulated from streaming chunks
+ * @param input structured tool-call input map
+ * @return safe JSON object string for downstream provider formatters
+ */
+ public static String sanitizeArgumentsJson(String rawJson, Map input) {
+ if (isValidJsonObject(rawJson)) {
+ return rawJson;
+ }
+ return serializeInputOrEmpty(input);
+ }
+
+ /**
+ * Check whether a raw tool-call payload is a valid JSON object.
+ *
+ * @param rawJson raw tool-call content
+ * @return true when the payload parses as a JSON object
+ */
+ public static boolean isValidJsonObject(String rawJson) {
+ return !parseJsonObjectOrEmpty(rawJson).isEmpty()
+ || (rawJson != null && "{}".equals(rawJson.trim()));
+ }
+
+ private static String serializeInputOrEmpty(Map input) {
+ if (input == null || input.isEmpty()) {
+ return "{}";
+ }
+
+ try {
+ return JsonUtils.getJsonCodec().toJson(input);
+ } catch (Exception ignored) {
+ return "{}";
+ }
+ }
+}
diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/accumulator/ToolCallsAccumulatorTest.java b/agentscope-core/src/test/java/io/agentscope/core/agent/accumulator/ToolCallsAccumulatorTest.java
index eb197d574..7c984793b 100644
--- a/agentscope-core/src/test/java/io/agentscope/core/agent/accumulator/ToolCallsAccumulatorTest.java
+++ b/agentscope-core/src/test/java/io/agentscope/core/agent/accumulator/ToolCallsAccumulatorTest.java
@@ -1,326 +1,365 @@
-/*
- * 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.agent.accumulator;
-
-import static org.junit.jupiter.api.Assertions.assertArrayEquals;
-import static org.junit.jupiter.api.Assertions.assertEquals;
-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 io.agentscope.core.message.ToolUseBlock;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Test;
-
-/**
- * Unit tests for ToolCallsAccumulator.
- */
-@DisplayName("ToolCallsAccumulator Tests")
-class ToolCallsAccumulatorTest {
-
- private ToolCallsAccumulator accumulator;
-
- @BeforeEach
- void setUp() {
- accumulator = new ToolCallsAccumulator();
- }
-
- @Test
- @DisplayName("Should accumulate metadata from tool call chunks")
- void testAccumulateMetadata() {
- // First chunk with thoughtSignature
- byte[] signature = "test-thought-signature".getBytes();
- Map metadata = new HashMap<>();
- metadata.put(ToolUseBlock.METADATA_THOUGHT_SIGNATURE, signature);
-
- ToolUseBlock chunk1 =
- ToolUseBlock.builder().id("call_1").name("get_weather").metadata(metadata).build();
-
- // Second chunk with arguments (no metadata)
- Map args = new HashMap<>();
- args.put("city", "Tokyo");
-
- ToolUseBlock chunk2 =
- ToolUseBlock.builder().id("call_1").name("get_weather").input(args).build();
-
- // Accumulate both chunks
- accumulator.add(chunk1);
- accumulator.add(chunk2);
-
- // Build and verify
- List result = accumulator.buildAllToolCalls();
-
- assertEquals(1, result.size());
- ToolUseBlock toolCall = result.get(0);
-
- assertEquals("call_1", toolCall.getId());
- assertEquals("get_weather", toolCall.getName());
- assertEquals("Tokyo", toolCall.getInput().get("city"));
-
- // Verify metadata is preserved
- assertNotNull(toolCall.getMetadata());
- assertTrue(toolCall.getMetadata().containsKey(ToolUseBlock.METADATA_THOUGHT_SIGNATURE));
- assertArrayEquals(
- signature,
- (byte[]) toolCall.getMetadata().get(ToolUseBlock.METADATA_THOUGHT_SIGNATURE));
- }
-
- @Test
- @DisplayName("Should accumulate without metadata")
- void testAccumulateWithoutMetadata() {
- Map args = new HashMap<>();
- args.put("query", "test");
-
- ToolUseBlock chunk = ToolUseBlock.builder().id("call_2").name("search").input(args).build();
-
- accumulator.add(chunk);
-
- List result = accumulator.buildAllToolCalls();
-
- assertEquals(1, result.size());
- ToolUseBlock toolCall = result.get(0);
-
- assertEquals("call_2", toolCall.getId());
- assertEquals("search", toolCall.getName());
- // Metadata should be empty (null metadata passed to builder)
- assertTrue(toolCall.getMetadata().isEmpty());
- }
-
- @Test
- @DisplayName("Should handle parallel tool calls with different metadata")
- void testParallelToolCallsWithMetadata() {
- // First tool call with metadata
- byte[] sig1 = "sig-1".getBytes();
- Map metadata1 = new HashMap<>();
- metadata1.put(ToolUseBlock.METADATA_THOUGHT_SIGNATURE, sig1);
-
- ToolUseBlock call1 =
- ToolUseBlock.builder()
- .id("call_a")
- .name("tool_a")
- .input(Map.of("param", "value_a"))
- .metadata(metadata1)
- .build();
-
- // Second tool call without metadata
- ToolUseBlock call2 =
- ToolUseBlock.builder()
- .id("call_b")
- .name("tool_b")
- .input(Map.of("param", "value_b"))
- .build();
-
- accumulator.add(call1);
- accumulator.add(call2);
-
- List result = accumulator.buildAllToolCalls();
-
- assertEquals(2, result.size());
-
- // First call should have metadata
- ToolUseBlock resultA =
- result.stream().filter(t -> "call_a".equals(t.getId())).findFirst().orElse(null);
- assertNotNull(resultA);
- assertTrue(resultA.getMetadata().containsKey(ToolUseBlock.METADATA_THOUGHT_SIGNATURE));
-
- // Second call should not have metadata
- ToolUseBlock resultB =
- result.stream().filter(t -> "call_b".equals(t.getId())).findFirst().orElse(null);
- assertNotNull(resultB);
- assertTrue(resultB.getMetadata().isEmpty());
- }
-
- @Test
- @DisplayName("Should save raw content to content field in build()")
- void testBuildSavesRawContentToContentField() {
- // Simulate streaming chunks with raw content
- ToolUseBlock chunk1 =
- ToolUseBlock.builder()
- .id("call_1")
- .name("get_weather")
- .content("{\"city\":")
- .build();
-
- ToolUseBlock chunk2 =
- ToolUseBlock.builder()
- .id("call_1")
- .name("__fragment__")
- .content("\"Beijing\"}")
- .build();
-
- accumulator.add(chunk1);
- accumulator.add(chunk2);
-
- List result = accumulator.buildAllToolCalls();
-
- assertEquals(1, result.size());
- ToolUseBlock toolCall = result.get(0);
-
- // Verify content field contains accumulated raw content
- assertEquals("{\"city\":\"Beijing\"}", toolCall.getContent());
- // Verify input was parsed from raw content
- assertEquals("Beijing", toolCall.getInput().get("city"));
- }
-
- @Test
- @DisplayName("Should get accumulated tool call by ID")
- void testGetAccumulatedToolCallById() {
- ToolUseBlock call1 =
- ToolUseBlock.builder()
- .id("call_1")
- .name("weather")
- .input(Map.of("city", "Tokyo"))
- .build();
-
- ToolUseBlock call2 =
- ToolUseBlock.builder()
- .id("call_2")
- .name("calculator")
- .input(Map.of("expr", "1+1"))
- .build();
-
- accumulator.add(call1);
- accumulator.add(call2);
-
- // Get by specific ID
- ToolUseBlock result1 = accumulator.getAccumulatedToolCall("call_1");
- assertNotNull(result1);
- assertEquals("call_1", result1.getId());
- assertEquals("weather", result1.getName());
- assertEquals("Tokyo", result1.getInput().get("city"));
-
- ToolUseBlock result2 = accumulator.getAccumulatedToolCall("call_2");
- assertNotNull(result2);
- assertEquals("call_2", result2.getId());
- assertEquals("calculator", result2.getName());
- }
-
- @Test
- @DisplayName("Should fallback to lastToolCallKey when ID is null or empty")
- void testGetAccumulatedToolCallFallbackToLastKey() {
- ToolUseBlock call =
- ToolUseBlock.builder()
- .id("call_1")
- .name("weather")
- .input(Map.of("city", "Tokyo"))
- .build();
-
- accumulator.add(call);
-
- // Get with null ID should fallback to last key
- ToolUseBlock resultNull = accumulator.getAccumulatedToolCall(null);
- assertNotNull(resultNull);
- assertEquals("call_1", resultNull.getId());
-
- // Get with empty ID should fallback to last key
- ToolUseBlock resultEmpty = accumulator.getAccumulatedToolCall("");
- assertNotNull(resultEmpty);
- assertEquals("call_1", resultEmpty.getId());
- }
-
- @Test
- @DisplayName("Should return null when no tool calls accumulated")
- void testGetAccumulatedToolCallReturnsNullWhenEmpty() {
- ToolUseBlock result = accumulator.getAccumulatedToolCall("nonexistent");
- assertNull(result);
-
- ToolUseBlock resultNull = accumulator.getAccumulatedToolCall(null);
- assertNull(resultNull);
- }
-
- @Test
- @DisplayName("Should get all accumulated tool calls")
- void testGetAllAccumulatedToolCalls() {
- ToolUseBlock call1 =
- ToolUseBlock.builder()
- .id("call_1")
- .name("weather")
- .input(Map.of("city", "Tokyo"))
- .build();
-
- ToolUseBlock call2 =
- ToolUseBlock.builder()
- .id("call_2")
- .name("calculator")
- .input(Map.of("expr", "1+1"))
- .build();
-
- accumulator.add(call1);
- accumulator.add(call2);
-
- List result = accumulator.getAllAccumulatedToolCalls();
-
- assertEquals(2, result.size());
- assertTrue(result.stream().anyMatch(t -> "call_1".equals(t.getId())));
- assertTrue(result.stream().anyMatch(t -> "call_2".equals(t.getId())));
- }
-
- @Test
- @DisplayName("Should accumulate multiple parallel tool calls with streaming chunks")
- void testMultipleParallelToolCallsWithStreamingChunks() {
- // Simulate interleaved streaming chunks for two parallel tool calls
- ToolUseBlock chunk1a =
- ToolUseBlock.builder().id("call_1").name("weather").content("{\"city\":").build();
-
- ToolUseBlock chunk2a =
- ToolUseBlock.builder()
- .id("call_2")
- .name("calculator")
- .content("{\"expr\":")
- .build();
-
- ToolUseBlock chunk1b =
- ToolUseBlock.builder()
- .id("call_1")
- .name("__fragment__")
- .content("\"Beijing\"}")
- .build();
-
- ToolUseBlock chunk2b =
- ToolUseBlock.builder()
- .id("call_2")
- .name("__fragment__")
- .content("\"1+1\"}")
- .build();
-
- // Add chunks in interleaved order
- accumulator.add(chunk1a);
- accumulator.add(chunk2a);
- accumulator.add(chunk1b);
- accumulator.add(chunk2b);
-
- // Verify both tool calls are accumulated correctly
- ToolUseBlock result1 = accumulator.getAccumulatedToolCall("call_1");
- assertNotNull(result1);
- assertEquals("weather", result1.getName());
- assertEquals("{\"city\":\"Beijing\"}", result1.getContent());
- assertEquals("Beijing", result1.getInput().get("city"));
-
- ToolUseBlock result2 = accumulator.getAccumulatedToolCall("call_2");
- assertNotNull(result2);
- assertEquals("calculator", result2.getName());
- assertEquals("{\"expr\":\"1+1\"}", result2.getContent());
- assertEquals("1+1", result2.getInput().get("expr"));
-
- // Verify getAllAccumulatedToolCalls returns both
- List allCalls = accumulator.getAllAccumulatedToolCalls();
- assertEquals(2, allCalls.size());
- }
-}
+/*
+ * 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.agent.accumulator;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+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 io.agentscope.core.message.ToolUseBlock;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for ToolCallsAccumulator.
+ */
+@DisplayName("ToolCallsAccumulator Tests")
+class ToolCallsAccumulatorTest {
+
+ private ToolCallsAccumulator accumulator;
+
+ @BeforeEach
+ void setUp() {
+ accumulator = new ToolCallsAccumulator();
+ }
+
+ @Test
+ @DisplayName("Should accumulate metadata from tool call chunks")
+ void testAccumulateMetadata() {
+ // First chunk with thoughtSignature
+ byte[] signature = "test-thought-signature".getBytes();
+ Map metadata = new HashMap<>();
+ metadata.put(ToolUseBlock.METADATA_THOUGHT_SIGNATURE, signature);
+
+ ToolUseBlock chunk1 =
+ ToolUseBlock.builder().id("call_1").name("get_weather").metadata(metadata).build();
+
+ // Second chunk with arguments (no metadata)
+ Map args = new HashMap<>();
+ args.put("city", "Tokyo");
+
+ ToolUseBlock chunk2 =
+ ToolUseBlock.builder().id("call_1").name("get_weather").input(args).build();
+
+ // Accumulate both chunks
+ accumulator.add(chunk1);
+ accumulator.add(chunk2);
+
+ // Build and verify
+ List result = accumulator.buildAllToolCalls();
+
+ assertEquals(1, result.size());
+ ToolUseBlock toolCall = result.get(0);
+
+ assertEquals("call_1", toolCall.getId());
+ assertEquals("get_weather", toolCall.getName());
+ assertEquals("Tokyo", toolCall.getInput().get("city"));
+
+ // Verify metadata is preserved
+ assertNotNull(toolCall.getMetadata());
+ assertTrue(toolCall.getMetadata().containsKey(ToolUseBlock.METADATA_THOUGHT_SIGNATURE));
+ assertArrayEquals(
+ signature,
+ (byte[]) toolCall.getMetadata().get(ToolUseBlock.METADATA_THOUGHT_SIGNATURE));
+ }
+
+ @Test
+ @DisplayName("Should accumulate without metadata")
+ void testAccumulateWithoutMetadata() {
+ Map args = new HashMap<>();
+ args.put("query", "test");
+
+ ToolUseBlock chunk = ToolUseBlock.builder().id("call_2").name("search").input(args).build();
+
+ accumulator.add(chunk);
+
+ List result = accumulator.buildAllToolCalls();
+
+ assertEquals(1, result.size());
+ ToolUseBlock toolCall = result.get(0);
+
+ assertEquals("call_2", toolCall.getId());
+ assertEquals("search", toolCall.getName());
+ // Metadata should be empty (null metadata passed to builder)
+ assertTrue(toolCall.getMetadata().isEmpty());
+ }
+
+ @Test
+ @DisplayName("Should handle parallel tool calls with different metadata")
+ void testParallelToolCallsWithMetadata() {
+ // First tool call with metadata
+ byte[] sig1 = "sig-1".getBytes();
+ Map metadata1 = new HashMap<>();
+ metadata1.put(ToolUseBlock.METADATA_THOUGHT_SIGNATURE, sig1);
+
+ ToolUseBlock call1 =
+ ToolUseBlock.builder()
+ .id("call_a")
+ .name("tool_a")
+ .input(Map.of("param", "value_a"))
+ .metadata(metadata1)
+ .build();
+
+ // Second tool call without metadata
+ ToolUseBlock call2 =
+ ToolUseBlock.builder()
+ .id("call_b")
+ .name("tool_b")
+ .input(Map.of("param", "value_b"))
+ .build();
+
+ accumulator.add(call1);
+ accumulator.add(call2);
+
+ List result = accumulator.buildAllToolCalls();
+
+ assertEquals(2, result.size());
+
+ // First call should have metadata
+ ToolUseBlock resultA =
+ result.stream().filter(t -> "call_a".equals(t.getId())).findFirst().orElse(null);
+ assertNotNull(resultA);
+ assertTrue(resultA.getMetadata().containsKey(ToolUseBlock.METADATA_THOUGHT_SIGNATURE));
+
+ // Second call should not have metadata
+ ToolUseBlock resultB =
+ result.stream().filter(t -> "call_b".equals(t.getId())).findFirst().orElse(null);
+ assertNotNull(resultB);
+ assertTrue(resultB.getMetadata().isEmpty());
+ }
+
+ @Test
+ @DisplayName("Should save raw content to content field in build()")
+ void testBuildSavesRawContentToContentField() {
+ // Simulate streaming chunks with raw content
+ ToolUseBlock chunk1 =
+ ToolUseBlock.builder()
+ .id("call_1")
+ .name("get_weather")
+ .content("{\"city\":")
+ .build();
+
+ ToolUseBlock chunk2 =
+ ToolUseBlock.builder()
+ .id("call_1")
+ .name("__fragment__")
+ .content("\"Beijing\"}")
+ .build();
+
+ accumulator.add(chunk1);
+ accumulator.add(chunk2);
+
+ List result = accumulator.buildAllToolCalls();
+
+ assertEquals(1, result.size());
+ ToolUseBlock toolCall = result.get(0);
+
+ // Verify content field contains accumulated raw content
+ assertEquals("{\"city\":\"Beijing\"}", toolCall.getContent());
+ // Verify input was parsed from raw content
+ assertEquals("Beijing", toolCall.getInput().get("city"));
+ }
+
+ @Test
+ @DisplayName("Should replace incomplete raw JSON with empty object when interrupted")
+ void testBuildSanitizesIncompleteRawContent() {
+ ToolUseBlock interruptedChunk =
+ ToolUseBlock.builder()
+ .id("call_incomplete")
+ .name("search")
+ .content("{\"query\":\"hello wor")
+ .build();
+
+ accumulator.add(interruptedChunk);
+
+ ToolUseBlock toolCall = accumulator.buildAllToolCalls().get(0);
+
+ assertEquals("{}", toolCall.getContent());
+ assertTrue(toolCall.getInput().isEmpty());
+ }
+
+ @Test
+ @DisplayName("Should serialize structured input when raw JSON fragments are invalid")
+ void testBuildFallsBackToStructuredInputWhenRawContentInvalid() {
+ ToolUseBlock interruptedChunk =
+ ToolUseBlock.builder()
+ .id("call_structured")
+ .name("search")
+ .input(Map.of("query", "hello world", "page", 2))
+ .content("{\"query\":\"hello wor")
+ .build();
+
+ accumulator.add(interruptedChunk);
+
+ ToolUseBlock toolCall = accumulator.buildAllToolCalls().get(0);
+
+ assertEquals("hello world", toolCall.getInput().get("query"));
+ assertEquals(2, toolCall.getInput().get("page"));
+ assertTrue(toolCall.getContent().contains("hello world"));
+ assertTrue(toolCall.getContent().contains("page"));
+ }
+
+ @Test
+ @DisplayName("Should get accumulated tool call by ID")
+ void testGetAccumulatedToolCallById() {
+ ToolUseBlock call1 =
+ ToolUseBlock.builder()
+ .id("call_1")
+ .name("weather")
+ .input(Map.of("city", "Tokyo"))
+ .build();
+
+ ToolUseBlock call2 =
+ ToolUseBlock.builder()
+ .id("call_2")
+ .name("calculator")
+ .input(Map.of("expr", "1+1"))
+ .build();
+
+ accumulator.add(call1);
+ accumulator.add(call2);
+
+ // Get by specific ID
+ ToolUseBlock result1 = accumulator.getAccumulatedToolCall("call_1");
+ assertNotNull(result1);
+ assertEquals("call_1", result1.getId());
+ assertEquals("weather", result1.getName());
+ assertEquals("Tokyo", result1.getInput().get("city"));
+
+ ToolUseBlock result2 = accumulator.getAccumulatedToolCall("call_2");
+ assertNotNull(result2);
+ assertEquals("call_2", result2.getId());
+ assertEquals("calculator", result2.getName());
+ }
+
+ @Test
+ @DisplayName("Should fallback to lastToolCallKey when ID is null or empty")
+ void testGetAccumulatedToolCallFallbackToLastKey() {
+ ToolUseBlock call =
+ ToolUseBlock.builder()
+ .id("call_1")
+ .name("weather")
+ .input(Map.of("city", "Tokyo"))
+ .build();
+
+ accumulator.add(call);
+
+ // Get with null ID should fallback to last key
+ ToolUseBlock resultNull = accumulator.getAccumulatedToolCall(null);
+ assertNotNull(resultNull);
+ assertEquals("call_1", resultNull.getId());
+
+ // Get with empty ID should fallback to last key
+ ToolUseBlock resultEmpty = accumulator.getAccumulatedToolCall("");
+ assertNotNull(resultEmpty);
+ assertEquals("call_1", resultEmpty.getId());
+ }
+
+ @Test
+ @DisplayName("Should return null when no tool calls accumulated")
+ void testGetAccumulatedToolCallReturnsNullWhenEmpty() {
+ ToolUseBlock result = accumulator.getAccumulatedToolCall("nonexistent");
+ assertNull(result);
+
+ ToolUseBlock resultNull = accumulator.getAccumulatedToolCall(null);
+ assertNull(resultNull);
+ }
+
+ @Test
+ @DisplayName("Should get all accumulated tool calls")
+ void testGetAllAccumulatedToolCalls() {
+ ToolUseBlock call1 =
+ ToolUseBlock.builder()
+ .id("call_1")
+ .name("weather")
+ .input(Map.of("city", "Tokyo"))
+ .build();
+
+ ToolUseBlock call2 =
+ ToolUseBlock.builder()
+ .id("call_2")
+ .name("calculator")
+ .input(Map.of("expr", "1+1"))
+ .build();
+
+ accumulator.add(call1);
+ accumulator.add(call2);
+
+ List result = accumulator.getAllAccumulatedToolCalls();
+
+ assertEquals(2, result.size());
+ assertTrue(result.stream().anyMatch(t -> "call_1".equals(t.getId())));
+ assertTrue(result.stream().anyMatch(t -> "call_2".equals(t.getId())));
+ }
+
+ @Test
+ @DisplayName("Should accumulate multiple parallel tool calls with streaming chunks")
+ void testMultipleParallelToolCallsWithStreamingChunks() {
+ // Simulate interleaved streaming chunks for two parallel tool calls
+ ToolUseBlock chunk1a =
+ ToolUseBlock.builder().id("call_1").name("weather").content("{\"city\":").build();
+
+ ToolUseBlock chunk2a =
+ ToolUseBlock.builder()
+ .id("call_2")
+ .name("calculator")
+ .content("{\"expr\":")
+ .build();
+
+ ToolUseBlock chunk1b =
+ ToolUseBlock.builder()
+ .id("call_1")
+ .name("__fragment__")
+ .content("\"Beijing\"}")
+ .build();
+
+ ToolUseBlock chunk2b =
+ ToolUseBlock.builder()
+ .id("call_2")
+ .name("__fragment__")
+ .content("\"1+1\"}")
+ .build();
+
+ // Add chunks in interleaved order
+ accumulator.add(chunk1a);
+ accumulator.add(chunk2a);
+ accumulator.add(chunk1b);
+ accumulator.add(chunk2b);
+
+ // Verify both tool calls are accumulated correctly
+ ToolUseBlock result1 = accumulator.getAccumulatedToolCall("call_1");
+ assertNotNull(result1);
+ assertEquals("weather", result1.getName());
+ assertEquals("{\"city\":\"Beijing\"}", result1.getContent());
+ assertEquals("Beijing", result1.getInput().get("city"));
+
+ ToolUseBlock result2 = accumulator.getAccumulatedToolCall("call_2");
+ assertNotNull(result2);
+ assertEquals("calculator", result2.getName());
+ assertEquals("{\"expr\":\"1+1\"}", result2.getContent());
+ assertEquals("1+1", result2.getInput().get("expr"));
+
+ // Verify getAllAccumulatedToolCalls returns both
+ List allCalls = accumulator.getAllAccumulatedToolCalls();
+ assertEquals(2, allCalls.size());
+ }
+}
diff --git a/agentscope-core/src/test/java/io/agentscope/core/formatter/dashscope/DashScopeToolsHelperComprehensiveTest.java b/agentscope-core/src/test/java/io/agentscope/core/formatter/dashscope/DashScopeToolsHelperComprehensiveTest.java
index 6374a7cc1..324764fc3 100644
--- a/agentscope-core/src/test/java/io/agentscope/core/formatter/dashscope/DashScopeToolsHelperComprehensiveTest.java
+++ b/agentscope-core/src/test/java/io/agentscope/core/formatter/dashscope/DashScopeToolsHelperComprehensiveTest.java
@@ -1,546 +1,578 @@
-/*
- * 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.dashscope;
-
-import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
-import static org.junit.jupiter.api.Assertions.assertEquals;
-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 io.agentscope.core.formatter.dashscope.dto.DashScopeParameters;
-import io.agentscope.core.formatter.dashscope.dto.DashScopeTool;
-import io.agentscope.core.formatter.dashscope.dto.DashScopeToolCall;
-import io.agentscope.core.message.ToolUseBlock;
-import io.agentscope.core.model.GenerateOptions;
-import io.agentscope.core.model.ToolChoice;
-import io.agentscope.core.model.ToolSchema;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-
-/** Comprehensive tests for DashScopeToolsHelper to achieve full code coverage. */
-class DashScopeToolsHelperComprehensiveTest {
-
- private DashScopeToolsHelper helper;
-
- @BeforeEach
- void setUp() {
- helper = new DashScopeToolsHelper();
- }
-
- // ==================== ToolChoice Tests ====================
-
- @Test
- void testApplyToolChoiceWithNull() {
- DashScopeParameters params = DashScopeParameters.builder().build();
-
- // Should not throw exception with null tool choice
- assertDoesNotThrow(() -> helper.applyToolChoice(params, null));
- assertNull(params.getToolChoice());
- }
-
- @Test
- void testApplyToolChoiceWithAuto() {
- DashScopeParameters params = DashScopeParameters.builder().build();
-
- helper.applyToolChoice(params, new ToolChoice.Auto());
-
- // Verify toolChoice is set to "auto"
- assertEquals("auto", params.getToolChoice());
- }
-
- @Test
- void testApplyToolChoiceWithNone() {
- DashScopeParameters params = DashScopeParameters.builder().build();
-
- helper.applyToolChoice(params, new ToolChoice.None());
-
- // Verify toolChoice is set to "none"
- assertEquals("none", params.getToolChoice());
- }
-
- @Test
- void testApplyToolChoiceWithRequired() {
- DashScopeParameters params = DashScopeParameters.builder().build();
-
- helper.applyToolChoice(params, new ToolChoice.Required());
-
- // Required falls back to "auto" (not directly supported by DashScope)
- assertEquals("auto", params.getToolChoice());
- }
-
- @Test
- @SuppressWarnings("unchecked")
- void testApplyToolChoiceWithSpecific() {
- DashScopeParameters params = DashScopeParameters.builder().build();
-
- helper.applyToolChoice(params, new ToolChoice.Specific("my_function"));
-
- // Verify toolChoice is set to the specific function object
- assertNotNull(params.getToolChoice());
- Map choice = (Map) params.getToolChoice();
- assertEquals("function", choice.get("type"));
- Map function = (Map) choice.get("function");
- assertEquals("my_function", function.get("name"));
- }
-
- // ==================== applyOptions Tests ====================
-
- @Test
- void testApplyOptionsWithAllOptions() {
- DashScopeParameters params = DashScopeParameters.builder().build();
- GenerateOptions options =
- GenerateOptions.builder()
- .temperature(0.8)
- .topP(0.9)
- .maxTokens(1024)
- .thinkingBudget(500)
- .build();
-
- helper.applyOptions(params, options, null);
-
- assertEquals(0.8, params.getTemperature());
- assertEquals(0.9, params.getTopP());
- assertEquals(1024, params.getMaxTokens());
- assertEquals(500, params.getThinkingBudget());
- assertTrue(params.getEnableThinking());
- }
-
- @Test
- void testApplyOptionsWithDefaultOptions() {
- DashScopeParameters params = DashScopeParameters.builder().build();
- GenerateOptions defaultOptions =
- GenerateOptions.builder().temperature(0.7).maxTokens(512).build();
-
- helper.applyOptions(params, null, defaultOptions);
-
- assertEquals(0.7, params.getTemperature());
- assertEquals(512, params.getMaxTokens());
- }
-
- @Test
- void testApplyOptionsOptionsOverrideDefault() {
- DashScopeParameters params = DashScopeParameters.builder().build();
- GenerateOptions options = GenerateOptions.builder().temperature(0.9).build();
- GenerateOptions defaultOptions =
- GenerateOptions.builder().temperature(0.5).maxTokens(256).build();
-
- helper.applyOptions(params, options, defaultOptions);
-
- // options.temperature should override defaultOptions.temperature
- assertEquals(0.9, params.getTemperature());
- // maxTokens from defaultOptions should be used
- assertEquals(256, params.getMaxTokens());
- }
-
- @Test
- void testApplyOptionsWithNullValues() {
- DashScopeParameters params = DashScopeParameters.builder().build();
- GenerateOptions options = GenerateOptions.builder().build();
-
- // Should not throw with all null values
- assertDoesNotThrow(() -> helper.applyOptions(params, options, null));
- }
-
- @Test
- void testApplyOptionsBothNull() {
- DashScopeParameters params = DashScopeParameters.builder().build();
-
- // Should not throw when both options are null
- assertDoesNotThrow(() -> helper.applyOptions(params, null, null));
- }
-
- // ==================== convertTools Tests ====================
-
- @Test
- void testConvertToolsWithValidTools() {
- Map params = new HashMap<>();
- params.put("type", "object");
- params.put("properties", Map.of("query", Map.of("type", "string")));
-
- ToolSchema tool1 =
- ToolSchema.builder()
- .name("search")
- .description("Search the web")
- .parameters(params)
- .build();
-
- ToolSchema tool2 =
- ToolSchema.builder()
- .name("calculator")
- .description("Calculate math")
- .parameters(params)
- .build();
-
- List result = helper.convertTools(List.of(tool1, tool2));
-
- assertEquals(2, result.size());
- assertEquals("function", result.get(0).getType());
- assertEquals("search", result.get(0).getFunction().getName());
- assertEquals("Search the web", result.get(0).getFunction().getDescription());
- assertEquals("calculator", result.get(1).getFunction().getName());
- }
-
- @Test
- void testConvertToolsWithEmptyList() {
- List result = helper.convertTools(List.of());
- assertTrue(result.isEmpty());
- }
-
- @Test
- void testConvertToolsWithNullList() {
- List result = helper.convertTools(null);
- assertTrue(result.isEmpty());
- }
-
- @Test
- void testConvertToolsWithMinimalTool() {
- ToolSchema minimalTool =
- ToolSchema.builder().name("simple").description("Simple tool").build();
-
- List result = helper.convertTools(List.of(minimalTool));
-
- assertEquals(1, result.size());
- assertEquals("simple", result.get(0).getFunction().getName());
- assertEquals("Simple tool", result.get(0).getFunction().getDescription());
- }
-
- // ==================== applyTools Tests ====================
-
- @Test
- void testApplyToolsWithValidTools() {
- DashScopeParameters params = DashScopeParameters.builder().build();
- ToolSchema tool = ToolSchema.builder().name("test_tool").description("A test tool").build();
-
- helper.applyTools(params, List.of(tool));
-
- assertNotNull(params.getTools());
- assertEquals(1, params.getTools().size());
- }
-
- @Test
- void testApplyToolsWithEmptyList() {
- DashScopeParameters params = DashScopeParameters.builder().build();
-
- helper.applyTools(params, List.of());
-
- assertNull(params.getTools());
- }
-
- @Test
- void testApplyToolsWithNull() {
- DashScopeParameters params = DashScopeParameters.builder().build();
-
- helper.applyTools(params, null);
-
- assertNull(params.getTools());
- }
-
- // ==================== convertToolCalls Tests ====================
-
- @Test
- void testConvertToolCallsWithValidBlocks() {
- Map args1 = new HashMap<>();
- args1.put("query", "test");
-
- Map args2 = new HashMap<>();
- args2.put("a", 1);
- args2.put("b", 2);
-
- List blocks =
- List.of(
- ToolUseBlock.builder().id("call_1").name("search").input(args1).build(),
- ToolUseBlock.builder().id("call_2").name("add").input(args2).build());
-
- List result = helper.convertToolCalls(blocks);
-
- assertEquals(2, result.size());
- assertEquals("call_1", result.get(0).getId());
- assertEquals("search", result.get(0).getFunction().getName());
- assertEquals("call_2", result.get(1).getId());
- assertEquals("add", result.get(1).getFunction().getName());
- }
-
- @Test
- void testConvertToolCallsWithEmptyList() {
- List result = helper.convertToolCalls(List.of());
- assertTrue(result.isEmpty());
- }
-
- @Test
- void testConvertToolCallsWithNullList() {
- List result = helper.convertToolCalls(null);
- assertTrue(result.isEmpty());
- }
-
- @Test
- void testConvertToolCallsSkipsNullBlocks() {
- List blocks = new ArrayList<>();
- blocks.add(ToolUseBlock.builder().id("call_1").name("tool1").input(Map.of()).build());
- blocks.add(null);
- blocks.add(ToolUseBlock.builder().id("call_2").name("tool2").input(Map.of()).build());
-
- List result = helper.convertToolCalls(blocks);
-
- assertEquals(2, result.size());
- assertEquals("call_1", result.get(0).getId());
- assertEquals("call_2", result.get(1).getId());
- }
-
- @Test
- void testConvertToolCallsWithEmptyInput() {
- ToolUseBlock block =
- ToolUseBlock.builder().id("call_123").name("no_args").input(Map.of()).build();
-
- List result = helper.convertToolCalls(List.of(block));
-
- assertEquals(1, result.size());
- assertEquals("no_args", result.get(0).getFunction().getName());
- assertEquals("{}", result.get(0).getFunction().getArguments());
- }
-
- @Test
- void testConvertToolCallsWithComplexArgs() {
- Map complexArgs = new HashMap<>();
- complexArgs.put("string", "value");
- complexArgs.put("number", 42);
- complexArgs.put("boolean", true);
- complexArgs.put("nested", Map.of("key", "nested_value"));
-
- ToolUseBlock block =
- ToolUseBlock.builder()
- .id("call_complex")
- .name("complex")
- .input(complexArgs)
- .build();
-
- List result = helper.convertToolCalls(List.of(block));
-
- assertEquals(1, result.size());
- String argsJson = result.get(0).getFunction().getArguments();
- assertNotNull(argsJson);
- assertTrue(argsJson.contains("string"));
- assertTrue(argsJson.contains("value"));
- }
-
- // ==================== convertToolChoice Tests ====================
-
- @Test
- void testConvertToolChoiceNull() {
- Object result = helper.convertToolChoice(null);
- assertNull(result);
- }
-
- @Test
- void testConvertToolChoiceAuto() {
- Object result = helper.convertToolChoice(new ToolChoice.Auto());
- assertEquals("auto", result);
- }
-
- @Test
- void testConvertToolChoiceNone() {
- Object result = helper.convertToolChoice(new ToolChoice.None());
- assertEquals("none", result);
- }
-
- @Test
- void testConvertToolChoiceRequired() {
- Object result = helper.convertToolChoice(new ToolChoice.Required());
- // Required falls back to auto
- assertEquals("auto", result);
- }
-
- @Test
- @SuppressWarnings("unchecked")
- void testConvertToolChoiceSpecific() {
- Object result = helper.convertToolChoice(new ToolChoice.Specific("my_tool"));
-
- assertNotNull(result);
- Map choice = (Map) result;
- assertEquals("function", choice.get("type"));
- Map function = (Map) choice.get("function");
- assertEquals("my_tool", function.get("name"));
- }
-
- // ==================== New Parameters Tests ====================
-
- @Test
- void testApplyOptionsWithTopK() {
- DashScopeParameters params = DashScopeParameters.builder().build();
- GenerateOptions options = GenerateOptions.builder().topK(40).build();
-
- helper.applyOptions(params, options, null);
-
- assertEquals(40, params.getTopK());
- }
-
- @Test
- void testApplyOptionsWithSeed() {
- DashScopeParameters params = DashScopeParameters.builder().build();
- GenerateOptions options = GenerateOptions.builder().seed(12345L).build();
-
- helper.applyOptions(params, options, null);
-
- assertEquals(12345, params.getSeed());
- }
-
- @Test
- void testApplyOptionsWithFrequencyPenalty() {
- DashScopeParameters params = DashScopeParameters.builder().build();
- GenerateOptions options = GenerateOptions.builder().frequencyPenalty(0.5).build();
-
- helper.applyOptions(params, options, null);
-
- assertEquals(0.5, params.getFrequencyPenalty());
- }
-
- @Test
- void testApplyOptionsWithPresencePenalty() {
- DashScopeParameters params = DashScopeParameters.builder().build();
- GenerateOptions options = GenerateOptions.builder().presencePenalty(0.3).build();
-
- helper.applyOptions(params, options, null);
-
- assertEquals(0.3, params.getPresencePenalty());
- }
-
- @Test
- void testApplyOptionsWithAllNewParameters() {
- DashScopeParameters params = DashScopeParameters.builder().build();
- GenerateOptions options =
- GenerateOptions.builder()
- .temperature(0.8)
- .topK(50)
- .seed(42L)
- .frequencyPenalty(0.5)
- .presencePenalty(0.3)
- .build();
-
- helper.applyOptions(params, options, null);
-
- assertEquals(0.8, params.getTemperature());
- assertEquals(50, params.getTopK());
- assertEquals(42, params.getSeed());
- assertEquals(0.5, params.getFrequencyPenalty());
- assertEquals(0.3, params.getPresencePenalty());
- }
-
- @Test
- void testApplyOptionsTopKFromDefaultOptions() {
- DashScopeParameters params = DashScopeParameters.builder().build();
- GenerateOptions options = GenerateOptions.builder().temperature(0.5).build();
- GenerateOptions defaultOptions = GenerateOptions.builder().topK(30).seed(999L).build();
-
- helper.applyOptions(params, options, defaultOptions);
-
- assertEquals(0.5, params.getTemperature());
- assertEquals(30, params.getTopK());
- assertEquals(999, params.getSeed());
- }
-
- // ==================== Merge Methods Tests ====================
-
- @Test
- void testMergeAdditionalHeadersWithBothOptions() {
- GenerateOptions defaultOptions =
- GenerateOptions.builder()
- .additionalHeader("X-Default", "default-value")
- .additionalHeader("X-Shared", "default-shared")
- .build();
- GenerateOptions options =
- GenerateOptions.builder()
- .additionalHeader("X-Custom", "custom-value")
- .additionalHeader("X-Shared", "custom-shared")
- .build();
-
- Map result = helper.mergeAdditionalHeaders(options, defaultOptions);
-
- assertNotNull(result);
- assertEquals(3, result.size());
- assertEquals("default-value", result.get("X-Default"));
- assertEquals("custom-value", result.get("X-Custom"));
- assertEquals("custom-shared", result.get("X-Shared")); // options overrides default
- }
-
- @Test
- void testMergeAdditionalHeadersWithNullOptions() {
- GenerateOptions defaultOptions =
- GenerateOptions.builder().additionalHeader("X-Default", "value").build();
-
- Map result = helper.mergeAdditionalHeaders(null, defaultOptions);
-
- assertNotNull(result);
- assertEquals("value", result.get("X-Default"));
- }
-
- @Test
- void testMergeAdditionalHeadersWithBothEmpty() {
- GenerateOptions options = GenerateOptions.builder().build();
- GenerateOptions defaultOptions = GenerateOptions.builder().build();
-
- Map result = helper.mergeAdditionalHeaders(options, defaultOptions);
-
- assertNull(result);
- }
-
- @Test
- void testMergeAdditionalBodyParamsWithBothOptions() {
- GenerateOptions defaultOptions =
- GenerateOptions.builder()
- .additionalBodyParam("default_key", "default_value")
- .additionalBodyParam("shared_key", "default_shared")
- .build();
- GenerateOptions options =
- GenerateOptions.builder()
- .additionalBodyParam("custom_key", 123)
- .additionalBodyParam("shared_key", "custom_shared")
- .build();
-
- Map result = helper.mergeAdditionalBodyParams(options, defaultOptions);
-
- assertNotNull(result);
- assertEquals(3, result.size());
- assertEquals("default_value", result.get("default_key"));
- assertEquals(123, result.get("custom_key"));
- assertEquals("custom_shared", result.get("shared_key")); // options overrides default
- }
-
- @Test
- void testMergeAdditionalQueryParamsWithBothOptions() {
- GenerateOptions defaultOptions =
- GenerateOptions.builder()
- .additionalQueryParam("default_param", "default_value")
- .additionalQueryParam("shared_param", "default_shared")
- .build();
- GenerateOptions options =
- GenerateOptions.builder()
- .additionalQueryParam("custom_param", "custom_value")
- .additionalQueryParam("shared_param", "custom_shared")
- .build();
-
- Map result = helper.mergeAdditionalQueryParams(options, defaultOptions);
-
- assertNotNull(result);
- assertEquals(3, result.size());
- assertEquals("default_value", result.get("default_param"));
- assertEquals("custom_value", result.get("custom_param"));
- assertEquals("custom_shared", result.get("shared_param")); // options overrides default
- }
-}
+/*
+ * 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.dashscope;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+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 io.agentscope.core.formatter.dashscope.dto.DashScopeParameters;
+import io.agentscope.core.formatter.dashscope.dto.DashScopeTool;
+import io.agentscope.core.formatter.dashscope.dto.DashScopeToolCall;
+import io.agentscope.core.message.ToolUseBlock;
+import io.agentscope.core.model.GenerateOptions;
+import io.agentscope.core.model.ToolChoice;
+import io.agentscope.core.model.ToolSchema;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/** Comprehensive tests for DashScopeToolsHelper to achieve full code coverage. */
+class DashScopeToolsHelperComprehensiveTest {
+
+ private DashScopeToolsHelper helper;
+
+ @BeforeEach
+ void setUp() {
+ helper = new DashScopeToolsHelper();
+ }
+
+ // ==================== ToolChoice Tests ====================
+
+ @Test
+ void testApplyToolChoiceWithNull() {
+ DashScopeParameters params = DashScopeParameters.builder().build();
+
+ // Should not throw exception with null tool choice
+ assertDoesNotThrow(() -> helper.applyToolChoice(params, null));
+ assertNull(params.getToolChoice());
+ }
+
+ @Test
+ void testApplyToolChoiceWithAuto() {
+ DashScopeParameters params = DashScopeParameters.builder().build();
+
+ helper.applyToolChoice(params, new ToolChoice.Auto());
+
+ // Verify toolChoice is set to "auto"
+ assertEquals("auto", params.getToolChoice());
+ }
+
+ @Test
+ void testApplyToolChoiceWithNone() {
+ DashScopeParameters params = DashScopeParameters.builder().build();
+
+ helper.applyToolChoice(params, new ToolChoice.None());
+
+ // Verify toolChoice is set to "none"
+ assertEquals("none", params.getToolChoice());
+ }
+
+ @Test
+ void testApplyToolChoiceWithRequired() {
+ DashScopeParameters params = DashScopeParameters.builder().build();
+
+ helper.applyToolChoice(params, new ToolChoice.Required());
+
+ // Required falls back to "auto" (not directly supported by DashScope)
+ assertEquals("auto", params.getToolChoice());
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void testApplyToolChoiceWithSpecific() {
+ DashScopeParameters params = DashScopeParameters.builder().build();
+
+ helper.applyToolChoice(params, new ToolChoice.Specific("my_function"));
+
+ // Verify toolChoice is set to the specific function object
+ assertNotNull(params.getToolChoice());
+ Map choice = (Map) params.getToolChoice();
+ assertEquals("function", choice.get("type"));
+ Map function = (Map) choice.get("function");
+ assertEquals("my_function", function.get("name"));
+ }
+
+ // ==================== applyOptions Tests ====================
+
+ @Test
+ void testApplyOptionsWithAllOptions() {
+ DashScopeParameters params = DashScopeParameters.builder().build();
+ GenerateOptions options =
+ GenerateOptions.builder()
+ .temperature(0.8)
+ .topP(0.9)
+ .maxTokens(1024)
+ .thinkingBudget(500)
+ .build();
+
+ helper.applyOptions(params, options, null);
+
+ assertEquals(0.8, params.getTemperature());
+ assertEquals(0.9, params.getTopP());
+ assertEquals(1024, params.getMaxTokens());
+ assertEquals(500, params.getThinkingBudget());
+ assertTrue(params.getEnableThinking());
+ }
+
+ @Test
+ void testApplyOptionsWithDefaultOptions() {
+ DashScopeParameters params = DashScopeParameters.builder().build();
+ GenerateOptions defaultOptions =
+ GenerateOptions.builder().temperature(0.7).maxTokens(512).build();
+
+ helper.applyOptions(params, null, defaultOptions);
+
+ assertEquals(0.7, params.getTemperature());
+ assertEquals(512, params.getMaxTokens());
+ }
+
+ @Test
+ void testApplyOptionsOptionsOverrideDefault() {
+ DashScopeParameters params = DashScopeParameters.builder().build();
+ GenerateOptions options = GenerateOptions.builder().temperature(0.9).build();
+ GenerateOptions defaultOptions =
+ GenerateOptions.builder().temperature(0.5).maxTokens(256).build();
+
+ helper.applyOptions(params, options, defaultOptions);
+
+ // options.temperature should override defaultOptions.temperature
+ assertEquals(0.9, params.getTemperature());
+ // maxTokens from defaultOptions should be used
+ assertEquals(256, params.getMaxTokens());
+ }
+
+ @Test
+ void testApplyOptionsWithNullValues() {
+ DashScopeParameters params = DashScopeParameters.builder().build();
+ GenerateOptions options = GenerateOptions.builder().build();
+
+ // Should not throw with all null values
+ assertDoesNotThrow(() -> helper.applyOptions(params, options, null));
+ }
+
+ @Test
+ void testApplyOptionsBothNull() {
+ DashScopeParameters params = DashScopeParameters.builder().build();
+
+ // Should not throw when both options are null
+ assertDoesNotThrow(() -> helper.applyOptions(params, null, null));
+ }
+
+ // ==================== convertTools Tests ====================
+
+ @Test
+ void testConvertToolsWithValidTools() {
+ Map params = new HashMap<>();
+ params.put("type", "object");
+ params.put("properties", Map.of("query", Map.of("type", "string")));
+
+ ToolSchema tool1 =
+ ToolSchema.builder()
+ .name("search")
+ .description("Search the web")
+ .parameters(params)
+ .build();
+
+ ToolSchema tool2 =
+ ToolSchema.builder()
+ .name("calculator")
+ .description("Calculate math")
+ .parameters(params)
+ .build();
+
+ List result = helper.convertTools(List.of(tool1, tool2));
+
+ assertEquals(2, result.size());
+ assertEquals("function", result.get(0).getType());
+ assertEquals("search", result.get(0).getFunction().getName());
+ assertEquals("Search the web", result.get(0).getFunction().getDescription());
+ assertEquals("calculator", result.get(1).getFunction().getName());
+ }
+
+ @Test
+ void testConvertToolsWithEmptyList() {
+ List result = helper.convertTools(List.of());
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ void testConvertToolsWithNullList() {
+ List result = helper.convertTools(null);
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ void testConvertToolsWithMinimalTool() {
+ ToolSchema minimalTool =
+ ToolSchema.builder().name("simple").description("Simple tool").build();
+
+ List result = helper.convertTools(List.of(minimalTool));
+
+ assertEquals(1, result.size());
+ assertEquals("simple", result.get(0).getFunction().getName());
+ assertEquals("Simple tool", result.get(0).getFunction().getDescription());
+ }
+
+ // ==================== applyTools Tests ====================
+
+ @Test
+ void testApplyToolsWithValidTools() {
+ DashScopeParameters params = DashScopeParameters.builder().build();
+ ToolSchema tool = ToolSchema.builder().name("test_tool").description("A test tool").build();
+
+ helper.applyTools(params, List.of(tool));
+
+ assertNotNull(params.getTools());
+ assertEquals(1, params.getTools().size());
+ }
+
+ @Test
+ void testApplyToolsWithEmptyList() {
+ DashScopeParameters params = DashScopeParameters.builder().build();
+
+ helper.applyTools(params, List.of());
+
+ assertNull(params.getTools());
+ }
+
+ @Test
+ void testApplyToolsWithNull() {
+ DashScopeParameters params = DashScopeParameters.builder().build();
+
+ helper.applyTools(params, null);
+
+ assertNull(params.getTools());
+ }
+
+ // ==================== convertToolCalls Tests ====================
+
+ @Test
+ void testConvertToolCallsWithValidBlocks() {
+ Map args1 = new HashMap<>();
+ args1.put("query", "test");
+
+ Map args2 = new HashMap<>();
+ args2.put("a", 1);
+ args2.put("b", 2);
+
+ List blocks =
+ List.of(
+ ToolUseBlock.builder().id("call_1").name("search").input(args1).build(),
+ ToolUseBlock.builder().id("call_2").name("add").input(args2).build());
+
+ List result = helper.convertToolCalls(blocks);
+
+ assertEquals(2, result.size());
+ assertEquals("call_1", result.get(0).getId());
+ assertEquals("search", result.get(0).getFunction().getName());
+ assertEquals("call_2", result.get(1).getId());
+ assertEquals("add", result.get(1).getFunction().getName());
+ }
+
+ @Test
+ void testConvertToolCallsWithEmptyList() {
+ List result = helper.convertToolCalls(List.of());
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ void testConvertToolCallsWithNullList() {
+ List result = helper.convertToolCalls(null);
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ void testConvertToolCallsSkipsNullBlocks() {
+ List blocks = new ArrayList<>();
+ blocks.add(ToolUseBlock.builder().id("call_1").name("tool1").input(Map.of()).build());
+ blocks.add(null);
+ blocks.add(ToolUseBlock.builder().id("call_2").name("tool2").input(Map.of()).build());
+
+ List result = helper.convertToolCalls(blocks);
+
+ assertEquals(2, result.size());
+ assertEquals("call_1", result.get(0).getId());
+ assertEquals("call_2", result.get(1).getId());
+ }
+
+ @Test
+ void testConvertToolCallsWithEmptyInput() {
+ ToolUseBlock block =
+ ToolUseBlock.builder().id("call_123").name("no_args").input(Map.of()).build();
+
+ List result = helper.convertToolCalls(List.of(block));
+
+ assertEquals(1, result.size());
+ assertEquals("no_args", result.get(0).getFunction().getName());
+ assertEquals("{}", result.get(0).getFunction().getArguments());
+ }
+
+ @Test
+ void testConvertToolCallsWithComplexArgs() {
+ Map complexArgs = new HashMap<>();
+ complexArgs.put("string", "value");
+ complexArgs.put("number", 42);
+ complexArgs.put("boolean", true);
+ complexArgs.put("nested", Map.of("key", "nested_value"));
+
+ ToolUseBlock block =
+ ToolUseBlock.builder()
+ .id("call_complex")
+ .name("complex")
+ .input(complexArgs)
+ .build();
+
+ List result = helper.convertToolCalls(List.of(block));
+
+ assertEquals(1, result.size());
+ String argsJson = result.get(0).getFunction().getArguments();
+ assertNotNull(argsJson);
+ assertTrue(argsJson.contains("string"));
+ assertTrue(argsJson.contains("value"));
+ }
+
+ @Test
+ void testConvertToolCallsFallsBackToStructuredInputWhenContentInvalid() {
+ ToolUseBlock block =
+ ToolUseBlock.builder()
+ .id("call_invalid_json")
+ .name("search")
+ .input(Map.of("query", "hello world"))
+ .content("{\"query\":\"hello wor")
+ .build();
+
+ List result = helper.convertToolCalls(List.of(block));
+
+ assertEquals(1, result.size());
+ assertEquals("search", result.get(0).getFunction().getName());
+ assertEquals("{\"query\":\"hello world\"}", result.get(0).getFunction().getArguments());
+ }
+
+ @Test
+ void testConvertToolCallsFallsBackToEmptyObjectWhenContentInvalidAndInputMissing() {
+ ToolUseBlock block =
+ ToolUseBlock.builder()
+ .id("call_invalid_empty")
+ .name("search")
+ .content("{\"query\":\"hello wor")
+ .build();
+
+ List result = helper.convertToolCalls(List.of(block));
+
+ assertEquals(1, result.size());
+ assertEquals("{}", result.get(0).getFunction().getArguments());
+ }
+
+ // ==================== convertToolChoice Tests ====================
+
+ @Test
+ void testConvertToolChoiceNull() {
+ Object result = helper.convertToolChoice(null);
+ assertNull(result);
+ }
+
+ @Test
+ void testConvertToolChoiceAuto() {
+ Object result = helper.convertToolChoice(new ToolChoice.Auto());
+ assertEquals("auto", result);
+ }
+
+ @Test
+ void testConvertToolChoiceNone() {
+ Object result = helper.convertToolChoice(new ToolChoice.None());
+ assertEquals("none", result);
+ }
+
+ @Test
+ void testConvertToolChoiceRequired() {
+ Object result = helper.convertToolChoice(new ToolChoice.Required());
+ // Required falls back to auto
+ assertEquals("auto", result);
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void testConvertToolChoiceSpecific() {
+ Object result = helper.convertToolChoice(new ToolChoice.Specific("my_tool"));
+
+ assertNotNull(result);
+ Map choice = (Map) result;
+ assertEquals("function", choice.get("type"));
+ Map function = (Map) choice.get("function");
+ assertEquals("my_tool", function.get("name"));
+ }
+
+ // ==================== New Parameters Tests ====================
+
+ @Test
+ void testApplyOptionsWithTopK() {
+ DashScopeParameters params = DashScopeParameters.builder().build();
+ GenerateOptions options = GenerateOptions.builder().topK(40).build();
+
+ helper.applyOptions(params, options, null);
+
+ assertEquals(40, params.getTopK());
+ }
+
+ @Test
+ void testApplyOptionsWithSeed() {
+ DashScopeParameters params = DashScopeParameters.builder().build();
+ GenerateOptions options = GenerateOptions.builder().seed(12345L).build();
+
+ helper.applyOptions(params, options, null);
+
+ assertEquals(12345, params.getSeed());
+ }
+
+ @Test
+ void testApplyOptionsWithFrequencyPenalty() {
+ DashScopeParameters params = DashScopeParameters.builder().build();
+ GenerateOptions options = GenerateOptions.builder().frequencyPenalty(0.5).build();
+
+ helper.applyOptions(params, options, null);
+
+ assertEquals(0.5, params.getFrequencyPenalty());
+ }
+
+ @Test
+ void testApplyOptionsWithPresencePenalty() {
+ DashScopeParameters params = DashScopeParameters.builder().build();
+ GenerateOptions options = GenerateOptions.builder().presencePenalty(0.3).build();
+
+ helper.applyOptions(params, options, null);
+
+ assertEquals(0.3, params.getPresencePenalty());
+ }
+
+ @Test
+ void testApplyOptionsWithAllNewParameters() {
+ DashScopeParameters params = DashScopeParameters.builder().build();
+ GenerateOptions options =
+ GenerateOptions.builder()
+ .temperature(0.8)
+ .topK(50)
+ .seed(42L)
+ .frequencyPenalty(0.5)
+ .presencePenalty(0.3)
+ .build();
+
+ helper.applyOptions(params, options, null);
+
+ assertEquals(0.8, params.getTemperature());
+ assertEquals(50, params.getTopK());
+ assertEquals(42, params.getSeed());
+ assertEquals(0.5, params.getFrequencyPenalty());
+ assertEquals(0.3, params.getPresencePenalty());
+ }
+
+ @Test
+ void testApplyOptionsTopKFromDefaultOptions() {
+ DashScopeParameters params = DashScopeParameters.builder().build();
+ GenerateOptions options = GenerateOptions.builder().temperature(0.5).build();
+ GenerateOptions defaultOptions = GenerateOptions.builder().topK(30).seed(999L).build();
+
+ helper.applyOptions(params, options, defaultOptions);
+
+ assertEquals(0.5, params.getTemperature());
+ assertEquals(30, params.getTopK());
+ assertEquals(999, params.getSeed());
+ }
+
+ // ==================== Merge Methods Tests ====================
+
+ @Test
+ void testMergeAdditionalHeadersWithBothOptions() {
+ GenerateOptions defaultOptions =
+ GenerateOptions.builder()
+ .additionalHeader("X-Default", "default-value")
+ .additionalHeader("X-Shared", "default-shared")
+ .build();
+ GenerateOptions options =
+ GenerateOptions.builder()
+ .additionalHeader("X-Custom", "custom-value")
+ .additionalHeader("X-Shared", "custom-shared")
+ .build();
+
+ Map result = helper.mergeAdditionalHeaders(options, defaultOptions);
+
+ assertNotNull(result);
+ assertEquals(3, result.size());
+ assertEquals("default-value", result.get("X-Default"));
+ assertEquals("custom-value", result.get("X-Custom"));
+ assertEquals("custom-shared", result.get("X-Shared")); // options overrides default
+ }
+
+ @Test
+ void testMergeAdditionalHeadersWithNullOptions() {
+ GenerateOptions defaultOptions =
+ GenerateOptions.builder().additionalHeader("X-Default", "value").build();
+
+ Map result = helper.mergeAdditionalHeaders(null, defaultOptions);
+
+ assertNotNull(result);
+ assertEquals("value", result.get("X-Default"));
+ }
+
+ @Test
+ void testMergeAdditionalHeadersWithBothEmpty() {
+ GenerateOptions options = GenerateOptions.builder().build();
+ GenerateOptions defaultOptions = GenerateOptions.builder().build();
+
+ Map result = helper.mergeAdditionalHeaders(options, defaultOptions);
+
+ assertNull(result);
+ }
+
+ @Test
+ void testMergeAdditionalBodyParamsWithBothOptions() {
+ GenerateOptions defaultOptions =
+ GenerateOptions.builder()
+ .additionalBodyParam("default_key", "default_value")
+ .additionalBodyParam("shared_key", "default_shared")
+ .build();
+ GenerateOptions options =
+ GenerateOptions.builder()
+ .additionalBodyParam("custom_key", 123)
+ .additionalBodyParam("shared_key", "custom_shared")
+ .build();
+
+ Map result = helper.mergeAdditionalBodyParams(options, defaultOptions);
+
+ assertNotNull(result);
+ assertEquals(3, result.size());
+ assertEquals("default_value", result.get("default_key"));
+ assertEquals(123, result.get("custom_key"));
+ assertEquals("custom_shared", result.get("shared_key")); // options overrides default
+ }
+
+ @Test
+ void testMergeAdditionalQueryParamsWithBothOptions() {
+ GenerateOptions defaultOptions =
+ GenerateOptions.builder()
+ .additionalQueryParam("default_param", "default_value")
+ .additionalQueryParam("shared_param", "default_shared")
+ .build();
+ GenerateOptions options =
+ GenerateOptions.builder()
+ .additionalQueryParam("custom_param", "custom_value")
+ .additionalQueryParam("shared_param", "custom_shared")
+ .build();
+
+ Map result = helper.mergeAdditionalQueryParams(options, defaultOptions);
+
+ assertNotNull(result);
+ assertEquals(3, result.size());
+ assertEquals("default_value", result.get("default_param"));
+ assertEquals("custom_value", result.get("custom_param"));
+ assertEquals("custom_shared", result.get("shared_param")); // options overrides default
+ }
+}
diff --git a/agentscope-core/src/test/java/io/agentscope/core/formatter/openai/OpenAIMessageConverterTest.java b/agentscope-core/src/test/java/io/agentscope/core/formatter/openai/OpenAIMessageConverterTest.java
index 665fce463..16796f326 100644
--- a/agentscope-core/src/test/java/io/agentscope/core/formatter/openai/OpenAIMessageConverterTest.java
+++ b/agentscope-core/src/test/java/io/agentscope/core/formatter/openai/OpenAIMessageConverterTest.java
@@ -1,906 +1,950 @@
-/*
- * 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.openai;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-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 io.agentscope.core.formatter.openai.dto.OpenAIContentPart;
-import io.agentscope.core.formatter.openai.dto.OpenAIMessage;
-import io.agentscope.core.formatter.openai.dto.OpenAIReasoningDetail;
-import io.agentscope.core.message.AudioBlock;
-import io.agentscope.core.message.Base64Source;
-import io.agentscope.core.message.ContentBlock;
-import io.agentscope.core.message.ImageBlock;
-import io.agentscope.core.message.Msg;
-import io.agentscope.core.message.MsgRole;
-import io.agentscope.core.message.TextBlock;
-import io.agentscope.core.message.ThinkingBlock;
-import io.agentscope.core.message.ToolResultBlock;
-import io.agentscope.core.message.ToolUseBlock;
-import io.agentscope.core.message.URLSource;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.function.Function;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Nested;
-import org.junit.jupiter.api.Tag;
-import org.junit.jupiter.api.Test;
-
-/**
- * Unit tests for OpenAIMessageConverter.
- *
- * These tests verify the message conversion logic including:
- *
- * - Text message conversion
- * - Multimodal content (image, audio) conversion
- * - Null source handling for ImageBlock and AudioBlock
- * - Role mapping
- *
- */
-@Tag("unit")
-@DisplayName("OpenAIMessageConverter Unit Tests")
-class OpenAIMessageConverterTest {
-
- private OpenAIMessageConverter converter;
-
- @BeforeEach
- void setUp() {
- // Create converter with simple text extractor and tool result converter
- Function textExtractor =
- msg -> {
- StringBuilder sb = new StringBuilder();
- for (ContentBlock block : msg.getContent()) {
- if (block instanceof TextBlock tb) {
- sb.append(tb.getText());
- }
- }
- return sb.toString();
- };
-
- Function, String> toolResultConverter =
- blocks -> {
- StringBuilder sb = new StringBuilder();
- for (ContentBlock block : blocks) {
- if (block instanceof TextBlock tb) {
- sb.append(tb.getText());
- }
- }
- return sb.toString();
- };
-
- converter = new OpenAIMessageConverter(textExtractor, toolResultConverter);
- }
-
- @Test
- @DisplayName("Should convert simple text message")
- void testConvertSimpleTextMessage() {
- Msg msg =
- Msg.builder()
- .role(MsgRole.USER)
- .content(List.of(TextBlock.builder().text("Hello").build()))
- .build();
-
- OpenAIMessage result = converter.convertToMessage(msg, false);
-
- assertNotNull(result);
- assertEquals("user", result.getRole());
- assertEquals("Hello", result.getContentAsString());
- }
-
- // Note: ImageBlock and AudioBlock constructors require non-null source,
- // so we cannot test null source handling directly. The null checks in
- // OpenAIMessageConverter are defensive programming for edge cases that
- // might occur through deserialization or other means.
-
- @Test
- @DisplayName("Should convert ImageBlock with valid URLSource")
- void testImageBlockWithValidURLSource() {
- URLSource source = URLSource.builder().url("https://example.com/image.png").build();
- ImageBlock imageBlock = ImageBlock.builder().source(source).build();
-
- Msg msg =
- Msg.builder()
- .role(MsgRole.USER)
- .content(
- List.of(
- TextBlock.builder().text("See this image").build(),
- imageBlock))
- .build();
-
- OpenAIMessage result = converter.convertToMessage(msg, true);
-
- assertNotNull(result);
- assertEquals("user", result.getRole());
- Object content = result.getContent();
- assertNotNull(content);
- assertTrue(content instanceof List);
- @SuppressWarnings("unchecked")
- List parts = (List) content;
- assertEquals(2, parts.size());
- assertEquals("See this image", parts.get(0).getText());
- assertNotNull(parts.get(1).getImageUrl());
- assertEquals("https://example.com/image.png", parts.get(1).getImageUrl().getUrl());
- }
-
- @Test
- @DisplayName("Should convert ImageBlock with valid Base64Source")
- void testImageBlockWithValidBase64Source() {
- String base64Data =
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
- Base64Source source =
- Base64Source.builder().data(base64Data).mediaType("image/png").build();
- ImageBlock imageBlock = ImageBlock.builder().source(source).build();
-
- Msg msg =
- Msg.builder()
- .role(MsgRole.USER)
- .content(
- List.of(
- TextBlock.builder().text("See this image").build(),
- imageBlock))
- .build();
-
- OpenAIMessage result = converter.convertToMessage(msg, true);
-
- assertNotNull(result);
- assertEquals("user", result.getRole());
- Object content = result.getContent();
- assertNotNull(content);
- assertTrue(content instanceof List);
- @SuppressWarnings("unchecked")
- List parts = (List) content;
- assertEquals(2, parts.size());
- assertEquals("See this image", parts.get(0).getText());
- assertNotNull(parts.get(1).getImageUrl());
- assertTrue(parts.get(1).getImageUrl().getUrl().startsWith("data:image/png;base64,"));
- }
-
- @Test
- @DisplayName("Should convert AudioBlock with valid Base64Source")
- void testAudioBlockWithValidBase64Source() {
- String base64Data = "UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQAAAAA=";
- Base64Source source =
- Base64Source.builder().data(base64Data).mediaType("audio/wav").build();
- AudioBlock audioBlock = AudioBlock.builder().source(source).build();
-
- Msg msg =
- Msg.builder()
- .role(MsgRole.USER)
- .content(
- List.of(
- TextBlock.builder().text("Listen to this").build(),
- audioBlock))
- .build();
-
- OpenAIMessage result = converter.convertToMessage(msg, true);
-
- assertNotNull(result);
- assertEquals("user", result.getRole());
- Object content = result.getContent();
- assertNotNull(content);
- assertTrue(content instanceof List);
- @SuppressWarnings("unchecked")
- List parts = (List) content;
- assertEquals(2, parts.size());
- assertEquals("Listen to this", parts.get(0).getText());
- assertNotNull(parts.get(1).getInputAudio());
- assertEquals(base64Data, parts.get(1).getInputAudio().getData());
- }
-
- @Test
- @DisplayName("Should convert system message")
- void testConvertSystemMessage() {
- Msg msg =
- Msg.builder()
- .role(MsgRole.SYSTEM)
- .content(
- List.of(
- TextBlock.builder()
- .text("You are a helpful assistant")
- .build()))
- .build();
-
- OpenAIMessage result = converter.convertToMessage(msg, false);
-
- assertNotNull(result);
- assertEquals("system", result.getRole());
- assertEquals("You are a helpful assistant", result.getContentAsString());
- }
-
- @Test
- @DisplayName("Should convert assistant message")
- void testConvertAssistantMessage() {
- Msg msg =
- Msg.builder()
- .role(MsgRole.ASSISTANT)
- .content(
- List.of(TextBlock.builder().text("Hello! How can I help?").build()))
- .build();
-
- OpenAIMessage result = converter.convertToMessage(msg, false);
-
- assertNotNull(result);
- assertEquals("assistant", result.getRole());
- assertEquals("Hello! How can I help?", result.getContentAsString());
- }
-
- @Test
- @DisplayName("Should handle ImageBlock with URLSource")
- void testImageBlockWithURLSource() {
- ImageBlock imageBlock =
- ImageBlock.builder()
- .source(URLSource.builder().url("http://example.com/image.png").build())
- .build();
-
- Msg msg = Msg.builder().role(MsgRole.USER).content(List.of(imageBlock)).build();
-
- OpenAIMessage result = converter.convertToMessage(msg, true);
-
- assertNotNull(result);
- assertEquals("user", result.getRole());
- assertTrue(result.isMultimodal());
- List content = result.getContentAsList();
- assertNotNull(content);
- assertTrue(!content.isEmpty());
- }
-
- @Test
- @DisplayName("Should handle AudioBlock with URLSource (converted to text reference)")
- void testAudioBlockWithURLSource() {
- AudioBlock audioBlock =
- AudioBlock.builder()
- .source(URLSource.builder().url("http://example.com/audio.wav").build())
- .build();
-
- Msg msg = Msg.builder().role(MsgRole.USER).content(List.of(audioBlock)).build();
-
- OpenAIMessage result = converter.convertToMessage(msg, true);
-
- assertNotNull(result);
- assertEquals("user", result.getRole());
- assertTrue(result.isMultimodal());
- List content = result.getContentAsList();
- assertNotNull(content);
- // Should contain text reference since URL-based audio is not directly supported
- assertTrue(!content.isEmpty());
- }
-
- @Test
- @DisplayName("Should handle Base64 AudioBlock correctly")
- void testBase64AudioBlockConversion() {
- Base64Source audioSource =
- Base64Source.builder()
- .data("SGVsbG8gV29ybGQ=") // Base64 for "Hello World"
- .mediaType("audio/wav")
- .build();
-
- AudioBlock audioBlock = AudioBlock.builder().source(audioSource).build();
-
- Msg msg = Msg.builder().role(MsgRole.USER).content(List.of(audioBlock)).build();
-
- OpenAIMessage result = converter.convertToMessage(msg, true);
-
- assertNotNull(result);
- assertEquals("user", result.getRole());
- assertTrue(result.isMultimodal());
- List content = result.getContentAsList();
- assertNotNull(content);
- assertTrue(!content.isEmpty());
- }
-
- @Test
- @DisplayName("Should handle multimodal content with mixed types")
- void testMultimodalMixedContent() {
- Base64Source imageSource =
- Base64Source.builder()
- .data(
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==")
- .mediaType("image/png")
- .build();
-
- ImageBlock imageBlock = ImageBlock.builder().source(imageSource).build();
-
- TextBlock textBlock = TextBlock.builder().text("Here's an image:").build();
-
- Msg msg = Msg.builder().role(MsgRole.USER).content(List.of(textBlock, imageBlock)).build();
-
- OpenAIMessage result = converter.convertToMessage(msg, true);
-
- assertNotNull(result);
- assertEquals("user", result.getRole());
- assertTrue(result.isMultimodal());
- List content = result.getContentAsList();
- assertNotNull(content);
- assertTrue(content.size() >= 2, "Should contain both text and image");
- }
-
- @Test
- @DisplayName("Should handle URLSource for ImageBlock as data URL")
- void testURLSourceImageBlock() {
- URLSource imageSource = URLSource.builder().url("http://example.com/image.png").build();
-
- ImageBlock imageBlock = ImageBlock.builder().source(imageSource).build();
-
- Msg msg = Msg.builder().role(MsgRole.USER).content(List.of(imageBlock)).build();
-
- OpenAIMessage result = converter.convertToMessage(msg, true);
-
- assertNotNull(result);
- assertEquals("user", result.getRole());
- assertTrue(result.isMultimodal());
- List content = result.getContentAsList();
- assertNotNull(content);
- assertTrue(!content.isEmpty(), "Should contain image content");
- }
-
- @Nested
- @DisplayName("Tool Use Block Tests")
- class ToolUseBlockTests {
-
- @Test
- @DisplayName("Should convert assistant message with tool calls")
- void testAssistantWithToolCalls() {
- ToolUseBlock toolBlock =
- ToolUseBlock.builder()
- .id("call_123")
- .name("get_weather")
- .input(Map.of("city", "Beijing"))
- .build();
-
- Msg msg = Msg.builder().role(MsgRole.ASSISTANT).content(List.of(toolBlock)).build();
-
- OpenAIMessage result = converter.convertToMessage(msg, false);
-
- assertNotNull(result);
- assertEquals("assistant", result.getRole());
- assertNotNull(result.getToolCalls());
- assertEquals(1, result.getToolCalls().size());
- assertEquals("call_123", result.getToolCalls().get(0).getId());
- assertEquals("get_weather", result.getToolCalls().get(0).getFunction().getName());
- }
-
- @Test
- @DisplayName("Should convert assistant message with text and tool calls")
- void testAssistantWithTextAndToolCalls() {
- TextBlock textBlock = TextBlock.builder().text("Let me check the weather").build();
- ToolUseBlock toolBlock =
- ToolUseBlock.builder()
- .id("call_456")
- .name("get_weather")
- .input(Map.of("city", "Shanghai"))
- .build();
-
- Msg msg =
- Msg.builder()
- .role(MsgRole.ASSISTANT)
- .content(List.of(textBlock, toolBlock))
- .build();
-
- OpenAIMessage result = converter.convertToMessage(msg, false);
-
- assertNotNull(result);
- assertEquals("assistant", result.getRole());
- assertEquals("Let me check the weather", result.getContentAsString());
- assertNotNull(result.getToolCalls());
- assertEquals(1, result.getToolCalls().size());
- }
-
- @Test
- @DisplayName("Should include thought signature in tool call metadata")
- void testToolCallWithThoughtSignature() {
- Map metadata = new HashMap<>();
- metadata.put(ToolUseBlock.METADATA_THOUGHT_SIGNATURE, "signature_abc");
-
- ToolUseBlock toolBlock =
- ToolUseBlock.builder()
- .id("call_789")
- .name("test_tool")
- .input(Map.of())
- .metadata(metadata)
- .build();
-
- Msg msg = Msg.builder().role(MsgRole.ASSISTANT).content(List.of(toolBlock)).build();
-
- OpenAIMessage result = converter.convertToMessage(msg, false);
-
- assertNotNull(result);
- assertNotNull(result.getToolCalls());
- assertEquals(
- "signature_abc",
- result.getToolCalls().get(0).getFunction().getThoughtSignature());
- }
-
- @Test
- @DisplayName("Should handle multiple tool calls")
- void testMultipleToolCalls() {
- ToolUseBlock tool1 =
- ToolUseBlock.builder()
- .id("call_1")
- .name("tool1")
- .input(Map.of("a", "1"))
- .build();
- ToolUseBlock tool2 =
- ToolUseBlock.builder()
- .id("call_2")
- .name("tool2")
- .input(Map.of("b", "2"))
- .build();
-
- Msg msg = Msg.builder().role(MsgRole.ASSISTANT).content(List.of(tool1, tool2)).build();
-
- OpenAIMessage result = converter.convertToMessage(msg, false);
-
- assertNotNull(result);
- assertNotNull(result.getToolCalls());
- assertEquals(2, result.getToolCalls().size());
- assertEquals("tool1", result.getToolCalls().get(0).getFunction().getName());
- assertEquals("tool2", result.getToolCalls().get(1).getFunction().getName());
- }
-
- @Test
- @DisplayName("Should use content field when present for tool call arguments")
- void testToolCallUsesContentFieldWhenPresent() {
- // Create a ToolUseBlock with both content (raw string) and input map
- // The content field should be used preferentially
- String rawContent = "{\"city\":\"Beijing\",\"unit\":\"celsius\"}";
- ToolUseBlock toolBlock =
- ToolUseBlock.builder()
- .id("call_content_test")
- .name("get_weather")
- .input(Map.of("city", "Shanghai", "unit", "fahrenheit"))
- .content(rawContent)
- .build();
-
- Msg msg = Msg.builder().role(MsgRole.ASSISTANT).content(List.of(toolBlock)).build();
-
- OpenAIMessage result = converter.convertToMessage(msg, false);
-
- assertNotNull(result);
- assertNotNull(result.getToolCalls());
- assertEquals(1, result.getToolCalls().size());
- // Should use the content field (raw string) instead of serializing input map
- assertEquals(rawContent, result.getToolCalls().get(0).getFunction().getArguments());
- }
-
- @Test
- @DisplayName("Should fallback to input map serialization when content is null")
- void testToolCallFallbackToInputMapWhenContentNull() {
- // Create a ToolUseBlock with only input map (content is null)
- ToolUseBlock toolBlock =
- ToolUseBlock.builder()
- .id("call_fallback_test")
- .name("get_weather")
- .input(Map.of("city", "Beijing"))
- .content(null)
- .build();
-
- Msg msg = Msg.builder().role(MsgRole.ASSISTANT).content(List.of(toolBlock)).build();
-
- OpenAIMessage result = converter.convertToMessage(msg, false);
-
- assertNotNull(result);
- assertNotNull(result.getToolCalls());
- assertEquals(1, result.getToolCalls().size());
- // Should serialize the input map since content is null
- String args = result.getToolCalls().get(0).getFunction().getArguments();
- assertNotNull(args);
- assertTrue(args.contains("city"));
- assertTrue(args.contains("Beijing"));
- }
-
- @Test
- @DisplayName("Should fallback to input map serialization when content is empty")
- void testToolCallFallbackToInputMapWhenContentEmpty() {
- // Create a ToolUseBlock with empty content string
- ToolUseBlock toolBlock =
- ToolUseBlock.builder()
- .id("call_empty_content_test")
- .name("get_weather")
- .input(Map.of("city", "Shanghai"))
- .content("")
- .build();
-
- Msg msg = Msg.builder().role(MsgRole.ASSISTANT).content(List.of(toolBlock)).build();
-
- OpenAIMessage result = converter.convertToMessage(msg, false);
-
- assertNotNull(result);
- assertNotNull(result.getToolCalls());
- assertEquals(1, result.getToolCalls().size());
- // Should serialize the input map since content is empty
- String args = result.getToolCalls().get(0).getFunction().getArguments();
- assertNotNull(args);
- assertTrue(args.contains("city"));
- assertTrue(args.contains("Shanghai"));
- }
- }
-
- @Nested
- @DisplayName("Tool Result Block Tests")
- class ToolResultBlockTests {
-
- @Test
- @DisplayName("Should convert tool message with result")
- void testToolMessageWithResult() {
- ToolResultBlock resultBlock =
- new ToolResultBlock(
- "call_123",
- "get_weather",
- List.of(TextBlock.builder().text("Sunny, 25°C").build()),
- null);
-
- Msg msg = Msg.builder().role(MsgRole.TOOL).content(List.of(resultBlock)).build();
-
- OpenAIMessage result = converter.convertToMessage(msg, false);
-
- assertNotNull(result);
- assertEquals("tool", result.getRole());
- assertEquals("call_123", result.getToolCallId());
- assertEquals("Sunny, 25°C", result.getContentAsString());
- }
-
- @Test
- @DisplayName("Should handle tool message with error")
- void testToolMessageWithError() {
- ToolResultBlock resultBlock =
- new ToolResultBlock(
- "call_error",
- "failing_tool",
- List.of(TextBlock.builder().text("Error: Connection timeout").build()),
- null);
-
- Msg msg = Msg.builder().role(MsgRole.TOOL).content(List.of(resultBlock)).build();
-
- OpenAIMessage result = converter.convertToMessage(msg, false);
-
- assertNotNull(result);
- assertEquals("tool", result.getRole());
- assertEquals("call_error", result.getToolCallId());
- // Error should be included in content
- assertTrue(result.getContentAsString().contains("Connection timeout"));
- }
- }
-
- @Nested
- @DisplayName("Thinking Block Tests")
- class ThinkingBlockTests {
-
- @Test
- @DisplayName("Should convert thinking block to reasoning_content")
- void testThinkingBlockToReasoningContent() {
- ThinkingBlock thinkingBlock =
- ThinkingBlock.builder().thinking("My reasoning process").build();
- TextBlock textBlock = TextBlock.builder().text("Final answer").build();
-
- Msg msg =
- Msg.builder()
- .role(MsgRole.ASSISTANT)
- .content(List.of(thinkingBlock, textBlock))
- .build();
-
- OpenAIMessage result = converter.convertToMessage(msg, false);
-
- assertNotNull(result);
- assertEquals("assistant", result.getRole());
- assertEquals("My reasoning process", result.getReasoningContent());
- assertEquals("Final answer", result.getContentAsString());
- }
-
- @Test
- @DisplayName("Should handle thinking block with reasoning details metadata")
- void testThinkingBlockWithReasoningDetailsMetadata() {
- List details =
- List.of(
- new OpenAIReasoningDetail() {
- {
- setType("reasoning.encrypted");
- setData("encrypted_signature");
- }
- });
-
- Map metadata = new HashMap<>();
- metadata.put(ThinkingBlock.METADATA_REASONING_DETAILS, details);
-
- ThinkingBlock thinkingBlock =
- ThinkingBlock.builder().thinking("My thinking").metadata(metadata).build();
-
- Msg msg = Msg.builder().role(MsgRole.ASSISTANT).content(List.of(thinkingBlock)).build();
-
- OpenAIMessage result = converter.convertToMessage(msg, false);
-
- assertNotNull(result);
- assertNotNull(result.getReasoningDetails());
- }
-
- @Test
- @DisplayName("Should use first thinking block only (getFirstContentBlock behavior)")
- void testMultipleThinkingBlocks() {
- ThinkingBlock thinking1 = ThinkingBlock.builder().thinking("First thought").build();
- ThinkingBlock thinking2 = ThinkingBlock.builder().thinking("Second thought").build();
- TextBlock textBlock = TextBlock.builder().text("Answer").build();
-
- Msg msg =
- Msg.builder()
- .role(MsgRole.ASSISTANT)
- .content(List.of(thinking1, thinking2, textBlock))
- .build();
-
- OpenAIMessage result = converter.convertToMessage(msg, false);
-
- assertNotNull(result);
- // Implementation uses getFirstContentBlock, so only first ThinkingBlock is used
- assertEquals("First thought", result.getReasoningContent());
- }
- }
-
- @Nested
- @DisplayName("Name Field Tests")
- class NameFieldTests {
-
- @Test
- @DisplayName("Should set name field for user message")
- void testUserMessageWithName() {
- Msg msg =
- Msg.builder()
- .role(MsgRole.USER)
- .name("Alice")
- .content(List.of(TextBlock.builder().text("Hello").build()))
- .build();
-
- OpenAIMessage result = converter.convertToMessage(msg, false);
-
- assertNotNull(result);
- assertEquals("Alice", result.getName());
- }
-
- @Test
- @DisplayName("Should set name field for assistant message")
- void testAssistantMessageWithName() {
- Msg msg =
- Msg.builder()
- .role(MsgRole.ASSISTANT)
- .name("Agent")
- .content(List.of(TextBlock.builder().text("Hi").build()))
- .build();
-
- OpenAIMessage result = converter.convertToMessage(msg, false);
-
- assertNotNull(result);
- assertEquals("Agent", result.getName());
- }
-
- @Test
- @DisplayName("Should handle null name")
- void testMessageWithNullName() {
- Msg msg =
- Msg.builder()
- .role(MsgRole.USER)
- .name(null)
- .content(List.of(TextBlock.builder().text("Hello").build()))
- .build();
-
- OpenAIMessage result = converter.convertToMessage(msg, false);
-
- assertNotNull(result);
- assertNull(result.getName());
- }
- }
-
- @Nested
- @DisplayName("Edge Cases")
- class EdgeCaseTests {
-
- @Test
- @DisplayName("Should handle empty content list")
- void testEmptyContentList() {
- Msg msg = Msg.builder().role(MsgRole.USER).content(List.of()).build();
-
- OpenAIMessage result = converter.convertToMessage(msg, false);
-
- assertNotNull(result);
- assertEquals("user", result.getRole());
- }
-
- @Test
- @DisplayName("Should handle file URL for image")
- void testFileURLImageBlock() {
- URLSource imageSource =
- URLSource.builder().url("file:///path/to/local/image.png").build();
-
- ImageBlock imageBlock = ImageBlock.builder().source(imageSource).build();
-
- Msg msg = Msg.builder().role(MsgRole.USER).content(List.of(imageBlock)).build();
-
- OpenAIMessage result = converter.convertToMessage(msg, true);
-
- assertNotNull(result);
- assertTrue(result.isMultimodal());
- }
-
- @Test
- @DisplayName("Should handle non-multimodal conversion for user message with images")
- void testNonMultimodalConversion() {
- Base64Source imageSource =
- Base64Source.builder()
- .data("iVBORw0KGgoAAAANSUhEUg==")
- .mediaType("image/png")
- .build();
- ImageBlock imageBlock = ImageBlock.builder().source(imageSource).build();
- TextBlock textBlock = TextBlock.builder().text("See image").build();
-
- Msg msg =
- Msg.builder()
- .role(MsgRole.USER)
- .content(List.of(textBlock, imageBlock))
- .build();
-
- // Convert without multimodal support
- OpenAIMessage result = converter.convertToMessage(msg, false);
-
- assertNotNull(result);
- // Should still extract text
- assertEquals("See image", result.getContentAsString());
- }
-
- @Test
- @DisplayName("Should handle null content")
- void testNullContent() {
- Msg msg = Msg.builder().role(MsgRole.USER).content((List) null).build();
-
- OpenAIMessage result = converter.convertToMessage(msg, false);
-
- assertNotNull(result);
- assertEquals("user", result.getRole());
- }
-
- @Test
- @DisplayName("Should handle system message with null content")
- void testSystemMessageWithNullContent() {
- Msg msg = Msg.builder().role(MsgRole.SYSTEM).content((List) null).build();
-
- OpenAIMessage result = converter.convertToMessage(msg, false);
-
- assertNotNull(result);
- assertEquals("system", result.getRole());
- assertEquals("", result.getContentAsString());
- }
-
- @Test
- @DisplayName("Should handle tool message with multimodal result")
- void testToolMessageWithMultimodalResult() {
- Base64Source imageSource =
- Base64Source.builder()
- .data("iVBORw0KGgoAAAANSUhEUg==")
- .mediaType("image/png")
- .build();
- ImageBlock imageBlock = ImageBlock.builder().source(imageSource).build();
-
- ToolResultBlock resultBlock =
- new ToolResultBlock(
- "call_123",
- "screenshot_tool",
- List.of(
- TextBlock.builder().text("Screenshot taken").build(),
- imageBlock),
- null);
-
- Msg msg = Msg.builder().role(MsgRole.TOOL).content(List.of(resultBlock)).build();
-
- OpenAIMessage result = converter.convertToMessage(msg, true);
-
- assertNotNull(result);
- assertEquals("tool", result.getRole());
- assertEquals("call_123", result.getToolCallId());
- assertTrue(result.isMultimodal());
- }
-
- @Test
- @DisplayName("Should handle tool message without result block")
- void testToolMessageWithoutResultBlock() {
- Msg msg =
- Msg.builder()
- .role(MsgRole.TOOL)
- .content(List.of(TextBlock.builder().text("No result").build()))
- .build();
-
- OpenAIMessage result = converter.convertToMessage(msg, false);
-
- assertNotNull(result);
- assertEquals("tool", result.getRole());
- // Should generate tool call ID
- assertTrue(result.getToolCallId().startsWith("tool_call_"));
- }
-
- @Test
- @DisplayName("Should handle system message with tool result block")
- void testSystemMessageWithToolResultBlock() {
- ToolResultBlock resultBlock =
- new ToolResultBlock(
- "call_456",
- "tool_name",
- List.of(TextBlock.builder().text("Result").build()),
- null);
-
- Msg msg = Msg.builder().role(MsgRole.SYSTEM).content(List.of(resultBlock)).build();
-
- // System message with tool result should be treated as TOOL role
- OpenAIMessage result = converter.convertToMessage(msg, false);
-
- assertNotNull(result);
- assertEquals("tool", result.getRole());
- assertEquals("call_456", result.getToolCallId());
- }
-
- @Test
- @DisplayName("Should handle assistant message with null tool input")
- void testAssistantWithNullToolInput() {
- ToolUseBlock toolBlock =
- ToolUseBlock.builder().id("call_789").name("test_tool").input(null).build();
-
- Msg msg = Msg.builder().role(MsgRole.ASSISTANT).content(List.of(toolBlock)).build();
-
- OpenAIMessage result = converter.convertToMessage(msg, false);
-
- assertNotNull(result);
- assertEquals("assistant", result.getRole());
- assertNotNull(result.getToolCalls());
- assertEquals(1, result.getToolCalls().size());
- }
-
- @Test
- @DisplayName("Should handle assistant message with null id in tool block")
- void testAssistantWithNullToolId() {
- ToolUseBlock toolBlock =
- ToolUseBlock.builder()
- .id(null)
- .name("test_tool")
- .input(Map.of("key", "value"))
- .build();
-
- Msg msg = Msg.builder().role(MsgRole.ASSISTANT).content(List.of(toolBlock)).build();
-
- OpenAIMessage result = converter.convertToMessage(msg, false);
-
- assertNotNull(result);
- // Tool call with null id should be skipped
- assertEquals("assistant", result.getRole());
- }
-
- @Test
- @DisplayName("Should handle assistant message with null name in tool block")
- void testAssistantWithNullToolName() {
- ToolUseBlock toolBlock =
- ToolUseBlock.builder()
- .id("call_abc")
- .name(null)
- .input(Map.of("key", "value"))
- .build();
-
- Msg msg = Msg.builder().role(MsgRole.ASSISTANT).content(List.of(toolBlock)).build();
-
- OpenAIMessage result = converter.convertToMessage(msg, false);
-
- assertNotNull(result);
- // Tool call with null name should be skipped
- assertEquals("assistant", result.getRole());
- }
- }
-}
+/*
+ * 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.openai;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+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 io.agentscope.core.formatter.openai.dto.OpenAIContentPart;
+import io.agentscope.core.formatter.openai.dto.OpenAIMessage;
+import io.agentscope.core.formatter.openai.dto.OpenAIReasoningDetail;
+import io.agentscope.core.message.AudioBlock;
+import io.agentscope.core.message.Base64Source;
+import io.agentscope.core.message.ContentBlock;
+import io.agentscope.core.message.ImageBlock;
+import io.agentscope.core.message.Msg;
+import io.agentscope.core.message.MsgRole;
+import io.agentscope.core.message.TextBlock;
+import io.agentscope.core.message.ThinkingBlock;
+import io.agentscope.core.message.ToolResultBlock;
+import io.agentscope.core.message.ToolUseBlock;
+import io.agentscope.core.message.URLSource;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for OpenAIMessageConverter.
+ *
+ * These tests verify the message conversion logic including:
+ *
+ * - Text message conversion
+ * - Multimodal content (image, audio) conversion
+ * - Null source handling for ImageBlock and AudioBlock
+ * - Role mapping
+ *
+ */
+@Tag("unit")
+@DisplayName("OpenAIMessageConverter Unit Tests")
+class OpenAIMessageConverterTest {
+
+ private OpenAIMessageConverter converter;
+
+ @BeforeEach
+ void setUp() {
+ // Create converter with simple text extractor and tool result converter
+ Function textExtractor =
+ msg -> {
+ StringBuilder sb = new StringBuilder();
+ for (ContentBlock block : msg.getContent()) {
+ if (block instanceof TextBlock tb) {
+ sb.append(tb.getText());
+ }
+ }
+ return sb.toString();
+ };
+
+ Function, String> toolResultConverter =
+ blocks -> {
+ StringBuilder sb = new StringBuilder();
+ for (ContentBlock block : blocks) {
+ if (block instanceof TextBlock tb) {
+ sb.append(tb.getText());
+ }
+ }
+ return sb.toString();
+ };
+
+ converter = new OpenAIMessageConverter(textExtractor, toolResultConverter);
+ }
+
+ @Test
+ @DisplayName("Should convert simple text message")
+ void testConvertSimpleTextMessage() {
+ Msg msg =
+ Msg.builder()
+ .role(MsgRole.USER)
+ .content(List.of(TextBlock.builder().text("Hello").build()))
+ .build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, false);
+
+ assertNotNull(result);
+ assertEquals("user", result.getRole());
+ assertEquals("Hello", result.getContentAsString());
+ }
+
+ // Note: ImageBlock and AudioBlock constructors require non-null source,
+ // so we cannot test null source handling directly. The null checks in
+ // OpenAIMessageConverter are defensive programming for edge cases that
+ // might occur through deserialization or other means.
+
+ @Test
+ @DisplayName("Should convert ImageBlock with valid URLSource")
+ void testImageBlockWithValidURLSource() {
+ URLSource source = URLSource.builder().url("https://example.com/image.png").build();
+ ImageBlock imageBlock = ImageBlock.builder().source(source).build();
+
+ Msg msg =
+ Msg.builder()
+ .role(MsgRole.USER)
+ .content(
+ List.of(
+ TextBlock.builder().text("See this image").build(),
+ imageBlock))
+ .build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, true);
+
+ assertNotNull(result);
+ assertEquals("user", result.getRole());
+ Object content = result.getContent();
+ assertNotNull(content);
+ assertTrue(content instanceof List);
+ @SuppressWarnings("unchecked")
+ List parts = (List) content;
+ assertEquals(2, parts.size());
+ assertEquals("See this image", parts.get(0).getText());
+ assertNotNull(parts.get(1).getImageUrl());
+ assertEquals("https://example.com/image.png", parts.get(1).getImageUrl().getUrl());
+ }
+
+ @Test
+ @DisplayName("Should convert ImageBlock with valid Base64Source")
+ void testImageBlockWithValidBase64Source() {
+ String base64Data =
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
+ Base64Source source =
+ Base64Source.builder().data(base64Data).mediaType("image/png").build();
+ ImageBlock imageBlock = ImageBlock.builder().source(source).build();
+
+ Msg msg =
+ Msg.builder()
+ .role(MsgRole.USER)
+ .content(
+ List.of(
+ TextBlock.builder().text("See this image").build(),
+ imageBlock))
+ .build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, true);
+
+ assertNotNull(result);
+ assertEquals("user", result.getRole());
+ Object content = result.getContent();
+ assertNotNull(content);
+ assertTrue(content instanceof List);
+ @SuppressWarnings("unchecked")
+ List parts = (List) content;
+ assertEquals(2, parts.size());
+ assertEquals("See this image", parts.get(0).getText());
+ assertNotNull(parts.get(1).getImageUrl());
+ assertTrue(parts.get(1).getImageUrl().getUrl().startsWith("data:image/png;base64,"));
+ }
+
+ @Test
+ @DisplayName("Should convert AudioBlock with valid Base64Source")
+ void testAudioBlockWithValidBase64Source() {
+ String base64Data = "UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQAAAAA=";
+ Base64Source source =
+ Base64Source.builder().data(base64Data).mediaType("audio/wav").build();
+ AudioBlock audioBlock = AudioBlock.builder().source(source).build();
+
+ Msg msg =
+ Msg.builder()
+ .role(MsgRole.USER)
+ .content(
+ List.of(
+ TextBlock.builder().text("Listen to this").build(),
+ audioBlock))
+ .build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, true);
+
+ assertNotNull(result);
+ assertEquals("user", result.getRole());
+ Object content = result.getContent();
+ assertNotNull(content);
+ assertTrue(content instanceof List);
+ @SuppressWarnings("unchecked")
+ List parts = (List) content;
+ assertEquals(2, parts.size());
+ assertEquals("Listen to this", parts.get(0).getText());
+ assertNotNull(parts.get(1).getInputAudio());
+ assertEquals(base64Data, parts.get(1).getInputAudio().getData());
+ }
+
+ @Test
+ @DisplayName("Should convert system message")
+ void testConvertSystemMessage() {
+ Msg msg =
+ Msg.builder()
+ .role(MsgRole.SYSTEM)
+ .content(
+ List.of(
+ TextBlock.builder()
+ .text("You are a helpful assistant")
+ .build()))
+ .build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, false);
+
+ assertNotNull(result);
+ assertEquals("system", result.getRole());
+ assertEquals("You are a helpful assistant", result.getContentAsString());
+ }
+
+ @Test
+ @DisplayName("Should convert assistant message")
+ void testConvertAssistantMessage() {
+ Msg msg =
+ Msg.builder()
+ .role(MsgRole.ASSISTANT)
+ .content(
+ List.of(TextBlock.builder().text("Hello! How can I help?").build()))
+ .build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, false);
+
+ assertNotNull(result);
+ assertEquals("assistant", result.getRole());
+ assertEquals("Hello! How can I help?", result.getContentAsString());
+ }
+
+ @Test
+ @DisplayName("Should handle ImageBlock with URLSource")
+ void testImageBlockWithURLSource() {
+ ImageBlock imageBlock =
+ ImageBlock.builder()
+ .source(URLSource.builder().url("http://example.com/image.png").build())
+ .build();
+
+ Msg msg = Msg.builder().role(MsgRole.USER).content(List.of(imageBlock)).build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, true);
+
+ assertNotNull(result);
+ assertEquals("user", result.getRole());
+ assertTrue(result.isMultimodal());
+ List content = result.getContentAsList();
+ assertNotNull(content);
+ assertTrue(!content.isEmpty());
+ }
+
+ @Test
+ @DisplayName("Should handle AudioBlock with URLSource (converted to text reference)")
+ void testAudioBlockWithURLSource() {
+ AudioBlock audioBlock =
+ AudioBlock.builder()
+ .source(URLSource.builder().url("http://example.com/audio.wav").build())
+ .build();
+
+ Msg msg = Msg.builder().role(MsgRole.USER).content(List.of(audioBlock)).build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, true);
+
+ assertNotNull(result);
+ assertEquals("user", result.getRole());
+ assertTrue(result.isMultimodal());
+ List content = result.getContentAsList();
+ assertNotNull(content);
+ // Should contain text reference since URL-based audio is not directly supported
+ assertTrue(!content.isEmpty());
+ }
+
+ @Test
+ @DisplayName("Should handle Base64 AudioBlock correctly")
+ void testBase64AudioBlockConversion() {
+ Base64Source audioSource =
+ Base64Source.builder()
+ .data("SGVsbG8gV29ybGQ=") // Base64 for "Hello World"
+ .mediaType("audio/wav")
+ .build();
+
+ AudioBlock audioBlock = AudioBlock.builder().source(audioSource).build();
+
+ Msg msg = Msg.builder().role(MsgRole.USER).content(List.of(audioBlock)).build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, true);
+
+ assertNotNull(result);
+ assertEquals("user", result.getRole());
+ assertTrue(result.isMultimodal());
+ List content = result.getContentAsList();
+ assertNotNull(content);
+ assertTrue(!content.isEmpty());
+ }
+
+ @Test
+ @DisplayName("Should handle multimodal content with mixed types")
+ void testMultimodalMixedContent() {
+ Base64Source imageSource =
+ Base64Source.builder()
+ .data(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==")
+ .mediaType("image/png")
+ .build();
+
+ ImageBlock imageBlock = ImageBlock.builder().source(imageSource).build();
+
+ TextBlock textBlock = TextBlock.builder().text("Here's an image:").build();
+
+ Msg msg = Msg.builder().role(MsgRole.USER).content(List.of(textBlock, imageBlock)).build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, true);
+
+ assertNotNull(result);
+ assertEquals("user", result.getRole());
+ assertTrue(result.isMultimodal());
+ List content = result.getContentAsList();
+ assertNotNull(content);
+ assertTrue(content.size() >= 2, "Should contain both text and image");
+ }
+
+ @Test
+ @DisplayName("Should handle URLSource for ImageBlock as data URL")
+ void testURLSourceImageBlock() {
+ URLSource imageSource = URLSource.builder().url("http://example.com/image.png").build();
+
+ ImageBlock imageBlock = ImageBlock.builder().source(imageSource).build();
+
+ Msg msg = Msg.builder().role(MsgRole.USER).content(List.of(imageBlock)).build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, true);
+
+ assertNotNull(result);
+ assertEquals("user", result.getRole());
+ assertTrue(result.isMultimodal());
+ List content = result.getContentAsList();
+ assertNotNull(content);
+ assertTrue(!content.isEmpty(), "Should contain image content");
+ }
+
+ @Nested
+ @DisplayName("Tool Use Block Tests")
+ class ToolUseBlockTests {
+
+ @Test
+ @DisplayName("Should convert assistant message with tool calls")
+ void testAssistantWithToolCalls() {
+ ToolUseBlock toolBlock =
+ ToolUseBlock.builder()
+ .id("call_123")
+ .name("get_weather")
+ .input(Map.of("city", "Beijing"))
+ .build();
+
+ Msg msg = Msg.builder().role(MsgRole.ASSISTANT).content(List.of(toolBlock)).build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, false);
+
+ assertNotNull(result);
+ assertEquals("assistant", result.getRole());
+ assertNotNull(result.getToolCalls());
+ assertEquals(1, result.getToolCalls().size());
+ assertEquals("call_123", result.getToolCalls().get(0).getId());
+ assertEquals("get_weather", result.getToolCalls().get(0).getFunction().getName());
+ }
+
+ @Test
+ @DisplayName("Should convert assistant message with text and tool calls")
+ void testAssistantWithTextAndToolCalls() {
+ TextBlock textBlock = TextBlock.builder().text("Let me check the weather").build();
+ ToolUseBlock toolBlock =
+ ToolUseBlock.builder()
+ .id("call_456")
+ .name("get_weather")
+ .input(Map.of("city", "Shanghai"))
+ .build();
+
+ Msg msg =
+ Msg.builder()
+ .role(MsgRole.ASSISTANT)
+ .content(List.of(textBlock, toolBlock))
+ .build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, false);
+
+ assertNotNull(result);
+ assertEquals("assistant", result.getRole());
+ assertEquals("Let me check the weather", result.getContentAsString());
+ assertNotNull(result.getToolCalls());
+ assertEquals(1, result.getToolCalls().size());
+ }
+
+ @Test
+ @DisplayName("Should include thought signature in tool call metadata")
+ void testToolCallWithThoughtSignature() {
+ Map metadata = new HashMap<>();
+ metadata.put(ToolUseBlock.METADATA_THOUGHT_SIGNATURE, "signature_abc");
+
+ ToolUseBlock toolBlock =
+ ToolUseBlock.builder()
+ .id("call_789")
+ .name("test_tool")
+ .input(Map.of())
+ .metadata(metadata)
+ .build();
+
+ Msg msg = Msg.builder().role(MsgRole.ASSISTANT).content(List.of(toolBlock)).build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, false);
+
+ assertNotNull(result);
+ assertNotNull(result.getToolCalls());
+ assertEquals(
+ "signature_abc",
+ result.getToolCalls().get(0).getFunction().getThoughtSignature());
+ }
+
+ @Test
+ @DisplayName("Should handle multiple tool calls")
+ void testMultipleToolCalls() {
+ ToolUseBlock tool1 =
+ ToolUseBlock.builder()
+ .id("call_1")
+ .name("tool1")
+ .input(Map.of("a", "1"))
+ .build();
+ ToolUseBlock tool2 =
+ ToolUseBlock.builder()
+ .id("call_2")
+ .name("tool2")
+ .input(Map.of("b", "2"))
+ .build();
+
+ Msg msg = Msg.builder().role(MsgRole.ASSISTANT).content(List.of(tool1, tool2)).build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, false);
+
+ assertNotNull(result);
+ assertNotNull(result.getToolCalls());
+ assertEquals(2, result.getToolCalls().size());
+ assertEquals("tool1", result.getToolCalls().get(0).getFunction().getName());
+ assertEquals("tool2", result.getToolCalls().get(1).getFunction().getName());
+ }
+
+ @Test
+ @DisplayName("Should use content field when present for tool call arguments")
+ void testToolCallUsesContentFieldWhenPresent() {
+ // Create a ToolUseBlock with both content (raw string) and input map
+ // The content field should be used preferentially
+ String rawContent = "{\"city\":\"Beijing\",\"unit\":\"celsius\"}";
+ ToolUseBlock toolBlock =
+ ToolUseBlock.builder()
+ .id("call_content_test")
+ .name("get_weather")
+ .input(Map.of("city", "Shanghai", "unit", "fahrenheit"))
+ .content(rawContent)
+ .build();
+
+ Msg msg = Msg.builder().role(MsgRole.ASSISTANT).content(List.of(toolBlock)).build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, false);
+
+ assertNotNull(result);
+ assertNotNull(result.getToolCalls());
+ assertEquals(1, result.getToolCalls().size());
+ // Should use the content field (raw string) instead of serializing input map
+ assertEquals(rawContent, result.getToolCalls().get(0).getFunction().getArguments());
+ }
+
+ @Test
+ @DisplayName("Should fallback to input map serialization when content is null")
+ void testToolCallFallbackToInputMapWhenContentNull() {
+ // Create a ToolUseBlock with only input map (content is null)
+ ToolUseBlock toolBlock =
+ ToolUseBlock.builder()
+ .id("call_fallback_test")
+ .name("get_weather")
+ .input(Map.of("city", "Beijing"))
+ .content(null)
+ .build();
+
+ Msg msg = Msg.builder().role(MsgRole.ASSISTANT).content(List.of(toolBlock)).build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, false);
+
+ assertNotNull(result);
+ assertNotNull(result.getToolCalls());
+ assertEquals(1, result.getToolCalls().size());
+ // Should serialize the input map since content is null
+ String args = result.getToolCalls().get(0).getFunction().getArguments();
+ assertNotNull(args);
+ assertTrue(args.contains("city"));
+ assertTrue(args.contains("Beijing"));
+ }
+
+ @Test
+ @DisplayName("Should fallback to input map serialization when content is empty")
+ void testToolCallFallbackToInputMapWhenContentEmpty() {
+ // Create a ToolUseBlock with empty content string
+ ToolUseBlock toolBlock =
+ ToolUseBlock.builder()
+ .id("call_empty_content_test")
+ .name("get_weather")
+ .input(Map.of("city", "Shanghai"))
+ .content("")
+ .build();
+
+ Msg msg = Msg.builder().role(MsgRole.ASSISTANT).content(List.of(toolBlock)).build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, false);
+
+ assertNotNull(result);
+ assertNotNull(result.getToolCalls());
+ assertEquals(1, result.getToolCalls().size());
+ // Should serialize the input map since content is empty
+ String args = result.getToolCalls().get(0).getFunction().getArguments();
+ assertNotNull(args);
+ assertTrue(args.contains("city"));
+ assertTrue(args.contains("Shanghai"));
+ }
+
+ @Test
+ @DisplayName("Should fallback to input map serialization when content is invalid JSON")
+ void testToolCallFallbackToInputMapWhenContentInvalid() {
+ ToolUseBlock toolBlock =
+ ToolUseBlock.builder()
+ .id("call_invalid_content_test")
+ .name("get_weather")
+ .input(Map.of("city", "Beijing"))
+ .content("{\"city\":\"Beiji")
+ .build();
+
+ Msg msg = Msg.builder().role(MsgRole.ASSISTANT).content(List.of(toolBlock)).build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, false);
+
+ assertNotNull(result);
+ assertNotNull(result.getToolCalls());
+ assertEquals(1, result.getToolCalls().size());
+ assertEquals(
+ "{\"city\":\"Beijing\"}",
+ result.getToolCalls().get(0).getFunction().getArguments());
+ }
+
+ @Test
+ @DisplayName(
+ "Should fallback to empty object when content is invalid JSON and input is absent")
+ void testToolCallFallbackToEmptyObjectWhenContentInvalidAndInputAbsent() {
+ ToolUseBlock toolBlock =
+ ToolUseBlock.builder()
+ .id("call_invalid_content_empty")
+ .name("get_weather")
+ .content("{\"city\":\"Beiji")
+ .build();
+
+ Msg msg = Msg.builder().role(MsgRole.ASSISTANT).content(List.of(toolBlock)).build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, false);
+
+ assertNotNull(result);
+ assertNotNull(result.getToolCalls());
+ assertEquals(1, result.getToolCalls().size());
+ assertEquals("{}", result.getToolCalls().get(0).getFunction().getArguments());
+ }
+ }
+
+ @Nested
+ @DisplayName("Tool Result Block Tests")
+ class ToolResultBlockTests {
+
+ @Test
+ @DisplayName("Should convert tool message with result")
+ void testToolMessageWithResult() {
+ ToolResultBlock resultBlock =
+ new ToolResultBlock(
+ "call_123",
+ "get_weather",
+ List.of(TextBlock.builder().text("Sunny, 25°C").build()),
+ null);
+
+ Msg msg = Msg.builder().role(MsgRole.TOOL).content(List.of(resultBlock)).build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, false);
+
+ assertNotNull(result);
+ assertEquals("tool", result.getRole());
+ assertEquals("call_123", result.getToolCallId());
+ assertEquals("Sunny, 25°C", result.getContentAsString());
+ }
+
+ @Test
+ @DisplayName("Should handle tool message with error")
+ void testToolMessageWithError() {
+ ToolResultBlock resultBlock =
+ new ToolResultBlock(
+ "call_error",
+ "failing_tool",
+ List.of(TextBlock.builder().text("Error: Connection timeout").build()),
+ null);
+
+ Msg msg = Msg.builder().role(MsgRole.TOOL).content(List.of(resultBlock)).build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, false);
+
+ assertNotNull(result);
+ assertEquals("tool", result.getRole());
+ assertEquals("call_error", result.getToolCallId());
+ // Error should be included in content
+ assertTrue(result.getContentAsString().contains("Connection timeout"));
+ }
+ }
+
+ @Nested
+ @DisplayName("Thinking Block Tests")
+ class ThinkingBlockTests {
+
+ @Test
+ @DisplayName("Should convert thinking block to reasoning_content")
+ void testThinkingBlockToReasoningContent() {
+ ThinkingBlock thinkingBlock =
+ ThinkingBlock.builder().thinking("My reasoning process").build();
+ TextBlock textBlock = TextBlock.builder().text("Final answer").build();
+
+ Msg msg =
+ Msg.builder()
+ .role(MsgRole.ASSISTANT)
+ .content(List.of(thinkingBlock, textBlock))
+ .build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, false);
+
+ assertNotNull(result);
+ assertEquals("assistant", result.getRole());
+ assertEquals("My reasoning process", result.getReasoningContent());
+ assertEquals("Final answer", result.getContentAsString());
+ }
+
+ @Test
+ @DisplayName("Should handle thinking block with reasoning details metadata")
+ void testThinkingBlockWithReasoningDetailsMetadata() {
+ List details =
+ List.of(
+ new OpenAIReasoningDetail() {
+ {
+ setType("reasoning.encrypted");
+ setData("encrypted_signature");
+ }
+ });
+
+ Map metadata = new HashMap<>();
+ metadata.put(ThinkingBlock.METADATA_REASONING_DETAILS, details);
+
+ ThinkingBlock thinkingBlock =
+ ThinkingBlock.builder().thinking("My thinking").metadata(metadata).build();
+
+ Msg msg = Msg.builder().role(MsgRole.ASSISTANT).content(List.of(thinkingBlock)).build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, false);
+
+ assertNotNull(result);
+ assertNotNull(result.getReasoningDetails());
+ }
+
+ @Test
+ @DisplayName("Should use first thinking block only (getFirstContentBlock behavior)")
+ void testMultipleThinkingBlocks() {
+ ThinkingBlock thinking1 = ThinkingBlock.builder().thinking("First thought").build();
+ ThinkingBlock thinking2 = ThinkingBlock.builder().thinking("Second thought").build();
+ TextBlock textBlock = TextBlock.builder().text("Answer").build();
+
+ Msg msg =
+ Msg.builder()
+ .role(MsgRole.ASSISTANT)
+ .content(List.of(thinking1, thinking2, textBlock))
+ .build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, false);
+
+ assertNotNull(result);
+ // Implementation uses getFirstContentBlock, so only first ThinkingBlock is used
+ assertEquals("First thought", result.getReasoningContent());
+ }
+ }
+
+ @Nested
+ @DisplayName("Name Field Tests")
+ class NameFieldTests {
+
+ @Test
+ @DisplayName("Should set name field for user message")
+ void testUserMessageWithName() {
+ Msg msg =
+ Msg.builder()
+ .role(MsgRole.USER)
+ .name("Alice")
+ .content(List.of(TextBlock.builder().text("Hello").build()))
+ .build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, false);
+
+ assertNotNull(result);
+ assertEquals("Alice", result.getName());
+ }
+
+ @Test
+ @DisplayName("Should set name field for assistant message")
+ void testAssistantMessageWithName() {
+ Msg msg =
+ Msg.builder()
+ .role(MsgRole.ASSISTANT)
+ .name("Agent")
+ .content(List.of(TextBlock.builder().text("Hi").build()))
+ .build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, false);
+
+ assertNotNull(result);
+ assertEquals("Agent", result.getName());
+ }
+
+ @Test
+ @DisplayName("Should handle null name")
+ void testMessageWithNullName() {
+ Msg msg =
+ Msg.builder()
+ .role(MsgRole.USER)
+ .name(null)
+ .content(List.of(TextBlock.builder().text("Hello").build()))
+ .build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, false);
+
+ assertNotNull(result);
+ assertNull(result.getName());
+ }
+ }
+
+ @Nested
+ @DisplayName("Edge Cases")
+ class EdgeCaseTests {
+
+ @Test
+ @DisplayName("Should handle empty content list")
+ void testEmptyContentList() {
+ Msg msg = Msg.builder().role(MsgRole.USER).content(List.of()).build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, false);
+
+ assertNotNull(result);
+ assertEquals("user", result.getRole());
+ }
+
+ @Test
+ @DisplayName("Should handle file URL for image")
+ void testFileURLImageBlock() {
+ URLSource imageSource =
+ URLSource.builder().url("file:///path/to/local/image.png").build();
+
+ ImageBlock imageBlock = ImageBlock.builder().source(imageSource).build();
+
+ Msg msg = Msg.builder().role(MsgRole.USER).content(List.of(imageBlock)).build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, true);
+
+ assertNotNull(result);
+ assertTrue(result.isMultimodal());
+ }
+
+ @Test
+ @DisplayName("Should handle non-multimodal conversion for user message with images")
+ void testNonMultimodalConversion() {
+ Base64Source imageSource =
+ Base64Source.builder()
+ .data("iVBORw0KGgoAAAANSUhEUg==")
+ .mediaType("image/png")
+ .build();
+ ImageBlock imageBlock = ImageBlock.builder().source(imageSource).build();
+ TextBlock textBlock = TextBlock.builder().text("See image").build();
+
+ Msg msg =
+ Msg.builder()
+ .role(MsgRole.USER)
+ .content(List.of(textBlock, imageBlock))
+ .build();
+
+ // Convert without multimodal support
+ OpenAIMessage result = converter.convertToMessage(msg, false);
+
+ assertNotNull(result);
+ // Should still extract text
+ assertEquals("See image", result.getContentAsString());
+ }
+
+ @Test
+ @DisplayName("Should handle null content")
+ void testNullContent() {
+ Msg msg = Msg.builder().role(MsgRole.USER).content((List) null).build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, false);
+
+ assertNotNull(result);
+ assertEquals("user", result.getRole());
+ }
+
+ @Test
+ @DisplayName("Should handle system message with null content")
+ void testSystemMessageWithNullContent() {
+ Msg msg = Msg.builder().role(MsgRole.SYSTEM).content((List) null).build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, false);
+
+ assertNotNull(result);
+ assertEquals("system", result.getRole());
+ assertEquals("", result.getContentAsString());
+ }
+
+ @Test
+ @DisplayName("Should handle tool message with multimodal result")
+ void testToolMessageWithMultimodalResult() {
+ Base64Source imageSource =
+ Base64Source.builder()
+ .data("iVBORw0KGgoAAAANSUhEUg==")
+ .mediaType("image/png")
+ .build();
+ ImageBlock imageBlock = ImageBlock.builder().source(imageSource).build();
+
+ ToolResultBlock resultBlock =
+ new ToolResultBlock(
+ "call_123",
+ "screenshot_tool",
+ List.of(
+ TextBlock.builder().text("Screenshot taken").build(),
+ imageBlock),
+ null);
+
+ Msg msg = Msg.builder().role(MsgRole.TOOL).content(List.of(resultBlock)).build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, true);
+
+ assertNotNull(result);
+ assertEquals("tool", result.getRole());
+ assertEquals("call_123", result.getToolCallId());
+ assertTrue(result.isMultimodal());
+ }
+
+ @Test
+ @DisplayName("Should handle tool message without result block")
+ void testToolMessageWithoutResultBlock() {
+ Msg msg =
+ Msg.builder()
+ .role(MsgRole.TOOL)
+ .content(List.of(TextBlock.builder().text("No result").build()))
+ .build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, false);
+
+ assertNotNull(result);
+ assertEquals("tool", result.getRole());
+ // Should generate tool call ID
+ assertTrue(result.getToolCallId().startsWith("tool_call_"));
+ }
+
+ @Test
+ @DisplayName("Should handle system message with tool result block")
+ void testSystemMessageWithToolResultBlock() {
+ ToolResultBlock resultBlock =
+ new ToolResultBlock(
+ "call_456",
+ "tool_name",
+ List.of(TextBlock.builder().text("Result").build()),
+ null);
+
+ Msg msg = Msg.builder().role(MsgRole.SYSTEM).content(List.of(resultBlock)).build();
+
+ // System message with tool result should be treated as TOOL role
+ OpenAIMessage result = converter.convertToMessage(msg, false);
+
+ assertNotNull(result);
+ assertEquals("tool", result.getRole());
+ assertEquals("call_456", result.getToolCallId());
+ }
+
+ @Test
+ @DisplayName("Should handle assistant message with null tool input")
+ void testAssistantWithNullToolInput() {
+ ToolUseBlock toolBlock =
+ ToolUseBlock.builder().id("call_789").name("test_tool").input(null).build();
+
+ Msg msg = Msg.builder().role(MsgRole.ASSISTANT).content(List.of(toolBlock)).build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, false);
+
+ assertNotNull(result);
+ assertEquals("assistant", result.getRole());
+ assertNotNull(result.getToolCalls());
+ assertEquals(1, result.getToolCalls().size());
+ }
+
+ @Test
+ @DisplayName("Should handle assistant message with null id in tool block")
+ void testAssistantWithNullToolId() {
+ ToolUseBlock toolBlock =
+ ToolUseBlock.builder()
+ .id(null)
+ .name("test_tool")
+ .input(Map.of("key", "value"))
+ .build();
+
+ Msg msg = Msg.builder().role(MsgRole.ASSISTANT).content(List.of(toolBlock)).build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, false);
+
+ assertNotNull(result);
+ // Tool call with null id should be skipped
+ assertEquals("assistant", result.getRole());
+ }
+
+ @Test
+ @DisplayName("Should handle assistant message with null name in tool block")
+ void testAssistantWithNullToolName() {
+ ToolUseBlock toolBlock =
+ ToolUseBlock.builder()
+ .id("call_abc")
+ .name(null)
+ .input(Map.of("key", "value"))
+ .build();
+
+ Msg msg = Msg.builder().role(MsgRole.ASSISTANT).content(List.of(toolBlock)).build();
+
+ OpenAIMessage result = converter.convertToMessage(msg, false);
+
+ assertNotNull(result);
+ // Tool call with null name should be skipped
+ assertEquals("assistant", result.getRole());
+ }
+ }
+}
diff --git a/agentscope-core/src/test/java/io/agentscope/core/util/ToolCallJsonUtilsTest.java b/agentscope-core/src/test/java/io/agentscope/core/util/ToolCallJsonUtilsTest.java
new file mode 100644
index 000000000..25df67ce2
--- /dev/null
+++ b/agentscope-core/src/test/java/io/agentscope/core/util/ToolCallJsonUtilsTest.java
@@ -0,0 +1,85 @@
+/*
+ * 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.util;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Map;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/** Unit tests for {@link ToolCallJsonUtils}. */
+@DisplayName("ToolCallJsonUtils Tests")
+class ToolCallJsonUtilsTest {
+
+ @Test
+ @DisplayName("Should parse valid JSON object payloads")
+ void testParseJsonObjectOrEmptyWithValidObject() {
+ Map parsed =
+ ToolCallJsonUtils.parseJsonObjectOrEmpty("{\"query\":\"hello\",\"page\":2}");
+
+ assertEquals("hello", parsed.get("query"));
+ assertEquals(2, parsed.get("page"));
+ }
+
+ @Test
+ @DisplayName("Should return empty map for invalid JSON object payloads")
+ void testParseJsonObjectOrEmptyWithInvalidObject() {
+ Map parsed = ToolCallJsonUtils.parseJsonObjectOrEmpty("{\"query\":\"hello");
+
+ assertTrue(parsed.isEmpty());
+ }
+
+ @Test
+ @DisplayName("Should recognize valid JSON objects")
+ void testIsValidJsonObject() {
+ assertTrue(ToolCallJsonUtils.isValidJsonObject("{\"query\":\"hello\"}"));
+ assertTrue(ToolCallJsonUtils.isValidJsonObject("{}"));
+ assertFalse(ToolCallJsonUtils.isValidJsonObject("{\"query\":\"hello"));
+ assertFalse(ToolCallJsonUtils.isValidJsonObject("[]"));
+ }
+
+ @Test
+ @DisplayName("Should preserve valid raw JSON arguments")
+ void testSanitizeArgumentsJsonPreservesValidContent() {
+ String sanitized =
+ ToolCallJsonUtils.sanitizeArgumentsJson(
+ "{\"query\":\"hello\"}", Map.of("query", "ignored"));
+
+ assertEquals("{\"query\":\"hello\"}", sanitized);
+ }
+
+ @Test
+ @DisplayName("Should fallback to structured input when raw JSON is invalid")
+ void testSanitizeArgumentsJsonFallsBackToStructuredInput() {
+ String sanitized =
+ ToolCallJsonUtils.sanitizeArgumentsJson(
+ "{\"query\":\"hello", Map.of("query", "hello", "page", 2));
+
+ assertTrue(sanitized.contains("hello"));
+ assertTrue(sanitized.contains("page"));
+ }
+
+ @Test
+ @DisplayName("Should fallback to empty object when both raw JSON and input are missing")
+ void testSanitizeArgumentsJsonFallsBackToEmptyObject() {
+ String sanitized = ToolCallJsonUtils.sanitizeArgumentsJson("{\"query\":\"hello", Map.of());
+
+ assertEquals("{}", sanitized);
+ }
+}