From 9c238566dc633be70a2b8d0f01ae626f49c5a7bf Mon Sep 17 00:00:00 2001 From: Damian Osadczuk Date: Wed, 28 Oct 2026 08:01:53 -0700 Subject: [PATCH] feat: serve an app's structure, including workflow graphs PiperOrigin-RevId: 1008912733 --- .../java/com/google/adk/web/AdkWebServer.java | 20 +- .../java/com/google/adk/web/AppLoader.java | 44 +++ .../com/google/adk/web/InMemoryAppLoader.java | 55 +++ .../adk/web/controller/AgentController.java | 42 ++- .../google/adk/web/controller/AppGraph.java | 190 ++++++++++ .../web/controller/AppGraphController.java | 59 ++++ .../adk/web/controller/GraphController.java | 50 ++- .../google/adk/web/service/RunnerService.java | 159 +++++++-- .../google/adk/web/service/ServedApps.java | 153 +++++++++ .../adk/web/AdkWebServerAppLoaderTest.java | 105 ++++++ .../adk/web/AdkWebServerAppRunTest.java | 228 ++++++++++++ .../google/adk/web/InMemoryAppLoaderTest.java | 60 ++++ .../web/controller/AgentControllerTest.java | 47 +++ .../controller/AppGraphControllerTest.java | 104 ++++++ .../adk/web/controller/AppGraphTest.java | 232 +++++++++++++ .../web/controller/GraphControllerTest.java | 87 +++++ .../adk/web/service/RunnerServiceTest.java | 324 ++++++++++++++++++ .../google/adk/web/testing/GraphFixtures.java | 87 +++++ .../com/google/adk/web/testing/StepNode.java | 35 ++ 19 files changed, 2024 insertions(+), 57 deletions(-) create mode 100644 dev/src/main/java/com/google/adk/web/AppLoader.java create mode 100644 dev/src/main/java/com/google/adk/web/InMemoryAppLoader.java create mode 100644 dev/src/main/java/com/google/adk/web/controller/AppGraph.java create mode 100644 dev/src/main/java/com/google/adk/web/controller/AppGraphController.java create mode 100644 dev/src/main/java/com/google/adk/web/service/ServedApps.java create mode 100644 dev/src/test/java/com/google/adk/web/AdkWebServerAppLoaderTest.java create mode 100644 dev/src/test/java/com/google/adk/web/AdkWebServerAppRunTest.java create mode 100644 dev/src/test/java/com/google/adk/web/InMemoryAppLoaderTest.java create mode 100644 dev/src/test/java/com/google/adk/web/controller/AgentControllerTest.java create mode 100644 dev/src/test/java/com/google/adk/web/controller/AppGraphControllerTest.java create mode 100644 dev/src/test/java/com/google/adk/web/controller/AppGraphTest.java create mode 100644 dev/src/test/java/com/google/adk/web/controller/GraphControllerTest.java create mode 100644 dev/src/test/java/com/google/adk/web/service/RunnerServiceTest.java create mode 100644 dev/src/test/java/com/google/adk/web/testing/GraphFixtures.java create mode 100644 dev/src/test/java/com/google/adk/web/testing/StepNode.java diff --git a/dev/src/main/java/com/google/adk/web/AdkWebServer.java b/dev/src/main/java/com/google/adk/web/AdkWebServer.java index ac1879a10..c69734d3f 100644 --- a/dev/src/main/java/com/google/adk/web/AdkWebServer.java +++ b/dev/src/main/java/com/google/adk/web/AdkWebServer.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.google.adk.JsonBaseModel; import com.google.adk.agents.BaseAgent; +import com.google.adk.apps.App; import com.google.adk.artifacts.BaseArtifactService; import com.google.adk.artifacts.InMemoryArtifactService; import com.google.adk.memory.BaseMemoryService; @@ -27,6 +28,7 @@ import com.google.adk.sessions.InMemorySessionService; import com.google.adk.web.config.BackendUrl; import com.google.adk.web.config.DevUiAssets; +import com.google.common.collect.ObjectArrays; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -172,6 +174,20 @@ public static void main(String[] args) { // TODO: return a Closeable that stops the server and resets the static state. public static void start(BaseAgent... agents) { + start("agentLoader", new AgentStaticLoader(agents)); + } + + /** + * Starts the server with {@code app} and {@code moreApps}, each served under its name. An app can + * run an agent or a workflow. + * + * @throws IllegalArgumentException if two apps share a name + */ + public static void start(App app, App... moreApps) { + start("appLoader", new InMemoryAppLoader(ObjectArrays.concat(app, moreApps))); + } + + private static void start(String loaderBeanName, Object loader) { // Disable CompiledAgentLoader by setting property to prevent its creation System.setProperty("adk.agents.loader", "static"); // Increase the default websocket buffer size to 10MB to accommodate live API messages. @@ -184,10 +200,10 @@ public static void start(BaseAgent... agents) { new ApplicationContextInitializer() { @Override public void initialize(ConfigurableApplicationContext context) { - // Register the AgentStaticLoader bean before context refresh + // Register the loader bean before context refresh DefaultListableBeanFactory beanFactory = (DefaultListableBeanFactory) context.getBeanFactory(); - beanFactory.registerSingleton("agentLoader", new AgentStaticLoader(agents)); + beanFactory.registerSingleton(loaderBeanName, loader); } }); diff --git a/dev/src/main/java/com/google/adk/web/AppLoader.java b/dev/src/main/java/com/google/adk/web/AppLoader.java new file mode 100644 index 000000000..2f11510d8 --- /dev/null +++ b/dev/src/main/java/com/google/adk/web/AppLoader.java @@ -0,0 +1,44 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.web; + +import com.google.adk.apps.App; +import com.google.common.collect.ImmutableList; +import java.util.Optional; +import javax.annotation.concurrent.ThreadSafe; + +/** + * Loads what the ADK Web Server serves: apps, agents and workflows, each as an {@link App} keyed by + * its app name. An agent is served as an app whose root agent it is, and a workflow as an app whose + * root node it is. + * + *

