Skip to content

Commit 857ee63

Browse files
committed
feat: expose turnComplete for live content
1 parent dc56f11 commit 857ee63

9 files changed

Lines changed: 457 additions & 4 deletions

File tree

‎core/src/main/java/com/google/adk/agents/LiveRequest.java‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,16 @@ public abstract class LiveRequest extends JsonBaseModel {
4545
@JsonProperty("content")
4646
public abstract Optional<Content> content();
4747

48+
/**
49+
* Returns whether {@link #content()} is a partial update that leaves the current turn open.
50+
*
51+
* <p>Defaults to false, so content sends complete the turn unless marked as partial.
52+
*
53+
* @return An optional boolean indicating whether the content is a partial turn update.
54+
*/
55+
@JsonProperty("partial")
56+
public abstract Optional<Boolean> partial();
57+
4858
/**
4959
* Returns the blob of the request.
5060
*
@@ -83,6 +93,9 @@ static LiveRequest.Builder jacksonBuilder() {
8393
@JsonProperty("content")
8494
public abstract Builder content(@Nullable Content content);
8595

96+
@JsonProperty("partial")
97+
public abstract Builder partial(@Nullable Boolean partial);
98+
8699
@JsonProperty("blob")
87100
public abstract Builder blob(@Nullable Blob blob);
88101

@@ -103,7 +116,7 @@ public final LiveRequest build() {
103116
}
104117

105118
public static Builder builder() {
106-
return new AutoValue_LiveRequest.Builder().close(false);
119+
return new AutoValue_LiveRequest.Builder().close(false).partial(false);
107120
}
108121

109122
public abstract Builder toBuilder();

‎core/src/main/java/com/google/adk/agents/LiveRequestQueue.java‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,17 @@ public void close() {
3838
}
3939

4040
public void content(Content content) {
41-
processor.onNext(LiveRequest.builder().content(content).build());
41+
content(content, false);
42+
}
43+
44+
/**
45+
* Sends content to the model, optionally as a partial update that leaves the current turn open.
46+
*
47+
* @param content the content to send
48+
* @param partial whether this content is a partial update that does not complete the current turn
49+
*/
50+
public void content(Content content, boolean partial) {
51+
processor.onNext(LiveRequest.builder().content(content).partial(partial).build());
4252
}
4353

4454
public void realtime(Blob blob) {

‎core/src/main/java/com/google/adk/flows/llmflows/BaseLlmFlow.java‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -657,7 +657,8 @@ public Flowable<Event> runLive(InvocationContext invocationContext) {
657657
.concatMapCompletable(
658658
request -> {
659659
if (request.content().isPresent()) {
660-
return connection.sendContent(request.content().get());
660+
return connection.sendContent(
661+
request.content().get(), request.partial().orElse(false));
661662
} else if (request.blob().isPresent()) {
662663
return connection.sendRealtime(request.blob().get());
663664
}

‎core/src/main/java/com/google/adk/models/BaseLlmConnection.java‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,19 @@ public interface BaseLlmConnection {
4141
*/
4242
Completable sendContent(Content content);
4343

44+
/**
45+
* Sends user content to the model, optionally leaving the current turn open for more content.
46+
*
47+
* <p>Implementations that do not support incomplete turns retain the existing behavior. Function
48+
* responses use the existing tool-response path, regardless of {@code partial}.
49+
*
50+
* @param content the content to send
51+
* @param partial whether this content is a partial update that does not complete the current turn
52+
*/
53+
default Completable sendContent(Content content, boolean partial) {
54+
return sendContent(content);
55+
}
56+
4457
/**
4558
* Sends a chunk of audio or a frame of video to the model in realtime.
4659
*

‎core/src/main/java/com/google/adk/models/GeminiLlmConnection.java‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,14 +249,19 @@ public Completable sendHistory(List<Content> history) {
249249

250250
@Override
251251
public Completable sendContent(Content content) {
252+
return sendContent(content, false);
253+
}
254+
255+
@Override
256+
public Completable sendContent(Content content, boolean partial) {
252257
Objects.requireNonNull(content, "content cannot be null");
253258

254259
List<FunctionResponse> functionResponses = extractFunctionResponses(content);
255260
if (functionResponses.isEmpty()) {
256261
return sendClientContentInternal(
257262
LiveSendClientContentParameters.builder()
258263
.turns(ImmutableList.of(content))
259-
.turnComplete(true)
264+
.turnComplete(!partial)
260265
.build());
261266
}
262267
return sendToolResponseInternal(
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/*
2+
* Copyright 2026 Google LLC
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+
17+
package com.google.adk.agents;
18+
19+
import static com.google.common.truth.Truth.assertThat;
20+
21+
import com.fasterxml.jackson.databind.JsonNode;
22+
import com.google.adk.JsonBaseModel;
23+
import com.google.genai.types.Content;
24+
import com.google.genai.types.Part;
25+
import io.reactivex.rxjava3.subscribers.TestSubscriber;
26+
import org.junit.Test;
27+
import org.junit.runner.RunWith;
28+
import org.junit.runners.JUnit4;
29+
30+
@RunWith(JUnit4.class)
31+
public final class LiveRequestQueueTest {
32+
33+
private static final Content CONTENT = Content.fromParts(Part.fromText("context"));
34+
35+
@Test
36+
public void content_defaultsToCompletingTurn() {
37+
LiveRequestQueue queue = new LiveRequestQueue();
38+
TestSubscriber<LiveRequest> subscriber = queue.get().test();
39+
40+
queue.content(CONTENT);
41+
42+
subscriber.assertValueCount(1);
43+
LiveRequest request = subscriber.values().get(0);
44+
assertThat(request.content()).hasValue(CONTENT);
45+
assertThat(request.partial()).hasValue(false);
46+
}
47+
48+
@Test
49+
public void content_withPartialTrue_preservesValue() {
50+
LiveRequestQueue queue = new LiveRequestQueue();
51+
TestSubscriber<LiveRequest> subscriber = queue.get().test();
52+
53+
queue.content(CONTENT, true);
54+
55+
subscriber.assertValueCount(1);
56+
LiveRequest request = subscriber.values().get(0);
57+
assertThat(request.content()).hasValue(CONTENT);
58+
assertThat(request.partial()).hasValue(true);
59+
}
60+
61+
@Test
62+
public void content_withPartialFalse_preservesValue() {
63+
LiveRequestQueue queue = new LiveRequestQueue();
64+
TestSubscriber<LiveRequest> subscriber = queue.get().test();
65+
66+
queue.content(CONTENT, false);
67+
68+
subscriber.assertValueCount(1);
69+
LiveRequest request = subscriber.values().get(0);
70+
assertThat(request.content()).hasValue(CONTENT);
71+
assertThat(request.partial()).hasValue(false);
72+
}
73+
74+
@Test
75+
public void liveRequest_builderDefaultsToNonPartial() {
76+
LiveRequest request = LiveRequest.builder().content(CONTENT).build();
77+
78+
assertThat(request.partial()).hasValue(false);
79+
}
80+
81+
@Test
82+
public void liveRequest_jsonRoundTrip_preservesPartialTrue() {
83+
LiveRequest request = LiveRequest.builder().content(CONTENT).partial(true).build();
84+
85+
JsonNode json = JsonBaseModel.getMapper().valueToTree(request);
86+
LiveRequest restored = LiveRequest.fromJsonString(request.toJson());
87+
88+
assertThat(json.get("partial").booleanValue()).isTrue();
89+
assertThat(json.has("turnComplete")).isFalse();
90+
assertThat(restored.content()).hasValue(CONTENT);
91+
assertThat(restored.partial()).hasValue(true);
92+
}
93+
94+
@Test
95+
public void liveRequest_jsonRoundTrip_preservesPartialFalse() {
96+
LiveRequest request = LiveRequest.builder().content(CONTENT).partial(false).build();
97+
98+
JsonNode json = JsonBaseModel.getMapper().valueToTree(request);
99+
LiveRequest restored = LiveRequest.fromJsonString(request.toJson());
100+
101+
assertThat(json.get("partial").booleanValue()).isFalse();
102+
assertThat(json.has("turnComplete")).isFalse();
103+
assertThat(restored.content()).hasValue(CONTENT);
104+
assertThat(restored.partial()).hasValue(false);
105+
}
106+
107+
@Test
108+
public void liveRequest_jsonWithoutPartial_defaultsToNonPartial() {
109+
LiveRequest request =
110+
LiveRequest.fromJsonString(
111+
"{\"content\":{\"role\":\"user\",\"parts\":[{\"text\":\"context\"}]}}");
112+
113+
assertThat(request.content()).hasValue(CONTENT);
114+
assertThat(request.partial()).hasValue(false);
115+
}
116+
117+
@Test
118+
public void liveRequest_toBuilder_preservesPartial() {
119+
LiveRequest request = LiveRequest.builder().content(CONTENT).partial(true).build();
120+
121+
assertThat(request.toBuilder().build().partial()).hasValue(true);
122+
}
123+
}

‎core/src/test/java/com/google/adk/flows/llmflows/BaseLlmFlowTest.java‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,24 @@
2626
import static com.google.common.collect.Iterables.getOnlyElement;
2727
import static com.google.common.truth.Truth.assertThat;
2828
import static org.junit.Assert.assertThrows;
29+
import static org.mockito.ArgumentMatchers.any;
30+
import static org.mockito.ArgumentMatchers.anyBoolean;
31+
import static org.mockito.Mockito.inOrder;
32+
import static org.mockito.Mockito.mock;
33+
import static org.mockito.Mockito.when;
2934

3035
import com.google.adk.agents.Callbacks;
3136
import com.google.adk.agents.InvocationContext;
37+
import com.google.adk.agents.LiveRequest;
38+
import com.google.adk.agents.LiveRequestQueue;
3239
import com.google.adk.agents.LlmAgent;
3340
import com.google.adk.agents.ReadonlyContext;
3441
import com.google.adk.agents.RunConfig;
3542
import com.google.adk.events.Event;
3643
import com.google.adk.flows.llmflows.RequestProcessor.RequestProcessingResult;
3744
import com.google.adk.flows.llmflows.ResponseProcessor.ResponseProcessingResult;
45+
import com.google.adk.models.BaseLlm;
46+
import com.google.adk.models.BaseLlmConnection;
3847
import com.google.adk.models.CacheMetadata;
3948
import com.google.adk.models.LlmCallsLimitExceededException;
4049
import com.google.adk.models.LlmRequest;
@@ -76,11 +85,54 @@
7685
import org.junit.Test;
7786
import org.junit.runner.RunWith;
7887
import org.junit.runners.JUnit4;
88+
import org.mockito.InOrder;
7989

8090
/** Unit tests for {@link BaseLlmFlow}. */
8191
@RunWith(JUnit4.class)
8292
public final class BaseLlmFlowTest {
8393

94+
@Test
95+
public void runLive_contentForwardsPartialFlagsInOrder() {
96+
Content firstContent = Content.fromParts(Part.fromText("first context"));
97+
Content secondContent = Content.fromParts(Part.fromText("second context"));
98+
Content finalContent = Content.fromParts(Part.fromText("final context"));
99+
Content defaultContent = Content.fromParts(Part.fromText("complete input"));
100+
Content unsetContent = Content.fromParts(Part.fromText("unset partial"));
101+
BaseLlm llm = mock(BaseLlm.class);
102+
BaseLlmConnection connection = mock(BaseLlmConnection.class);
103+
when(llm.model()).thenReturn("test-model");
104+
when(llm.connect(any())).thenReturn(connection);
105+
when(connection.sendContent(any(), anyBoolean())).thenReturn(Completable.complete());
106+
when(connection.receive()).thenReturn(Flowable.never());
107+
LiveRequestQueue queue = new LiveRequestQueue();
108+
InvocationContext context =
109+
createInvocationContext(createTestAgentBuilder(llm).executor(Runnable::run).build())
110+
.toBuilder()
111+
.liveRequestQueue(queue)
112+
.build();
113+
TestSubscriber<Event> subscriber = createBaseLlmFlowWithoutProcessors().runLive(context).test();
114+
115+
try {
116+
subscriber.assertNoErrors();
117+
queue.content(firstContent, true);
118+
queue.content(secondContent, true);
119+
queue.content(finalContent, false);
120+
queue.content(defaultContent);
121+
queue.send(LiveRequest.builder().content(unsetContent).partial(null).build());
122+
123+
InOrder order = inOrder(connection);
124+
order.verify(connection).sendContent(firstContent, true);
125+
order.verify(connection).sendContent(secondContent, true);
126+
order.verify(connection).sendContent(finalContent, false);
127+
order.verify(connection).sendContent(defaultContent, false);
128+
order.verify(connection).sendContent(unsetContent, false);
129+
subscriber.assertNoErrors();
130+
} finally {
131+
queue.close();
132+
subscriber.cancel();
133+
}
134+
}
135+
84136
@Test
85137
public void run_singleTextResponse_returnsSingleEvent() {
86138
Content content = Content.fromParts(Part.fromText("LLM response"));
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*
2+
* Copyright 2026 Google LLC
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+
17+
package com.google.adk.models;
18+
19+
import static com.google.common.truth.Truth.assertThat;
20+
21+
import com.google.genai.types.Blob;
22+
import com.google.genai.types.Content;
23+
import com.google.genai.types.Part;
24+
import io.reactivex.rxjava3.core.Completable;
25+
import io.reactivex.rxjava3.core.Flowable;
26+
import java.util.ArrayList;
27+
import java.util.List;
28+
import org.junit.Test;
29+
import org.junit.runner.RunWith;
30+
import org.junit.runners.JUnit4;
31+
32+
@RunWith(JUnit4.class)
33+
public final class BaseLlmConnectionTest {
34+
35+
@Test
36+
public void sendContent_withPartialFlag_delegatesToLegacyImplementation() {
37+
LegacyConnection connection = new LegacyConnection();
38+
Content content = Content.fromParts(Part.fromText("context"));
39+
40+
connection.sendContent(content, true).test().assertComplete().assertNoErrors();
41+
connection.sendContent(content, false).test().assertComplete().assertNoErrors();
42+
43+
assertThat(connection.sentContent).containsExactly(content, content).inOrder();
44+
}
45+
46+
/** Implements only the original connection API, not the new two-argument overload. */
47+
private static final class LegacyConnection implements BaseLlmConnection {
48+
final List<Content> sentContent = new ArrayList<>();
49+
50+
@Override
51+
public Completable sendHistory(List<Content> history) {
52+
return Completable.complete();
53+
}
54+
55+
@Override
56+
public Completable sendContent(Content content) {
57+
return Completable.fromAction(() -> sentContent.add(content));
58+
}
59+
60+
@Override
61+
public Completable sendRealtime(Blob blob) {
62+
return Completable.complete();
63+
}
64+
65+
@Override
66+
public Flowable<LlmResponse> receive() {
67+
return Flowable.empty();
68+
}
69+
70+
@Override
71+
public void close() {}
72+
73+
@Override
74+
public void close(Throwable throwable) {}
75+
}
76+
}

0 commit comments

Comments
 (0)