Skip to content

Commit 9a775b9

Browse files
committed
feat(tests): add unit tests for Gemini formatter and response parser
Signed-off-by: liuhy <liuhongyu@apache.org>
1 parent b8a006b commit 9a775b9

7 files changed

Lines changed: 767 additions & 0 deletions

File tree

agentscope-core/src/test/java/io/agentscope/core/formatter/gemini/GeminiChatFormatterTest.java

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import io.agentscope.core.model.ToolChoice;
3232
import io.agentscope.core.model.ToolSchema;
3333
import java.time.Instant;
34+
import java.util.ArrayList;
3435
import java.util.HashMap;
3536
import java.util.List;
3637
import java.util.Map;
@@ -192,4 +193,77 @@ void testApplySystemInstructionIsStateless() {
192193
formatter.applySystemInstruction(requestWithoutSystem, List.of(userMsg));
193194
assertNull(requestWithoutSystem.getSystemInstruction());
194195
}
196+
197+
@Test
198+
void testFormatSkipsLeadingSystemMessage() {
199+
Msg systemMsg =
200+
Msg.builder()
201+
.role(MsgRole.SYSTEM)
202+
.content(List.of(TextBlock.builder().text("system").build()))
203+
.build();
204+
Msg userMsg =
205+
Msg.builder()
206+
.role(MsgRole.USER)
207+
.content(List.of(TextBlock.builder().text("hello").build()))
208+
.build();
209+
210+
List<GeminiContent> contents = formatter.format(List.of(systemMsg, userMsg));
211+
assertEquals(1, contents.size());
212+
assertEquals("user", contents.get(0).getRole());
213+
assertEquals("hello", contents.get(0).getParts().get(0).getText());
214+
}
215+
216+
@Test
217+
void testApplyOptionsUsesDefaultOptionsFallback() {
218+
GeminiRequest request = new GeminiRequest();
219+
220+
GenerateOptions defaults =
221+
GenerateOptions.builder().temperature(0.6).maxTokens(500).topP(0.8).build();
222+
223+
formatter.applyOptions(request, GenerateOptions.builder().build(), defaults);
224+
225+
assertNotNull(request.getGenerationConfig());
226+
assertEquals(0.6, request.getGenerationConfig().getTemperature(), 0.001);
227+
assertEquals(500, request.getGenerationConfig().getMaxOutputTokens());
228+
assertEquals(0.8, request.getGenerationConfig().getTopP(), 0.001);
229+
}
230+
231+
@Test
232+
void testApplyOptionsWithNoOptionsDoesNotCreateGenerationConfig() {
233+
GeminiRequest request = new GeminiRequest();
234+
235+
formatter.applyOptions(request, null, null);
236+
237+
assertNull(request.getGenerationConfig());
238+
}
239+
240+
@Test
241+
void testApplyOptionsForTopKSeedThinkingBudget() {
242+
GeminiRequest request = new GeminiRequest();
243+
GenerateOptions options =
244+
GenerateOptions.builder().topK(12).seed(123L).thinkingBudget(200).build();
245+
246+
formatter.applyOptions(request, options, null);
247+
248+
assertNotNull(request.getGenerationConfig());
249+
assertEquals(12.0, request.getGenerationConfig().getTopK(), 0.001);
250+
assertEquals(123, request.getGenerationConfig().getSeed());
251+
assertNotNull(request.getGenerationConfig().getThinkingConfig());
252+
assertEquals(true, request.getGenerationConfig().getThinkingConfig().getIncludeThoughts());
253+
assertEquals(200, request.getGenerationConfig().getThinkingConfig().getThinkingBudget());
254+
}
255+
256+
@Test
257+
void testApplyToolChoiceAutoKeepsToolConfigNull() {
258+
GeminiRequest request = new GeminiRequest();
259+
formatter.applyToolChoice(request, new ToolChoice.Auto());
260+
assertNull(request.getToolConfig());
261+
}
262+
263+
@Test
264+
void testApplyToolsWithEmptyListDoesNothing() {
265+
GeminiRequest request = new GeminiRequest();
266+
formatter.applyTools(request, new ArrayList<>());
267+
assertNull(request.getTools());
268+
}
195269
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/*
2+
* Copyright 2024-2026 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.agentscope.core.formatter.gemini;
17+
18+
import static org.junit.jupiter.api.Assertions.assertEquals;
19+
import static org.junit.jupiter.api.Assertions.assertNotNull;
20+
import static org.junit.jupiter.api.Assertions.assertTrue;
21+
22+
import io.agentscope.core.formatter.gemini.dto.GeminiContent;
23+
import io.agentscope.core.message.Base64Source;
24+
import io.agentscope.core.message.ImageBlock;
25+
import io.agentscope.core.message.Msg;
26+
import io.agentscope.core.message.MsgRole;
27+
import io.agentscope.core.message.TextBlock;
28+
import io.agentscope.core.message.ThinkingBlock;
29+
import io.agentscope.core.message.ToolResultBlock;
30+
import java.util.Base64;
31+
import java.util.List;
32+
import org.junit.jupiter.api.DisplayName;
33+
import org.junit.jupiter.api.Tag;
34+
import org.junit.jupiter.api.Test;
35+
36+
@Tag("unit")
37+
@DisplayName("GeminiConversationMerger Unit Tests")
38+
class GeminiConversationMergerTest {
39+
40+
private static final String HISTORY_PROMPT =
41+
"# Conversation History\nThe content between <history></history> tags contains your"
42+
+ " conversation history\n";
43+
44+
private final GeminiConversationMerger merger = new GeminiConversationMerger(HISTORY_PROMPT);
45+
46+
@Test
47+
@DisplayName("Should merge text conversation with history tags and skip thinking block")
48+
void testMergeTextConversation() {
49+
Msg user =
50+
Msg.builder()
51+
.name("user")
52+
.role(MsgRole.USER)
53+
.content(List.of(TextBlock.builder().text("Hello").build()))
54+
.build();
55+
Msg assistantThinking =
56+
Msg.builder()
57+
.name("assistant")
58+
.role(MsgRole.ASSISTANT)
59+
.content(List.of(ThinkingBlock.builder().thinking("internal").build()))
60+
.build();
61+
Msg toolResult =
62+
Msg.builder()
63+
.name("system")
64+
.role(MsgRole.SYSTEM)
65+
.content(
66+
List.of(
67+
ToolResultBlock.builder()
68+
.id("call-1")
69+
.name("search")
70+
.output(List.of(TextBlock.builder().text("ok").build()))
71+
.build()))
72+
.build();
73+
74+
GeminiContent merged =
75+
merger.mergeToContent(
76+
List.of(user, assistantThinking, toolResult),
77+
m -> m.getName() != null ? m.getName() : m.getRole().name().toLowerCase(),
78+
blocks -> "tool output",
79+
HISTORY_PROMPT);
80+
81+
assertEquals("user", merged.getRole());
82+
assertEquals(1, merged.getParts().size());
83+
String text = merged.getParts().get(0).getText();
84+
assertTrue(text.startsWith(HISTORY_PROMPT + "<history>"));
85+
assertTrue(text.contains("user: Hello"));
86+
assertTrue(text.contains("Tool: search\ntool output"));
87+
assertTrue(text.endsWith("</history>"));
88+
assertTrue(!text.contains("internal"));
89+
}
90+
91+
@Test
92+
@DisplayName("Should insert history tags when first and last parts are media")
93+
void testMergeMediaOnlyConversation() {
94+
String base64 = Base64.getEncoder().encodeToString("img".getBytes());
95+
ImageBlock image =
96+
ImageBlock.builder()
97+
.source(Base64Source.builder().mediaType("image/png").data(base64).build())
98+
.build();
99+
100+
Msg mediaMsg =
101+
Msg.builder().name("user").role(MsgRole.USER).content(List.of(image)).build();
102+
103+
GeminiContent merged =
104+
merger.mergeToContent(
105+
List.of(mediaMsg),
106+
m -> m.getName() != null ? m.getName() : "user",
107+
blocks -> "",
108+
"");
109+
110+
assertEquals("user", merged.getRole());
111+
assertEquals(3, merged.getParts().size());
112+
assertEquals("<history>", merged.getParts().get(0).getText());
113+
assertNotNull(merged.getParts().get(1).getInlineData());
114+
assertEquals("</history>", merged.getParts().get(2).getText());
115+
}
116+
117+
@Test
118+
@DisplayName("Should append closing tag as separate part when last part is media")
119+
void testMergeTextThenMediaConversation() {
120+
String base64 = Base64.getEncoder().encodeToString("img".getBytes());
121+
ImageBlock image =
122+
ImageBlock.builder()
123+
.source(Base64Source.builder().mediaType("image/png").data(base64).build())
124+
.build();
125+
126+
Msg msg =
127+
Msg.builder()
128+
.name("agent")
129+
.role(MsgRole.USER)
130+
.content(List.of(TextBlock.builder().text("Look").build(), image))
131+
.build();
132+
133+
GeminiContent merged =
134+
merger.mergeToContent(
135+
List.of(msg), m -> "agent", blocks -> "", "# Prompt\n");
136+
137+
assertEquals(3, merged.getParts().size());
138+
assertTrue(merged.getParts().get(0).getText().startsWith("# Prompt\n<history>agent: Look"));
139+
assertNotNull(merged.getParts().get(1).getInlineData());
140+
assertEquals("</history>", merged.getParts().get(2).getText());
141+
}
142+
143+
@Test
144+
@DisplayName("Should return empty parts for empty conversation input")
145+
void testMergeEmptyConversation() {
146+
GeminiContent merged =
147+
merger.mergeToContent(
148+
List.of(),
149+
m -> m.getName() != null ? m.getName() : "unknown",
150+
blocks -> "",
151+
HISTORY_PROMPT);
152+
153+
assertEquals("user", merged.getRole());
154+
assertNotNull(merged.getParts());
155+
assertTrue(merged.getParts().isEmpty());
156+
}
157+
}
158+

agentscope-core/src/test/java/io/agentscope/core/formatter/gemini/GeminiMessageConverterTest.java

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
package io.agentscope.core.formatter.gemini;
1717

1818
import static org.junit.jupiter.api.Assertions.assertEquals;
19+
import static org.junit.jupiter.api.Assertions.assertFalse;
1920
import static org.junit.jupiter.api.Assertions.assertNotNull;
21+
import static org.junit.jupiter.api.Assertions.assertNull;
2022
import static org.junit.jupiter.api.Assertions.assertTrue;
2123

2224
import io.agentscope.core.formatter.gemini.dto.GeminiContent;
@@ -941,4 +943,135 @@ void testToolCallFallbackToInputMapWhenContentInvalidJson() {
941943
assertEquals("Tokyo", args.get("city"));
942944
assertEquals("celsius", args.get("unit"));
943945
}
946+
947+
@Test
948+
@DisplayName("Should convert synthetic tool call to text part")
949+
void testConvertSyntheticToolUseBlockToText() {
950+
ToolUseBlock syntheticToolCall =
951+
ToolUseBlock.builder()
952+
.id("call_syn")
953+
.name("generate_response")
954+
.input(Map.of("k", "v"))
955+
.metadata(Map.of("synthetic", true))
956+
.build();
957+
958+
Msg msg =
959+
Msg.builder()
960+
.name("assistant")
961+
.role(MsgRole.ASSISTANT)
962+
.content(List.of(syntheticToolCall))
963+
.build();
964+
965+
List<GeminiContent> result = converter.convertMessages(List.of(msg));
966+
967+
assertEquals(1, result.size());
968+
GeminiPart part = result.get(0).getParts().get(0);
969+
assertNull(part.getFunctionCall());
970+
assertEquals("[Synthetic tool call: generate_response]", part.getText());
971+
}
972+
973+
@Test
974+
@DisplayName("Should carry thinking signature from ThinkingBlock to next tool call")
975+
void testToolCallUsesPrecedingThinkingSignature() {
976+
ThinkingBlock thinking =
977+
ThinkingBlock.builder().metadata(Map.of("thoughtSignature", "sig_pre")).build();
978+
ToolUseBlock toolUse =
979+
ToolUseBlock.builder()
980+
.id("call_pre_sig")
981+
.name("search")
982+
.input(Map.of("q", "hello"))
983+
.build();
984+
985+
Msg msg =
986+
Msg.builder()
987+
.name("assistant")
988+
.role(MsgRole.ASSISTANT)
989+
.content(List.of(thinking, toolUse))
990+
.build();
991+
992+
List<GeminiContent> result = converter.convertMessages(List.of(msg));
993+
994+
assertEquals(1, result.size());
995+
GeminiPart part = result.get(0).getParts().get(0);
996+
assertNotNull(part.getFunctionCall());
997+
assertEquals("sig_pre", part.getThoughtSignature());
998+
}
999+
1000+
@Test
1001+
@DisplayName("Should prefer metadata thought signature over preceding thinking signature")
1002+
void testToolCallMetadataSignatureOverridesThinkingSignature() {
1003+
ThinkingBlock thinking =
1004+
ThinkingBlock.builder().metadata(Map.of("thoughtSignature", "sig_from_thinking")).build();
1005+
ToolUseBlock toolUse =
1006+
ToolUseBlock.builder()
1007+
.id("call_meta_sig")
1008+
.name("search")
1009+
.input(Map.of("q", "hello"))
1010+
.metadata(Map.of(ToolUseBlock.METADATA_THOUGHT_SIGNATURE, "sig_from_tool"))
1011+
.build();
1012+
1013+
Msg msg =
1014+
Msg.builder()
1015+
.name("assistant")
1016+
.role(MsgRole.ASSISTANT)
1017+
.content(List.of(thinking, toolUse))
1018+
.build();
1019+
1020+
List<GeminiContent> result = converter.convertMessages(List.of(msg));
1021+
1022+
GeminiPart part = result.get(0).getParts().get(0);
1023+
assertEquals("sig_from_tool", part.getThoughtSignature());
1024+
}
1025+
1026+
@Test
1027+
@DisplayName("Should convert byte array thought signature metadata to base64")
1028+
void testToolCallByteArrayThoughtSignature() {
1029+
byte[] signatureBytes = new byte[] {1, 2, 3, 4};
1030+
ToolUseBlock toolUse =
1031+
ToolUseBlock.builder()
1032+
.id("call_byte_sig")
1033+
.name("search")
1034+
.input(Map.of("q", "hello"))
1035+
.metadata(Map.of(ToolUseBlock.METADATA_THOUGHT_SIGNATURE, signatureBytes))
1036+
.build();
1037+
1038+
Msg msg =
1039+
Msg.builder()
1040+
.name("assistant")
1041+
.role(MsgRole.ASSISTANT)
1042+
.content(List.of(toolUse))
1043+
.build();
1044+
1045+
List<GeminiContent> result = converter.convertMessages(List.of(msg));
1046+
GeminiPart part = result.get(0).getParts().get(0);
1047+
1048+
assertEquals(Base64.getEncoder().encodeToString(signatureBytes), part.getThoughtSignature());
1049+
}
1050+
1051+
@Test
1052+
@DisplayName("Should not merge same-role contents when function call is present")
1053+
void testSameRoleNotMergedWhenFunctionExists() {
1054+
ToolUseBlock toolUse =
1055+
ToolUseBlock.builder()
1056+
.id("call_merge_guard")
1057+
.name("search")
1058+
.input(Map.of("q", "hello"))
1059+
.build();
1060+
1061+
Msg assistantToolMsg =
1062+
Msg.builder().name("assistant").role(MsgRole.ASSISTANT).content(List.of(toolUse)).build();
1063+
Msg assistantTextMsg =
1064+
Msg.builder()
1065+
.name("assistant")
1066+
.role(MsgRole.ASSISTANT)
1067+
.content(List.of(TextBlock.builder().text("after tool").build()))
1068+
.build();
1069+
1070+
List<GeminiContent> result = converter.convertMessages(List.of(assistantToolMsg, assistantTextMsg));
1071+
1072+
assertEquals(2, result.size());
1073+
assertFalse(result.get(0).getParts().isEmpty());
1074+
assertNotNull(result.get(0).getParts().get(0).getFunctionCall());
1075+
assertEquals("after tool", result.get(1).getParts().get(0).getText());
1076+
}
9441077
}

0 commit comments

Comments
 (0)