Skip to main content

adk_ui/tools/
render_app.rs

1use crate::application::{UiApplication, UiApplicationPage};
2use crate::compat::{Result, Tool, ToolContext};
3use crate::schema::{Component, UiResponse};
4use crate::tools::{LegacyProtocolOptions, render_ui_response_with_protocol};
5use async_trait::async_trait;
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8use serde_json::{Map, Value, json};
9use std::sync::Arc;
10
11/// Parameters for rendering a complete, navigable application.
12#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
13pub struct RenderAppParams {
14    pub application: UiApplication,
15    /// Optional protocol output configuration. Every page is also emitted as an
16    /// independent surface for clients that do not understand application graphs.
17    #[serde(flatten)]
18    pub protocol: LegacyProtocolOptions,
19}
20
21pub struct RenderAppTool;
22
23impl RenderAppTool {
24    pub fn new() -> Self {
25        Self
26    }
27}
28
29impl Default for RenderAppTool {
30    fn default() -> Self {
31        Self::new()
32    }
33}
34
35fn page_components(page: &UiApplicationPage) -> Vec<Component> {
36    let mut components = Vec::new();
37    if let Some(hero) = &page.hero {
38        components.extend(hero.content.clone());
39        components.extend(hero.actions.clone());
40        components.extend(hero.visual.clone());
41    }
42    for region in &page.regions {
43        components.extend(region.components.clone());
44    }
45    components.extend(page.aside.clone());
46    components.extend(page.footer.clone());
47    components
48}
49
50fn add_application_fields(
51    primary: Value,
52    application: &UiApplication,
53    surfaces: Vec<Value>,
54) -> Result<Value> {
55    let mut object = match primary {
56        Value::Object(object) => object,
57        other => {
58            let mut object = Map::new();
59            object.insert("payload".to_string(), other);
60            object
61        }
62    };
63    object.insert(
64        "application".to_string(),
65        serde_json::to_value(application).map_err(|error| {
66            crate::compat::AdkError::tool(format!("Failed to serialize application: {error}"))
67        })?,
68    );
69    object.insert("surfaces".to_string(), Value::Array(surfaces));
70    object.insert(
71        "application_version".to_string(),
72        json!(application.version),
73    );
74    Ok(Value::Object(object))
75}
76
77#[async_trait]
78impl Tool for RenderAppTool {
79    fn name(&self) -> &str {
80        "render_app"
81    }
82
83    fn description(&self) -> &str {
84        "Render a complete multi-page application with brand-aware page composition, navigation, shared state, and optional declarative 3D scenes. Use this instead of render_page for products, sites, dashboards, portals, and end-to-end workflows with multiple routes."
85    }
86
87    fn parameters_schema(&self) -> Option<Value> {
88        Some(super::generate_gemini_schema::<RenderAppParams>())
89    }
90
91    async fn execute(&self, ctx: Arc<dyn ToolContext>, args: Value) -> Result<Value> {
92        let mut params: RenderAppParams = serde_json::from_value(args).map_err(|error| {
93            crate::compat::AdkError::tool(format!("Invalid parameters: {error}"))
94        })?;
95        params
96            .application
97            .normalize_metadata(crate::surface_runtime::surface_owner(ctx.as_ref()));
98        params.application.validate().map_err(|errors| {
99            crate::compat::AdkError::tool(format!("Invalid application: {}", errors.join("; ")))
100        })?;
101
102        let mut surfaces = Vec::with_capacity(params.application.pages.len());
103        for page in &params.application.pages {
104            let mut options = params.protocol.clone();
105            options.surface_id = Some(format!("{}-{}", params.application.id, page.id));
106            if options.kit_id.is_none() {
107                options.kit_id = params.application.kit_id.clone();
108            }
109            let ui = UiResponse::new(page_components(page))
110                .with_id(format!("{}:{}", params.application.id, page.id));
111            let ui = match &params.application.kit_id {
112                Some(kit_id) => ui.with_kit_id(kit_id),
113                None => ui,
114            };
115            surfaces.push(render_ui_response_with_protocol(ui, &options, &page.id)?);
116        }
117
118        let primary_index = params
119            .application
120            .pages
121            .iter()
122            .position(|page| page.route == params.application.initial_route)
123            .unwrap_or(0);
124        let primary = surfaces
125            .get(primary_index)
126            .cloned()
127            .unwrap_or_else(|| json!({}));
128        let output = if params.protocol.protocol.is_none() {
129            json!({
130                "application_version": params.application.version,
131                "application": &params.application,
132                "surfaces": surfaces,
133            })
134        } else {
135            add_application_fields(primary, &params.application, surfaces)?
136        };
137
138        let refs = params
139            .application
140            .pages
141            .iter()
142            .map(|page| {
143                crate::surface_runtime::next_surface_ref(
144                    &ctx,
145                    format!("{}-{}", params.application.id, page.id),
146                )
147            })
148            .collect::<Vec<_>>();
149        if let Some(active) = refs.get(primary_index) {
150            crate::surface_runtime::record_surface_refs(&ctx, active, &refs);
151        }
152        Ok(output)
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use crate::application::*;
160    use crate::compat::{Content, EventActions, ReadonlyContext};
161    use crate::interop::UiProtocol;
162    use async_trait::async_trait;
163    use std::collections::HashMap;
164    use std::sync::Mutex;
165
166    struct TestContext {
167        content: Content,
168        actions: Mutex<EventActions>,
169    }
170    #[async_trait]
171    impl ReadonlyContext for TestContext {
172        fn invocation_id(&self) -> &str {
173            "test"
174        }
175        fn agent_name(&self) -> &str {
176            "test"
177        }
178        fn user_id(&self) -> &str {
179            "user"
180        }
181        fn app_name(&self) -> &str {
182            "app"
183        }
184        fn session_id(&self) -> &str {
185            "session"
186        }
187        fn branch(&self) -> &str {
188            ""
189        }
190        fn user_content(&self) -> &Content {
191            &self.content
192        }
193    }
194    #[async_trait]
195    impl crate::compat::CallbackContext for TestContext {
196        fn artifacts(&self) -> Option<Arc<dyn crate::compat::Artifacts>> {
197            None
198        }
199    }
200    #[async_trait]
201    impl ToolContext for TestContext {
202        fn function_call_id(&self) -> &str {
203            "call"
204        }
205        fn actions(&self) -> EventActions {
206            self.actions.lock().unwrap().clone()
207        }
208        fn set_actions(&self, actions: EventActions) {
209            *self.actions.lock().unwrap() = actions;
210        }
211        async fn search_memory(
212            &self,
213            _: &str,
214        ) -> crate::compat::Result<Vec<crate::compat::MemoryEntry>> {
215            Ok(vec![])
216        }
217    }
218
219    fn application() -> UiApplication {
220        UiApplication {
221            id: "app".into(),
222            name: "App".into(),
223            version: 1,
224            owner: "test".into(),
225            updated_at: chrono::Utc::now().to_rfc3339(),
226            kit_id: None,
227            initial_route: "/".into(),
228            navigation: vec![],
229            state: HashMap::new(),
230            pages: vec![UiApplicationPage {
231                id: "home".into(),
232                route: "/".into(),
233                title: "Home".into(),
234                description: None,
235                template: UiPageTemplate::Dashboard,
236                eyebrow: None,
237                hero: None,
238                regions: vec![UiPageRegion {
239                    id: "main".into(),
240                    title: None,
241                    description: None,
242                    layout: UiRegionLayout::Stack,
243                    columns: 1,
244                    tone: UiRegionTone::Default,
245                    components: vec![crate::schema::Component::Text(crate::schema::Text {
246                        id: None,
247                        content: "Hello".into(),
248                        variant: crate::schema::TextVariant::Body,
249                    })],
250                }],
251                aside: vec![],
252                footer: vec![],
253                atmosphere: UiPageAtmosphere::Clean,
254            }],
255        }
256    }
257
258    #[tokio::test]
259    async fn emits_application_and_protocol_fallback_surfaces() {
260        let args = serde_json::to_value(RenderAppParams {
261            application: application(),
262            protocol: LegacyProtocolOptions {
263                protocol: Some(UiProtocol::A2ui),
264                ..Default::default()
265            },
266        })
267        .unwrap();
268        let ctx: Arc<dyn ToolContext> = Arc::new(TestContext {
269            content: Content::new("user"),
270            actions: Mutex::new(EventActions::default()),
271        });
272        let value = RenderAppTool.execute(ctx, args).await.unwrap();
273        assert_eq!(value["protocol"], "a2ui");
274        assert_eq!(value["application"]["initial_route"], "/");
275        assert_eq!(value["surfaces"].as_array().unwrap().len(), 1);
276    }
277}