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..5c7e31646
--- /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, though the server's mapper writes camelCase, 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..90460821d
--- /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 (RuntimeException 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/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/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 extends BaseAgent> 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() {}
+}