diff --git a/agentscope-core/src/main/java/io/agentscope/core/formatter/gemini/GeminiMessageConverter.java b/agentscope-core/src/main/java/io/agentscope/core/formatter/gemini/GeminiMessageConverter.java index 48ddb3de7e..efd2766f6d 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/formatter/gemini/GeminiMessageConverter.java +++ b/agentscope-core/src/main/java/io/agentscope/core/formatter/gemini/GeminiMessageConverter.java @@ -122,13 +122,22 @@ public List convertMessages(List msgs) { // Build Part with FunctionCall and optional thought signature Part.Builder partBuilder = Part.builder().functionCall(functionCall); - // Check for thought signature in metadata + // Check for thought signature in metadata (always stored as Base64 String, + // see ToolUseBlock#normalizeMetadata) Map metadata = tub.getMetadata(); if (metadata != null && metadata.containsKey(ToolUseBlock.METADATA_THOUGHT_SIGNATURE)) { Object signature = metadata.get(ToolUseBlock.METADATA_THOUGHT_SIGNATURE); - if (signature instanceof byte[]) { - partBuilder.thoughtSignature((byte[]) signature); + if (signature instanceof String encodedSignature + && !encodedSignature.isEmpty()) { + try { + partBuilder.thoughtSignature( + Base64.getDecoder().decode(encodedSignature)); + } catch (IllegalArgumentException e) { + log.warn( + "Invalid Base64 thought signature in ToolUseBlock metadata," + + " skipping"); + } } } diff --git a/agentscope-core/src/main/java/io/agentscope/core/formatter/gemini/GeminiResponseParser.java b/agentscope-core/src/main/java/io/agentscope/core/formatter/gemini/GeminiResponseParser.java index de879e408d..fe56eb5c13 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/formatter/gemini/GeminiResponseParser.java +++ b/agentscope-core/src/main/java/io/agentscope/core/formatter/gemini/GeminiResponseParser.java @@ -32,6 +32,7 @@ import java.time.Duration; import java.time.Instant; import java.util.ArrayList; +import java.util.Base64; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -199,7 +200,9 @@ protected void parseToolCall( Map metadata = null; if (thoughtSignature != null) { metadata = new HashMap<>(); - metadata.put(ToolUseBlock.METADATA_THOUGHT_SIGNATURE, thoughtSignature); + metadata.put( + ToolUseBlock.METADATA_THOUGHT_SIGNATURE, + Base64.getEncoder().encodeToString(thoughtSignature)); } blocks.add( diff --git a/agentscope-core/src/main/java/io/agentscope/core/formatter/openai/dto/OpenAIReasoningDetail.java b/agentscope-core/src/main/java/io/agentscope/core/formatter/openai/dto/OpenAIReasoningDetail.java index ddce36886a..be277f48cb 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/formatter/openai/dto/OpenAIReasoningDetail.java +++ b/agentscope-core/src/main/java/io/agentscope/core/formatter/openai/dto/OpenAIReasoningDetail.java @@ -18,6 +18,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Objects; /** * OpenAI Reasoning Detail DTO (OpenRouter specific for Gemini). @@ -90,4 +91,26 @@ public Integer getIndex() { public void setIndex(Integer index) { this.index = index; } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof OpenAIReasoningDetail)) { + return false; + } + OpenAIReasoningDetail that = (OpenAIReasoningDetail) o; + return Objects.equals(this.id, that.id) + && Objects.equals(this.type, that.type) + && Objects.equals(this.data, that.data) + && Objects.equals(this.text, that.text) + && Objects.equals(this.format, that.format) + && Objects.equals(this.index, that.index); + } + + @Override + public int hashCode() { + return Objects.hash(this.id, this.type, this.data, this.text, this.format, this.index); + } } diff --git a/agentscope-core/src/main/java/io/agentscope/core/message/AudioBlock.java b/agentscope-core/src/main/java/io/agentscope/core/message/AudioBlock.java index 02f102b5ab..0d8f8cf784 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/message/AudioBlock.java +++ b/agentscope-core/src/main/java/io/agentscope/core/message/AudioBlock.java @@ -56,6 +56,23 @@ public Source getSource() { return source; } + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof AudioBlock)) { + return false; + } + AudioBlock that = (AudioBlock) o; + return Objects.equals(this.source, that.source); + } + + @Override + public int hashCode() { + return Objects.hash(this.source); + } + /** * Creates a new builder for constructing AudioBlock instances. * diff --git a/agentscope-core/src/main/java/io/agentscope/core/message/Base64Source.java b/agentscope-core/src/main/java/io/agentscope/core/message/Base64Source.java index 35408aec33..beafd3c158 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/message/Base64Source.java +++ b/agentscope-core/src/main/java/io/agentscope/core/message/Base64Source.java @@ -75,6 +75,24 @@ public String getData() { return data; } + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Base64Source)) { + return false; + } + Base64Source that = (Base64Source) o; + return Objects.equals(this.mediaType, that.mediaType) + && Objects.equals(this.data, that.data); + } + + @Override + public int hashCode() { + return Objects.hash(this.mediaType, this.data); + } + /** * Creates a new builder for constructing Base64Source instances. * diff --git a/agentscope-core/src/main/java/io/agentscope/core/message/ImageBlock.java b/agentscope-core/src/main/java/io/agentscope/core/message/ImageBlock.java index b57866f024..c1f41ba07d 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/message/ImageBlock.java +++ b/agentscope-core/src/main/java/io/agentscope/core/message/ImageBlock.java @@ -97,6 +97,25 @@ public Integer getMaxPixels() { return maxPixels; } + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ImageBlock)) { + return false; + } + ImageBlock that = (ImageBlock) o; + return Objects.equals(this.source, that.source) + && Objects.equals(this.minPixels, that.minPixels) + && Objects.equals(this.maxPixels, that.maxPixels); + } + + @Override + public int hashCode() { + return Objects.hash(this.source, this.minPixels, this.maxPixels); + } + /** * Creates a new builder for constructing ImageBlock instances. * diff --git a/agentscope-core/src/main/java/io/agentscope/core/message/Msg.java b/agentscope-core/src/main/java/io/agentscope/core/message/Msg.java index 573882e06b..9daec701a1 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/message/Msg.java +++ b/agentscope-core/src/main/java/io/agentscope/core/message/Msg.java @@ -197,6 +197,29 @@ private static void validateRoleContent(MsgRole role, List content } } + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (!(o instanceof Msg)) { + return false; + } else { + Msg that = (Msg) o; + return Objects.equals(this.id, that.id) + && Objects.equals(this.name, that.name) + && this.role == that.role + && Objects.equals(this.content, that.content) + && Objects.equals(this.metadata, that.metadata) + && Objects.equals(this.timestamp, that.timestamp); + } + } + + @Override + public int hashCode() { + return Objects.hash( + this.id, this.name, this.role, this.content, this.metadata, this.timestamp); + } + /** * Creates a new message builder with a randomly generated ID. * diff --git a/agentscope-core/src/main/java/io/agentscope/core/message/TextBlock.java b/agentscope-core/src/main/java/io/agentscope/core/message/TextBlock.java index 622be04920..85d9e837d8 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/message/TextBlock.java +++ b/agentscope-core/src/main/java/io/agentscope/core/message/TextBlock.java @@ -17,6 +17,7 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Objects; /** * Represents plain text content in a message. @@ -56,6 +57,23 @@ public String toString() { return text; } + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (!(o instanceof TextBlock)) { + return false; + } else { + TextBlock that = (TextBlock) o; + return Objects.equals(this.text, that.text); + } + } + + @Override + public int hashCode() { + return Objects.hash(this.text); + } + /** * Creates a new builder for constructing TextBlock instances. * diff --git a/agentscope-core/src/main/java/io/agentscope/core/message/ThinkingBlock.java b/agentscope-core/src/main/java/io/agentscope/core/message/ThinkingBlock.java index fc337fdb3b..71699695c2 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/message/ThinkingBlock.java +++ b/agentscope-core/src/main/java/io/agentscope/core/message/ThinkingBlock.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.util.HashMap; import java.util.Map; +import java.util.Objects; /** * Represents reasoning or thinking content in a message. @@ -81,6 +82,24 @@ public Map getMetadata() { return metadata; } + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ThinkingBlock)) { + return false; + } + ThinkingBlock that = (ThinkingBlock) o; + return Objects.equals(this.thinking, that.thinking) + && Objects.equals(this.metadata, that.metadata); + } + + @Override + public int hashCode() { + return Objects.hash(this.thinking, this.metadata); + } + /** * Creates a new builder for constructing ThinkingBlock instances. * diff --git a/agentscope-core/src/main/java/io/agentscope/core/message/ToolResultBlock.java b/agentscope-core/src/main/java/io/agentscope/core/message/ToolResultBlock.java index 3f64596f22..373f28c1d2 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/message/ToolResultBlock.java +++ b/agentscope-core/src/main/java/io/agentscope/core/message/ToolResultBlock.java @@ -22,6 +22,7 @@ import java.beans.Transient; import java.util.List; import java.util.Map; +import java.util.Objects; /** * Represents the result of a tool execution. @@ -316,6 +317,26 @@ public ToolResultBlock withIdAndName(String id, String name) { return new ToolResultBlock(id, name, this.output, this.metadata, this.state); } + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ToolResultBlock)) { + return false; + } + ToolResultBlock that = (ToolResultBlock) o; + return Objects.equals(this.id, that.id) + && Objects.equals(this.name, that.name) + && Objects.equals(this.output, that.output) + && Objects.equals(this.metadata, that.metadata); + } + + @Override + public int hashCode() { + return Objects.hash(this.id, this.name, this.output, this.metadata); + } + /** * Creates a new builder for constructing ToolResultBlock instances. * diff --git a/agentscope-core/src/main/java/io/agentscope/core/message/ToolUseBlock.java b/agentscope-core/src/main/java/io/agentscope/core/message/ToolUseBlock.java index eb0fbe7fe9..405d7e4050 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/message/ToolUseBlock.java +++ b/agentscope-core/src/main/java/io/agentscope/core/message/ToolUseBlock.java @@ -17,9 +17,11 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Base64; import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.Objects; /** * Represents a tool use request within a message. @@ -33,7 +35,7 @@ */ public final class ToolUseBlock extends ContentBlock { - /** Metadata key for Gemini thought signature (byte[] value). */ + /** Metadata key for provider thought signatures (String value). */ public static final String METADATA_THOUGHT_SIGNATURE = "thoughtSignature"; private final String id; @@ -114,10 +116,28 @@ public ToolUseBlock( this.metadata = metadata == null ? Collections.emptyMap() - : Collections.unmodifiableMap(new HashMap<>(metadata)); + : Collections.unmodifiableMap(normalizeMetadata(metadata)); this.state = state != null ? state : ToolCallState.PENDING; } + /** + * Normalizes metadata values to ensure stable {@link #equals(Object)} and {@link #hashCode()} + * across serialization round-trips. + * + *

Specifically, a {@code byte[]} stored under {@link #METADATA_THOUGHT_SIGNATURE} is + * converted to a Base64-encoded {@code String}, because {@code byte[]} relies on identity + * equality and would otherwise cause two semantically equal blocks to hash differently after + * deserialization. + */ + private static Map normalizeMetadata(Map metadata) { + Map copy = new HashMap<>(metadata); + Object signature = copy.get(METADATA_THOUGHT_SIGNATURE); + if (signature instanceof byte[] bytes) { + copy.put(METADATA_THOUGHT_SIGNATURE, Base64.getEncoder().encodeToString(bytes)); + } + return copy; + } + /** * Gets the unique identifier of this tool call. * @@ -157,7 +177,7 @@ public String getContent() { /** * Gets the provider-specific metadata. * - *

For Gemini, this may contain the thought signature under the key + *

For Gemini, this may contain a Base64-encoded thought signature under the key * {@link #METADATA_THOUGHT_SIGNATURE}. * * @return The metadata map, or an empty map if not set @@ -185,6 +205,29 @@ public ToolUseBlock withState(ToolCallState state) { return new ToolUseBlock(this.id, this.name, this.input, this.content, this.metadata, state); } + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ToolUseBlock)) { + return false; + } + ToolUseBlock that = (ToolUseBlock) o; + return Objects.equals(this.id, that.id) + && Objects.equals(this.name, that.name) + && Objects.equals(this.input, that.input) + && Objects.equals(this.content, that.content) + && Objects.equals(this.metadata, that.metadata) + && Objects.equals(this.state, that.state); + } + + @Override + public int hashCode() { + return Objects.hash( + this.id, this.name, this.input, this.content, this.metadata, this.state); + } + /** * Creates a new builder for constructing a ToolUseBlock. * @@ -253,7 +296,7 @@ public Builder content(String content) { * Sets the provider-specific metadata. * *

For Gemini, use {@link ToolUseBlock#METADATA_THOUGHT_SIGNATURE} as the key - * to store thought signatures. + * to store Base64-encoded thought signatures. * * @param metadata The metadata map * @return This builder for chaining diff --git a/agentscope-core/src/main/java/io/agentscope/core/message/URLSource.java b/agentscope-core/src/main/java/io/agentscope/core/message/URLSource.java index 35d25e2b2e..00c0a48b53 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/message/URLSource.java +++ b/agentscope-core/src/main/java/io/agentscope/core/message/URLSource.java @@ -60,6 +60,23 @@ public String getUrl() { return url; } + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof URLSource)) { + return false; + } + URLSource that = (URLSource) o; + return Objects.equals(this.url, that.url); + } + + @Override + public int hashCode() { + return Objects.hash(this.url); + } + /** * Creates a new builder for constructing URLSource instances. * diff --git a/agentscope-core/src/main/java/io/agentscope/core/message/VideoBlock.java b/agentscope-core/src/main/java/io/agentscope/core/message/VideoBlock.java index 09dc51cc98..a3733e1123 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/message/VideoBlock.java +++ b/agentscope-core/src/main/java/io/agentscope/core/message/VideoBlock.java @@ -18,6 +18,7 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Objects; /** * Represents video content in a message. @@ -135,6 +136,34 @@ public Integer getTotalPixels() { return totalPixels; } + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof VideoBlock)) { + return false; + } + VideoBlock that = (VideoBlock) o; + return Objects.equals(this.source, that.source) + && Objects.equals(this.fps, that.fps) + && Objects.equals(this.maxFrames, that.maxFrames) + && Objects.equals(this.minPixels, that.minPixels) + && Objects.equals(this.maxPixels, that.maxPixels) + && Objects.equals(this.totalPixels, that.totalPixels); + } + + @Override + public int hashCode() { + return Objects.hash( + this.source, + this.fps, + this.maxFrames, + this.minPixels, + this.maxPixels, + this.totalPixels); + } + /** * Creates a new builder for constructing VideoBlock instances. * diff --git a/agentscope-core/src/main/java/io/agentscope/core/model/ChatUsage.java b/agentscope-core/src/main/java/io/agentscope/core/model/ChatUsage.java index 59ca854712..394c55cd86 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/model/ChatUsage.java +++ b/agentscope-core/src/main/java/io/agentscope/core/model/ChatUsage.java @@ -17,6 +17,7 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Objects; /** * Represents token usage information for chat completion responses. @@ -83,6 +84,25 @@ public double getTime() { return time; } + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ChatUsage)) { + return false; + } + ChatUsage that = (ChatUsage) o; + return this.inputTokens == that.inputTokens + && this.outputTokens == that.outputTokens + && Double.compare(this.time, that.time) == 0; + } + + @Override + public int hashCode() { + return Objects.hash(this.inputTokens, this.outputTokens, this.time); + } + /** * Creates a new builder for ChatUsage. * 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 3e54465266..3191bc9fce 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 @@ -15,13 +15,13 @@ */ 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.Base64; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -46,7 +46,7 @@ void setUp() { @DisplayName("Should accumulate metadata from tool call chunks") void testAccumulateMetadata() { // First chunk with thoughtSignature - byte[] signature = "test-thought-signature".getBytes(); + String signature = Base64.getEncoder().encodeToString("test-thought-signature".getBytes()); Map metadata = new HashMap<>(); metadata.put(ToolUseBlock.METADATA_THOUGHT_SIGNATURE, signature); @@ -77,9 +77,8 @@ void testAccumulateMetadata() { // 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)); + assertEquals( + signature, toolCall.getMetadata().get(ToolUseBlock.METADATA_THOUGHT_SIGNATURE)); } @Test @@ -107,7 +106,7 @@ void testAccumulateWithoutMetadata() { @DisplayName("Should handle parallel tool calls with different metadata") void testParallelToolCallsWithMetadata() { // First tool call with metadata - byte[] sig1 = "sig-1".getBytes(); + String sig1 = Base64.getEncoder().encodeToString("sig-1".getBytes()); Map metadata1 = new HashMap<>(); metadata1.put(ToolUseBlock.METADATA_THOUGHT_SIGNATURE, sig1); @@ -139,6 +138,7 @@ void testParallelToolCallsWithMetadata() { result.stream().filter(t -> "call_a".equals(t.getId())).findFirst().orElse(null); assertNotNull(resultA); assertTrue(resultA.getMetadata().containsKey(ToolUseBlock.METADATA_THOUGHT_SIGNATURE)); + assertEquals(sig1, resultA.getMetadata().get(ToolUseBlock.METADATA_THOUGHT_SIGNATURE)); // Second call should not have metadata ToolUseBlock resultB = diff --git a/agentscope-core/src/test/java/io/agentscope/core/formatter/gemini/GeminiMessageConverterTest.java b/agentscope-core/src/test/java/io/agentscope/core/formatter/gemini/GeminiMessageConverterTest.java index 8c77904e22..a1125d682f 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/formatter/gemini/GeminiMessageConverterTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/formatter/gemini/GeminiMessageConverterTest.java @@ -18,6 +18,7 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -770,8 +771,9 @@ void testConvertToolUseBlockWithThoughtSignature() { input.put("query", "test"); byte[] thoughtSignature = "test-signature".getBytes(); + String encodedSignature = Base64.getEncoder().encodeToString(thoughtSignature); Map metadata = new HashMap<>(); - metadata.put(ToolUseBlock.METADATA_THOUGHT_SIGNATURE, thoughtSignature); + metadata.put(ToolUseBlock.METADATA_THOUGHT_SIGNATURE, encodedSignature); ToolUseBlock toolUseBlock = ToolUseBlock.builder() @@ -804,6 +806,43 @@ void testConvertToolUseBlockWithThoughtSignature() { assertArrayEquals(thoughtSignature, part.thoughtSignature().get()); } + @Test + @DisplayName("Should normalize legacy byte[] thoughtSignature metadata to Base64 String") + void testConvertToolUseBlockWithLegacyByteArrayThoughtSignature() { + Map input = new HashMap<>(); + input.put("query", "test"); + + byte[] thoughtSignature = "legacy-signature".getBytes(); + Map metadata = new HashMap<>(); + metadata.put(ToolUseBlock.METADATA_THOUGHT_SIGNATURE, thoughtSignature); + + ToolUseBlock toolUseBlock = + ToolUseBlock.builder() + .id("call_with_legacy_sig") + .name("search") + .input(input) + .metadata(metadata) + .build(); + + // Constructor normalizes byte[] to Base64 String so equals/hashCode stay stable + Object stored = toolUseBlock.getMetadata().get(ToolUseBlock.METADATA_THOUGHT_SIGNATURE); + assertInstanceOf(String.class, stored); + assertEquals(Base64.getEncoder().encodeToString(thoughtSignature), stored); + + Msg msg = + Msg.builder() + .name("assistant") + .content(List.of(toolUseBlock)) + .role(MsgRole.ASSISTANT) + .build(); + + List result = converter.convertMessages(List.of(msg)); + + Part part = result.get(0).parts().get().get(0); + assertTrue(part.thoughtSignature().isPresent()); + assertArrayEquals(thoughtSignature, part.thoughtSignature().get()); + } + @Test @DisplayName("Should convert ToolUseBlock without thoughtSignature") void testConvertToolUseBlockWithoutThoughtSignature() { @@ -842,8 +881,9 @@ void testThoughtSignatureRoundTrip() { input.put("location", "Tokyo"); byte[] signature = "gemini3-thought-sig-abc123".getBytes(); + String encodedSignature = Base64.getEncoder().encodeToString(signature); Map metadata = new HashMap<>(); - metadata.put(ToolUseBlock.METADATA_THOUGHT_SIGNATURE, signature); + metadata.put(ToolUseBlock.METADATA_THOUGHT_SIGNATURE, encodedSignature); // Simulate assistant message with tool call (as would be constructed from parsed response) ToolUseBlock toolUseBlock = diff --git a/agentscope-core/src/test/java/io/agentscope/core/formatter/gemini/GeminiResponseParserTest.java b/agentscope-core/src/test/java/io/agentscope/core/formatter/gemini/GeminiResponseParserTest.java index 542d979869..82029ac734 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/formatter/gemini/GeminiResponseParserTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/formatter/gemini/GeminiResponseParserTest.java @@ -15,7 +15,6 @@ */ package io.agentscope.core.formatter.gemini; -import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -34,6 +33,7 @@ import io.agentscope.core.model.ChatResponse; import io.agentscope.core.model.ChatUsage; import java.time.Instant; +import java.util.Base64; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -353,9 +353,9 @@ void testParseToolCallWithThoughtSignature() { // Verify thought signature is stored in metadata assertNotNull(toolUse.getMetadata()); assertTrue(toolUse.getMetadata().containsKey(ToolUseBlock.METADATA_THOUGHT_SIGNATURE)); - byte[] extractedSig = - (byte[]) toolUse.getMetadata().get(ToolUseBlock.METADATA_THOUGHT_SIGNATURE); - assertArrayEquals(thoughtSignature, extractedSig); + String extractedSig = + (String) toolUse.getMetadata().get(ToolUseBlock.METADATA_THOUGHT_SIGNATURE); + assertEquals(Base64.getEncoder().encodeToString(thoughtSignature), extractedSig); } @Test @@ -432,6 +432,9 @@ void testParseParallelFunctionCallsWithThoughtSignature() { ToolUseBlock toolUse1 = (ToolUseBlock) chatResponse.getContent().get(0); assertEquals("call-1", toolUse1.getId()); assertTrue(toolUse1.getMetadata().containsKey(ToolUseBlock.METADATA_THOUGHT_SIGNATURE)); + assertEquals( + Base64.getEncoder().encodeToString(thoughtSignature), + toolUse1.getMetadata().get(ToolUseBlock.METADATA_THOUGHT_SIGNATURE)); // Second tool call should not have signature ToolUseBlock toolUse2 = (ToolUseBlock) chatResponse.getContent().get(1); diff --git a/agentscope-core/src/test/java/io/agentscope/core/formatter/openai/dto/OpenAIMessageReasoningFieldTest.java b/agentscope-core/src/test/java/io/agentscope/core/formatter/openai/dto/OpenAIMessageReasoningFieldTest.java index d40226e16b..f56fbdaf10 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/formatter/openai/dto/OpenAIMessageReasoningFieldTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/formatter/openai/dto/OpenAIMessageReasoningFieldTest.java @@ -16,6 +16,7 @@ package io.agentscope.core.formatter.openai.dto; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -168,4 +169,31 @@ void testDeserializeVllmStreamingChunk() { assertEquals("Step 1: parse the input...", delta.getReasoningContent()); } + + @Test + @DisplayName("Should compare reasoning details by value") + void testReasoningDetailEqualsAndHashCodeUseValues() { + OpenAIReasoningDetail first = reasoningDetail("reasoning.text"); + OpenAIReasoningDetail second = reasoningDetail("reasoning.text"); + OpenAIReasoningDetail different = reasoningDetail("reasoning.summary"); + + assertEquals(first, first); + assertNotEquals(first, null); + assertNotEquals(first, "other"); + assertEquals(first, second); + assertEquals(second, first); + assertEquals(first.hashCode(), second.hashCode()); + assertNotEquals(first, different); + } + + private OpenAIReasoningDetail reasoningDetail(String type) { + OpenAIReasoningDetail detail = new OpenAIReasoningDetail(); + detail.setId("reasoning-1"); + detail.setType(type); + detail.setData("encrypted-data"); + detail.setText("visible reasoning"); + detail.setFormat("openai-responses-v1"); + detail.setIndex(0); + return detail; + } } diff --git a/agentscope-core/src/test/java/io/agentscope/core/message/MsgTest.java b/agentscope-core/src/test/java/io/agentscope/core/message/MsgTest.java index 23fafec953..6a6cb5dbd1 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/message/MsgTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/message/MsgTest.java @@ -16,9 +16,11 @@ package io.agentscope.core.message; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -205,4 +207,167 @@ void testTextContentConvenienceMethod() { assertTrue(msg.getFirstContentBlock() instanceof TextBlock); assertEquals("Hello World", ((TextBlock) msg.getFirstContentBlock()).getText()); } + + @Test + void testContentBlocksEqualsAndHashCodeUseValues() { + assertValueEquality( + TextBlock.builder().text("hello").build(), + TextBlock.builder().text("hello").build(), + TextBlock.builder().text("different").build()); + + assertValueEquality( + URLSource.builder().url("https://example.com/image.png").build(), + URLSource.builder().url("https://example.com/image.png").build(), + URLSource.builder().url("https://example.com/other.png").build()); + + assertValueEquality( + Base64Source.builder().mediaType("image/png").data("abc").build(), + Base64Source.builder().mediaType("image/png").data("abc").build(), + Base64Source.builder().mediaType("image/png").data("def").build()); + + assertValueEquality( + ImageBlock.builder() + .source(URLSource.builder().url("https://example.com/image.png").build()) + .minPixels(128) + .maxPixels(512) + .build(), + ImageBlock.builder() + .source(URLSource.builder().url("https://example.com/image.png").build()) + .minPixels(128) + .maxPixels(512) + .build(), + ImageBlock.builder() + .source(URLSource.builder().url("https://example.com/image.png").build()) + .minPixels(128) + .maxPixels(1024) + .build()); + + assertValueEquality( + AudioBlock.builder() + .source(Base64Source.builder().mediaType("audio/mp3").data("abc").build()) + .build(), + AudioBlock.builder() + .source(Base64Source.builder().mediaType("audio/mp3").data("abc").build()) + .build(), + AudioBlock.builder() + .source(Base64Source.builder().mediaType("audio/mp3").data("def").build()) + .build()); + + assertValueEquality( + VideoBlock.builder() + .source(URLSource.builder().url("https://example.com/video.mp4").build()) + .fps(2.0f) + .maxFrames(12) + .minPixels(128) + .maxPixels(512) + .totalPixels(2048) + .build(), + VideoBlock.builder() + .source(URLSource.builder().url("https://example.com/video.mp4").build()) + .fps(2.0f) + .maxFrames(12) + .minPixels(128) + .maxPixels(512) + .totalPixels(2048) + .build(), + VideoBlock.builder() + .source(URLSource.builder().url("https://example.com/video.mp4").build()) + .fps(2.0f) + .maxFrames(12) + .minPixels(128) + .maxPixels(512) + .totalPixels(4096) + .build()); + + assertValueEquality( + ThinkingBlock.builder().thinking("thinking").metadata(Map.of("k", "v")).build(), + ThinkingBlock.builder().thinking("thinking").metadata(Map.of("k", "v")).build(), + ThinkingBlock.builder() + .thinking("thinking") + .metadata(Map.of("k", "different")) + .build()); + + assertValueEquality( + ToolUseBlock.builder() + .id("call-1") + .name("search") + .input(Map.of("query", "agent")) + .content("{\"query\":\"agent\"}") + .metadata(Map.of("signature", "sig-1")) + .build(), + ToolUseBlock.builder() + .id("call-1") + .name("search") + .input(Map.of("query", "agent")) + .content("{\"query\":\"agent\"}") + .metadata(Map.of("signature", "sig-1")) + .build(), + ToolUseBlock.builder() + .id("call-1") + .name("search") + .input(Map.of("query", "agent")) + .content("{\"query\":\"agent\"}") + .metadata(Map.of("signature", "sig-2")) + .build()); + + assertValueEquality( + ToolResultBlock.of( + "call-1", + "search", + List.of(TextBlock.builder().text("result").build()), + Map.of("status", "ok")), + ToolResultBlock.of( + "call-1", + "search", + List.of(TextBlock.builder().text("result").build()), + Map.of("status", "ok")), + ToolResultBlock.of( + "call-1", + "search", + List.of(TextBlock.builder().text("result").build()), + Map.of("status", "different"))); + } + + @Test + void testMsgEqualsAndHashCodeUseValues() { + Msg first = + Msg.builder() + .id("msg-1") + .name("assistant") + .role(MsgRole.ASSISTANT) + .content(TextBlock.builder().text("hello").build()) + .metadata(Map.of("k", "v")) + .timestamp("2026-05-13 11:00:00.000") + .build(); + Msg second = + Msg.builder() + .id("msg-1") + .name("assistant") + .role(MsgRole.ASSISTANT) + .content(TextBlock.builder().text("hello").build()) + .metadata(Map.of("k", "v")) + .timestamp("2026-05-13 11:00:00.000") + .build(); + Msg different = + Msg.builder() + .id("msg-1") + .name("assistant") + .role(MsgRole.ASSISTANT) + .content(TextBlock.builder().text("hello").build()) + .metadata(Map.of("k", "v")) + .timestamp("2026-05-13 11:00:01.000") + .build(); + + assertValueEquality(first, second, different); + } + + private void assertValueEquality(Object first, Object second, Object different) { + assertEquals(first, first); + assertNotEquals(first, null); + assertNotEquals(first, "other"); + assertEquals(first, second); + assertEquals(second, first); + assertEquals(first.hashCode(), second.hashCode()); + assertNotEquals(first, different); + } } diff --git a/agentscope-core/src/test/java/io/agentscope/core/message/ToolUseBlockTest.java b/agentscope-core/src/test/java/io/agentscope/core/message/ToolUseBlockTest.java index f91a51958e..c217bbe3b9 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/message/ToolUseBlockTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/message/ToolUseBlockTest.java @@ -17,11 +17,14 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Base64; +import java.util.HashMap; import java.util.Map; import org.junit.jupiter.api.Test; @@ -185,6 +188,69 @@ void testRoundTripSerialization() throws JsonProcessingException { assertEquals(original.getMetadata(), deserialized.getMetadata()); } + @Test + void testThoughtSignatureStringRoundTripKeepsValueHashCode() throws JsonProcessingException { + ToolUseBlock original = + ToolUseBlock.builder() + .id("tool-gemini") + .name("search") + .input(Map.of("query", "agent")) + .metadata(Map.of(ToolUseBlock.METADATA_THOUGHT_SIGNATURE, "dGVzdA==")) + .build(); + + String json = objectMapper.writeValueAsString(original); + ToolUseBlock deserialized = objectMapper.readValue(json, ToolUseBlock.class); + + assertEquals(original, deserialized); + assertEquals(original.hashCode(), deserialized.hashCode()); + } + + @Test + void testConstructorNormalizesByteArrayThoughtSignatureToBase64String() { + byte[] rawSignature = "raw-thought-signature".getBytes(); + Map metadata = new HashMap<>(); + metadata.put(ToolUseBlock.METADATA_THOUGHT_SIGNATURE, rawSignature); + + ToolUseBlock block = + ToolUseBlock.builder() + .id("tool-bytes") + .name("search") + .input(Map.of("query", "agent")) + .metadata(metadata) + .build(); + + Object stored = block.getMetadata().get(ToolUseBlock.METADATA_THOUGHT_SIGNATURE); + assertInstanceOf(String.class, stored); + assertEquals(Base64.getEncoder().encodeToString(rawSignature), stored); + } + + @Test + void testByteArrayAndBase64StringMetadataProduceEqualBlocks() { + byte[] rawSignature = "same-signature".getBytes(); + String encoded = Base64.getEncoder().encodeToString(rawSignature); + + Map bytesMetadata = new HashMap<>(); + bytesMetadata.put(ToolUseBlock.METADATA_THOUGHT_SIGNATURE, rawSignature); + ToolUseBlock fromBytes = + ToolUseBlock.builder() + .id("tool-id") + .name("search") + .input(Map.of("query", "agent")) + .metadata(bytesMetadata) + .build(); + + ToolUseBlock fromString = + ToolUseBlock.builder() + .id("tool-id") + .name("search") + .input(Map.of("query", "agent")) + .metadata(Map.of(ToolUseBlock.METADATA_THOUGHT_SIGNATURE, encoded)) + .build(); + + assertEquals(fromString, fromBytes); + assertEquals(fromString.hashCode(), fromBytes.hashCode()); + } + @Test void testInputMapIsUnmodifiable() { Map inputMap = Map.of("key1", "value1", "key2", "value2"); diff --git a/agentscope-core/src/test/java/io/agentscope/core/model/OpenAIChatModelTest.java b/agentscope-core/src/test/java/io/agentscope/core/model/OpenAIChatModelTest.java index 2f0c254d3d..9e2ac58778 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/model/OpenAIChatModelTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/model/OpenAIChatModelTest.java @@ -17,6 +17,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -257,6 +258,21 @@ void testGetModelName() { assertEquals("gpt-4", model.getModelName()); } + @Test + @DisplayName("Should compare chat usage by value") + void testChatUsageEqualsAndHashCodeUseValues() { + ChatUsage first = ChatUsage.builder().inputTokens(10).outputTokens(20).time(1.5).build(); + ChatUsage second = ChatUsage.builder().inputTokens(10).outputTokens(20).time(1.5).build(); + ChatUsage different = + ChatUsage.builder().inputTokens(10).outputTokens(21).time(1.5).build(); + + assertEquals(first, first); + assertNotEquals(first, null); + assertEquals(first, second); + assertEquals(first.hashCode(), second.hashCode()); + assertNotEquals(first, different); + } + @Test @DisplayName("Should build model with custom endpoint path") void testBuildModelWithEndpointPath() throws Exception { diff --git a/agentscope-core/src/test/java/io/agentscope/core/state/ListHashUtilTest.java b/agentscope-core/src/test/java/io/agentscope/core/state/ListHashUtilTest.java index 9368fe4850..0ab3c02bd4 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/state/ListHashUtilTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/state/ListHashUtilTest.java @@ -20,11 +20,16 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import io.agentscope.core.formatter.openai.dto.OpenAIReasoningDetail; +import io.agentscope.core.message.MessageMetadataKeys; 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.model.ChatUsage; import java.util.ArrayList; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.Test; /** @@ -55,6 +60,133 @@ void testComputeHashSameListSameHash() { assertEquals(hash1, hash2); } + @Test + void testComputeHashEquivalentMessageLists() { + List first = + List.of( + Msg.builder() + .id("m-user-1") + .timestamp("2026-05-08 14:00:00.000") + .role(MsgRole.USER) + .content(TextBlock.builder().text("hello").build()) + .build(), + Msg.builder() + .id("m-assistant-1") + .timestamp("2026-05-08 14:00:01.000") + .role(MsgRole.ASSISTANT) + .content(TextBlock.builder().text("hello").build()) + .build()); + + List second = + List.of( + Msg.builder() + .id("m-user-1") + .timestamp("2026-05-08 14:00:00.000") + .role(MsgRole.USER) + .content(TextBlock.builder().text("hello").build()) + .build(), + Msg.builder() + .id("m-assistant-1") + .timestamp("2026-05-08 14:00:01.000") + .role(MsgRole.ASSISTANT) + .content(TextBlock.builder().text("hello").build()) + .build()); + + String h1 = ListHashUtil.computeHash(first); + String h2 = ListHashUtil.computeHash(second); + + assertEquals(h1, h2); + } + + @Test + void testComputeHashListWithNullItem() { + List list = new ArrayList<>(); + list.add(null); + + String hash1 = ListHashUtil.computeHash(list); + String hash2 = ListHashUtil.computeHash(list); + + assertEquals(hash1, hash2); + } + + @Test + void testComputeHashEquivalentMessagesWithChatUsage() { + List first = + List.of( + Msg.builder() + .id("m-assistant-usage") + .timestamp("2026-05-08 14:00:01.000") + .role(MsgRole.ASSISTANT) + .content(TextBlock.builder().text("hello").build()) + .metadata( + Map.of( + MessageMetadataKeys.CHAT_USAGE, + ChatUsage.builder() + .inputTokens(10) + .outputTokens(20) + .time(1.5) + .build())) + .build()); + + List second = + List.of( + Msg.builder() + .id("m-assistant-usage") + .timestamp("2026-05-08 14:00:01.000") + .role(MsgRole.ASSISTANT) + .content(TextBlock.builder().text("hello").build()) + .metadata( + Map.of( + MessageMetadataKeys.CHAT_USAGE, + ChatUsage.builder() + .inputTokens(10) + .outputTokens(20) + .time(1.5) + .build())) + .build()); + + assertEquals(ListHashUtil.computeHash(first), ListHashUtil.computeHash(second)); + } + + @Test + void testComputeHashEquivalentThinkingBlocksWithReasoningDetails() { + List first = + List.of( + Msg.builder() + .id("m-assistant-thinking") + .timestamp("2026-05-08 14:00:01.000") + .role(MsgRole.ASSISTANT) + .content( + ThinkingBlock.builder() + .thinking("thinking") + .metadata( + Map.of( + ThinkingBlock + .METADATA_REASONING_DETAILS, + List.of(createReasoningDetail()))) + .build()) + .build()); + + List second = + List.of( + Msg.builder() + .id("m-assistant-thinking") + .timestamp("2026-05-08 14:00:01.000") + .role(MsgRole.ASSISTANT) + .content( + ThinkingBlock.builder() + .thinking("thinking") + .metadata( + Map.of( + ThinkingBlock + .METADATA_REASONING_DETAILS, + List.of(createReasoningDetail()))) + .build()) + .build()); + + assertEquals(ListHashUtil.computeHash(first), ListHashUtil.computeHash(second)); + } + @Test void testComputeHashListModifiedDifferentHash() { List list = createMsgList(5); @@ -205,4 +337,15 @@ private List createMsgList(int size) { } return list; } + + private OpenAIReasoningDetail createReasoningDetail() { + OpenAIReasoningDetail detail = new OpenAIReasoningDetail(); + detail.setId("reasoning-1"); + detail.setType("reasoning.text"); + detail.setData("encrypted-data"); + detail.setText("visible reasoning"); + detail.setFormat("openai-responses-v1"); + detail.setIndex(0); + return detail; + } }