use crate::application::{UiApplication, UiApplicationPage};
use crate::compat::{Result, Tool, ToolContext};
use crate::schema::{Component, UiResponse};
use crate::tools::{LegacyProtocolOptions, render_ui_response_with_protocol};
use async_trait::async_trait;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use std::sync::Arc;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct RenderAppParams {
pub application: UiApplication,
#[serde(flatten)]
pub protocol: LegacyProtocolOptions,
}
pub struct RenderAppTool;
impl RenderAppTool {
pub fn new() -> Self {
Self
}
}
impl Default for RenderAppTool {
fn default() -> Self {
Self::new()
}
}
fn page_components(page: &UiApplicationPage) -> Vec<Component> {
let mut components = Vec::new();
if let Some(hero) = &page.hero {
components.extend(hero.content.clone());
components.extend(hero.actions.clone());
components.extend(hero.visual.clone());
}
for region in &page.regions {
components.extend(region.components.clone());
}
components.extend(page.aside.clone());
components.extend(page.footer.clone());
components
}
fn add_application_fields(
primary: Value,
application: &UiApplication,
surfaces: Vec<Value>,
) -> Result<Value> {
let mut object = match primary {
Value::Object(object) => object,
other => {
let mut object = Map::new();
object.insert("payload".to_string(), other);
object
}
};
object.insert(
"application".to_string(),
serde_json::to_value(application).map_err(|error| {
crate::compat::AdkError::tool(format!("Failed to serialize application: {error}"))
})?,
);
object.insert("surfaces".to_string(), Value::Array(surfaces));
object.insert(
"application_version".to_string(),
json!(application.version),
);
Ok(Value::Object(object))
}
#[async_trait]
impl Tool for RenderAppTool {
fn name(&self) -> &str {
"render_app"
}
fn description(&self) -> &str {
"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."
}
fn parameters_schema(&self) -> Option<Value> {
Some(super::generate_gemini_schema::<RenderAppParams>())
}
async fn execute(&self, ctx: Arc<dyn ToolContext>, args: Value) -> Result<Value> {
let mut params: RenderAppParams = serde_json::from_value(args).map_err(|error| {
crate::compat::AdkError::tool(format!("Invalid parameters: {error}"))
})?;
params
.application
.normalize_metadata(crate::surface_runtime::surface_owner(ctx.as_ref()));
params.application.validate().map_err(|errors| {
crate::compat::AdkError::tool(format!("Invalid application: {}", errors.join("; ")))
})?;
let mut surfaces = Vec::with_capacity(params.application.pages.len());
for page in ¶ms.application.pages {
let mut options = params.protocol.clone();
options.surface_id = Some(format!("{}-{}", params.application.id, page.id));
if options.kit_id.is_none() {
options.kit_id = params.application.kit_id.clone();
}
let ui = UiResponse::new(page_components(page))
.with_id(format!("{}:{}", params.application.id, page.id));
let ui = match ¶ms.application.kit_id {
Some(kit_id) => ui.with_kit_id(kit_id),
None => ui,
};
surfaces.push(render_ui_response_with_protocol(ui, &options, &page.id)?);
}
let primary_index = params
.application
.pages
.iter()
.position(|page| page.route == params.application.initial_route)
.unwrap_or(0);
let primary = surfaces
.get(primary_index)
.cloned()
.unwrap_or_else(|| json!({}));
let output = if params.protocol.protocol.is_none() {
json!({
"application_version": params.application.version,
"application": ¶ms.application,
"surfaces": surfaces,
})
} else {
add_application_fields(primary, ¶ms.application, surfaces)?
};
let refs = params
.application
.pages
.iter()
.map(|page| {
crate::surface_runtime::next_surface_ref(
&ctx,
format!("{}-{}", params.application.id, page.id),
)
})
.collect::<Vec<_>>();
if let Some(active) = refs.get(primary_index) {
crate::surface_runtime::record_surface_refs(&ctx, active, &refs);
}
Ok(output)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::application::*;
use crate::compat::{Content, EventActions, ReadonlyContext};
use crate::interop::UiProtocol;
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::Mutex;
struct TestContext {
content: Content,
actions: Mutex<EventActions>,
}
#[async_trait]
impl ReadonlyContext for TestContext {
fn invocation_id(&self) -> &str {
"test"
}
fn agent_name(&self) -> &str {
"test"
}
fn user_id(&self) -> &str {
"user"
}
fn app_name(&self) -> &str {
"app"
}
fn session_id(&self) -> &str {
"session"
}
fn branch(&self) -> &str {
""
}
fn user_content(&self) -> &Content {
&self.content
}
}
#[async_trait]
impl crate::compat::CallbackContext for TestContext {
fn artifacts(&self) -> Option<Arc<dyn crate::compat::Artifacts>> {
None
}
}
#[async_trait]
impl ToolContext for TestContext {
fn function_call_id(&self) -> &str {
"call"
}
fn actions(&self) -> EventActions {
self.actions.lock().unwrap().clone()
}
fn set_actions(&self, actions: EventActions) {
*self.actions.lock().unwrap() = actions;
}
async fn search_memory(
&self,
_: &str,
) -> crate::compat::Result<Vec<crate::compat::MemoryEntry>> {
Ok(vec![])
}
}
fn application() -> UiApplication {
UiApplication {
id: "app".into(),
name: "App".into(),
version: 1,
owner: "test".into(),
updated_at: chrono::Utc::now().to_rfc3339(),
kit_id: None,
initial_route: "/".into(),
navigation: vec![],
state: HashMap::new(),
pages: vec![UiApplicationPage {
id: "home".into(),
route: "/".into(),
title: "Home".into(),
description: None,
template: UiPageTemplate::Dashboard,
eyebrow: None,
hero: None,
regions: vec![UiPageRegion {
id: "main".into(),
title: None,
description: None,
layout: UiRegionLayout::Stack,
columns: 1,
tone: UiRegionTone::Default,
components: vec![crate::schema::Component::Text(crate::schema::Text {
id: None,
content: "Hello".into(),
variant: crate::schema::TextVariant::Body,
})],
}],
aside: vec![],
footer: vec![],
atmosphere: UiPageAtmosphere::Clean,
}],
}
}
#[tokio::test]
async fn emits_application_and_protocol_fallback_surfaces() {
let args = serde_json::to_value(RenderAppParams {
application: application(),
protocol: LegacyProtocolOptions {
protocol: Some(UiProtocol::A2ui),
..Default::default()
},
})
.unwrap();
let ctx: Arc<dyn ToolContext> = Arc::new(TestContext {
content: Content::new("user"),
actions: Mutex::new(EventActions::default()),
});
let value = RenderAppTool.execute(ctx, args).await.unwrap();
assert_eq!(value["protocol"], "a2ui");
assert_eq!(value["application"]["initial_route"], "/");
assert_eq!(value["surfaces"].as_array().unwrap().len(), 1);
}
}