Skip to content

Commit a9c5f61

Browse files
damianmomotgooglecopybara-github
authored andcommitted
test: enhance regression checks for streaming FCs - tools are executed only once
PiperOrigin-RevId: 946633583
1 parent 760c8da commit a9c5f61

1 file changed

Lines changed: 134 additions & 0 deletions

File tree

core/src/test/java/com/google/adk/runner/RunnerTest.java

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,15 @@
6868
import com.google.adk.testing.TestUtils;
6969
import com.google.adk.testing.TestUtils.EchoTool;
7070
import com.google.adk.testing.TestUtils.FailingEchoTool;
71+
import com.google.adk.tools.BaseTool;
7172
import com.google.adk.tools.FunctionTool;
73+
import com.google.adk.tools.ToolContext;
7274
import com.google.common.collect.ImmutableList;
7375
import com.google.common.collect.ImmutableMap;
7476
import com.google.common.collect.Iterables;
7577
import com.google.genai.types.Content;
7678
import com.google.genai.types.FunctionCall;
79+
import com.google.genai.types.FunctionDeclaration;
7780
import com.google.genai.types.FunctionResponse;
7881
import com.google.genai.types.Part;
7982
import io.opentelemetry.api.trace.Tracer;
@@ -92,6 +95,7 @@
9295
import java.util.ArrayList;
9396
import java.util.Collections;
9497
import java.util.List;
98+
import java.util.Map;
9599
import java.util.Objects;
96100
import java.util.Optional;
97101
import java.util.UUID;
@@ -572,6 +576,136 @@ public void onToolErrorCallback_success() {
572576
verify(plugin).onToolErrorCallback(any(), any(), any(), any());
573577
}
574578

