Skip to content

Commit c3863e0

Browse files
committed
[gemini] Add test for parallel function call request reconstruction
The batch JSON helpers were covered by unit tests, but the request shape that actually fixes the parallel call rejection was not. Add a test that drives sendPrompt() with a two-call batch history, captures the serialized request body and asserts that it contains one model content with both functionCall parts followed by one user content with both matching functionResponse parts, preserving the first call's thoughtSignature and both call ids. A second test covers the legacy single-call message format to make sure conversations stored by earlier versions still replay and that absent call ids are omitted from the request. Signed-off-by: Christian Heldt <snaut@tutanota.com>
1 parent e5d0bd9 commit c3863e0

1 file changed

Lines changed: 205 additions & 0 deletions

File tree

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
/*
2+
* Copyright (c) 2010-2026 Contributors to the openHAB project
3+
*
4+
* See the NOTICE file(s) distributed with this work for additional
5+
* information.
6+
*
7+
* This program and the accompanying materials are made available under the
8+
* terms of the Eclipse Public License 2.0 which is available at
9+
* http://www.eclipse.org/legal/epl-2.0
10+
*
11+
* SPDX-License-Identifier: EPL-2.0
12+
*/
13+
package org.openhab.binding.gemini.internal.api;
14+
15+
import static org.junit.jupiter.api.Assertions.assertEquals;
16+
import static org.junit.jupiter.api.Assertions.assertFalse;
17+
import static org.junit.jupiter.api.Assertions.assertTrue;
18+
import static org.mockito.ArgumentMatchers.any;
19+
import static org.mockito.ArgumentMatchers.anyLong;
20+
import static org.mockito.ArgumentMatchers.anyString;
21+
import static org.mockito.Mockito.mock;
22+
import static org.mockito.Mockito.verify;
23+
import static org.mockito.Mockito.when;
24+
25+
import java.nio.ByteBuffer;
26+
import java.nio.charset.StandardCharsets;
27+
import java.util.List;
28+
import java.util.Map;
29+
import java.util.Objects;
30+
import java.util.concurrent.TimeUnit;
31+
32+
import org.eclipse.jdt.annotation.NonNullByDefault;
33+
import org.eclipse.jetty.client.HttpClient;
34+
import org.eclipse.jetty.client.api.ContentProvider;
35+
import org.eclipse.jetty.client.api.ContentResponse;
36+
import org.eclipse.jetty.client.api.Request;
37+
import org.eclipse.jetty.http.HttpHeader;
38+
import org.eclipse.jetty.http.HttpMethod;
39+
import org.eclipse.jetty.http.HttpStatus;
40+
import org.junit.jupiter.api.BeforeEach;
41+
import org.junit.jupiter.api.Test;
42+
import org.mockito.ArgumentCaptor;
43+
import org.openhab.core.voice.text.conversation.Conversation;
44+
import org.openhab.core.voice.text.conversation.ConversationRole;
45+
46+
import com.fasterxml.jackson.databind.JsonNode;
47+
import com.fasterxml.jackson.databind.ObjectMapper;
48+
49+
/**
50+
* Tests for the request reconstruction of {@link GeminiApiClient}, i.e. how a stored conversation
51+
* history is turned back into the {@code contents} of a Gemini {@code generateContent} request.
52+
*
53+
* <p>
54+
* The parallel function call case is the behaviour that must not regress: all calls of one batch have
55+
* to be replayed as the parts of a single model turn, followed by a single user turn holding all
56+
* function responses. Splitting them into separate turns makes Gemini 3.x reject the request with
57+
* {@code 400 INVALID_ARGUMENT} because only the first call carries a thought signature.
58+
*
59+
* @author Christian Heldt - Initial contribution
60+
*/
61+
@NonNullByDefault
62+
public class GeminiApiClientTest {
63+
64+
private static final String MODEL = "gemini-3.5-flash-lite";
65+
private static final String PROMPT = "Which lamps in the living room are on?";
66+
private static final String RESPONSE_JSON = """
67+
{"candidates":[{"content":{"role":"model","parts":[{"text":"Lamp1 is on."}]}}]}""";
68+
69+
private final ObjectMapper objectMapper = new ObjectMapper();
70+
71+
private @NonNullByDefault({}) HttpClient httpClient;
72+
private @NonNullByDefault({}) Request request;
73+
private @NonNullByDefault({}) ContentResponse response;
74+
private @NonNullByDefault({}) GeminiApiClient apiClient;
75+
76+
private static <T> T typedMock(Class<T> clazz) {
77+
return Objects.requireNonNull(mock(clazz));
78+
}
79+
80+
@BeforeEach
81+
public void setUp() throws Exception {
82+
httpClient = typedMock(HttpClient.class);
83+
request = typedMock(Request.class);
84+
response = typedMock(ContentResponse.class);
85+
86+
when(httpClient.newRequest(anyString())).thenReturn(request);
87+
when(request.method(any(HttpMethod.class))).thenReturn(request);
88+
when(request.timeout(anyLong(), any(TimeUnit.class))).thenReturn(request);
89+
when(request.header(any(HttpHeader.class), anyString())).thenReturn(request);
90+
when(request.header(anyString(), anyString())).thenReturn(request);
91+
when(request.content(any(ContentProvider.class))).thenReturn(request);
92+
when(request.send()).thenReturn(response);
93+
when(response.getStatus()).thenReturn(HttpStatus.OK_200);
94+
when(response.getContentAsString()).thenReturn(RESPONSE_JSON);
95+
96+
apiClient = new GeminiApiClient(httpClient, "test-api-key");
97+
}
98+
99+
@Test
100+
public void parallelToolCallsAreSerializedAsOneModelTurnAndOneUserTurn() throws Exception {
101+
// Gemini sends parallel calls as one model turn and puts the thought signature on the first part only
102+
List<GeminiLLMToolCall> calls = List.of(
103+
new GeminiLLMToolCall("item-get-state", Map.of("item", "Lamp1"), "call-1", "signature-abc"),
104+
new GeminiLLMToolCall("item-get-state", Map.of("item", "Lamp2"), "call-2", null));
105+
List<Conversation.Message> history = List.of(new Conversation.Message(1, ConversationRole.USER, PROMPT),
106+
new Conversation.Message(2, ConversationRole.TOOL_CALL, GeminiLLMToolCall.toJsonList(calls)),
107+
new Conversation.Message(3, ConversationRole.TOOL_RETURN,
108+
GeminiLLMToolCall.resultsToJson(List.of("Lamp1 is ON", "Lamp2 is OFF"))));
109+
110+
apiClient.sendPrompt(MODEL, history, List.of(), null, null, null, null, null);
111+
112+
JsonNode contents = captureRequestBody().get("contents");
113+
// one user turn, one model turn with both calls, one user turn with both responses - a split
114+
// into per-call turns would yield four contents here
115+
assertEquals(3, contents.size());
116+
117+
JsonNode userTurn = contents.get(0);
118+
assertEquals("user", userTurn.get("role").asText());
119+
assertEquals(PROMPT, userTurn.get("parts").get(0).get("text").asText());
120+
121+
JsonNode callTurn = contents.get(1);
122+
assertEquals("model", callTurn.get("role").asText());
123+
JsonNode callParts = callTurn.get("parts");
124+
assertEquals(2, callParts.size());
125+
126+
JsonNode firstCallPart = callParts.get(0);
127+
assertFalse(firstCallPart.has("text"));
128+
assertEquals("signature-abc", firstCallPart.get("thoughtSignature").asText());
129+
JsonNode firstCall = firstCallPart.get("functionCall");
130+
assertEquals("item-get-state", firstCall.get("name").asText());
131+
assertEquals("Lamp1", firstCall.get("args").get("item").asText());
132+
assertEquals("call-1", firstCall.get("id").asText());
133+
134+
JsonNode secondCallPart = callParts.get(1);
135+
assertFalse(secondCallPart.has("text"));
136+
// Gemini only signs the first part of a batch, so the second one must not carry a signature
137+
assertFalse(secondCallPart.has("thoughtSignature"));
138+
JsonNode secondCall = secondCallPart.get("functionCall");
139+
assertEquals("item-get-state", secondCall.get("name").asText());
140+
assertEquals("Lamp2", secondCall.get("args").get("item").asText());
141+
assertEquals("call-2", secondCall.get("id").asText());
142+
143+
JsonNode returnTurn = contents.get(2);
144+
assertEquals("user", returnTurn.get("role").asText());
145+
JsonNode returnParts = returnTurn.get("parts");
146+
assertEquals(2, returnParts.size());
147+
148+
// the results are paired with the calls by position, and the call id is echoed so that
149+
// equally named parallel calls can be told apart
150+
JsonNode firstResponse = returnParts.get(0).get("functionResponse");
151+
assertEquals("item-get-state", firstResponse.get("name").asText());
152+
assertEquals("call-1", firstResponse.get("id").asText());
153+
assertEquals("Lamp1 is ON", firstResponse.get("response").get("result").asText());
154+
155+
JsonNode secondResponse = returnParts.get(1).get("functionResponse");
156+
assertEquals("item-get-state", secondResponse.get("name").asText());
157+
assertEquals("call-2", secondResponse.get("id").asText());
158+
assertEquals("Lamp2 is OFF", secondResponse.get("response").get("result").asText());
159+
}
160+
161+
@Test
162+
public void singleToolCallUsesLegacyMessageFormatAndOmitsAbsentIds() throws Exception {
163+
// conversations stored by earlier versions hold a single call as a JSON object and its result as plain text
164+
GeminiLLMToolCall call = new GeminiLLMToolCall("item-get-state", Map.of("item", "Lamp1"), null, null);
165+
List<Conversation.Message> history = List.of(new Conversation.Message(1, ConversationRole.USER, PROMPT),
166+
new Conversation.Message(2, ConversationRole.TOOL_CALL, call.toJson()),
167+
new Conversation.Message(3, ConversationRole.TOOL_RETURN, "Lamp1 is ON"));
168+
169+
apiClient.sendPrompt(MODEL, history, List.of(), null, null, null, null, null);
170+
171+
JsonNode contents = captureRequestBody().get("contents");
172+
assertEquals(3, contents.size());
173+
174+
JsonNode callParts = contents.get(1).get("parts");
175+
assertEquals("model", contents.get(1).get("role").asText());
176+
assertEquals(1, callParts.size());
177+
assertFalse(callParts.get(0).has("thoughtSignature"));
178+
JsonNode functionCall = callParts.get(0).get("functionCall");
179+
assertEquals("item-get-state", functionCall.get("name").asText());
180+
assertEquals("Lamp1", functionCall.get("args").get("item").asText());
181+
// no id was stored, so none must be sent
182+
assertFalse(functionCall.has("id"));
183+
184+
JsonNode returnParts = contents.get(2).get("parts");
185+
assertEquals("user", contents.get(2).get("role").asText());
186+
assertEquals(1, returnParts.size());
187+
JsonNode functionResponse = returnParts.get(0).get("functionResponse");
188+
assertEquals("item-get-state", functionResponse.get("name").asText());
189+
assertEquals("Lamp1 is ON", functionResponse.get("response").get("result").asText());
190+
assertFalse(functionResponse.has("id"));
191+
}
192+
193+
private JsonNode captureRequestBody() throws Exception {
194+
ArgumentCaptor<ContentProvider> captor = ArgumentCaptor.forClass(ContentProvider.class);
195+
verify(request).content(captor.capture());
196+
197+
StringBuilder body = new StringBuilder();
198+
for (ByteBuffer buffer : Objects.requireNonNull(captor.getValue())) {
199+
body.append(StandardCharsets.UTF_8.decode(buffer));
200+
}
201+
JsonNode root = objectMapper.readTree(body.toString());
202+
assertTrue(root.has("contents"), "request payload has no contents");
203+
return root;
204+
}
205+
}

0 commit comments

Comments
 (0)