When the server has an {@code AppLoader}, it uses it in place of the default {@link + * CompiledAgentLoader}, and fails at startup if another {@link AgentLoader} is configured too. + */ +@ThreadSafe +public interface AppLoader { + + /** Returns the names of the available apps, each a valid app name; empty if there are none. */ + ImmutableList listApps(); + + /** + * Returns the app served as {@code appName}, or empty if there is none. + * + * @throws RuntimeException if the app exists but fails to load + */ + Optional loadApp(String appName); +} diff --git a/dev/src/main/java/com/google/adk/web/InMemoryAppLoader.java b/dev/src/main/java/com/google/adk/web/InMemoryAppLoader.java new file mode 100644 index 000000000..f203ff0a9 --- /dev/null +++ b/dev/src/main/java/com/google/adk/web/InMemoryAppLoader.java @@ -0,0 +1,55 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.web; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.adk.apps.App; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSortedMap; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** An {@link AppLoader} serving a fixed set of apps, each under its {@link App#name()}. */ +public final class InMemoryAppLoader implements AppLoader { + + private final ImmutableSortedMap apps; + + /** + * Serves {@code apps}. + * + * @throws IllegalArgumentException if two apps share a name + */ + public InMemoryAppLoader(App... apps) { + Map byName = new HashMap<>(); + for (App app : apps) { + checkArgument(byName.putIfAbsent(app.name(), app) == null, "App names must be unique."); + } + this.apps = ImmutableSortedMap.copyOf(byName); + } + + @Override + public ImmutableList listApps() { + return apps.keySet().asList(); + } + + @Override + public Optional loadApp(String appName) { + return Optional.ofNullable(apps.get(appName)); + } +} diff --git a/dev/src/main/java/com/google/adk/web/controller/AgentController.java b/dev/src/main/java/com/google/adk/web/controller/AgentController.java index 08e93cd1d..87ed49a3a 100644 --- a/dev/src/main/java/com/google/adk/web/controller/AgentController.java +++ b/dev/src/main/java/com/google/adk/web/controller/AgentController.java @@ -18,7 +18,11 @@ import static java.util.stream.Collectors.toList; +import com.google.adk.artifacts.InMemoryArtifactService; +import com.google.adk.memory.InMemoryMemoryService; +import com.google.adk.sessions.InMemorySessionService; import com.google.adk.web.AgentLoader; +import com.google.adk.web.service.RunnerService; import com.google.common.collect.ImmutableList; import java.util.List; import org.slf4j.Logger; @@ -33,34 +37,52 @@ public class AgentController { private static final Logger log = LoggerFactory.getLogger(AgentController.class); - private final AgentLoader agentProvider; + private final RunnerService runnerService; /** * Constructs the AgentController. * - * @param agentProvider The provider for loading agents. + * @param runnerService The service serving the configured apps. */ @Autowired - public AgentController(AgentLoader agentProvider) { - this.agentProvider = agentProvider; - ImmutableList agentNames = this.agentProvider.listAgents(); + public AgentController(RunnerService runnerService) { + this.runnerService = runnerService; + ImmutableList agentNames = runnerService.listApps(); log.info( "AgentController initialized with {} dynamic agents: {}", agentNames.size(), agentNames); if (agentNames.isEmpty()) { log.warn( - "Agent registry is empty. Check 'adk.agents.source-dir' property and compilation" - + " logs."); + "No apps are served. Check the configured loader, such as the 'adk.agents.source-dir'" + + " property and compilation logs for compiled agents."); } } /** - * Lists available applications. Currently returns only the configured root agent's name. + * Constructs an AgentController that serves only the agents of {@code agentProvider}. + * + * @param agentProvider The provider for loading agents. + * @deprecated Use {@link #AgentController(RunnerService)} instead. + */ + @Deprecated + public AgentController(AgentLoader agentProvider) { + // Listing apps reads only the loader, so these runner services are never used. + this( + new RunnerService( + agentProvider, + new InMemoryArtifactService(), + new InMemorySessionService(), + new InMemoryMemoryService(), + ImmutableList.of())); + } + + /** + * Lists the available applications. * - * @return A list containing the root agent's name. + * @return The names of the served apps, sorted. */ @GetMapping("/list-apps") public List listApps() { - ImmutableList agentNames = agentProvider.listAgents(); + ImmutableList agentNames = runnerService.listApps(); log.info("Listing apps from dynamic registry. Found: {}", agentNames); return agentNames.stream().sorted().collect(toList()); } diff --git a/dev/src/main/java/com/google/adk/web/controller/AppGraph.java b/dev/src/main/java/com/google/adk/web/controller/AppGraph.java new file mode 100644 index 000000000..d3f543931 --- /dev/null +++ b/dev/src/main/java/com/google/adk/web/controller/AppGraph.java @@ -0,0 +1,190 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.web.controller; + +import static com.google.common.collect.ImmutableList.toImmutableList; +import static com.google.common.collect.ImmutableSet.toImmutableSet; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.adk.agents.BaseAgent; +import com.google.adk.agents.LlmAgent; +import com.google.adk.tools.BaseTool; +import com.google.adk.workflow.Edge; +import com.google.adk.workflow.JoinNode; +import com.google.adk.workflow.Node; +import com.google.adk.workflow.Route; +import com.google.adk.workflow.Start; +import com.google.adk.workflow.Workflow; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Sets; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import org.jspecify.annotations.Nullable; + +/** + * An app's root and everything under it, as the Dev UI reads it from {@code + * /dev/apps/{appName}/build_graph}. + * + *

Keys are snake_case, unlike the rest of the API, because that is how the Dev UI reads this + * payload. + */ +record AppGraph(String name, @JsonProperty("root_agent") GraphNode rootAgent) { + + static final String AGENT_NODE_TYPE = "agent"; + static final String WORKFLOW_NODE_TYPE = "workflow"; + static final String TOOL_NODE_TYPE = "tool"; + static final String JOIN_NODE_TYPE = "join"; + + /** The name a toolset without a class name, such as an anonymous class, is drawn under. */ + private static final String TOOLSET_NAME = "Toolset"; + + /** Describes the app {@code appName}, rooted at {@code root}. */ + static AppGraph of(String appName, Node root) { + return new AppGraph(appName, graphNodeOf(root)); + } + + /** Describes {@code node} and everything under it. */ + static GraphNode graphNodeOf(Node node) { + if (node instanceof Workflow workflow) { + return new GraphNode( + node.name(), + typeOf(node), + /* subAgents= */ null, + /* tools= */ null, + workflowGraphOf(workflow)); + } + if (node instanceof BaseAgent agent) { + return new GraphNode( + agent.name(), + typeOf(agent), + agent.subAgents().stream().map(AppGraph::graphNodeOf).collect(toImmutableList()), + agent instanceof LlmAgent llmAgent ? toolsOf(llmAgent) : null, + /* graph= */ null); + } + return new GraphNode(node.name(), typeOf(node)); + } + + /** + * One node of an app: an agent with its sub-agents and tools, a workflow with its graph, or any + * other node. + * + * @param type how the graph draws the node: {@code agent}, {@code workflow}, {@code join}, {@code + * start} or {@code node} + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + record GraphNode( + String name, + String type, + @JsonProperty("sub_agents") @Nullable ImmutableList subAgents, + @Nullable ImmutableList tools, + @Nullable WorkflowGraph graph) { + + GraphNode(String name, String type) { + this(name, type, /* subAgents= */ null, /* tools= */ null, /* graph= */ null); + } + } + + record GraphTool(String name, String type) {} + + record WorkflowGraph(ImmutableList nodes, ImmutableList edges) {} + + /** An edge between two nodes; {@code route} is absent on an unconditional one. */ + @JsonInclude(JsonInclude.Include.NON_NULL) + record GraphEdge( + @JsonProperty("from_node") GraphNodeRef from, + @JsonProperty("to_node") GraphNodeRef to, + @Nullable Object route) {} + + record GraphNodeRef(String name, String type) {} + + /** Returns the node type string used by the Dev UI graph renderer for {@code node}. */ + private static String typeOf(Node node) { + if (node instanceof Start) { + return "start"; + } + if (node instanceof Workflow) { + return WORKFLOW_NODE_TYPE; + } + if (node instanceof BaseAgent) { + return AGENT_NODE_TYPE; + } + if (node instanceof JoinNode) { + return JOIN_NODE_TYPE; + } + return "node"; + } + + private static @Nullable WorkflowGraph workflowGraphOf(Workflow workflow) { + if (workflow.edges().isEmpty()) { + return null; + } + // Preserve first-appearance order and deduplicate by node identity. + Set seen = Sets.newIdentityHashSet(); + List nodes = new ArrayList<>(); + for (Edge edge : workflow.edges()) { + for (Node node : List.of(edge.from(), edge.to())) { + if (seen.add(node)) { + nodes.add(node); + } + } + } + return new WorkflowGraph( + nodes.stream().map(AppGraph::graphNodeOf).collect(toImmutableList()), + workflow.edges().stream() + .map(edge -> new GraphEdge(refOf(edge.from()), refOf(edge.to()), routeOrNull(edge))) + .collect(toImmutableList())); + } + + private static GraphNodeRef refOf(Node node) { + return new GraphNodeRef(node.name(), typeOf(node)); + } + + /** + * Returns {@code edge}'s routes as null when the edge is unconditional, a single route as itself, + * which serializes as a scalar, and any other route list as a list. + */ + private static @Nullable Object routeOrNull(Edge edge) { + if (edge.isUnconditional()) { + return null; + } + ImmutableList routes = edge.routes(); + return routes.size() == 1 ? routes.get(0) : routes; + } + + /** The agent's tools and toolsets in declaration order, minus any named after a sub-agent. */ + private static ImmutableList toolsOf(LlmAgent agent) { + ImmutableSet subAgentNames = + agent.subAgents().stream().map(BaseAgent::name).collect(toImmutableSet()); + return agent.toolsUnion().stream() + .map(AppGraph::toolName) + .filter(name -> !subAgentNames.contains(name)) + .map(name -> new GraphTool(name, TOOL_NODE_TYPE)) + .collect(toImmutableList()); + } + + /** Names a tool by its name, and a toolset, which has none, by its class. */ + private static String toolName(Object toolOrToolset) { + if (toolOrToolset instanceof BaseTool tool) { + return tool.name(); + } + String className = toolOrToolset.getClass().getSimpleName(); + return className.isEmpty() ? TOOLSET_NAME : className; + } +} diff --git a/dev/src/main/java/com/google/adk/web/controller/AppGraphController.java b/dev/src/main/java/com/google/adk/web/controller/AppGraphController.java new file mode 100644 index 000000000..1c140728a --- /dev/null +++ b/dev/src/main/java/com/google/adk/web/controller/AppGraphController.java @@ -0,0 +1,59 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.web.controller; + +import com.google.adk.web.service.RunnerService; +import com.google.adk.workflow.Node; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +/** Serves the app structure that the Dev UI navigates and draws its graph views from. */ +@RestController +class AppGraphController { + + private static final Logger log = LoggerFactory.getLogger(AppGraphController.class); + + private final RunnerService runnerService; + + AppGraphController(RunnerService runnerService) { + this.runnerService = runnerService; + } + + @GetMapping("/dev/apps/{appName}/build_graph") + AppGraph buildGraph(@PathVariable String appName) { + return AppGraph.of(appName, loadRoot(appName)); + } + + /** Returns the root of the app {@code appName}, or throws the error response to send. */ + private Node loadRoot(String appName) { + Optional root; + try { + root = runnerService.loadRoot(appName); + } catch (IllegalStateException e) { + log.warn("The requested app failed to load for its graph.", e); + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "App failed to load.", e); + } + return root.orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "App not found.")); + } +} diff --git a/dev/src/main/java/com/google/adk/web/controller/GraphController.java b/dev/src/main/java/com/google/adk/web/controller/GraphController.java index df2af39af..4f115f72a 100644 --- a/dev/src/main/java/com/google/adk/web/controller/GraphController.java +++ b/dev/src/main/java/com/google/adk/web/controller/GraphController.java @@ -17,12 +17,15 @@ package com.google.adk.web.controller; import com.google.adk.agents.BaseAgent; +import com.google.adk.artifacts.InMemoryArtifactService; import com.google.adk.events.Event; +import com.google.adk.memory.InMemoryMemoryService; import com.google.adk.sessions.BaseSessionService; import com.google.adk.sessions.Session; import com.google.adk.web.AgentGraphGenerator; import com.google.adk.web.AgentLoader; import com.google.adk.web.dto.GraphResponse; +import com.google.adk.web.service.RunnerService; import com.google.common.collect.ImmutableList; import com.google.genai.types.FunctionCall; import com.google.genai.types.FunctionResponse; @@ -48,12 +51,32 @@ public class GraphController { private static final Logger log = LoggerFactory.getLogger(GraphController.class); private final BaseSessionService sessionService; - private final AgentLoader agentProvider; + private final RunnerService runnerService; @Autowired - public GraphController(BaseSessionService sessionService, AgentLoader agentProvider) { + public GraphController(BaseSessionService sessionService, RunnerService runnerService) { this.sessionService = sessionService; - this.agentProvider = agentProvider; + this.runnerService = runnerService; + } + + /** + * Constructs a GraphController that draws only the agents of {@code agentProvider}. + * + * @param sessionService The service holding the sessions whose events are drawn. + * @param agentProvider The provider for loading agents. + * @deprecated Use {@link #GraphController(BaseSessionService, RunnerService)} instead. + */ + @Deprecated + public GraphController(BaseSessionService sessionService, AgentLoader agentProvider) { + // Loading an app's root reads only the loader, so these runner services are never used. + this( + sessionService, + new RunnerService( + agentProvider, + new InMemoryArtifactService(), + sessionService, + new InMemoryMemoryService(), + ImmutableList.of())); } /** @@ -124,18 +147,25 @@ public ResponseEntity getEventGraph( sessionId, eventId); - BaseAgent currentAppAgent; + Optional rootAgent; try { - currentAppAgent = agentProvider.loadAgent(appName); - } catch (java.util.NoSuchElementException e) { - log.warn("Agent app '{}' not found for graph generation.", appName); - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(new GraphResponse("Agent app not found: " + appName)); + // This route draws agent trees only, so an app that runs a workflow node has no graph here. + rootAgent = + runnerService + .loadRoot(appName) + .filter(BaseAgent.class::isInstance) + .map(BaseAgent.class::cast); } catch (IllegalStateException e) { - log.warn("Agent app '{}' failed to load for graph generation: {}", appName, e.getMessage()); + log.warn("Agent app '{}' failed to load for graph generation.", appName, e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(new GraphResponse("Agent app failed to load: " + appName)); } + if (rootAgent.isEmpty()) { + log.warn("Agent app '{}' not found for graph generation.", appName); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(new GraphResponse("Agent app not found: " + appName)); + } + BaseAgent currentAppAgent = rootAgent.get(); Session session = findSessionOrThrow(appName, userId, sessionId); Event event = diff --git a/dev/src/main/java/com/google/adk/web/service/RunnerService.java b/dev/src/main/java/com/google/adk/web/service/RunnerService.java index 480c68472..38c270b09 100644 --- a/dev/src/main/java/com/google/adk/web/service/RunnerService.java +++ b/dev/src/main/java/com/google/adk/web/service/RunnerService.java @@ -16,44 +16,105 @@ package com.google.adk.web.service; -import com.google.adk.agents.BaseAgent; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.adk.annotations.Experimental; import com.google.adk.artifacts.BaseArtifactService; import com.google.adk.memory.BaseMemoryService; import com.google.adk.plugins.BasePlugin; import com.google.adk.runner.Runner; import com.google.adk.sessions.BaseSessionService; import com.google.adk.web.AgentLoader; +import com.google.adk.web.AppLoader; +import com.google.adk.web.CompiledAgentLoader; +import com.google.adk.workflow.Node; import com.google.common.collect.ImmutableList; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; import org.springframework.web.server.ResponseStatusException; -/** Service for creating and caching Runner instances. */ +/** Serves the configured apps, and creates and caches a Runner for each. */ @Component public class RunnerService { private static final Logger log = LoggerFactory.getLogger(RunnerService.class); - private final AgentLoader agentProvider; + private final ServedApps servedApps; private final BaseArtifactService artifactService; private final BaseSessionService sessionService; private final BaseMemoryService memoryService; private final List extraPlugins; private final Map runnerCache = new ConcurrentHashMap<>(); + /** + * Serves the apps of the configured {@link AppLoader}, or the agents of the configured {@link + * AgentLoader} if there is no {@code AppLoader}. + * + * @throws IllegalStateException if neither loader is configured, or if both are and the agent + * loader is not the default {@link CompiledAgentLoader} + */ + @Autowired + RunnerService( + ObjectProvider appLoader, + ObjectProvider agentLoader, + BaseArtifactService artifactService, + BaseSessionService sessionService, + BaseMemoryService memoryService, + @Autowired(required = false) @Qualifier("extraPlugins") List extraPlugins) { + this( + selectServedApps(appLoader.getIfAvailable(), agentLoader), + artifactService, + sessionService, + memoryService, + extraPlugins); + } + + /** Serves the apps of {@code appLoader}. */ + public RunnerService( + AppLoader appLoader, + BaseArtifactService artifactService, + BaseSessionService sessionService, + BaseMemoryService memoryService, + List extraPlugins) { + this( + new ServedApps.AppLoaderApps(checkNotNull(appLoader)), + artifactService, + sessionService, + memoryService, + extraPlugins); + } + + /** Serves the agents of {@code agentProvider}. */ public RunnerService( AgentLoader agentProvider, BaseArtifactService artifactService, BaseSessionService sessionService, BaseMemoryService memoryService, - @Autowired(required = false) @Qualifier("extraPlugins") List extraPlugins) { - this.agentProvider = agentProvider; + List extraPlugins) { + this( + new ServedApps.AgentLoaderApps(checkNotNull(agentProvider)), + artifactService, + sessionService, + memoryService, + extraPlugins); + } + + private RunnerService( + ServedApps servedApps, + BaseArtifactService artifactService, + BaseSessionService sessionService, + BaseMemoryService memoryService, + List extraPlugins) { + this.servedApps = servedApps; this.artifactService = artifactService; this.sessionService = sessionService; this.memoryService = memoryService; @@ -61,42 +122,70 @@ public RunnerService( extraPlugins != null ? ImmutableList.copyOf(extraPlugins) : ImmutableList.of(); } + private static ServedApps selectServedApps( + @Nullable AppLoader appLoader, ObjectProvider agentLoaders) { + if (appLoader == null) { + return ServedApps.select(null, agentLoaders.getIfAvailable()); + } + // The default loader is @Primary, so look past it for one the app loader would hide. + return ServedApps.select( + appLoader, + agentLoaders + .orderedStream() + .filter(loader -> !(loader instanceof CompiledAgentLoader)) + .findFirst() + .orElse(null)); + } + + /** Returns the names of the apps this server serves. */ + public ImmutableList listApps() { + return servedApps.listApps(); + } + /** - * Gets the Runner instance for a given application name. Handles potential agent engine ID - * overrides. + * Returns the root that the app {@code appName} runs: the workflow node it runs, or else its root + * agent. Empty if there is no such app. + * + * @throws IllegalStateException if the app exists but fails to load + */ + @Experimental + public Optional loadRoot(String appName) { + return servedApps.loadRoot(appName); + } + + /** + * Gets the Runner instance for a given application name. An app runs under the requested name, + * with this server's plugins after its own. * * @param appName The application name requested by the user. * @return A configured Runner instance. + * @throws ResponseStatusException with 404 if there is no such app, or 500 if it fails to load */ public Runner getRunner(String appName) { - return runnerCache.computeIfAbsent( - appName, - key -> { - try { - BaseAgent agent = agentProvider.loadAgent(key); - log.info( - "RunnerService: Creating Runner for appName: {}, using agent definition: {}", - appName, - agent.name()); - return Runner.builder() - .agent(agent) - .appName(appName) - .artifactService(this.artifactService) - .sessionService(this.sessionService) - .memoryService(this.memoryService) - .plugins(this.extraPlugins) - .build(); - } catch (java.util.NoSuchElementException e) { - log.error( - "Agent/App named '{}' not found in registry. Available apps: {}", - key, - agentProvider.listAgents()); - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Agent/App not found: " + key); - } catch (IllegalStateException e) { - log.error("Agent '{}' exists but failed to load: {}", key, e.getMessage()); - throw new ResponseStatusException( - HttpStatus.INTERNAL_SERVER_ERROR, "Agent failed to load: " + key, e); - } + return runnerCache.computeIfAbsent(appName, this::createRunner); + } + + private Runner createRunner(String appName) { + Optional runner; + try { + runner = + servedApps.createRunner( + appName, + Runner.builder() + .artifactService(artifactService) + .sessionService(sessionService) + .memoryService(memoryService), + extraPlugins); + } catch (RuntimeException e) { + log.error("The requested app failed to load.", e); + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "App failed to load.", e); + } + return runner.orElseThrow( + () -> { + log.warn( + "No app is served under the requested name; {} apps are available.", + servedApps.listApps().size()); + return new ResponseStatusException(HttpStatus.NOT_FOUND, "App not found."); }); } diff --git a/dev/src/main/java/com/google/adk/web/service/ServedApps.java b/dev/src/main/java/com/google/adk/web/service/ServedApps.java new file mode 100644 index 000000000..23c7fae0a --- /dev/null +++ b/dev/src/main/java/com/google/adk/web/service/ServedApps.java @@ -0,0 +1,153 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.web.service; + +import static com.google.common.base.Preconditions.checkState; + +import com.google.adk.agents.BaseAgent; +import com.google.adk.apps.App; +import com.google.adk.plugins.Plugin; +import com.google.adk.runner.Runner; +import com.google.adk.web.AgentLoader; +import com.google.adk.web.AppLoader; +import com.google.adk.workflow.Node; +import com.google.common.collect.ImmutableList; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Optional; +import org.jspecify.annotations.Nullable; + +/** What the server serves, read from whichever loader it was configured with. */ +interface ServedApps { + + /** Returns the names that {@code /list-apps} lists. */ + ImmutableList listApps(); + + /** + * Returns the root that the app {@code appName} runs, or empty if there is no such app. + * + * @throws IllegalStateException if the app exists but fails to load + */ + Optional loadRoot(String appName); + + /** + * Returns a runner for {@code appName}, built on {@code builder} with {@code plugins} after the + * app's own, or empty if there is no such app. + * + * @throws IllegalStateException if the app exists but fails to load + */ + Optional createRunner( + String appName, Runner.Builder builder, List plugins); + + /** + * Serves {@code appLoader}'s apps if there is one, else {@code agentLoader}'s agents. + * + * @throws IllegalStateException if there is neither, or both + */ + static ServedApps select(@Nullable AppLoader appLoader, @Nullable AgentLoader agentLoader) { + if (appLoader != null) { + checkState(agentLoader == null, "Configure an AppLoader or an AgentLoader, not both."); + return new AppLoaderApps(appLoader); + } + checkState(agentLoader != null, "Configure an AppLoader or an AgentLoader."); + return new AgentLoaderApps(agentLoader); + } + + /** Serves an {@link AppLoader}'s apps, each under the requested name. */ + final class AppLoaderApps implements ServedApps { + private final AppLoader loader; + + AppLoaderApps(AppLoader loader) { + this.loader = loader; + } + + @Override + public ImmutableList listApps() { + return loader.listApps(); + } + + @Override + public Optional loadRoot(String appName) { + return load(appName).map(app -> app.rootNode().orElse(app.rootAgent())); + } + + @Override + public Optional createRunner( + String appName, Runner.Builder builder, List plugins) { + return load(appName) + .map( + app -> + builder + .app( + app.toBuilder() + .name(appName) + .plugins( + ImmutableList.builder() + .addAll(app.plugins()) + .addAll(plugins) + .build()) + .build()) + .build()); + } + + private Optional load(String appName) { + try { + return loader.loadApp(appName); + } catch (RuntimeException e) { + throw new IllegalStateException("The app failed to load.", e); + } + } + } + + /** + * Serves an {@link AgentLoader}'s agents as the server did before apps: without an {@link App}. + */ + final class AgentLoaderApps implements ServedApps { + private final AgentLoader loader; + + AgentLoaderApps(AgentLoader loader) { + this.loader = loader; + } + + @Override + public ImmutableList listApps() { + return loader.listAgents(); + } + + @Override + public Optional loadRoot(String appName) { + return load(appName).map(Node.class::cast); + } + + @Override + public Optional createRunner( + String appName, Runner.Builder builder, List plugins) { + return load(appName) + .map(agent -> builder.agent(agent).appName(appName).plugins(plugins).build()); + } + + private Optional load(String appName) { + try { + return Optional.of(loader.loadAgent(appName)); + } catch (NoSuchElementException e) { + return Optional.empty(); + } catch (RuntimeException e) { + throw new IllegalStateException("The app failed to load.", e); + } + } + } +} diff --git a/dev/src/test/java/com/google/adk/web/AdkWebServerAppLoaderTest.java b/dev/src/test/java/com/google/adk/web/AdkWebServerAppLoaderTest.java new file mode 100644 index 000000000..297150ba4 --- /dev/null +++ b/dev/src/test/java/com/google/adk/web/AdkWebServerAppLoaderTest.java @@ -0,0 +1,105 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.web; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.google.adk.agents.LlmAgent; +import com.google.adk.apps.App; +import com.google.adk.web.testing.StepNode; +import com.google.adk.workflow.Edge; +import com.google.adk.workflow.Start; +import com.google.adk.workflow.Workflow; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.annotation.Bean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +/** Tests the server with an {@link AppLoader} bean next to the default compiled agent loader. */ +@SpringBootTest +@AutoConfigureMockMvc +class AdkWebServerAppLoaderTest { + + @TestConfiguration + static class Apps { + @Bean + AppLoader appLoader() { + Workflow workflow = + Workflow.builder() + .name("flow") + .edges(new Edge(Start.INSTANCE, new StepNode("step"))) + .build(); + return new InMemoryAppLoader( + App.builder().name("flow_app").rootNode(workflow).build(), + App.builder() + .name("agent_app") + .rootAgent(LlmAgent.builder().name("assistant").model("gemini-2.5-flash").build()) + .build()); + } + } + + @Autowired private MockMvc mockMvc; + + @Test + void listApps_appLoader_returnsItsAppsSorted() throws Exception { + mockMvc + .perform(get("/list-apps")) + .andExpect(status().isOk()) + .andExpect(content().string("[\"agent_app\",\"flow_app\"]")); + } + + @Test + void run_unknownApp_returnsNotFound() throws Exception { + mockMvc + .perform( + post("/run") + .contentType(MediaType.APPLICATION_JSON) + .content( + "{\"appName\":\"missing\",\"userId\":\"user\",\"sessionId\":\"session\"," + + "\"newMessage\":{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}}")) + .andExpect(status().isNotFound()); + } + + @Test + void eventGraph_agentApp_findsTheRootAgent() throws Exception { + mockMvc + .perform( + post("/apps/agent_app/users/user/sessions/session") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isOk()); + + // An unknown event gets an empty graph only once the app's root agent is found. + mockMvc + .perform(get("/apps/agent_app/users/user/sessions/session/events/event/graph")) + .andExpect(status().isOk()); + } + + @Test + void eventGraph_workflowApp_returnsNotFound() throws Exception { + mockMvc + .perform(get("/apps/flow_app/users/user/sessions/session/events/event/graph")) + .andExpect(status().isNotFound()); + } +} diff --git a/dev/src/test/java/com/google/adk/web/AdkWebServerAppRunTest.java b/dev/src/test/java/com/google/adk/web/AdkWebServerAppRunTest.java new file mode 100644 index 000000000..8bf2c126b --- /dev/null +++ b/dev/src/test/java/com/google/adk/web/AdkWebServerAppRunTest.java @@ -0,0 +1,228 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.web; + +import static com.google.common.truth.Truth.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.google.adk.agents.InvocationContext; +import com.google.adk.apps.App; +import com.google.adk.apps.ResumabilityConfig; +import com.google.adk.plugins.BasePlugin; +import com.google.adk.web.testing.StepNode; +import com.google.adk.workflow.Edge; +import com.google.adk.workflow.Node; +import com.google.adk.workflow.Start; +import com.google.adk.workflow.Workflow; +import com.google.common.collect.ImmutableList; +import com.google.genai.types.Content; +import io.reactivex.rxjava3.core.Maybe; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.annotation.Bean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.RequestBuilder; + +/** Runs apps end to end through {@code /run} and {@code /run_sse}. */ +@SpringBootTest +@AutoConfigureMockMvc +class AdkWebServerAppRunTest { + + private static final String USER_ID = "user"; + + /** The names of the plugins whose before-run callback ran, in order. */ + private static final List beforeRunCalls = new CopyOnWriteArrayList<>(); + + @TestConfiguration + static class Apps { + @Bean + AppLoader appLoader() { + App flowApp = + App.builder() + .name("flow_app") + .rootNode(greetingWorkflow()) + .plugins(new RecordingPlugin("app_plugin")) + .build(); + App resumableApp = + App.builder() + .name("resumable_app") + .rootNode(greetingWorkflow()) + .resumabilityConfig(ResumabilityConfig.builder().resumable(true).build()) + .build(); + App greeter = App.builder().name("greeter").rootNode(greetingWorkflow()).build(); + InMemoryAppLoader apps = new InMemoryAppLoader(flowApp, resumableApp); + // Serves greeter under "alias", not its own name, as a loader keyed by something else may. + return new AppLoader() { + @Override + public ImmutableList listApps() { + return ImmutableList.builder().addAll(apps.listApps()).add("alias").build(); + } + + @Override + public Optional loadApp(String appName) { + return appName.equals("alias") ? Optional.of(greeter) : apps.loadApp(appName); + } + }; + } + + @Bean(name = "extraPlugins") + List extraPlugins() { + return ImmutableList.of(new RecordingPlugin("server_plugin")); + } + } + + @Autowired private MockMvc mockMvc; + + @BeforeEach + void clearCalls() { + beforeRunCalls.clear(); + } + + @Test + void run_workflowApp_runsTheGraph() throws Exception { + String sessionId = createSession("flow_app"); + + mockMvc + .perform(runRequest("/run", "flow_app", sessionId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[*].output", Matchers.hasItems("greet", "done"))) + .andExpect(jsonPath("$[*].nodeInfo.path", Matchers.hasItem("flow@1/done@1"))) + .andExpect(jsonPath("$[*].actions.agentState").isEmpty()); + } + + @Test + void run_appAndServerPlugins_runsTheAppsFirst() throws Exception { + String sessionId = createSession("flow_app"); + + mockMvc.perform(runRequest("/run", "flow_app", sessionId)).andExpect(status().isOk()); + + assertThat(beforeRunCalls).containsExactly("app_plugin", "server_plugin").inOrder(); + } + + @Test + void runSse_workflowApp_streamsGraphEvents() throws Exception { + String sessionId = createSession("flow_app"); + + String stream = runSse("flow_app", sessionId); + + assertThat(stream).contains("\"output\":\"greet\""); + assertThat(stream).contains("\"output\":\"done\""); + } + + @Test + void run_resumableApp_emitsTheWorkflowCheckpoints() throws Exception { + String sessionId = createSession("resumable_app"); + + mockMvc + .perform(runRequest("/run", "resumable_app", sessionId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[*].actions.agentState.nodes").isNotEmpty()) + .andExpect(jsonPath("$[*].output", Matchers.hasItem("done"))); + } + + @Test + void runSse_resumableApp_streamsTheWorkflowCheckpoints() throws Exception { + String sessionId = createSession("resumable_app"); + + String stream = runSse("resumable_app", sessionId); + + assertThat(stream).contains("\"agentState\":{\"nodes\":"); + assertThat(stream).contains("\"output\":\"done\""); + } + + @Test + void run_appServedUnderAnotherName_keepsTheSessionUnderTheRequestedName() throws Exception { + String sessionId = createSession("alias"); + + mockMvc.perform(runRequest("/run", "alias", sessionId)).andExpect(status().isOk()); + + mockMvc + .perform(get("/apps/alias/users/" + USER_ID + "/sessions/" + sessionId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.events[*].output", Matchers.hasItem("done"))); + } + + private String createSession(String appName) throws Exception { + String sessionId = UUID.randomUUID().toString(); + mockMvc + .perform( + post("/apps/" + appName + "/users/" + USER_ID + "/sessions/" + sessionId) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isOk()); + return sessionId; + } + + private String runSse(String appName, String sessionId) throws Exception { + MvcResult result = + mockMvc + .perform(runRequest("/run_sse", appName, sessionId)) + .andExpect(request().asyncStarted()) + .andReturn(); + // A stream that ended in an error would leave the error as the async result. + assertThat(result.getAsyncResult(10_000)).isNull(); + return result.getResponse().getContentAsString(); + } + + private static RequestBuilder runRequest(String path, String appName, String sessionId) { + return post(path) + .contentType(MediaType.APPLICATION_JSON) + .content( + String.format( + """ + {"appName": "%s", "userId": "%s", "sessionId": "%s", + "newMessage": {"role": "user", "parts": [{"text": "hi"}]}} + """, + appName, USER_ID, sessionId)); + } + + private static Workflow greetingWorkflow() { + Node greet = new StepNode("greet"); + return Workflow.builder() + .name("flow") + .edges(new Edge(Start.INSTANCE, greet), new Edge(greet, new StepNode("done"))) + .build(); + } + + /** Records that its before-run callback ran. */ + private static final class RecordingPlugin extends BasePlugin { + RecordingPlugin(String name) { + super(name); + } + + @Override + public Maybe beforeRunCallback(InvocationContext invocationContext) { + beforeRunCalls.add(getName()); + return Maybe.empty(); + } + } +} diff --git a/dev/src/test/java/com/google/adk/web/InMemoryAppLoaderTest.java b/dev/src/test/java/com/google/adk/web/InMemoryAppLoaderTest.java new file mode 100644 index 000000000..5ee511663 --- /dev/null +++ b/dev/src/test/java/com/google/adk/web/InMemoryAppLoaderTest.java @@ -0,0 +1,60 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.web; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.adk.apps.App; +import com.google.adk.web.testing.StepNode; +import org.junit.jupiter.api.Test; + +class InMemoryAppLoaderTest { + + private static final App FLOW_APP = + App.builder().name("flow").rootNode(new StepNode("a")).build(); + private static final App OTHER_APP = + App.builder().name("another").rootNode(new StepNode("b")).build(); + + @Test + void listApps_returnsTheAppNamesSorted() { + InMemoryAppLoader loader = new InMemoryAppLoader(FLOW_APP, OTHER_APP); + + assertThat(loader.listApps()).containsExactly("another", "flow").inOrder(); + } + + @Test + void loadApp_servedName_returnsTheApp() { + InMemoryAppLoader loader = new InMemoryAppLoader(FLOW_APP, OTHER_APP); + + assertThat(loader.loadApp("flow")).hasValue(FLOW_APP); + } + + @Test + void loadApp_unknownName_returnsEmpty() { + InMemoryAppLoader loader = new InMemoryAppLoader(FLOW_APP); + + assertThat(loader.loadApp("missing")).isEmpty(); + } + + @Test + void constructor_twoAppsWithOneName_throwsIllegalArgumentException() { + App sameName = App.builder().name("flow").rootNode(new StepNode("c")).build(); + + assertThrows(IllegalArgumentException.class, () -> new InMemoryAppLoader(FLOW_APP, sameName)); + } +} diff --git a/dev/src/test/java/com/google/adk/web/controller/AgentControllerTest.java b/dev/src/test/java/com/google/adk/web/controller/AgentControllerTest.java new file mode 100644 index 000000000..cf08ccd07 --- /dev/null +++ b/dev/src/test/java/com/google/adk/web/controller/AgentControllerTest.java @@ -0,0 +1,47 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.web.controller; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.adk.agents.LlmAgent; +import com.google.adk.web.AgentLoader; +import com.google.adk.web.AgentStaticLoader; +import org.junit.jupiter.api.Test; + +/** Tests the deprecated {@link AgentLoader} constructor, which Spring does not use. */ +@SuppressWarnings("deprecation") +class AgentControllerTest { + + @Test + void listApps_agentLoaderConstructor_returnsTheLoadersAgentsSorted() { + AgentController controller = + new AgentController(new AgentStaticLoader(agent("zeta"), agent("alpha"))); + + assertThat(controller.listApps()).containsExactly("alpha", "zeta").inOrder(); + } + + @Test + void agentLoaderConstructor_nullLoader_throwsNullPointerException() { + assertThrows(NullPointerException.class, () -> new AgentController((AgentLoader) null)); + } + + private static LlmAgent agent(String name) { + return LlmAgent.builder().name(name).model("gemini-2.5-flash").build(); + } +} diff --git a/dev/src/test/java/com/google/adk/web/controller/AppGraphControllerTest.java b/dev/src/test/java/com/google/adk/web/controller/AppGraphControllerTest.java new file mode 100644 index 000000000..1769d840a --- /dev/null +++ b/dev/src/test/java/com/google/adk/web/controller/AppGraphControllerTest.java @@ -0,0 +1,104 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.web.controller; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.google.adk.apps.App; +import com.google.adk.web.AppLoader; +import com.google.adk.web.InMemoryAppLoader; +import com.google.adk.web.testing.GraphFixtures; +import com.google.common.collect.ImmutableList; +import java.util.Optional; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.annotation.Bean; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +class AppGraphControllerTest { + + /** Serves a workflow app and an agent app, and lists one more app that fails to load. */ + @TestConfiguration + static class Apps { + @Bean + AppLoader appLoader() { + InMemoryAppLoader apps = + new InMemoryAppLoader( + App.builder().name("support_app").rootNode(GraphFixtures.supportWorkflow()).build(), + App.builder().name("assistant").rootAgent(GraphFixtures.assistantAgent()).build()); + return new AppLoader() { + @Override + public ImmutableList listApps() { + return ImmutableList.builder().addAll(apps.listApps()).add("broken").build(); + } + + @Override + public Optional loadApp(String appName) { + if (appName.equals("broken")) { + throw new IllegalStateException("cannot build the app"); + } + return apps.loadApp(appName); + } + }; + } + } + + @Autowired private MockMvc mockMvc; + + @Test + void buildGraph_workflowApp_describesItsGraphInSnakeCase() throws Exception { + mockMvc + .perform(get("/dev/apps/support_app/build_graph")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("support_app")) + .andExpect(jsonPath("$.root_agent.type").value("workflow")) + .andExpect(jsonPath("$.root_agent.graph.nodes[*].name", Matchers.hasItem("tech_flow"))) + .andExpect(jsonPath("$.root_agent.graph.nodes[2].sub_agents").isArray()) + .andExpect(jsonPath("$.root_agent.graph.edges[1].from_node.name").value("classify")) + .andExpect(jsonPath("$.root_agent.graph.edges[1].route").value("billing")) + .andExpect(jsonPath("$.root_agent.graph.edges[0].route").doesNotExist()); + } + + @Test + void buildGraph_agentApp_describesItsTree() throws Exception { + mockMvc + .perform(get("/dev/apps/assistant/build_graph")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.root_agent.type").value("agent")) + .andExpect(jsonPath("$.root_agent.sub_agents[0].name").value("helper")); + } + + @Test + void buildGraph_unknownApp_returnsNotFound() throws Exception { + mockMvc.perform(get("/dev/apps/missing/build_graph")).andExpect(status().isNotFound()); + } + + @Test + void buildGraph_appFailsToLoad_returnsServerError() throws Exception { + mockMvc + .perform(get("/dev/apps/broken/build_graph")) + .andExpect(status().isInternalServerError()); + } +} diff --git a/dev/src/test/java/com/google/adk/web/controller/AppGraphTest.java b/dev/src/test/java/com/google/adk/web/controller/AppGraphTest.java new file mode 100644 index 000000000..7a3a44bcc --- /dev/null +++ b/dev/src/test/java/com/google/adk/web/controller/AppGraphTest.java @@ -0,0 +1,232 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.web.controller; + +import static com.google.adk.web.testing.GraphFixtures.assistantAgent; +import static com.google.adk.web.testing.GraphFixtures.llmAgent; +import static com.google.adk.web.testing.GraphFixtures.step; +import static com.google.adk.web.testing.GraphFixtures.supportWorkflow; +import static com.google.adk.web.testing.GraphFixtures.tool; +import static com.google.common.collect.ImmutableList.toImmutableList; +import static com.google.common.truth.Truth.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.adk.JsonBaseModel; +import com.google.adk.agents.ReadonlyContext; +import com.google.adk.agents.SequentialAgent; +import com.google.adk.tools.BaseTool; +import com.google.adk.tools.BaseToolset; +import com.google.adk.web.controller.AppGraph.GraphNode; +import com.google.adk.web.controller.AppGraph.GraphNodeRef; +import com.google.adk.web.controller.AppGraph.GraphTool; +import com.google.adk.web.controller.AppGraph.WorkflowGraph; +import com.google.adk.workflow.Edge; +import com.google.adk.workflow.JoinNode; +import com.google.adk.workflow.Node; +import com.google.adk.workflow.Route; +import com.google.adk.workflow.Start; +import com.google.adk.workflow.Workflow; +import com.google.common.collect.ImmutableList; +import io.reactivex.rxjava3.core.Flowable; +import java.util.List; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +class AppGraphTest { + + private static final ObjectMapper objectMapper = JsonBaseModel.getMapper(); + + @Test + void of_workflowRoot_describesStartRoutesAndNestedWorkflows() throws Exception { + JsonNode json = objectMapper.valueToTree(AppGraph.of("support_app", supportWorkflow())); + + assertThat(json) + .isEqualTo( + objectMapper.readTree( + """ + {"name": "support_app", "root_agent": {"name": "support", "type": "workflow", + "graph": { + "nodes": [ + {"name": "__START__", "type": "start"}, + {"name": "classify", "type": "node"}, + {"name": "billing_agent", "type": "agent", "sub_agents": [], + "tools": [{"name": "lookup_invoice", "type": "tool"}]}, + {"name": "tech_flow", "type": "workflow", "graph": { + "nodes": [ + {"name": "__START__", "type": "start"}, + {"name": "diagnose", "type": "node"}, + {"name": "fix", "type": "node"}], + "edges": [ + {"from_node": {"name": "__START__", "type": "start"}, + "to_node": {"name": "diagnose", "type": "node"}}, + {"from_node": {"name": "diagnose", "type": "node"}, + "to_node": {"name": "fix", "type": "node"}}]}}, + {"name": "fallback", "type": "node"}, + {"name": "summarize", "type": "node"}], + "edges": [ + {"from_node": {"name": "__START__", "type": "start"}, + "to_node": {"name": "classify", "type": "node"}}, + {"from_node": {"name": "classify", "type": "node"}, + "to_node": {"name": "billing_agent", "type": "agent"}, "route": "billing"}, + {"from_node": {"name": "classify", "type": "node"}, + "to_node": {"name": "tech_flow", "type": "workflow"}, "route": "tech"}, + {"from_node": {"name": "classify", "type": "node"}, + "to_node": {"name": "fallback", "type": "node"}, "route": "__DEFAULT__"}, + {"from_node": {"name": "billing_agent", "type": "agent"}, + "to_node": {"name": "summarize", "type": "node"}}, + {"from_node": {"name": "tech_flow", "type": "workflow"}, + "to_node": {"name": "summarize", "type": "node"}}, + {"from_node": {"name": "fallback", "type": "node"}, + "to_node": {"name": "summarize", "type": "node"}}]}}} + """)); + } + + @Test + void of_agentRoot_describesSubAgentsAndTools() throws Exception { + JsonNode json = objectMapper.valueToTree(AppGraph.of("assistant", assistantAgent())); + + assertThat(json) + .isEqualTo( + objectMapper.readTree( + """ + {"name": "assistant", "root_agent": {"name": "assistant", "type": "agent", + "tools": [], + "sub_agents": [{"name": "helper", "type": "agent", "sub_agents": [], + "tools": [{"name": "lookup_invoice", "type": "tool"}]}]}} + """)); + } + + @Test + void graphNodeOf_nonLlmAgent_describesSubAgentsWithoutTools() { + GraphNode node = + AppGraph.graphNodeOf( + SequentialAgent.builder() + .name("pipeline") + .subAgents(llmAgent("first", ImmutableList.of(), ImmutableList.of())) + .build()); + + JsonNode json = objectMapper.valueToTree(node); + assertThat(json.get("sub_agents").get(0).get("name").asText()).isEqualTo("first"); + assertThat(json.has("tools")).isFalse(); + } + + @Test + void graphNodeOf_toolNamedLikeASubAgent_skipsItAndNamesToolsetsByClass() { + BaseToolset anonymousToolset = + new BaseToolset() { + @Override + public Flowable getTools(@Nullable ReadonlyContext readonlyContext) { + return Flowable.empty(); + } + + @Override + public void close() {} + }; + GraphNode agent = + AppGraph.graphNodeOf( + llmAgent( + "root", + List.of(tool("helper"), new InvoiceToolset(), tool("search"), anonymousToolset), + List.of(llmAgent("helper", List.of(), List.of())))); + + assertThat(agent.tools()) + .containsExactly( + new GraphTool("InvoiceToolset", "tool"), + new GraphTool("search", "tool"), + new GraphTool("Toolset", "tool")) + .inOrder(); + } + + @Test + void graphNodeOf_routedEdges_describesRoutesAsScalarsOrAList() throws Exception { + Node a = step("a"); + Workflow workflow = + Workflow.builder() + .name("flow") + .edges( + new Edge(Start.INSTANCE, a), + new Edge(a, step("b"), new Route.Num(1)), + new Edge(a, step("c"), new Route.Flag(false)), + new Edge(a, step("d"), ImmutableList.of(new Route.Tag("x"), new Route.Tag("y")))) + .build(); + + WorkflowGraph graph = AppGraph.graphNodeOf(workflow).graph(); + + assertThat(graph.edges().stream().map(AppGraphTest::routeJson).toList()) + .containsExactly(null, "1", "false", "[\"x\",\"y\"]") + .inOrder(); + } + + @Test + void graphNodeOf_joinNode_describesJoinTypeInNodesAndEdges() { + Node a = step("a"); + JoinNode join = JoinNode.builder().name("merge").build(); + Workflow workflow = + Workflow.builder() + .name("flow") + .edges(new Edge(Start.INSTANCE, a), new Edge(a, join)) + .build(); + + WorkflowGraph graph = AppGraph.graphNodeOf(workflow).graph(); + + assertThat(graph.nodes()).contains(new GraphNode("merge", "join")); + assertThat(graph.edges().get(1).to()).isEqualTo(new GraphNodeRef("merge", "join")); + } + + @Test + void graphNodeOf_nodeReachedByTwoEdges_listsItOnce() { + Node a = step("a"); + Node join = step("join"); + Workflow workflow = + Workflow.builder() + .name("flow") + .edges(new Edge(Start.INSTANCE, a), new Edge(a, join), new Edge(Start.INSTANCE, join)) + .build(); + + WorkflowGraph graph = AppGraph.graphNodeOf(workflow).graph(); + + assertThat(graph.nodes().stream().map(GraphNode::name).collect(toImmutableList())) + .containsExactly("__START__", "a", "join") + .inOrder(); + } + + @Test + void graphNodeOf_workflowWithoutEdges_hasNoGraph() { + GraphNode node = AppGraph.graphNodeOf(Workflow.builder().name("empty").build()); + + assertThat(node).isEqualTo(new GraphNode("empty", "workflow")); + assertThat(objectMapper.valueToTree(node).has("graph")).isFalse(); + } + + /** Returns the edge's {@code route} as JSON text, or null when the edge omits it. */ + private static @Nullable String routeJson(AppGraph.GraphEdge edge) { + JsonNode route = objectMapper.valueToTree(edge).get("route"); + return route == null ? null : route.toString(); + } + + /** A toolset the graph draws under its class name. */ + private static final class InvoiceToolset implements BaseToolset { + @Override + public Flowable getTools(@Nullable ReadonlyContext readonlyContext) { + return Flowable.empty(); + } + + @Override + public void close() {} + } +} diff --git a/dev/src/test/java/com/google/adk/web/controller/GraphControllerTest.java b/dev/src/test/java/com/google/adk/web/controller/GraphControllerTest.java new file mode 100644 index 000000000..e54c58b53 --- /dev/null +++ b/dev/src/test/java/com/google/adk/web/controller/GraphControllerTest.java @@ -0,0 +1,87 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.web.controller; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.adk.agents.BaseAgent; +import com.google.adk.agents.LlmAgent; +import com.google.adk.sessions.InMemorySessionService; +import com.google.adk.web.AgentLoader; +import com.google.adk.web.AgentStaticLoader; +import com.google.adk.web.dto.GraphResponse; +import com.google.common.collect.ImmutableList; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +/** Tests the deprecated {@link AgentLoader} constructor, which Spring does not use. */ +@SuppressWarnings("deprecation") +class GraphControllerTest { + + private static final String USER_ID = "user"; + + private final InMemorySessionService sessionService = new InMemorySessionService(); + + @Test + void getEventGraph_agentLoaderConstructorKnownAgent_findsTheAgent() { + GraphController controller = + new GraphController( + sessionService, + new AgentStaticLoader( + LlmAgent.builder().name("assistant").model("gemini-2.5-flash").build())); + String sessionId = sessionService.createSession("assistant", USER_ID).blockingGet().id(); + + ResponseEntity response = + controller.getEventGraph("assistant", USER_ID, sessionId, "unknown_event"); + + // An unknown event gets an empty graph only once the app's root agent is found. + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + void getEventGraph_agentLoaderConstructorUnknownApp_returnsNotFound() { + GraphController controller = new GraphController(sessionService, new AgentStaticLoader()); + + ResponseEntity response = + controller.getEventGraph("missing", USER_ID, "session", "event"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void getEventGraph_agentLoaderConstructorAgentFailsToLoad_returnsServerError() { + AgentLoader failingLoader = + new AgentLoader() { + @Override + public ImmutableList listAgents() { + return ImmutableList.of("broken"); + } + + @Override + public BaseAgent loadAgent(String name) { + throw new IllegalStateException("cannot build the agent"); + } + }; + GraphController controller = new GraphController(sessionService, failingLoader); + + ResponseEntity response = + controller.getEventGraph("broken", USER_ID, "session", "event"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/dev/src/test/java/com/google/adk/web/service/RunnerServiceTest.java b/dev/src/test/java/com/google/adk/web/service/RunnerServiceTest.java new file mode 100644 index 000000000..3b44d49c1 --- /dev/null +++ b/dev/src/test/java/com/google/adk/web/service/RunnerServiceTest.java @@ -0,0 +1,324 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.web.service; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.adk.agents.BaseAgent; +import com.google.adk.agents.LlmAgent; +import com.google.adk.apps.App; +import com.google.adk.artifacts.InMemoryArtifactService; +import com.google.adk.memory.InMemoryMemoryService; +import com.google.adk.plugins.BasePlugin; +import com.google.adk.runner.Runner; +import com.google.adk.sessions.InMemorySessionService; +import com.google.adk.web.AgentLoader; +import com.google.adk.web.AppLoader; +import com.google.adk.web.CompiledAgentLoader; +import com.google.adk.web.InMemoryAppLoader; +import com.google.adk.web.config.AgentLoadingProperties; +import com.google.adk.web.testing.StepNode; +import com.google.adk.workflow.Edge; +import com.google.adk.workflow.Start; +import com.google.adk.workflow.Workflow; +import com.google.common.collect.ImmutableList; +import java.util.NoSuchElementException; +import java.util.Optional; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +class RunnerServiceTest { + + private static final Workflow WORKFLOW = + Workflow.builder().name("flow").edges(new Edge(Start.INSTANCE, new StepNode("step"))).build(); + private static final BaseAgent AGENT = + LlmAgent.builder().name("assistant").model("gemini-2.5-flash").build(); + private static final BasePlugin APP_PLUGIN = new BasePlugin("app_plugin") {}; + private static final BasePlugin SERVER_PLUGIN = new BasePlugin("server_plugin") {}; + private static final App FLOW_APP = + App.builder().name("flow_app").rootNode(WORKFLOW).plugins(APP_PLUGIN).build(); + private static final App AGENT_APP = App.builder().name("agent_app").rootAgent(AGENT).build(); + + @Test + void listApps_appLoader_returnsItsApps() { + RunnerService service = runnerService(new InMemoryAppLoader(FLOW_APP, AGENT_APP), null); + + assertThat(service.listApps()).containsExactly("agent_app", "flow_app"); + } + + @Test + void loadRoot_workflowApp_returnsTheWorkflow() { + RunnerService service = runnerService(new InMemoryAppLoader(FLOW_APP), null); + + assertThat(service.loadRoot("flow_app")).hasValue(WORKFLOW); + } + + @Test + void loadRoot_agentApp_returnsTheRootAgent() { + RunnerService service = runnerService(new InMemoryAppLoader(AGENT_APP), null); + + assertThat(service.loadRoot("agent_app")).hasValue(AGENT); + } + + @Test + void loadRoot_unknownApp_returnsEmpty() { + RunnerService service = runnerService(new InMemoryAppLoader(FLOW_APP), null); + + assertThat(service.loadRoot("missing")).isEmpty(); + } + + @Test + void loadRoot_appFailsToLoad_throwsIllegalStateException() { + RunnerService service = runnerService(new FailingAppLoader(), null); + + assertThrows(IllegalStateException.class, () -> service.loadRoot("broken")); + } + + @Test + void getRunner_appLoader_runsTheAppWithItsPluginsBeforeTheServers() { + RunnerService service = runnerService(new InMemoryAppLoader(FLOW_APP), null, SERVER_PLUGIN); + + Runner runner = service.getRunner("flow_app"); + + assertThat(runner.appName()).isEqualTo("flow_app"); + assertThat(runner.node()).hasValue(WORKFLOW); + assertThat(runner.pluginManager().getPlugins()) + .containsExactly(APP_PLUGIN, SERVER_PLUGIN) + .inOrder(); + } + + @Test + void getRunner_appServedUnderAnotherName_runsItUnderTheRequestedName() { + AppLoader loader = + new AppLoader() { + @Override + public ImmutableList listApps() { + return ImmutableList.of("alias"); + } + + @Override + public Optional loadApp(String appName) { + return appName.equals("alias") ? Optional.of(AGENT_APP) : Optional.empty(); + } + }; + RunnerService service = runnerService(loader, null); + + assertThat(service.getRunner("alias").appName()).isEqualTo("alias"); + } + + @Test + void getRunner_calledTwice_returnsTheCachedRunner() { + RunnerService service = runnerService(new InMemoryAppLoader(FLOW_APP), null); + + assertThat(service.getRunner("flow_app")).isSameInstanceAs(service.getRunner("flow_app")); + } + + @Test + void getRunner_unknownApp_throwsNotFound() { + RunnerService service = runnerService(new InMemoryAppLoader(FLOW_APP), null); + + ResponseStatusException e = + assertThrows(ResponseStatusException.class, () -> service.getRunner("missing")); + + assertThat(e.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(e.getReason()).isEqualTo("App not found."); + } + + @Test + void getRunner_appFailsToLoad_throwsServerError() { + RunnerService service = runnerService(new FailingAppLoader(), null); + + ResponseStatusException e = + assertThrows(ResponseStatusException.class, () -> service.getRunner("broken")); + + assertThat(e.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(e.getReason()).isEqualTo("App failed to load."); + } + + @Test + void getRunner_agentLoader_runsTheAgentWithoutAnAppUnderAnyName() { + RunnerService service = + runnerService(null, new SingleAgentLoader("support.bot"), SERVER_PLUGIN); + + Runner runner = service.getRunner("support.bot"); + + assertThat(runner.appName()).isEqualTo("support.bot"); + assertThat(runner.agent()).isSameInstanceAs(AGENT); + assertThat(runner.pluginManager().getPlugins()).containsExactly(SERVER_PLUGIN); + } + + @Test + void getRunner_agentLoaderUnknownName_throwsNotFound() { + RunnerService service = runnerService(null, new SingleAgentLoader("support.bot")); + + ResponseStatusException e = + assertThrows(ResponseStatusException.class, () -> service.getRunner("missing")); + + assertThat(e.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(e.getReason()).isEqualTo("App not found."); + } + + @Test + void loadRoot_agentFailsToLoad_throwsIllegalStateException() { + AgentLoader loader = + new AgentLoader() { + @Override + public ImmutableList listAgents() { + return ImmutableList.of("broken"); + } + + @Override + public BaseAgent loadAgent(String name) { + throw new IllegalArgumentException("cannot build the agent"); + } + }; + RunnerService service = runnerService(null, loader); + + assertThrows(IllegalStateException.class, () -> service.loadRoot("broken")); + } + + @Test + void appLoaderConstructor_servesTheAppLoader() { + RunnerService service = + new RunnerService( + new InMemoryAppLoader(FLOW_APP), + new InMemoryArtifactService(), + new InMemorySessionService(), + new InMemoryMemoryService(), + ImmutableList.of()); + + assertThat(service.listApps()).containsExactly("flow_app"); + assertThat(service.loadRoot("flow_app")).hasValue(WORKFLOW); + } + + @Test + void agentLoaderConstructor_servesTheAgentLoader() { + RunnerService service = + new RunnerService( + new SingleAgentLoader("support.bot"), + new InMemoryArtifactService(), + new InMemorySessionService(), + new InMemoryMemoryService(), + ImmutableList.of()); + + assertThat(service.listApps()).containsExactly("support.bot"); + assertThat(service.loadRoot("support.bot")).hasValue(AGENT); + } + + @Test + void constructor_appLoaderAndDefaultCompiledLoader_servesTheAppLoader() { + RunnerService service = + runnerService( + new InMemoryAppLoader(FLOW_APP), new CompiledAgentLoader(new AgentLoadingProperties())); + + assertThat(service.listApps()).containsExactly("flow_app"); + } + + @Test + void constructor_appLoaderAndAnotherAgentLoader_throwsIllegalStateException() { + assertThrows( + IllegalStateException.class, + () -> runnerService(new InMemoryAppLoader(FLOW_APP), new SingleAgentLoader("support.bot"))); + } + + @Test + void constructor_appLoaderAndAnotherAgentLoaderNextToThePrimaryDefault_throwsIllegalState() { + DefaultListableBeanFactory beans = new DefaultListableBeanFactory(); + beans.registerSingleton("appLoader", new InMemoryAppLoader(FLOW_APP)); + // Registered like the server's default loader: a @Primary bean definition. + RootBeanDefinition compiled = + new RootBeanDefinition( + CompiledAgentLoader.class, () -> new CompiledAgentLoader(new AgentLoadingProperties())); + compiled.setPrimary(true); + beans.registerBeanDefinition("agentLoader", compiled); + beans.registerSingleton("customAgentLoader", new SingleAgentLoader("support.bot")); + + assertThrows( + IllegalStateException.class, + () -> + new RunnerService( + beans.getBeanProvider(AppLoader.class), + beans.getBeanProvider(AgentLoader.class), + new InMemoryArtifactService(), + new InMemorySessionService(), + new InMemoryMemoryService(), + ImmutableList.of())); + } + + @Test + void constructor_noLoader_throwsIllegalStateException() { + assertThrows(IllegalStateException.class, () -> runnerService(null, null)); + } + + private static RunnerService runnerService( + @Nullable AppLoader appLoader, @Nullable AgentLoader agentLoader, BasePlugin... plugins) { + DefaultListableBeanFactory beans = new DefaultListableBeanFactory(); + if (appLoader != null) { + beans.registerSingleton("appLoader", appLoader); + } + if (agentLoader != null) { + beans.registerSingleton("agentLoader", agentLoader); + } + return new RunnerService( + beans.getBeanProvider(AppLoader.class), + beans.getBeanProvider(AgentLoader.class), + new InMemoryArtifactService(), + new InMemorySessionService(), + new InMemoryMemoryService(), + ImmutableList.copyOf(plugins)); + } + + /** Serves {@link #AGENT} under one name. */ + private static final class SingleAgentLoader implements AgentLoader { + private final String name; + + SingleAgentLoader(String name) { + this.name = name; + } + + @Override + public ImmutableList listAgents() { + return ImmutableList.of(name); + } + + @Override + public BaseAgent loadAgent(String name) { + if (!name.equals(this.name)) { + throw new NoSuchElementException(); + } + return AGENT; + } + } + + /** Lists one app that fails to load. */ + private static final class FailingAppLoader implements AppLoader { + @Override + public ImmutableList listApps() { + return ImmutableList.of("broken"); + } + + @Override + public Optional loadApp(String appName) { + throw new IllegalArgumentException("cannot build the app"); + } + } +} diff --git a/dev/src/test/java/com/google/adk/web/testing/GraphFixtures.java b/dev/src/test/java/com/google/adk/web/testing/GraphFixtures.java new file mode 100644 index 000000000..2d69e4510 --- /dev/null +++ b/dev/src/test/java/com/google/adk/web/testing/GraphFixtures.java @@ -0,0 +1,87 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.web.testing; + +import com.google.adk.agents.BaseAgent; +import com.google.adk.agents.LlmAgent; +import com.google.adk.tools.BaseTool; +import com.google.adk.workflow.Edge; +import com.google.adk.workflow.Node; +import com.google.adk.workflow.Route; +import com.google.adk.workflow.Start; +import com.google.adk.workflow.Workflow; +import java.util.List; + +/** Graphs shared by the graph tests. */ +public final class GraphFixtures { + + /** + * A support desk: {@code classify} routes to a billing agent with a tool, a nested {@code + * tech_flow} workflow, or a default fallback, and every branch ends in {@code summarize}. + */ + public static Workflow supportWorkflow() { + Node classify = step("classify"); + BaseAgent billing = llmAgent("billing_agent", List.of(tool("lookup_invoice")), List.of()); + Node diagnose = step("diagnose"); + Workflow techFlow = + Workflow.builder() + .name("tech_flow") + .edges(new Edge(Start.INSTANCE, diagnose), new Edge(diagnose, step("fix"))) + .build(); + Node fallback = step("fallback"); + Node summarize = step("summarize"); + return Workflow.builder() + .name("support") + .edges( + new Edge(Start.INSTANCE, classify), + new Edge(classify, billing, new Route.Tag("billing")), + new Edge(classify, techFlow, new Route.Tag("tech")), + new Edge(classify, fallback, Route.Default.INSTANCE), + new Edge(billing, summarize), + new Edge(techFlow, summarize), + new Edge(fallback, summarize)) + .build(); + } + + /** An agent tree: {@code assistant} delegates to {@code helper}, which has a tool. */ + public static BaseAgent assistantAgent() { + return llmAgent( + "assistant", + List.of(), + List.of(llmAgent("helper", List.of(tool("lookup_invoice")), List.of()))); + } + + public static Node step(String name) { + return new StepNode(name); + } + + /** An agent with {@code tools}, which may hold tools and toolsets, and {@code subAgents}. */ + public static LlmAgent llmAgent(String name, List tools, List subAgents) { + return LlmAgent.builder() + .name(name) + .model("gemini-2.5-flash") + .tools(tools) + .subAgents(subAgents) + .build(); + } + + public static BaseTool tool(String name) { + return new BaseTool(name, "test tool") {}; + } + + private GraphFixtures() {} +} diff --git a/dev/src/test/java/com/google/adk/web/testing/StepNode.java b/dev/src/test/java/com/google/adk/web/testing/StepNode.java new file mode 100644 index 000000000..acbb27c7d --- /dev/null +++ b/dev/src/test/java/com/google/adk/web/testing/StepNode.java @@ -0,0 +1,35 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.web.testing; + +import com.google.adk.agents.Context; +import com.google.adk.workflow.BaseNode; +import io.reactivex.rxjava3.core.Flowable; +import org.jspecify.annotations.Nullable; + +/** A workflow node that outputs its own name, for tests that run a workflow without a model. */ +public final class StepNode extends BaseNode { + + public StepNode(String name) { + super(name, ""); + } + + @Override + public Flowable runNode(Context context, @Nullable Object nodeInput) { + return Flowable.just(name()); + } +}