579+
/**
580+
* Reproduces the real Vertex streaming (SSE) behavior for parallel function calls: the model
581+
* emits one partial event per tool as each call streams in, then a single final aggregated event
582+
* that carries all of the calls (reusing the same function-call IDs). Each tool must execute
583+
* exactly once -- the partial events are surfaced to consumers but skipped for execution (see the
584+
* {@code partial()} guard in {@code BaseLlmFlow}).
585+
*/
586+
@Test
587+
public void runAsync_streamingPartialParallelFunctionCalls_executesEachToolExactlyOnce() {
588+
Part temperaturePart =
589+
Part.builder()
590+
.functionCall(
591+
FunctionCall.builder()
592+
.id("adk-temperature-id")
593+
.name("getTemperature")
594+
.args(ImmutableMap.of("city", "London"))
595+
.build())
596+
.build();
597+
Part conditionPart =
598+
Part.builder()
599+
.functionCall(
600+
FunctionCall.builder()
601+
.id("adk-condition-id")
602+
.name("getCondition")
603+
.args(ImmutableMap.of("city", "London"))
604+
.build())
605+
.build();
606+
607+
// Turn 1 mirrors the real Vertex stream: a partial event for getTemperature, a partial event
608+
// for getCondition, then one aggregated (non-partial) event carrying both calls. Turn 2 is the
609+
// final text produced after both tools run.
610+
LlmResponse partialTemperature =
611+
LlmResponse.builder()
612+
.content(Content.builder().role("model").parts(temperaturePart).build())
613+
.partial(true)
614+
.build();
615+
LlmResponse partialCondition =
616+
LlmResponse.builder()
617+
.content(Content.builder().role("model").parts(conditionPart).build())
618+
.partial(true)
619+
.build();
620+
LlmResponse aggregated =
621+
LlmResponse.builder()
622+
.content(Content.builder().role("model").parts(temperaturePart, conditionPart).build())
623+
.partial(false)
624+
.build();
625+
TestLlm streamingTestLlm =
626+
createTestLlm(
627+
Flowable.just(partialTemperature, partialCondition, aggregated),
628+
Flowable.just(createTextLlmResponse("done")));
629+
630+
CountingTool temperatureTool = new CountingTool("getTemperature");
631+
CountingTool conditionTool = new CountingTool("getCondition");
632+
LlmAgent agent =
633+
createTestAgentBuilder(streamingTestLlm)
634+
.tools(ImmutableList.of(temperatureTool, conditionTool))
635+
.build();
636+
Runner runner =
637+
Runner.builder().app(App.builder().name("test").rootAgent(agent).build()).build();
638+
Session session = runner.sessionService().createSession("test", "user").blockingGet();
639+
640+
List<Event> events =
641+
runner
642+
.runAsync(
643+
"user",
644+
session.id(),
645+
createContent("weather in London?"),
646+
RunConfig.builder().setStreamingMode(RunConfig.StreamingMode.SSE).build())
647+
.toList()
648+
.blockingGet();
649+
650+
long partialFunctionCallEvents =
651+
events.stream()
652+
.filter(e -> !e.functionCalls().isEmpty() && e.partial().orElse(false))
653+
.count();
654+
long partialFunctionCalls =
655+
events.stream()
656+
.filter(e -> e.partial().orElse(false))
657+
.mapToLong(e -> e.functionCalls().size())
658+
.sum();
659+
long aggregatedFunctionCallEvents =
660+
events.stream()
661+
.filter(e -> !e.functionCalls().isEmpty() && !e.partial().orElse(false))
662+
.count();
663+
Event aggregatedEvent =
664+
events.stream()
665+
.filter(e -> !e.functionCalls().isEmpty() && !e.partial().orElse(false))
666+
.findFirst()
667+
.orElseThrow();
668+
List<String> aggregatedCallIds = new ArrayList<>();
669+
for (FunctionCall fc : aggregatedEvent.functionCalls()) {
670+
aggregatedCallIds.add(fc.id().orElseThrow());
671+
}
672+
long functionResponses = events.stream().mapToLong(e -> e.functionResponses().size()).sum();
673+
674+
// Two partial function-call events (one per tool) are surfaced to consumers ...
675+
assertThat(partialFunctionCallEvents).isEqualTo(2);
676+
assertThat(partialFunctionCalls).isEqualTo(2);
677+
// ... followed by exactly one final aggregated event that carries BOTH calls ...
678+
assertThat(aggregatedFunctionCallEvents).isEqualTo(1);
679+
assertThat(aggregatedEvent.functionCalls()).hasSize(2);
680+
// ... whose function-call IDs match the ones streamed in the partial events ...
681+
assertThat(aggregatedCallIds).containsExactly("adk-temperature-id", "adk-condition-id");
682+
// ... but each tool is executed exactly once (partial events are skipped) ...
683+
assertThat(temperatureTool.callCount.get()).isEqualTo(1);
684+
assertThat(conditionTool.callCount.get()).isEqualTo(1);
685+
// ... producing exactly two function responses (one per call).
686+
assertThat(functionResponses).isEqualTo(2);
687+
}
688+
689+
/** A tool that records how many times it is actually executed. */
690+
private static final class CountingTool extends BaseTool {
691+
final AtomicInteger callCount = new AtomicInteger(0);
692+
693+
CountingTool(String name) {
694+
super(name, "counts invocations");
695+
}
696+
697+
@Override
698+
public Optional<FunctionDeclaration> declaration() {
699+
return Optional.of(FunctionDeclaration.builder().name(name()).build());
700+
}
701+
702+
@Override
703+
public Single<Map<String, Object>> runAsync(Map<String, Object> args, ToolContext toolContext) {
704+
callCount.incrementAndGet();
705+
return Single.just(ImmutableMap.of("forecast", "sunny"));
706+
}
707+
}
708+
575709
@Test
576710
public void onToolErrorCallback_error() {
577711
when(plugin.onToolErrorCallback(any(), any(), any(), any())).thenReturn(Maybe.empty());

0 commit comments

Comments
 (0)