diff --git a/agentscope-core/src/main/java/io/agentscope/core/agent/accumulator/ToolCallsAccumulator.java b/agentscope-core/src/main/java/io/agentscope/core/agent/accumulator/ToolCallsAccumulator.java index 289e02382..b69695feb 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/agent/accumulator/ToolCallsAccumulator.java +++ b/agentscope-core/src/main/java/io/agentscope/core/agent/accumulator/ToolCallsAccumulator.java @@ -1,293 +1,286 @@ -/* - * 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.JsonUtils; -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: - * - *

- * @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 no parsed arguments but has raw JSON content, try to parse - if (finalArgs.isEmpty() && rawContentStr.length() > 0) { - try { - @SuppressWarnings("unchecked") - Map parsed = - JsonUtils.getJsonCodec().fromJson(rawContentStr, Map.class); - if (parsed != null) { - finalArgs.putAll(parsed); - } - } catch (Exception ignored) { - // Parsing failed, keep empty args - } - } - - return ToolUseBlock.builder() - .id(toolId != null ? toolId : generateId()) - .name(name) - .input(finalArgs) - .content(rawContentStr.isEmpty() ? "{}" : rawContentStr) - .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: - * - *

    - *
  1. Use tool ID if available (non-empty) - *
  2. Use tool name if available (non-placeholder) - *
  3. If this is a fragment (placeholder name), reuse the last tool call key - *
  4. 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; - } -} +/* + * 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: + * + *

    + *
  1. Use tool ID if available (non-empty) + *
  2. Use tool name if available (non-placeholder) + *
  3. If this is a fragment (placeholder name), reuse the last tool call key + *
  4. 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); + } +}