Skip to content

Commit 4e8ee71

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 4e8ee71

1 file changed

Lines changed: 204 additions & 0 deletions

File tree

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
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+
* The parallel function call case is the behaviour that must not regress: all calls of one batch have
54+
* to be replayed as the parts of a single model turn, followed by a single user turn holding all
55+
* function responses. Splitting them into separate turns makes Gemini 3.x reject the request with
56+
* {@code 400 INVALID_ARGUMENT} because only the first call carries a thought signature.
57+
*
58+
* @author Christian Heldt - Initial contribution
59+
*/
60+
@NonNullByDefault
61+
public class GeminiApiClientTest {
62+
63+
private static final String MODEL = "gemini-3.5-flash-lite";
64+
private static final String PROMPT = "Which lamps in the living room are on?";
65+
private static final String RESPONSE_JSON = """
66+
{"candidates":[{"content":{"role":"model","parts":[{"text":"Lamp1 is on."}]}}]}""";
67+
68+
private final ObjectMapper objectMapper = new ObjectMapper();
69+
70+
private @NonNullByDefault({}) HttpClient httpClient;
71+
private @NonNullByDefault({}) Request request;
72+
private @NonNullByDefault({}) ContentResponse response;
73+
private @NonNullByDefault({}) GeminiApiClient apiClient;
74+
75+
private static <T> T typedMock(Class<T> clazz) {
76+
return Objects.requireNonNull(mock(clazz));
77+
}
78+
79+
@BeforeEach
80+
public void setUp() throws Exception {
81+
httpClient = typedMock(HttpClient.class);
82+
request = typedMock(Request.class);
83+
response = typedMock(ContentResponse.class);
84+
85+
when(httpClient.newRequest(anyString())).thenReturn(request);
86+
when(request.method(any(HttpMethod.class))).thenReturn(request);
87+
when(request.timeout(anyLong(), any(TimeUnit.class))).thenReturn(request);
88+
when(request.header(any(HttpHeader.class), anyString())).thenReturn(request);
89+
when(request.header(anyString(), anyString())).thenReturn(request);
90+
when(request.content(any(ContentProvider.class))).thenReturn(request);
91+
when(request.send()).thenReturn(response);
92+
when(response.getStatus()).thenReturn(HttpStatus.OK_200);
93+
when(response.getContentAsString()).thenReturn(RESPONSE_JSON);
94+
95+
apiClient = new GeminiApiClient(httpClient, "test-api-key");
96+
}
97+
98+
@Test
99+
public void parallelToolCallsAreSerializedAsOneModelTurnAndOneUserTurn() throws Exception {
100+
// Gemini sends parallel calls as one model turn and puts the thought signature on the first part only
101+
List<GeminiLLMToolCall> calls = List.of(
102+
new GeminiLLMToolCall("item-get-state", Map.of("item", "Lamp1"), "call-1", "signature-abc"),
103+
new GeminiLLMToolCall("item-get-state", Map.of("item", "Lamp2"), "call-2", null));
104+
List<Conversation.Message> history = List.of(new Conversation.Message(1, ConversationRole.USER, PROMPT),
105+
new Conversation.Message(2, ConversationRole.TOOL_CALL, GeminiLLMToolCall.toJsonList(calls)),
106+
new Conversation.Message(3, ConversationRole.TOOL_RETURN,
107+
GeminiLLMToolCall.resultsToJson(List.of("Lamp1 is ON", "Lamp2 is OFF"))));
108+
109+
apiClient.sendPrompt(MODEL, history, List.of(), null, null, null, null, null);
110+
111+
JsonNode contents = captureRequestBody().get("contents");
112+
// one user turn, one model turn with both calls, one user turn with both responses - a split
113+
// into per-call turns would yield four contents here
114+
assertEquals(3, contents.size());
115+
116+
JsonNode userTurn = contents.get(0);
117+
assertEquals("user", userTurn.get("role").asText());
118+
assertEquals(PROMPT, userTurn.get("parts").get(0).get("text").asText());
119+
120+
JsonNode callTurn = contents.get(1);
121+
assertEquals("model", callTurn.get("role").asText());
122+
JsonNode callParts = callTurn.get("parts");
123+
assertEquals(2, callParts.size());
124+
125+
JsonNode firstCallPart = callParts.get(0);
126+
assertFalse(firstCallPart.has("text"));
127+
assertEquals("signature-abc", firstCallPart.get("thoughtSignature").asText());
128+
JsonNode firstCall = firstCallPart.get("functionCall");
129+
assertEquals("item-get-state", firstCall.get("name").asText());
130+
assertEquals("Lamp1", firstCall.get("args").get("item").asText());
131+
assertEquals("call-1", firstCall.get("id").asText());
132+
133+
JsonNode secondCallPart = callParts.get(1);
134+
assertFalse(secondCallPart.has("text"));
135+
// Gemini only signs the first part of a batch, so the second one must not carry a signature
136+
assertFalse(secondCallPart.has("thoughtSignature"));
137+
JsonNode secondCall = secondCallPart.get("functionCall");
138+
assertEquals("item-get-state", secondCall.get("name").asText());
139+
assertEquals("Lamp2", secondCall.get("args").get("item").asText());
140+
assertEquals("call-2", secondCall.get("id").asText());
141+
142+
JsonNode returnTurn = contents.get(2);
143+
assertEquals("user", returnTurn.get("role").asText());
144+
JsonNode returnParts = returnTurn.get("parts");
145+
assertEquals(2, returnParts.size());
146+
147+
// the results are paired with the calls by position, and the call id is echoed so that
148+
// equally named parallel calls can be told apart
149+
JsonNode firstResponse = returnParts.get(0).get("functionResponse");
150+
assertEquals("item-get-state", firstResponse.get("name").asText());
151+
assertEquals("call-1", firstResponse.get("id").asText());
152+
assertEquals("Lamp1 is ON", firstResponse.get("response").get("result").asText());
153+
154+
JsonNode secondResponse = returnParts.get(1).get("functionResponse");
155+
assertEquals("item-get-state", secondResponse.get("name").asText());
156+
assertEquals("call-2", secondResponse.get("id").asText());
157+
assertEquals("Lamp2 is OFF", secondResponse.get("response").get("result").asText());
158+
}
159+
160+
@Test
161+
public void singleToolCallUsesLegacyMessageFormatAndOmitsAbsentIds() throws Exception {
162+
// conversations stored by earlier versions hold a single call as a JSON object and its result as plain text
163+
GeminiLLMToolCall call = new GeminiLLMToolCall("item-get-state", Map.of("item", "Lamp1"), null, null);
164+
List<Conversation.Message> history = List.of(new Conversation.Message(1, ConversationRole.USER, PROMPT),
165+
new Conversation.Message(2, ConversationRole.TOOL_CALL, call.toJson()),
166+
new Conversation.Message(3, ConversationRole.TOOL_RETURN, "Lamp1 is ON"));
167+
168+
apiClient.sendPrompt(MODEL, history, List.of(), null, null, null, null, null);
169+
170+
JsonNode contents = captureRequestBody().get("contents");
171+
assertEquals(3, contents.size());
172+
173+
JsonNode callParts = contents.get(1).get("parts");
174+
assertEquals("model", contents.get(1).get("role").asText());
175+
assertEquals(1, callParts.size());
176+
assertFalse(callParts.get(0).has("thoughtSignature"));
177+
JsonNode functionCall = callParts.get(0).get("functionCall");
178+
assertEquals("item-get-state", functionCall.get("name").asText());
179+
assertEquals("Lamp1", functionCall.get("args").get("item").asText());
180+
// no id was stored, so none must be sent
181+
assertFalse(functionCall.has("id"));
182+
183+
JsonNode returnParts = contents.get(2).get("parts");
184+
assertEquals("user", contents.get(2).get("role").asText());
185+
assertEquals(1, returnParts.size());
186+
JsonNode functionResponse = returnParts.get(0).get("functionResponse");
187+
assertEquals("item-get-state", functionResponse.get("name").asText());
188+
assertEquals("Lamp1 is ON", functionResponse.get("response").get("result").asText());
189+
assertFalse(functionResponse.has("id"));
190+
}
191+
192+
private JsonNode captureRequestBody() throws Exception {
193+
ArgumentCaptor<ContentProvider> captor = ArgumentCaptor.forClass(ContentProvider.class);
194+
verify(request).content(captor.capture());
195+
196+
StringBuilder body = new StringBuilder();
197+
for (ByteBuffer buffer : Objects.requireNonNull(captor.getValue())) {
198+
body.append(StandardCharsets.UTF_8.decode(buffer));
199+
}
200+
JsonNode root = objectMapper.readTree(body.toString());
201+
assertTrue(root.has("contents"), "request payload has no contents");
202+
return root;
203+
}
204+
}

0 commit comments

Comments
 (0)