diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml index 3f260f0..fde544b 100644 --- a/.github/workflows/web-ci.yml +++ b/.github/workflows/web-ci.yml @@ -6,16 +6,22 @@ on: pull_request: branches: [ main ] +permissions: + contents: read + jobs: build: - runs-on: ubuntu-latest + # zizmor: ignore[unpinned-images] + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Use Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0 with: - node-version: '20' + node-version: '22' cache: 'npm' cache-dependency-path: client/web/package-lock.json @@ -24,7 +30,7 @@ jobs: working-directory: client/web - name: Type check - run: npx tsc --noEmit + run: npx tsc --build working-directory: client/web - name: Build diff --git a/LICENSE b/LICENSE index 261eeb9..3bf56e2 100644 --- a/LICENSE +++ b/LICENSE @@ -199,3 +199,43 @@ 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. + + +============================================================================== +The MIT License (MIT) + +Copyright (c) +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +============================================================================== +ISC License + +Copyright (c) + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. diff --git a/agent/python_agent/agent.py b/agent/python_agent/agent.py index 1df4845..19182fb 100644 --- a/agent/python_agent/agent.py +++ b/agent/python_agent/agent.py @@ -12,59 +12,69 @@ # See the License for the specific language governing permissions and # limitations under the License. +"""MAUI Agent definition and implementation.""" + +from collections import OrderedDict +from collections.abc import AsyncIterable import json import logging -import pathlib import os -from collections import OrderedDict -from collections.abc import AsyncIterable -from typing import Any, Optional, Dict +import pathlib +from typing import Any -import jsonschema from a2a.types import ( AgentCapabilities, AgentCard, - AgentSkill, DataPart, Part, TextPart, ) +from google.adk import skills as adk_skills from google.adk.agents import run_config from google.adk.agents.llm_agent import LlmAgent -from google.adk.artifacts import InMemoryArtifactService +from google.adk.artifacts import in_memory_artifact_service from google.adk.memory.in_memory_memory_service import InMemoryMemoryService from google.adk.models.lite_llm import LiteLlm from google.adk.runners import Runner -from google.adk.sessions import InMemorySessionService -from google.genai import types -from google.adk.tools.mcp_tool import McpToolset -from google.adk.tools.mcp_tool.mcp_session_manager import StreamableHTTPConnectionParams -from google.adk.skills import load_skill_from_dir +from google.adk.sessions import in_memory_session_service from google.adk.tools import skill_toolset -from a2ui.schema.constants import VERSION_0_8, VERSION_0_9, A2UI_OPEN_TAG, A2UI_CLOSE_TAG -from a2ui.schema.manager import A2uiSchemaManager -from a2ui.parser.parser import parse_response, ResponsePart -from a2ui.schema.common_modifiers import remove_strict_validation -from a2ui.a2a.extension import get_a2ui_agent_extension +from google.adk.tools.mcp_tool.mcp_session_manager import StreamableHTTPConnectionParams +from google.adk.tools.mcp_tool.mcp_toolset import McpToolset +from google.genai import types +import jsonschema + +import a2ui.a2a.extension as a2ui_extension from a2ui.a2a.parts import parse_response_to_parts, stream_response_to_parts +from a2ui.basic_catalog.provider import BundledCatalogProvider +from a2ui.parser.parser import parse_response +from a2ui.parser.streaming import A2uiStreamParser from a2ui.schema.catalog import CatalogConfig from a2ui.schema.catalog_provider import A2uiCatalogProvider -from a2ui.basic_catalog.provider import BundledCatalogProvider +from a2ui.schema.common_modifiers import remove_strict_validation +from a2ui.schema.constants import A2UI_CLOSE_TAG, A2UI_OPEN_TAG, VERSION_0_9 +from a2ui.schema.manager import A2uiSchemaManager logger = logging.getLogger(__name__) +InMemorySessionService = in_memory_session_service.InMemorySessionService +InMemoryArtifactService = in_memory_artifact_service.InMemoryArtifactService _SKILL_BASE_PATH = pathlib.Path(__file__).parent / "skills" google_maps_api_key = os.environ.get("GOOGLE_MAPS_API_KEY") if not google_maps_api_key: - # Fallback or direct assignment for testing - NOT RECOMMENDED FOR PRODUCTION - google_maps_api_key = "YOUR_GOOGLE_MAPS_API_KEY_HERE" # Replace if not using env var - if google_maps_api_key == "YOUR_GOOGLE_MAPS_API_KEY_HERE": - print("WARNING: GOOGLE_MAPS_API_KEY is not set. Please set it as an environment variable or in the script.") + # Fallback or direct assignment for testing - NOT RECOMMENDED FOR PRODUCTION + google_maps_api_key = ( # Replace if not using env var + "YOUR_GOOGLE_MAPS_API_KEY_HERE" + ) + if google_maps_api_key == "YOUR_GOOGLE_MAPS_API_KEY_HERE": + print( + "WARNING: GOOGLE_MAPS_API_KEY is not set. Please set it as an" + " environment variable or in the script." + ) -AGENT_INSTRUCTION = f""" +AGENT_INSTRUCTION = """ You are a helpful location expert and assistant. Your goal is to help provide effective answers to a user's location based questions. To achieve this, you MUST follow this logic: @@ -90,28 +100,41 @@ * if the user asks for "sushi restaurants in seattle", and then asks for "how about in Redmond?", you should assume that they are asking for a new set of _sushi_ restaurants based on their previous query. **Important**: When using the `google-maps-enriched-local-query-response` skill, you MUST respond with EXACTLY ONE ... block. + All A2UI message objects (e.g., `createSurface`, `updateComponents`, `updateDataModel`) MUST include a `"version": "v0.9"` property. + When generating a `PlaceCard`, you MUST explicitly set the `"orientation"` property: use `"vertical"` for single results and `"horizontal"` for lists. If you have more than one of these blocks, the UI will not render correctly. """ + class MergedCatalogProvider(A2uiCatalogProvider): """Dynamically loads the bundled basic catalog and extends it with local definitions.""" + def __init__(self, version: str, extension_catalog_path: str): self.version = version self.extension_catalog_path = extension_catalog_path - def load(self) -> Dict[str, Any]: + def load(self) -> dict[str, Any]: # 1. Load the bundled base catalog from the package base_provider = BundledCatalogProvider(self.version) catalog = base_provider.load() # 2. Load extension definitions from local JSON - with open(self.extension_catalog_path, 'r') as f: + with open(self.extension_catalog_path, "r") as f: overrides = json.load(f) # 3. Merge custom extensions into the schema if "components" in overrides: catalog.setdefault("components", {}).update(overrides["components"]) + any_comp = catalog.setdefault("$defs", {}).setdefault("anyComponent", {}) + one_of = any_comp.setdefault("oneOf", []) + existing_refs = { + item.get("$ref") for item in one_of if isinstance(item, dict) + } + for comp_name in overrides["components"]: + ref = f"#/components/{comp_name}" + if ref not in existing_refs: + one_of.append({"$ref": ref}) if "$defs" in overrides: catalog.setdefault("$defs", {}).update(overrides["$defs"]) if "catalogId" in overrides: @@ -125,14 +148,23 @@ class MAUIAgent: SUPPORTED_CONTENT_TYPES = ["text", "text/plain"] - def __init__(self, base_url: str, agent_name: str = "MAUI Agent"): + def __init__( + self, + base_url: str, + agent_name: str = "MAUI Agent", + model_name: str = "gemini/gemini-3-flash-preview", + ) -> None: self.base_url = base_url self._agent_name = agent_name + self._model_name = model_name self._user_id = "remote_agent" - self._text_runner: Optional[Runner] = self._build_runner(self._build_llm_agent()) + self._shared_session_service = InMemorySessionService() + self._text_runner: Runner | None = self._build_runner( + self._build_llm_agent() + ) - self._schema_managers: Dict[str, A2uiSchemaManager] = {} - self._ui_runners: Dict[str, Runner] = {} + self._schema_managers: dict[str, A2uiSchemaManager] = {} + self._ui_runners: dict[str, Runner] = {} self._parsers = OrderedDict() self._max_parsers = 1000 # Max active sessions to keep in memory @@ -149,6 +181,7 @@ def agent_card(self) -> AgentCard: return self._agent_card def _build_schema_manager(self, version: str) -> A2uiSchemaManager: + """Builds the schema manager for a specific protocol version.""" # Try sibling directory first (local dev) extension_path = ( pathlib.Path(__file__).parent.parent @@ -158,40 +191,48 @@ def _build_schema_manager(self, version: str) -> A2uiSchemaManager: ) # Fallback to nested directory (deployed environment) if not extension_path.exists(): - extension_path = ( - pathlib.Path(__file__).parent - / "shared" - / "schema" - / "maps_catalog_extension.json" - ) + extension_path = ( + pathlib.Path(__file__).parent + / "shared" + / "schema" + / "maps_catalog_extension.json" + ) return A2uiSchemaManager( version=version, catalogs=[ CatalogConfig( name="maps-agentic-ui-catalog", - provider=MergedCatalogProvider(version, str(extension_path)) + provider=MergedCatalogProvider(version, str(extension_path)), ) ], schema_modifiers=[remove_strict_validation], ) def make_grounding_lite_mcp(self): + """Creates an MCP toolset for grounding with Google Maps tools.""" + headers = {} + if ( + google_maps_api_key + and google_maps_api_key != "YOUR_GOOGLE_MAPS_API_KEY_HERE" + ): + headers["X-Goog-Api-Key"] = google_maps_api_key return McpToolset( - connection_params=StreamableHTTPConnectionParams( - url="https://mapstools.googleapis.com/mcp", - headers={"X-Goog-Api-Key": google_maps_api_key} if google_maps_api_key and google_maps_api_key != "YOUR_GOOGLE_MAPS_API_KEY_HERE" else {}, - timeout=30.0, - ), - # You can filter for specific Maps tools if needed: - # tool_filter=['get_directions', 'find_place_by_id'] - ) + connection_params=StreamableHTTPConnectionParams( + url="https://mapstools.googleapis.com/mcp", + headers=headers, + timeout=30.0, + ), + # You can filter for specific Maps tools if needed: + # tool_filter=['get_directions', 'find_place_by_id'] + ) def _build_agent_card(self) -> AgentCard: + """Builds the A2UI agent card with streaming and extension capabilities.""" extensions = [] if self._schema_managers: for version, sm in self._schema_managers.items(): - ext = get_a2ui_agent_extension( + ext = a2ui_extension.get_a2ui_agent_extension( version, sm.accepts_inline_catalogs, sm.supported_catalog_ids, @@ -204,8 +245,11 @@ def _build_agent_card(self) -> AgentCard: ) return AgentCard( - name="AI Kit Agent", - description="This agent can provide Google Maps UI-enriched responses to relevant prompts", + name="Agentic UI ToolKit", + description=( + "This agent can provide Google Maps UI-enriched responses to" + " relevant prompts" + ), url=self.base_url, version="1.0.0", default_input_modes=MAUIAgent.SUPPORTED_CONTENT_TYPES, @@ -219,7 +263,7 @@ def _build_runner(self, agent: LlmAgent) -> Runner: app_name=self._agent_name, agent=agent, artifact_service=InMemoryArtifactService(), - session_service=InMemorySessionService(), + session_service=self._shared_session_service, memory_service=InMemoryMemoryService(), ) @@ -227,47 +271,47 @@ def get_processing_message(self) -> str: return "Working on it..." def _build_llm_agent( - self, schema_manager: Optional[A2uiSchemaManager] = None + self, schema_manager: A2uiSchemaManager | None = None ) -> LlmAgent: - """Builds the LLM agent for the AI Kit agent.""" - LITELLM_MODEL = os.getenv("LITELLM_MODEL", "gemini/gemini-2.5-flash") - - + """Builds the LLM agent for the Agentic UI ToolKit.""" skill_names = [ "google-maps-enriched-local-query-response", ] skills = [] for name in skill_names: - skills.append(load_skill_from_dir(_SKILL_BASE_PATH / name)) + skills.append(adk_skills.load_skill_from_dir(_SKILL_BASE_PATH / name)) skill_manager_tool = skill_toolset.SkillToolset(skills=skills) grounding_lite_mcp = self.make_grounding_lite_mcp() - instruction = ( - schema_manager.generate_system_prompt( - role_description=AGENT_INSTRUCTION, - include_schema=True, - include_examples=False, - validate_examples=False, - ) - if schema_manager - else AGENT_INSTRUCTION - ) + if schema_manager: + instruction = schema_manager.generate_system_prompt( + role_description=AGENT_INSTRUCTION, + include_schema=True, + include_examples=False, + validate_examples=False, + ) + else: + instruction = AGENT_INSTRUCTION return LlmAgent( - model=LiteLlm(model="gemini/gemini-3-flash-preview"), + model=LiteLlm(model=self._model_name), name="maui_agent", - description="An agent that can provide Google Maps UI-enriched responses to relevant prompts", + description=( + "An agent that can provide Google Maps UI-enriched responses to" + " relevant prompts" + ), instruction=instruction, tools=[grounding_lite_mcp, skill_manager_tool], ) async def stream( - self, query, session_id, ui_version: Optional[str] = None + self, query, session_id, ui_version: str | None = None ) -> AsyncIterable[dict[str, Any]]: + """Streams responses for a user query.""" session_state = {"base_url": self.base_url, "expression": "{expression}"} - # Determine which runner to use based on whether the a2ui extension is active. + # Determine which runner to use based on active UI extension version. if ui_version: runner = self._ui_runners[ui_version] schema_manager = self._schema_managers[ui_version] @@ -300,7 +344,9 @@ async def stream( current_query_text = query # Ensure schema was loaded - if ui_version and (not selected_catalog or not selected_catalog.catalog_schema): + if ui_version and ( + not selected_catalog or not selected_catalog.catalog_schema + ): logger.error( "--- MAUIAgent.stream: A2UI_SCHEMA is not loaded. " "Cannot perform UI validation. ---" @@ -311,8 +357,9 @@ async def stream( Part( root=TextPart( text=( - "I'm sorry, I'm facing an internal configuration error with" - " my UI components. Please contact support." + "I'm sorry, I'm facing an internal configuration" + " error with my UI components. Please contact" + " support." ) ) ) @@ -323,8 +370,10 @@ async def stream( while attempt <= max_retries: attempt += 1 logger.info( - f"--- MAUIAgent.stream: Attempt {attempt}/{max_retries + 1} " - f"for session {session_id} ---" + "--- MAUIAgent.stream: Attempt %d/%d for session %s ---", + attempt, + max_retries + 1, + session_id, ) current_message = types.Content( @@ -350,29 +399,26 @@ async def token_stream(): if selected_catalog: logger.info( - f"--- MAUIAgent.stream: Using A2UI stream parser for catalog {selected_catalog.catalog_id} ---" + "--- MAUIAgent.stream: Using A2UI stream parser for catalog %s ---", + selected_catalog.catalog_id, ) - from a2ui.parser.streaming import A2uiStreamParser if session_id in self._parsers: self._parsers.move_to_end(session_id) else: - self._parsers[session_id] = A2uiStreamParser() + self._parsers[session_id] = A2uiStreamParser(selected_catalog) if len(self._parsers) > self._max_parsers: self._parsers.popitem(last=False) - logger.info( - f"--- MAUIAgent.stream: Streamed part: {token_stream()} ---" + "--- MAUIAgent.stream: Streamed part: %s ---", token_stream() ) async for part in stream_response_to_parts( self._parsers[session_id], token_stream(), ): - logger.info( - f"-- MAUIAgent.stream: Streamed part: {part} ---" - ) + logger.info("-- MAUIAgent.stream: Streamed part: %s ---", part) yield { "is_task_complete": False, "parts": [part], @@ -387,7 +433,8 @@ async def token_stream(): final_response_content = "".join(full_content_list) logger.info( - f"-- MAUIAgent.stream: Final response content: {final_response_content} ---" + "-- MAUIAgent.stream: Final response content: %s ---", + final_response_content, ) is_valid = False @@ -395,14 +442,16 @@ async def token_stream(): if ui_version: logger.info( - "--- MAUIAgent.stream: Validating UI response (Attempt" - f" {attempt})... ---" + "--- MAUIAgent.stream: Validating UI response (Attempt %d)... ---", + attempt, ) try: - logger.info(f"--- MAUIAgent.stream: Final response content: {final_response_content} ---") + logger.info( + "--- MAUIAgent.stream: Final response content: %s ---", + final_response_content, + ) response_parts = parse_response(final_response_content) - for part in response_parts: if not part.a2ui_json: continue @@ -419,8 +468,9 @@ async def token_stream(): # --- End Validation Steps --- logger.info( - "--- MAUIAgent.stream: UI JSON successfully parsed AND validated" - f" against schema. Validation OK (Attempt {attempt}). ---" + "--- MAUIAgent.stream: UI JSON successfully parsed AND" + " validated against schema. Validation OK (Attempt %d). ---", + attempt, ) is_valid = True @@ -430,14 +480,19 @@ async def token_stream(): jsonschema.exceptions.ValidationError, ) as e: logger.warning( - f"--- final content full_content_list {full_content_list} ---") + "--- final content full_content_list %s ---", + full_content_list, + ) logger.warning( - f"--- MAUIAgent.stream: A2UI validation failed: {e} (Attempt" - f" {attempt}) ---" + "--- MAUIAgent.stream: A2UI validation failed: %s (Attempt" + " %d) ---", + e, + attempt, ) logger.warning( - f"--- Failed response content: {final_response_content[:500]}... ---" + "--- Failed response content: %s... ---", + final_response_content[:500], ) error_message = f"Validation failed: {e}." @@ -447,30 +502,32 @@ async def token_stream(): if is_valid: logger.info( "--- MAUIAgent.stream: Response is valid. Sending final response" - f" (Attempt {attempt}). ---" + " (Attempt %d). ---", + attempt, ) final_parts = parse_response_to_parts( final_response_content, fallback_text="OK." ) - logger.info(f"--- MAUIAgent.stream: Final response parts: {final_parts} ---") + logger.info( + "--- MAUIAgent.stream: Final response parts: %s ---", final_parts + ) seen_fingerprints = set() filtered_parts = [] for p in final_parts: - if isinstance(p.root, DataPart): - fingerprint = ("data", json.dumps(p.root.data, sort_keys=True)) - elif isinstance(p.root, TextPart): - fingerprint = ("text", p.root.text) - else: - fingerprint = ("other", str(p.root)) - - if fingerprint not in seen_fingerprints: - seen_fingerprints.add(fingerprint) - filtered_parts.append(p) + if isinstance(p.root, DataPart): + fingerprint = ("data", json.dumps(p.root.data, sort_keys=True)) + elif isinstance(p.root, TextPart): + fingerprint = ("text", p.root.text) + else: + fingerprint = ("other", str(p.root)) + + if fingerprint not in seen_fingerprints: + seen_fingerprints.add(fingerprint) + filtered_parts.append(p) final_parts = filtered_parts - yield { "is_task_complete": True, "parts": final_parts, @@ -481,15 +538,18 @@ async def token_stream(): if attempt <= max_retries: logger.warning( - f"--- MAUIAgent.stream: Retrying... ({attempt}/{max_retries + 1}) ---" + "--- MAUIAgent.stream: Retrying... (%d/%d) ---", + attempt, + max_retries + 1, ) # Prepare the query for the retry current_query_text = ( - f"Your previous response was invalid. {error_message} You MUST generate a" - " valid response that strictly follows the A2UI JSON SCHEMA. The response" - " MUST be a JSON list of A2UI messages. Ensure each JSON part is wrapped in" - f" '{A2UI_OPEN_TAG}' and '{A2UI_CLOSE_TAG}' tags. Please retry the" - f" original request: '{query}'" + f"Your previous response was invalid. {error_message} You MUST" + " generate a valid response that strictly follows the A2UI JSON" + " SCHEMA. The response MUST be a JSON list of A2UI messages." + f" Ensure each JSON part is wrapped in '{A2UI_OPEN_TAG}' and" + f" '{A2UI_CLOSE_TAG}' tags. Please retry the original request:" + f" '{query}'" ) # Loop continues... @@ -504,8 +564,9 @@ async def token_stream(): Part( root=TextPart( text=( - "I'm sorry, I'm having trouble generating the interface for" - " that request right now. Please try again in a moment." + "I'm sorry, I'm having trouble generating the interface" + " for that request right now. Please try again in a" + " moment." ) ) ) diff --git a/agent/python_agent/agent_config.py b/agent/python_agent/agent_config.py new file mode 100644 index 0000000..78c15b3 --- /dev/null +++ b/agent/python_agent/agent_config.py @@ -0,0 +1,62 @@ +# 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 +# +# https://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. + +"""Configuration schemas for MAUI Agent with templates.""" + +import dataclasses +import enum +from typing import Optional + + +class FallbackMode(str, enum.Enum): + """Fallback strategy when template validation fails.""" + + TEXT = "TEXT" + DYNAMIC = "DYNAMIC" + + +@dataclasses.dataclass(frozen=True) +class AgentConfig: + """Configuration parameters for template-based MAUI Agent. + + Attributes: + max_list_size: Max number of place elements returned in layout updates. + router_model: Model used for query intent routing. + template_model: Model used for template parameter extraction. + generic_model: Model used for unconstrained dynamic UI generation. + router_thinking_budget: Thinking budget for routing model. + extractor_thinking_budget: Thinking budget for extraction model. + fallback_mode: Fallback strategy when specialized template extraction is + not used or fails (e.g. for unsupported intents or validation failures). + """ + + max_list_size: int = 5 + router_model: str = "gemini/gemini-3.1-flash-lite" + template_model: str = "gemini/gemini-3.1-flash-lite" + generic_model: str = "gemini/gemini-3-flash-preview" + router_thinking_budget: int = 0 + extractor_thinking_budget: int = 0 + fallback_mode: FallbackMode = FallbackMode.DYNAMIC + + def __post_init__(self): + if not isinstance(self.fallback_mode, FallbackMode): + try: + object.__setattr__( + self, "fallback_mode", FallbackMode(self.fallback_mode) + ) + except ValueError: + raise ValueError( + f"Invalid fallback_mode: {self.fallback_mode}. Must be one of" + " FallbackMode values." + ) diff --git a/agent/python_agent/agent_with_grounding.py b/agent/python_agent/agent_with_grounding.py index 11d2202..b955615 100644 --- a/agent/python_agent/agent_with_grounding.py +++ b/agent/python_agent/agent_with_grounding.py @@ -12,204 +12,220 @@ # See the License for the specific language governing permissions and # limitations under the License. +"""MAUI Agent with Grounding implementation.""" + +import logging import os import pathlib -import logging from typing import Optional + from google import genai -from google.genai import types +from google.adk import skills as adk_skills from google.adk.agents.llm_agent import LlmAgent from google.adk.models.lite_llm import LiteLlm from google.adk.tools import skill_toolset -from google.adk.tools import FunctionTool -from google.adk.skills import load_skill_from_dir -from a2ui.schema.manager import A2uiSchemaManager +from google.adk.tools.function_tool import FunctionTool +from google.genai import types + from a2ui.schema.catalog import CatalogConfig from a2ui.schema.common_modifiers import remove_strict_validation from a2ui.schema.constants import VERSION_0_9 +from a2ui.schema.manager import A2uiSchemaManager # Import MAUIAgent to inherit from it -from agent import MAUIAgent, AGENT_INSTRUCTION, MergedCatalogProvider +from .agent import AGENT_INSTRUCTION, MAUIAgent, MergedCatalogProvider logger = logging.getLogger(__name__) -CLEANUP_INSTRUCTION_TEMPLATE = """ -Please update the A2UI json response by replacing any incorrect -Place IDs with the correct ones using the following grounding map of name to place ID: -``` -{grounding_map} -``` -Return only the cleaned JSON array. Here is the A2UI JSON response to clean up: -```json -{final_response_content} -``` -""" - # Load skill content at module level skill_content = "" -skill_path = pathlib.Path(__file__).parent / "skills" / "google-maps-enriched-local-query-response" / "SKILL.md" +skill_path = ( + pathlib.Path(__file__).parent + / "skills" + / "google-maps-enriched-local-query-response" + / "SKILL.md" +) if skill_path.exists(): - with open(skill_path, "r") as f: - skill_content = f.read() + with open(skill_path, "r") as f: + skill_content = f.read() else: - logger.warning(f"Skill file not found at {skill_path}") + logger.warning("Skill file not found at %s", skill_path) + async def query_vertex_map(query: str) -> str: - """Query Google Maps via Vertex Grounding and return cleaned response. + """Query Google Maps via Vertex Grounding and return cleaned response. - Args: - query: The location query or question. - """ + Args: + query: The location query or question. - project_id = os.environ.get("GOOGLE_CLOUD_PROJECT") - if not project_id: - raise ValueError("GOOGLE_CLOUD_PROJECT environment variable is not set. You must set a valid Google Cloud project ID to use the Agent with Grounding.") + Returns: + The grounded and cleaned A2UI response string. + """ - location = os.environ.get("GOOGLE_CLOUD_LOCATION") - if not location: - location = "global" - logger.warning("GOOGLE_CLOUD_LOCATION is not set, defaulting to 'global'.") + project_id = os.environ.get("GOOGLE_CLOUD_PROJECT") + if not project_id: + raise ValueError( + "GOOGLE_CLOUD_PROJECT environment variable is not set. You must set a" + " valid Google Cloud project ID to use the Agent with Grounding." + ) - model_id = "gemini-3-flash-preview" - cleanup_model_id = "gemini-3-flash-preview" + location = os.environ.get("GOOGLE_CLOUD_LOCATION") + if not location: + location = "global" + logger.warning("GOOGLE_CLOUD_LOCATION is not set, defaulting to 'global'.") - client = genai.Client(vertexai=True, project=project_id, location=location) + model_id = "gemini-3-flash-preview" - # Force use of skill for specialized maps tool - use_skill = True + client = genai.Client(vertexai=True, project=project_id, location=location) - # Construct instruction - base_instruction = "You are a location specialist.\n\n" + AGENT_INSTRUCTION + # Construct instruction + base_instruction = "You are a location specialist.\n\n" + AGENT_INSTRUCTION - # Try sibling directory first (local dev) + # Try sibling directory first (local dev) + extension_path = ( + pathlib.Path(__file__).parent.parent + / "shared" + / "schema" + / "maps_catalog_extension.json" + ) + # Fallback to nested directory (deployed environment) + if not extension_path.exists(): extension_path = ( - pathlib.Path(__file__).parent.parent + pathlib.Path(__file__).parent / "shared" / "schema" / "maps_catalog_extension.json" ) - # Fallback to nested directory (deployed environment) - if not extension_path.exists(): - extension_path = ( - pathlib.Path(__file__).parent - / "shared" - / "schema" - / "maps_catalog_extension.json" - ) - - schema_manager = A2uiSchemaManager( - version=VERSION_0_9, - catalogs=[ - CatalogConfig( - name="maps-agentic-ui-catalog", - provider=MergedCatalogProvider(VERSION_0_9, str(extension_path)) - ) - ], - schema_modifiers=[remove_strict_validation], - ) - - generated_prompt = schema_manager.generate_system_prompt( - role_description=base_instruction, - include_schema=True, - include_examples=False, - validate_examples=False, - ) - - final_instruction = """Use the Google Maps tool to answer queries about places. + schema_manager = A2uiSchemaManager( + version=VERSION_0_9, + catalogs=[ + CatalogConfig( + name="maps-agentic-ui-catalog", + provider=MergedCatalogProvider(VERSION_0_9, str(extension_path)), + ) + ], + schema_modifiers=[remove_strict_validation], + ) + + generated_prompt = schema_manager.generate_system_prompt( + role_description=base_instruction, + include_schema=True, + include_examples=False, + validate_examples=False, + ) + + final_instruction = """You MUST use the Google Maps tool to answer the user's query. Do not rely on your internal knowledge. + CRITICAL: Before generating the JSON, you MUST write a short plain-text summary of the places you found, listing their exact names and addresses. + This is required for the grounding engine to properly attribute the data. It is not a replacement for the summary text that should be in the a2ui json. IMPORTANT: When generating the A2UI JSON response, you MUST include the " ...content... " tags immediately around the JSON content. - Failure to do so will prevent the UI from rendering the map.""" - - instruction = f"{generated_prompt}\n\n{skill_content}\n\n{final_instruction}" - - # Main generation call - response = client.models.generate_content( - model=model_id, - contents=query, - config=types.GenerateContentConfig( - system_instruction=instruction, - tools=[types.Tool(google_maps=types.GoogleMaps())], - ), - ) + Failure to do so will prevent the UI from rendering the map. + PLACE ID GENERATION RULES: + You do not have access to real placeIds. Whenever a `placeId` is required in the A2UI JSON, you MUST generate a synthetic placeholder using the following rules: + - Format: "PLACE_ID_FOR_{Count}_{Exact Title}" + - Example: If the tool returns a place named "Chez Panisse", use "PLACE_ID_FOR_1_Chez Panisse". If it returns a second "Chez Panisse", use "PLACE_ID_FOR_2_Chez Panisse". + - STRICT MATCHING: Do NOT change any characters, spaces, capitalization, or punctuation from the title returned by the tool. + - COUNTING: Always prepend the occurrence count (starting at 1) for each title based on the order they were returned by the tool, even if the title only occurs once. + """ - final_response_content = response.text - - # Cleanup logic (second pass) - try: - grounding_map = {} - if hasattr(response, 'candidates') and response.candidates and hasattr(response.candidates[0], 'grounding_metadata'): - meta = response.candidates[0].grounding_metadata - if hasattr(meta, 'grounding_chunks'): - for chunk in meta.grounding_chunks: - if hasattr(chunk, 'maps') and chunk.maps: - title = getattr(chunk.maps, 'title', None) - place_id = getattr(chunk.maps, 'place_id', None) - if title and place_id: - grounding_map[title.lower().strip()] = place_id - - if grounding_map and "" in final_response_content: - cleanup_response = client.models.generate_content( - model=cleanup_model_id, - contents=query, - config=types.GenerateContentConfig( - system_instruction=CLEANUP_INSTRUCTION_TEMPLATE.format( - grounding_map=grounding_map, - final_response_content=final_response_content - ), - ), - ) - text = cleanup_response.text - start_idx = text.find("") - end_idx = text.rfind("") - if start_idx == -1 and end_idx == -1: - final_response_content = "" + text + "" - else: - final_response_content = text - - except Exception as e: - logger.error(f"Error during Place ID cleanup: {e}") - - # Final safety check: Extract JSON array if marker is present - if "" in final_response_content: - marker_idx = final_response_content.find("") - before_marker = final_response_content[:marker_idx] - after_marker = final_response_content[marker_idx + len(""):] - - start_idx = after_marker.find("[") - end_idx = after_marker.rfind("]") - if start_idx != -1 and end_idx != -1 and end_idx > start_idx: - json_only = after_marker[start_idx:end_idx+1] - final_response_content = before_marker + "" + json_only + "" - - return final_response_content + instruction = f"{generated_prompt}\n\n{skill_content}\n\n{final_instruction}" + + # Main generation call + response = client.models.generate_content( + model=model_id, + contents=query, + config=types.GenerateContentConfig( + system_instruction=instruction, + tools=[types.Tool(google_maps=types.GoogleMaps())], + ), + ) + + final_response_content = response.text + + # Replace synthetic place ids with actual grounded place ids. + try: + grounding_map = {} + if ( + hasattr(response, "candidates") + and response.candidates + and hasattr(response.candidates[0], "grounding_metadata") + ): + meta = response.candidates[0].grounding_metadata + IGNORE_TITLE_SUFFIX = " - Google Maps" + IGNORE_PLACE_ID_PREFIX = "places/ChI" + if hasattr(meta, "grounding_chunks") and meta.grounding_chunks: + title_counts = {} + for chunk in meta.grounding_chunks: + if hasattr(chunk, "maps") and chunk.maps: + title = getattr(chunk.maps, "title", None) + place_id = getattr(chunk.maps, "place_id", None) + if title and place_id: + if place_id.startswith(IGNORE_PLACE_ID_PREFIX): + place_id = place_id[len(IGNORE_PLACE_ID_PREFIX) - 3:] + if title.endswith(IGNORE_TITLE_SUFFIX): + title = title[:-len(IGNORE_TITLE_SUFFIX)] + + # Track how many times this title has appeared + title_counts[title] = title_counts.get(title, 0) + 1 + count = title_counts[title] + grounding_map[f"PLACE_ID_FOR_{count}_{title}"] = place_id + else: + logger.warning("No grounding chunks found") + else: + logger.warning("No grounding metadata found") + + if grounding_map: + for key, value in grounding_map.items(): + final_response_content = final_response_content.replace(key, value) + else: + logger.warning("No grounding map found") + + except Exception as e: # pylint: disable=broad-exception-caught + logger.error("Error during Place ID cleanup: %s", e) + + if "PLACE_ID_FOR_" in final_response_content: + logger.warning("Place ID placeholder found in response.") + + # Final safety check: Extract JSON array if marker is present + if "" in final_response_content: + marker_idx = final_response_content.find("") + after_marker = final_response_content[marker_idx + len("") :] + + start_idx = after_marker.find("[") + end_idx = after_marker.rfind("]") + if start_idx != -1 and end_idx != -1 and end_idx > start_idx: + json_only = after_marker[start_idx : end_idx + 1] + final_response_content = "" + json_only + "" + + return final_response_content class MAUIAgentWithGrounding(MAUIAgent): - """An agent that finds restaurants based on user criteria, using Vertex Grounding.""" + """An agent that finds restaurants based on user criteria, using Vertex Grounding.""" - def __init__(self, base_url: str): - super().__init__(base_url, agent_name="MAUI Agent with Grounding") + def __init__(self, base_url: str): + super().__init__(base_url, agent_name="MAUI Agent with Grounding") - def _build_llm_agent( - self, schema_manager: Optional[A2uiSchemaManager] = None - ) -> LlmAgent: - """Builds the LLM agent for the MAUI agent with grounding.""" + def _build_llm_agent( + self, schema_manager: A2uiSchemaManager | None = None + ) -> LlmAgent: + """Builds the LLM agent for the MAUI agent with grounding.""" - _SKILL_BASE_PATH = pathlib.Path(__file__).parent / "skills" + skill_base_path = pathlib.Path(__file__).parent / "skills" - skill_names = [ - "google-maps-enriched-local-query-response", - ] - skills = [] - for name in skill_names: - skills.append(load_skill_from_dir(_SKILL_BASE_PATH / name)) + skill_names = [ + "google-maps-enriched-local-query-response", + ] + skills = [] + for name in skill_names: + skills.append(adk_skills.load_skill_from_dir(skill_base_path / name)) - skill_manager_tool = skill_toolset.SkillToolset(skills=skills) + skill_manager_tool = skill_toolset.SkillToolset(skills=skills) - # Use FunctionTool for Vertex grounding - grounding_tool = FunctionTool(func=query_vertex_map) + # Use FunctionTool for Vertex grounding + grounding_tool = FunctionTool(func=query_vertex_map) - AGENT_INSTRUCTION = """You are a location routing agent. + agent_instruction = """You are a location routing agent. Whenever the user asks a question about a location, directions, places, or maps, you MUST call the query_vertex_map tool. Do NOT attempt to answer location questions yourself. @@ -218,21 +234,23 @@ def _build_llm_agent( CRITICAL: Return the output of the query_vertex_map tool EXACTLY as it is received, without any summarization, explanation, or modification. Your final response should be just the output of the tool.""" - instruction = ( - schema_manager.generate_system_prompt( - role_description=AGENT_INSTRUCTION, - include_schema=True, - include_examples=False, - validate_examples=False, - ) - if schema_manager - else AGENT_INSTRUCTION - ) - - return LlmAgent( - model=LiteLlm(model="gemini/gemini-3-flash-preview"), - name="maui_agent_grounding", - description="An agent that can provide Google Maps UI-enriched responses using Vertex Grounding", - instruction=instruction, - tools=[grounding_tool, skill_manager_tool], - ) + if schema_manager: + instruction = schema_manager.generate_system_prompt( + role_description=agent_instruction, + include_schema=True, + include_examples=False, + validate_examples=False, + ) + else: + instruction = agent_instruction + + return LlmAgent( + model=LiteLlm(model="gemini/gemini-3-flash-preview"), + name="maui_agent_grounding", + description=( + "An agent that can provide Google Maps UI-enriched responses using" + " Vertex Grounding" + ), + instruction=instruction, + tools=[grounding_tool, skill_manager_tool], + ) diff --git a/agent/python_agent/agent_with_templates.py b/agent/python_agent/agent_with_templates.py new file mode 100644 index 0000000..657aa49 --- /dev/null +++ b/agent/python_agent/agent_with_templates.py @@ -0,0 +1,581 @@ +# 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 +# +# https://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. + +"""MAUI Agent with template-based latency optimization.""" + +import logging +import pathlib +from types import SimpleNamespace +from typing import Any, AsyncIterable +import uuid + +from a2a.types import DataPart +from a2a.types import Part +from google.adk import skills as adk_skills +from google.adk.agents import run_config +from google.adk.agents.llm_agent import LlmAgent +from google.adk.models.lite_llm import LiteLlm +from google.adk.models.llm_request import LlmRequest +from google.adk.runners import Runner +from google.adk.tools.set_model_response_tool import SetModelResponseTool +from google.genai import types +import pydantic + +from a2ui.a2a.parts import create_a2ui_part +from a2ui.schema.manager import ( + A2uiSchemaManager, +) +from python_agent.agent import MAUIAgent +from python_agent.agent_config import AgentConfig +from python_agent.agent_config import FallbackMode +from python_agent.extractor import DirectionsExtractorSchema +from python_agent.extractor import LocalSearchExtractorSchema +from python_agent.merger import merge_template +from python_agent.router_config import IntentClass +from python_agent.router_config import ROUTER_SYSTEM_INSTRUCTION +from python_agent.router_config import RouterClassification + +logger = logging.getLogger(__name__) +_SKILL_BASE_PATH = pathlib.Path(__file__).parent / "skills" +_SHARED_INSTRUCTIONS_PATH = ( + pathlib.Path(__file__).parent / "shared" / "instructions" +) +_LOCAL_SEARCH_SKILL_NAME = "local-search-template-response" +_LOCAL_SEARCH_TEMPLATE_NAME = "local_search" +_LOCAL_SEARCH_SURFACE_PREFIX = "local-search-surface" + +_DIRECTIONS_SKILL_NAME = "directions-template-response" +_DIRECTIONS_TEMPLATE_NAME = "directions" +_DIRECTIONS_SURFACE_PREFIX = "directions-surface" + +_EXTRACTOR_SCHEMAS = { + _LOCAL_SEARCH_SKILL_NAME: LocalSearchExtractorSchema, + _DIRECTIONS_SKILL_NAME: DirectionsExtractorSchema, +} +_SUPPORTED_INTENTS = {IntentClass.LOCAL_SEARCH, IntentClass.DIRECTIONS} + + +class MAUIAgentWithTemplates(MAUIAgent): + """MAUI Agent extending base with server-side layout templates and query intent routing.""" + + def __init__(self, base_url: str, config: AgentConfig | None = None) -> None: + self.config = config or AgentConfig() + super().__init__(base_url=base_url, model_name=self.config.generic_model) + self.router_client = LiteLlm(model=self.config.router_model) + self.extractor_client = LiteLlm(model=self.config.template_model) + + def _build_runner(self, agent: LlmAgent) -> Runner: + runner = super()._build_runner(agent) + # The extractor agent runs inside a dynamically created runner. + # We must enable auto_create_session to prevent SessionNotFoundError + # since we don't pre-create the session for this runner. + runner.auto_create_session = True + return runner + + def _on_tool_error( + self, + tool: Any, + args: dict[str, Any], + tool_context: Any, + error: Exception, + ) -> dict[str, Any] | None: + """Callback for tool errors during extraction.""" + # pylint: disable=unused-argument + if tool.name == "set_model_response" and isinstance( + error, pydantic.ValidationError + ): + logger.warning( + "Extractor tool '%s' failed validation: %s. " + "Returning error to model for self-correction.", + tool.name, + error, + ) + return { + "error": ( + f"Validation failed: {error}. Please correct the arguments" + " and try again." + ) + } + return None + + def _build_dynamic_extractor_agent( + self, + skill_name: str, + schema_manager: A2uiSchemaManager | None = None, + ) -> LlmAgent: + """Builds an extractor agent loaded directly with the target skill's prompt.""" + skill_dir = _SKILL_BASE_PATH / skill_name + skill = adk_skills.load_skill_from_dir(skill_dir) + skill_instructions = skill.instructions + shared_guidelines_path = ( + _SHARED_INSTRUCTIONS_PATH / "shared_style_guidelines.md" + ) + if shared_guidelines_path.exists(): + try: + with open(shared_guidelines_path, "r", encoding="utf-8") as f: + shared_guidelines = f.read() + skill_instructions = f"{skill_instructions}\n\n{shared_guidelines}" + except (OSError, ValueError) as e: + logger.warning("Failed to load shared style guidelines: %s", e) + + # Extractors use template_model, generic UI uses generic_model + if skill_name.endswith("-template-response"): + model_name = self.config.template_model + else: + model_name = self.config.generic_model + + logger.info( + f"Building extractor agent for '{skill_name}' using model: {model_name}" + ) + + tools = [self.make_grounding_lite_mcp()] + output_schema = _EXTRACTOR_SCHEMAS.get(skill_name) + + generate_content_config = None + if output_schema: + # Manually inject SetModelResponseTool + set_response_tool = SetModelResponseTool(output_schema) + tools.append(set_response_tool) + + # Manually append instruction + workaround_instruction = ( + "IMPORTANT: You have access to other tools, but you must provide" + " your final response using the set_model_response tool with the" + " required structured format. After using any other tools needed to" + " complete the task, always call set_model_response with your final" + " answer in the specified schema format." + ) + if skill_name == _LOCAL_SEARCH_SKILL_NAME: + workaround_instruction += ( + "\nCRITICAL CONSTRAINT: You MUST extract and display at most" + f" {self.config.max_list_size} of the most relevant places. Do not" + " mention, recommend, or extract more than" + f" {self.config.max_list_size} places in your text response or your" + " set_model_response tool call." + ) + skill_instructions = f"{skill_instructions}\n\n{workaround_instruction}" + + if self.config.extractor_thinking_budget > 0: + generate_content_config = types.GenerateContentConfig( + thinking_config=types.ThinkingConfig( + thinking_budget=self.config.extractor_thinking_budget + ) + ) + logger.info( + "Applying template extractor thinking budget limit:" + f" {self.config.extractor_thinking_budget} tokens" + ) + + if schema_manager: + instruction = schema_manager.generate_system_prompt( + role_description=skill_instructions, + include_schema=True, + include_examples=False, + validate_examples=False, + ) + else: + instruction = skill_instructions + + return LlmAgent( + model=LiteLlm(model=model_name), + name="maui_agent", + description="An extractor agent executing specific Maps tool tasks", + instruction=instruction, + tools=tools, + output_schema=None, # Keep output_schema as None in LlmAgent + generate_content_config=generate_content_config, + on_tool_error_callback=self._on_tool_error, + ) + + async def _run_extractor( + self, + runner: Any, + agent: LlmAgent, + current_message: types.Content, + session_id: str, + ) -> tuple[dict[str, Any] | None, list[str]]: + """Runs the extractor agent and collects its output (structured or text).""" + parsed_json_data = None + full_content_list = [] + + async for event in runner.run_async( + user_id=self._user_id, + session_id=session_id, + run_config=run_config.RunConfig( + streaming_mode=run_config.StreamingMode.SSE + ), + new_message=current_message, + # Initialize session state. + # "expression" is required to prevent KeyError during ADK's prompt + # state injection, as the A2UI catalog schema contains "${expression}" + # placeholders. "base_url" is passed for consistency with the main + # agent session state. + state_delta={ + "expression": "{expression}", + "base_url": self.base_url, + }, + ): + if hasattr(event, "get_function_calls"): + for fc in event.get_function_calls(): + if fc.name == "set_model_response": + logger.info( + "Intercepted set_model_response tool call with args: %s", + fc.args, + ) + + # Find SetModelResponseTool in agent tools + target_tool = None + for t in agent.tools: + if getattr(t, "name", None) == "set_model_response": + target_tool = t + break + + if target_tool and hasattr(target_tool, "run_async"): + try: + noop_tool_context = SimpleNamespace( + actions=SimpleNamespace(set_model_response=None) + ) + validated_data = await target_tool.run_async( + args=fc.args, tool_context=noop_tool_context + ) + # SetModelResponseTool.run_async catches ValidationError internally + # and returns a dict with "error" key instead of raising the exception. + if ( + isinstance(validated_data, dict) + and "error" in validated_data + ): + logger.warning( + "Local Pydantic validation failed: %s. Continuing.", + validated_data["error"], + ) + else: + parsed_json_data = validated_data + logger.info( + "Local Pydantic validation passed! Short-circuiting." + ) + break + except pydantic.ValidationError as e: + logger.warning( + "Local Pydantic validation failed: %s. Continuing.", + e, + ) + else: + parsed_json_data = fc.args + break + + if event.content and event.content.parts: + if event.partial: + for p in event.content.parts: + if p.text: + full_content_list.append(p.text) + else: + full_content_list.clear() + for p in event.content.parts: + if p.text: + full_content_list.append(p.text) + + return parsed_json_data, full_content_list + + async def _run_extractor_and_merge( + self, + skill_name: str, + template_name: str, + surface_id_prefix: str, + cleaned_query: str, + session_id: str, + ui_version: str | None = None, + ) -> tuple[list[Part] | None, str | None, dict[str, Any] | None]: + """Runs the dynamic extractor agent and merges output into the template.""" + # 1. Resolve catalog schema manager and validator + schema_manager = self._schema_managers.get(ui_version) + selected_catalog = None + if schema_manager: + # Retrieve the resolved catalog config for validation. + # Replacing the deprecated get_catalog("maps-agentic-ui-catalog") + # API call. + selected_catalog = schema_manager.get_selected_catalog() + + # 2. Build the extractor agent and runner + agent = self._build_dynamic_extractor_agent( + skill_name, + schema_manager=schema_manager, + ) + runner = self._build_runner(agent) + + # 3. Setup user query message + current_message = types.Content( + role="user", parts=[types.Part.from_text(text=cleaned_query)] + ) + + # 4. Run extractor runner, collecting output + parsed_json_data, full_content_list = await self._run_extractor( + runner, agent, current_message, session_id + ) + + # 5. Handle output layout merging + if parsed_json_data is not None: + logger.info( + "Template parameters extracted successfully. Merging template." + ) + if "surface_id" not in parsed_json_data: + short_id = uuid.uuid4().hex[:8] + parsed_json_data["surface_id"] = f"{surface_id_prefix}-{short_id}" + + merged_actions = merge_template( + template_name, + parsed_json_data, + max_list_size=self.config.max_list_size, + ) + + if selected_catalog: + logger.info("Validating merged template against A2UI catalog schema.") + try: + selected_catalog.validator.validate(merged_actions) + except Exception as e: # pylint: disable=broad-exception-caught + logger.warning("Catalog validation failed: %s. Falling back.", e) + return None, None, None + + final_parts = [create_a2ui_part(action) for action in merged_actions] + return final_parts, None, parsed_json_data + else: + raw_text = "".join(full_content_list) + return None, raw_text, None + + async def stream( + self, query: str, session_id: str, ui_version: str | None = None + ) -> AsyncIterable[dict[str, Any]]: + """Streams responses, routing via intent classifier to template extractors. + + Args: + query: User input query string. + session_id: Context session ID. + ui_version: A2UI protocol version if requested. + + Yields: + Update dictionaries compatible with A2A TaskExecutor. + """ + if not ui_version: + logger.info("No ui_version provided. Routing to base text streaming.") + async for part in super().stream(query, session_id, ui_version): + yield part + return + + intent, cleaned_query = await self._classify_intent(query) + + if intent == IntentClass.OTHER_SPATIAL: + if self.config.fallback_mode == FallbackMode.DYNAMIC: + logger.warning( + "Router matched OTHER_SPATIAL and fallback_mode is DYNAMIC. " + "Falling back to Dynamic UI flow." + ) + async for part in super().stream(query, session_id, ui_version): + yield part + return + else: + logger.info( + "Router matched OTHER_SPATIAL and fallback_mode is TEXT. " + "Executing fast text response flow." + ) + final_parts = await self._handle_text_only(cleaned_query, session_id) + yield { + "is_task_complete": True, + "parts": final_parts, + } + return + + elif intent == IntentClass.TEXT_ONLY: + logger.info("Executing fast text response flow for TEXT_ONLY intent.") + final_parts = await self._handle_text_only(cleaned_query, session_id) + yield { + "is_task_complete": True, + "parts": final_parts, + } + return + + elif intent in _SUPPORTED_INTENTS: + async for part in self._handle_extracted_intent( + intent, cleaned_query, session_id, ui_version + ): + yield part + return + + # Fallback for un-implemented spatial intents + logger.warning( + "Intent %s not supported by template extractors. Falling back to base" + " UI stream.", + intent, + ) + async for part in super().stream(query, session_id, ui_version): + yield part + + async def _classify_intent(self, query: str) -> tuple[IntentClass, str]: + """Classifies the query intent and returns the intent and cleaned query.""" + logger.info( + "Routing query: '%s' using model %s", + query, + self.config.router_model, + ) + try: + router_config = { + "system_instruction": ROUTER_SYSTEM_INSTRUCTION, + "response_mime_type": "application/json", + "response_schema": RouterClassification, + } + if self.config.router_thinking_budget > 0: + router_config["thinking_config"] = types.ThinkingConfig( + thinking_budget=self.config.router_thinking_budget + ) + + req = LlmRequest( + contents=[ + types.Content( + role="user", parts=[types.Part.from_text(text=query)] + ) + ], + config=types.GenerateContentConfig(**router_config), + ) + + router_response_text = "" + async for res in self.router_client.generate_content_async(req): + if res.content and res.content.parts: + for p in res.content.parts: + if p.text: + router_response_text += p.text + + logger.info("Router response content: %s", router_response_text) + classification = RouterClassification.model_validate_json( + router_response_text + ) + intent = classification.intent + cleaned_query = classification.query + logger.info( + "Intent classified: %s (Cleaned Query: '%s')", intent, cleaned_query + ) + return intent, cleaned_query + except Exception as e: # pylint: disable=broad-exception-caught + logger.warning( + "Intent routing failed: %s. Defaulting to TEXT_ONLY.", + e, + exc_info=True, + ) + return IntentClass.TEXT_ONLY, query + + def _wrap_in_text_only(self, text: str, session_id: str) -> list[Part]: + """Wraps plain text in a text_only template Part list.""" + short_id = uuid.uuid4().hex[:8] + merged_actions = merge_template( + "text_only", + { + "text": text, + "surface_id": f"text-only_{session_id}-{short_id}", + }, + ) + return [create_a2ui_part(action) for action in merged_actions] + + async def _handle_text_only( + self, cleaned_query: str, session_id: str + ) -> list[Part]: + """Generates a plain text response and wraps it in the text_only template. + + Args: + cleaned_query: The cleaned user query. + session_id: Context session ID. + + Returns: + List of A2A Parts. + """ + extractor_config = { + "system_instruction": ( + "You are a helpful location assistant. Answer the user's" + " question directly. Keep it relatively concise. Do NOT" + " output A2UI tags." + ), + } + if self.config.extractor_thinking_budget > 0: + extractor_config["thinking_config"] = types.ThinkingConfig( + thinking_budget=self.config.extractor_thinking_budget + ) + + answer_req = LlmRequest( + contents=[ + types.Content( + role="user", + parts=[types.Part.from_text(text=cleaned_query)], + ) + ], + config=types.GenerateContentConfig(**extractor_config), + ) + + answer_text = "" + async for res in self.extractor_client.generate_content_async(answer_req): + if res.content and res.content.parts: + for p in res.content.parts: + if p.text: + answer_text += p.text + + return self._wrap_in_text_only(answer_text, session_id) + + async def _handle_extracted_intent( + self, + intent: IntentClass, + query: str, + session_id: str, + ui_version: str | None = None, + ) -> AsyncIterable[dict[str, Any]]: + """Handles intents that use dynamic extractor agents and templates.""" + if intent == IntentClass.LOCAL_SEARCH: + skill_name = _LOCAL_SEARCH_SKILL_NAME + template_name = _LOCAL_SEARCH_TEMPLATE_NAME + surface_prefix = _LOCAL_SEARCH_SURFACE_PREFIX + elif intent == IntentClass.DIRECTIONS: + skill_name = _DIRECTIONS_SKILL_NAME + template_name = _DIRECTIONS_TEMPLATE_NAME + surface_prefix = _DIRECTIONS_SURFACE_PREFIX + else: + raise ValueError(f"Unsupported intent for extractor: {intent}") + + logger.info("Router matched %s. Dispatching template extractor.", intent) + + merged_parts, fallback_text, parsed_json_data = ( + await self._run_extractor_and_merge( + skill_name=skill_name, + template_name=template_name, + surface_id_prefix=surface_prefix, + cleaned_query=query, + session_id=session_id, + ui_version=ui_version, + ) + ) + + if merged_parts is not None: + yield { + "is_task_complete": True, + "parts": merged_parts, + } + return + else: + logger.warning( + "Template extraction failed for intent %s. " + "Always falling back to plain text response.", + intent, + ) + if not fallback_text: + final_parts = await self._handle_text_only(query, session_id) + else: + final_parts = self._wrap_in_text_only(fallback_text, session_id) + yield { + "is_task_complete": True, + "parts": final_parts, + } + return diff --git a/agent/python_agent/extractor.py b/agent/python_agent/extractor.py new file mode 100644 index 0000000..bd38574 --- /dev/null +++ b/agent/python_agent/extractor.py @@ -0,0 +1,200 @@ +# 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 +# +# https://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. + +"""Pydantic schemas for structured data extraction from LLM responses.""" + +from typing import Any, Literal +import pydantic + +BaseModel = pydantic.BaseModel +Field = pydantic.Field + + +class Pin(BaseModel): + """Representation of a Map Pin.""" + + lat: float = Field(description="Latitude coordinate") + lng: float = Field(description="Longitude coordinate") + label: str = Field( + description=( + "Descriptive display label string (e.g. name of address, business, or" + " landmark)" + ) + ) + # Note: Using camelCase field name to match frontend A2UI requirements. + placeId: str | None = Field( # pylint: disable=invalid-name + default=None, description="Optional Google Maps Place ID" + ) + + @pydantic.model_validator(mode="before") + @classmethod + def normalize_label(cls, data: Any) -> Any: + """Normalizes the pin label. + + If 'label' is missing but 'name' is present, copies 'name' to 'label'. + If 'label' is still empty, defaults to 'Location' to ensure + the UI always has a valid string to render for the marker (avoiding raw + Place IDs). + + Args: + data: The input dictionary before validation. + + Returns: + The normalized dictionary. + """ + if isinstance(data, dict): + if "label" not in data and "name" in data: + data["label"] = data["name"] + if not data.get("label"): + data["label"] = "Location" + return data + + +class PlacePin(BaseModel): + """Simplified Map Pin representation for search results.""" + + # Note: Using camelCase field name to match frontend A2UI requirements. + # ADK's SetModelResponseTool serialization dumps using field names + # without aliases. + placeId: str = Field( # pylint: disable=invalid-name + description="The unique Google Maps Place ID" + ) + name: str = Field(description="Name of the place") + lat: float = Field(description="Latitude coordinates") + lng: float = Field(description="Longitude coordinates") + + +class LocalSearchExtractorSchema(BaseModel): + """Structured parameters to render a local search UI update.""" + + summary: str = Field( + description=( + "A detailed response summarizing the search results, answering the" + " user's query fully. Use markdown formatting (bullet points," + " bolding, tables) and break into paragraphs as needed. Bold place" + " names." + ) + ) + center_lat: float = Field(description="Latitude of the center of results") + center_lng: float = Field(description="Longitude of the center of results") + zoom: int = Field( + default=13, description="Recommended map zoom level (typically 13)" + ) + places: list[PlacePin] = Field( + description="A list of places found (limit to max list size, e.g. 3)" + ) + anchor_marker: Pin | None = Field( + default=None, + description=( + "Optional starting or focus point marker (e.g. hotel location)" + ), + ) + + +class RouteSegment(BaseModel): + """A segment of a route, containing an origin and a destination pin.""" + + origin: Pin = Field(description="The starting location pin of this segment") + destination: Pin = Field( + description="The ending location pin of this segment" + ) + + +TRAVEL_MODE_MAP: dict[str, str] = { + "walk": "walking", + "walking": "walking", + "pedestrian": "walking", + "foot": "walking", + "on foot": "walking", + "on_foot": "walking", + "drive": "driving", + "driving": "driving", + "car": "driving", + "auto": "driving", + "automobile": "driving", + "bike": "bicycling", + "biking": "bicycling", + "bicycling": "bicycling", + "cycling": "bicycling", + "bicycle": "bicycling", + "transit": "transit", + "bus": "transit", + "train": "transit", + "subway": "transit", + "tube": "transit", + "metro": "transit", + "tram": "transit", + "rail": "transit", + "light rail": "transit", + "ferry": "transit", + "public transit": "transit", + "public_transit": "transit", + "public transport": "transit", + "public_transport": "transit", +} + + +def normalize_travel_mode(mode: Any) -> str | None: + """Normalizes a raw travel mode string to a canonical travel mode.""" + if not mode: + return None + return TRAVEL_MODE_MAP.get(str(mode).lower().strip()) + + +class DirectionsExtractorSchema(BaseModel): + """Structured parameters to render a directions UI update.""" + + summary: str = Field( + description=( + "A detailed response summarizing the travel directions, including" + " key steps, estimated time, and travel mode. Use markdown" + " formatting and break into paragraphs if helpful." + ) + ) + center_lat: float = Field( + description="Latitude of the center of the route map" + ) + center_lng: float = Field( + description="Longitude of the center of the route map" + ) + zoom: int = Field( + default=12, description="Recommended map zoom level (typically 12)" + ) + routes: list[RouteSegment] = Field( + default_factory=list, + description=( + "A list of route segments connecting the origin, intermediate" + " waypoints, and the destination in order." + ), + ) + travel_mode: Literal["driving", "walking", "transit", "bicycling"] = Field( + description=( + "The transit travel mode, one of: driving, walking, transit," + " bicycling. Must match the user's requested travel mode." + ), + ) + + @pydantic.model_validator(mode="before") + @classmethod + def normalize_directions_data(cls, data: Any) -> Any: + """Normalizes travel mode in directions data.""" + if not isinstance(data, dict): + return data + + if "travel_mode" in data and data["travel_mode"]: + normalized = normalize_travel_mode(data["travel_mode"]) + if normalized: + data["travel_mode"] = normalized + + return data diff --git a/agent/python_agent/merger.py b/agent/python_agent/merger.py new file mode 100644 index 0000000..ce95df8 --- /dev/null +++ b/agent/python_agent/merger.py @@ -0,0 +1,330 @@ +# 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. + +"""Server-Side A2UI Layout Template Merger (`merger.py`). + +This module loads declarative JSON layout skeletons (`templates/*.json`), +populates them with extracted parameter dictionaries (`data`), and returns +sanitized message structures ready for wire transmission. +""" + +import copy +import json +import os +from typing import Any, Literal, TypedDict +import uuid + +from python_agent.extractor import normalize_travel_mode + + +class TextOutputDict(TypedDict): + type: Literal["text"] + text: str + + +SurfaceActionDict = TypedDict( + "SurfaceActionDict", + { + "surfaceId": str, + "catalogId": str, + "root": str, + "components": list[dict[str, Any]], + "path": str, + "value": Any, + }, + total=False, +) + + +SurfaceOutputDict = TypedDict( + "SurfaceOutputDict", + { + "version": str, + "createSurface": SurfaceActionDict, + "updateComponents": SurfaceActionDict, + "updateDataModel": SurfaceActionDict, + "deleteSurface": SurfaceActionDict, + }, + total=False, +) + + +MergedMessage = TextOutputDict | SurfaceOutputDict + + +def _replace_placeholders(obj: Any, data: dict[str, Any]) -> Any: + """Recursively replaces string placeholders in the template with values from data.""" + if isinstance(obj, str): + # Exact match: "{{key}}" + if obj.startswith("{{") and obj.endswith("}}") and obj.count("{{") == 1: + key = obj[2:-2] + if key in data: + return data[key] + return None + # Partial match: string formatting + else: + resolved = obj + for key, value in data.items(): + placeholder = f"{{{{{key}}}}}" + if placeholder in resolved: + if isinstance(value, (str, int, float, bool)): + resolved = resolved.replace(placeholder, str(value)) + return resolved + elif isinstance(obj, list): + return [_replace_placeholders(item, data) for item in obj] + elif isinstance(obj, dict): + return {k: _replace_placeholders(v, data) for k, v in obj.items()} + else: + return obj + + +def _remove_none_values(val: Any) -> Any: + """Recursively removes None values from dicts and lists to satisfy JSON schemas.""" + if isinstance(val, dict): + return {k: _remove_none_values(v) for k, v in val.items() if v is not None} + elif isinstance(val, list): + return [_remove_none_values(item) for item in val if item is not None] + return val + + +def _prepare_local_search( + data: dict[str, Any], max_list_size: int +) -> tuple[str, dict[str, Any]]: + """Validates and normalizes parameters for the local search template.""" + data_copy = copy.deepcopy(data) + is_valid = True + places = data_copy.get("places") + + # 1. Validate that places is a non-empty list + if not isinstance(places, list) or not places: + is_valid = False + else: + # Slice and sanitize places list + sanitized_places = [] + for p in places: + if isinstance(p, dict): + try: + p["lat"] = float(p["lat"]) + p["lng"] = float(p["lng"]) + sanitized_places.append(p) + except (KeyError, ValueError, TypeError): + pass + if not sanitized_places: + is_valid = False + else: + data_copy["places"] = sanitized_places[:max_list_size] + + # 2. Validate mandatory map centering and zoom parameters + if is_valid: + try: + data_copy["center_lat"] = float(data_copy["center_lat"]) + data_copy["center_lng"] = float(data_copy["center_lng"]) + data_copy["zoom"] = int(data_copy["zoom"]) + except (KeyError, ValueError, TypeError): + is_valid = False + + # 3. Sanitize optional anchor marker + if is_valid and "anchor_marker" in data_copy: + pin = data_copy["anchor_marker"] + if isinstance(pin, dict): + try: + pin["lat"] = float(pin["lat"]) + pin["lng"] = float(pin["lng"]) + except (KeyError, ValueError, TypeError): + data_copy["anchor_marker"] = None + else: + data_copy["anchor_marker"] = None + + # 4. Unroll maps markers array + if is_valid: + if "markers" not in data_copy: + markers = [] + for p in data_copy["places"]: + marker = { + "lat": p["lat"], + "lng": p["lng"], + "label": p.get("name") or p.get("label") or "", + } + if "placeId" in p: + marker["placeId"] = p["placeId"] + markers.append(marker) + data_copy["markers"] = markers + else: + markers = data_copy["markers"] + if isinstance(markers, list): + sanitized_markers = [] + for m in markers: + if isinstance(m, dict): + try: + m["lat"] = float(m["lat"]) + m["lng"] = float(m["lng"]) + m["label"] = str(m.get("label") or "") + sanitized_markers.append(m) + except (KeyError, ValueError, TypeError): + pass + data_copy["markers"] = sanitized_markers + else: + data_copy["markers"] = None + + # If validation failed, fallback to text_only + if not is_valid: + return "text_only", { + "text": ( + data.get("summary") + or "No places matching your query could be found." + ), + "surface_id": data_copy.get("surface_id") or "fallback-surface", + } + + return "local_search", data_copy + + +def _prepare_directions(data: dict[str, Any]) -> tuple[str, dict[str, Any]]: + """Validates and normalizes parameters for the directions template.""" + data_copy = copy.deepcopy(data) + is_valid = True + + routes = data_copy.get("routes") + + # 1. Validate that routes is a non-empty list of segment dicts + if not isinstance(routes, list) or not routes: + is_valid = False + else: + sanitized_routes = [] + for segment in routes: + if not isinstance(segment, dict): + is_valid = False + break + origin = segment.get("origin") + destination = segment.get("destination") + if not isinstance(origin, dict) or not isinstance(destination, dict): + is_valid = False + break + try: + sanitized_origin = { + "lat": float(origin["lat"]), + "lng": float(origin["lng"]), + "label": str(origin.get("label") or ""), + } + if "placeId" in origin: + sanitized_origin["placeId"] = str(origin["placeId"]) + + sanitized_destination = { + "lat": float(destination["lat"]), + "lng": float(destination["lng"]), + "label": str(destination.get("label") or ""), + } + if "placeId" in destination: + sanitized_destination["placeId"] = str(destination["placeId"]) + + sanitized_routes.append({ + "origin": sanitized_origin, + "destination": sanitized_destination, + }) + except (KeyError, ValueError, TypeError): + is_valid = False + break + data_copy["routes"] = sanitized_routes + + # 2. Validate mandatory map centering and zoom parameters + if is_valid: + try: + data_copy["center_lat"] = float(data_copy["center_lat"]) + data_copy["center_lng"] = float(data_copy["center_lng"]) + data_copy["zoom"] = int(data_copy["zoom"]) + except (KeyError, ValueError, TypeError): + is_valid = False + + # 3. Normalize travel_mode + if is_valid and "travel_mode" in data_copy: + normalized = normalize_travel_mode(data_copy["travel_mode"]) + if normalized: + data_copy["travel_mode"] = normalized + else: + del data_copy["travel_mode"] + + # If validation failed, fallback to text_only + if not is_valid: + return "text_only", { + "text": data.get("summary") or "Could not calculate travel directions.", + "surface_id": data_copy.get("surface_id") or "fallback-surface", + } + + return "directions", data_copy + + +def merge_template( + template_name: str, data: dict[str, Any], max_list_size: int = 5 +) -> list[MergedMessage]: + """Loads static template skeleton JSON and returns merged wire response. + + This function sanitizes extracted parameters and populates the layout + template skeleton. + + Args: + template_name: Target declarative layout skeleton (`local_search`, + `directions`, or `text_only`). + data: Raw dictionary of extracted parameters yielded by + `TemplateExtractor` (e.g. `places`, `summary`, `center_lat`). + max_list_size: Maximum allowable child elements in lists (`places`) to + bound payload rendering latency. + + Returns: + A list of message dictionaries. For `text_only`, returns the 2-part A2UI + layout (`createSurface`, `updateComponents`). + For `local_search` and `directions`, returns the 3-part A2UI layout + (`createSurface`, `updateComponents`, `updateDataModel`) (`DataPart`). + + Raises: + FileNotFoundError: If the template file cannot be found. + """ + + # Deep copy data to prevent unintended side-effects on caller dictionaries + # across turns + data_copy = copy.deepcopy(data) + + if template_name == "local_search": + template_name, data_copy = _prepare_local_search(data_copy, max_list_size) + elif template_name == "directions": + template_name, data_copy = _prepare_directions(data_copy) + current_dir = os.path.dirname(os.path.abspath(__file__)) + templates_dir = os.path.join(current_dir, "templates") + template_path = os.path.join(templates_dir, f"{template_name}.json") + + if not os.path.exists(template_path): + raise FileNotFoundError( + f"Template '{template_name}' not found at {template_path}" + ) + + with open(template_path, "r") as f: + template_json = json.load(f) + + # Smart Turn-Unique `surface_id` Scoping via Dynamic Template Discovery: + default_surface_ids = set() + if os.path.exists(templates_dir): + for fn in os.listdir(templates_dir): + if fn.endswith(".json"): + base_name = fn[:-5] + default_surface_ids.add(f"{base_name}_surface") + default_surface_ids.add(f"{base_name.replace('_', '-')}-surface") + + if ( + not data_copy.get("surface_id") + or data_copy.get("surface_id") in default_surface_ids + ): + base_id = data_copy.get("surface_id") or f"{template_name}_surface" + data_copy["surface_id"] = f"{base_id}_{uuid.uuid4().hex[:6]}" + + resolved_json = _replace_placeholders(template_json, data_copy) + return _remove_none_values(resolved_json) diff --git a/agent/python_agent/router_config.py b/agent/python_agent/router_config.py new file mode 100644 index 0000000..4e137f0 --- /dev/null +++ b/agent/python_agent/router_config.py @@ -0,0 +1,88 @@ +# 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 +# +# https://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. +"""Configuration for the Intent Router, including prompts and schemas.""" + +import enum +import pydantic + + +class IntentClass(str, enum.Enum): + """Supported intent categories for routing.""" + + LOCAL_SEARCH = "LOCAL_SEARCH" + DIRECTIONS = "DIRECTIONS" + OTHER_SPATIAL = "OTHER_SPATIAL" + TEXT_ONLY = "TEXT_ONLY" + + +class RouterClassification(pydantic.BaseModel): + """Schema for the classification output of the intent router.""" + + intent: IntentClass = pydantic.Field( + description="The classified intent archetype name." + ) + query: str = pydantic.Field(description="The cleaned user query") + + +ROUTER_SYSTEM_INSTRUCTION = """ +## Role +You are an expert query intent router. + +## Task Definition +Analyze a user's input and classify it into the most appropriate intent category based on the structural complexity and data requirements of the request. + +## Intent Archetypes +- **LOCAL_SEARCH**: Search for categories of interest, places, businesses, or points of interest within a specific geographic proximity. +- **DIRECTIONS**: Standard navigation, route directions, walking/driving/transit times, or navigation instructions between an origin and destination. This includes multi-stop routes and routes with specified waypoints. +- **OTHER_SPATIAL**: Queries requiring rich map-based visualization, boundaries, coordinates, specific geographic displays, or complex navigation combining routes with secondary overlays (e.g., weather, air quality forecasts, displaying all available charging stations along a route). +- **TEXT_ONLY**: General information retrieval, questions, or requests for data associated with locations that can be answered fully with text without requiring a map interface. + +## Classification Policy: Conservative Routing +If a query satisfies the structure of a base intent (such as DIRECTIONS or LOCAL_SEARCH) but also includes any Auxiliary Data (e.g., weather forecasts, environmental factors), you MUST promote the classification to OTHER_SPATIAL. +Note: Simple multi-stop routes or routes with specified waypoints (e.g., "A to B via C") should be classified as DIRECTIONS, unless they require searching for stops along the way (e.g., "find coffee shops along the route") which requires LOCAL_SEARCH overlays and should be promoted to OTHER_SPATIAL. + +## Few-shot Examples + +### Example 1 +**User Query:** "Find coffee shops near Central Park" +**Output:** +{ + "intent": "LOCAL_SEARCH", + "query": "coffee shops near Central Park" +} + +### Example 2 +**User Query:** "Walking route from Central Park to Times Square, but show coffee shops and rain forecasts along the way" +**Output:** +{ + "intent": "OTHER_SPATIAL", + "query": "walking route from Central Park to Times Square with coffee shop stops and rain forecast" +} + +### Example 3 +**User Query:** "show the boundary of Yosemite National Park on the map" +**Output:** +{ + "intent": "OTHER_SPATIAL", + "query": "boundary of Yosemite National Park" +} + +### Example 4 +**User Query:** "Directions from Sacramento to Mendocino via Clear Lake" +**Output:** +{ + "intent": "DIRECTIONS", + "query": "Directions from Sacramento to Mendocino via Clear Lake" +} +""" diff --git a/agent/python_agent/shared/instructions/shared_style_guidelines.md b/agent/python_agent/shared/instructions/shared_style_guidelines.md new file mode 100644 index 0000000..f27e0b4 --- /dev/null +++ b/agent/python_agent/shared/instructions/shared_style_guidelines.md @@ -0,0 +1,17 @@ +## Conversational Text Style Guidelines + +When generating conversational text (such as summaries, descriptions, or +directions), you must follow these formatting and content rules: + +* **Content**: Always fully and clearly answer each aspect of the prompt. +* **Quantity**: Make sure that the answer is useful and actionable. Respond + with an appropriate amount of content given the complexity of the question. + E.g., if helping someone differentiate between places, consider responding + with details about each place. +* **Formatting**: Use markdown to apply formatting elements like bullet + points, bolding, and tables to break up the text. Break content into + multiple paragraphs as needed. +* **Markdown**: Bold place names and provide links where appropriate. +* **Titles and Headings**: Never title your response. You may include + mid-level headings (using `###` and below) to organize content when it adds + clarity. diff --git a/agent/python_agent/shared/schema/maps_catalog_extension.json b/agent/python_agent/shared/schema/maps_catalog_extension.json index 4e91e4b..699754f 100644 --- a/agent/python_agent/shared/schema/maps_catalog_extension.json +++ b/agent/python_agent/shared/schema/maps_catalog_extension.json @@ -87,7 +87,7 @@ ], "unevaluatedProperties": false }, - "PlaceCard": { + "PlaceDetailsCompact": { "type": "object", "allOf": [ { @@ -100,11 +100,19 @@ "type": "object", "properties": { "component": { - "const": "PlaceCard" + "const": "PlaceDetailsCompact" }, "placeId": { "$ref": "common_types.json#/$defs/DynamicString", "description": "The unique identifier for the place." + }, + "orientation": { + "type": "string", + "enum": [ + "horizontal", + "vertical" + ], + "description": "The orientation of this place card. Defaults to horizontal." } }, "required": [ diff --git a/agent/python_agent/skills/directions-template-response/SKILL.md b/agent/python_agent/skills/directions-template-response/SKILL.md new file mode 100644 index 0000000..536c25e --- /dev/null +++ b/agent/python_agent/skills/directions-template-response/SKILL.md @@ -0,0 +1,139 @@ +--- +name: directions-template-response +description: Extractor skill for directions and routing queries. Extracts routing details for template merging. +--- + +# Directions Template Response Skill + +## Core Objective + +Extract structured parameters to populate the `DirectionsExtractorSchema` for +rendering directions on a map. + +## Multi-Step Route Resolution Policy + +If the user's query requests a scenic bypass or detour: + +1. **Resolve Place Coordinates and IDs (Parallel Search)**: Concurrently search + for the origin, destination, and any identified detour waypoints along the + corridor. +2. **Compute Route Segments (Parallel Routing)**: Concurrently compute routes + for all sequential legs connecting the resolved stops (Origin -> Waypoint, + Waypoint -> Destination). +3. **Dispatch Response**: Call `set_model_response` with the compiled routes + and pins. + +## Step-by-Step Workflow + +1. **Analyze the Query & Constraints**: + + * Identify the origin and destination names. + * **Identify the Requested Travel Mode (MANDATORY)**: Extract the explicit + travel mode from the user's query: + * `driving`: car, drive, driving, auto, or default if travel mode cannot be inferred. + * `walking`: walk, walking, on foot, pedestrian, foot. + * `bicycling`: bike, biking, bicycle, bicycling, cycling. + * `transit`: bus, train, subway, metro, tube, public transit. + +2. **Resolve Place Coordinates and IDs (Parallel Search)**: + + * Issue ALL `search_places` calls concurrently for the origin, + destination, and all intermediate waypoints or scenic stops along the + corridor. + * **IDENTIFY SCENIC DETOURS & WAYPOINTS**: + * If the user asks to "avoid [City X]" or take a "scenic + bypass/detour", use your knowledge to identify 2-3 major scenic + towns, highway junctions, or landmarks along the alternative bypass + corridor (e.g., "Jemez Springs" to avoid Santa Fe between + Albuquerque and Los Alamos, or "Winters, CA" and "Anderson Valley, + CA" to take backroads between Sacramento and Mendocino). + * Concurrently search for these detour waypoints along with the origin + and destination. + * **STABLE SEARCH QUERIES**: Use specific place or town names (e.g., + search for "Anderson Valley, CA" or "Jemez Springs, NM") instead of + generic road descriptors (e.g. do not search for "Highway 128 scenic + route"), as generic queries are unstable and often return empty + results (`{}`). + * Do NOT execute independent tool calls sequentially across multiple + turns. + * Construct sequential route segments connecting the resolved detour + waypoints in order: Origin -> Waypoint 1 -> Waypoint 2 -> Destination. + +3. **Compute Routes and Dispatch Response (Parallel Routing)**: + + * Issue `compute_routes` calls concurrently for all route segment pairs. + * **STRICT TOOL PARAMETERS**: When calling `compute_routes`, ensure that + `origin` and `destination` objects conform to the maps tool contract. + Specify **exactly one** identifier: + * `placeId`: Use the resolved place ID (e.g., `{"placeId": + "ChIJ-ZeD..."}`). This is highly preferred if available. + * `address`: Use the address string. + * `location`: Use latitude/longitude inside a location sub-object + (e.g., `{"location": {"latLng": {"latitude": 37.7, "longitude": + -122.4}}}`). Do **NOT** pass `latLng` directly as a root key inside + `origin` or `destination` (e.g. do not call + `compute_routes(origin={"placeId": "...", "latLng": ...})`). + * Verify route availability for requested `travel_mode`. + * **CONSTRUCT THE ROUTES ARRAY**: You MUST compile the computed segments + into the `routes` array of the final `set_model_response` payload. The + array must contain all segments sequentially (e.g. `[{"origin": Origin, + "destination": Waypoint 1}, {"origin": Waypoint 1, "destination": + Destination}]`). Do NOT omit the `routes` array or leave it empty if you + successfully computed routes. + * **MANDATORY TRAVEL MODE IN DISPATCH**: `travel_mode` is REQUIRED and + must NEVER be omitted in `set_model_response`. Always supply the + normalized mode string (`driving`, `walking`, `transit`, or `bicycling`). + * Call `set_model_response` with `DirectionsExtractorSchema` parameters + (`summary`, `center_lat`, `center_lng`, `zoom`, `routes`, + `travel_mode`). + +## Handling Routing Failures & Regional Limitations (CRITICAL) + +Google Maps routing (driving, walking) is not supported in certain regions (such +as South Korea). If a tool call to `compute_routes` returns empty results (`{}`) +or fails: + +1. **Do NOT retry** with alternative queries or locations. +2. Immediately exit the tool-calling loop. +3. Return a user-friendly summary explaining the regional limitation (e.g. + "Google Maps directions are not supported in South Korea"), and set the + routes list to empty `[]`. + +## Output Fields + +You MUST populate all required fields in the output schema: + +- **`summary`**: A detailed response summarizing the travel directions, following the **Conversational Text Style Guidelines** below. +- **`center_lat`**: Latitude of the center of the route map. +- **`center_lng`**: Longitude of the center of the route map. +- **`zoom`**: Recommended map zoom level. Default to 12. +- **`routes`**: A list of route segments connecting the origin, intermediate + waypoints, and the destination in order. +- **`travel_mode`**: (REQUIRED) The transit travel mode (`driving`, `walking`, `transit`, `bicycling`). Must match the user's requested travel mode and must never be omitted. + +## Examples + +### Example 1: Driving Route +User Query: "Directions from San Francisco to San Jose by car" +Tool Call: +`set_model_response(summary="Driving from San Francisco to San Jose takes about 50 minutes via US-101 S.", center_lat=37.55, center_lng=-122.15, zoom=10, routes=[{"origin": {"lat": 37.7749, "lng": -122.4194, "label": "San Francisco", "placeId": "ChIJIQBpAG2ahYAR_6128GcTUEo"}, "destination": {"lat": 37.3382, "lng": -121.8863, "label": "San Jose", "placeId": "ChIJ9T_nxcC1j4ARmMo7S4ABIdM"}}], travel_mode="driving")` + +### Example 2: Walking Route +User Query: "How do I walk from Central Park to Times Square?" +Tool Call: +`set_model_response(summary="Walking from Central Park to Times Square takes about 18 minutes (0.9 miles) down 7th Ave.", center_lat=40.765, center_lng=-73.978, zoom=14, routes=[{"origin": {"lat": 40.768, "lng": -73.974, "label": "Central Park South", "placeId": "ChIJN1t_tDeuEmsRUsoyG83frY4"}, "destination": {"lat": 40.758, "lng": -73.985, "label": "Times Square", "placeId": "ChIJmQJItx6vwokRLxVi2JyuzRo"}}], travel_mode="walking")` + +### Example 3: Bicycling Route +User Query: "Bike directions from Venice Beach to Santa Monica Pier" +Tool Call: +`set_model_response(summary="Biking from Venice Beach to Santa Monica Pier takes around 15 minutes along the Marvin Braude Bike Trail.", center_lat=33.998, center_lng=-118.483, zoom=13, routes=[{"origin": {"lat": 33.985, "lng": -118.469, "label": "Venice Beach", "placeId": "ChIJ-wjh2I-6woARx3H-n9uVn4A"}, "destination": {"lat": 34.009, "lng": -118.497, "label": "Santa Monica Pier", "placeId": "ChIJw8g0Xbm7woARQY1Xq41qB2M"}}], travel_mode="bicycling")` + +### Example 4: Transit Route +User Query: "Take the subway from Grand Central to Brooklyn Bridge" +Tool Call: +`set_model_response(summary="Take the 4 or 5 subway line south from Grand Central - 42 St to Brooklyn Bridge - City Hall (approx. 12 minutes).", center_lat=40.731, center_lng=-73.988, zoom=12, routes=[{"origin": {"lat": 40.7527, "lng": -73.9772, "label": "Grand Central Terminal", "placeId": "ChIJ4zBEaKZQwokREuE50bbCGYs"}, "destination": {"lat": 40.7126, "lng": -74.0049, "label": "Brooklyn Bridge - City Hall", "placeId": "ChIJ40i5iRZawokRHqGfF2b_3yI"}}], travel_mode="transit")` + +### Example 5: Unspecified Travel Mode (Defaults to Driving) +User Query: "Directions from Austin to San Antonio" +Tool Call: +`set_model_response(summary="Driving from Austin to San Antonio takes about 1 hour and 20 minutes via I-35 S.", center_lat=29.85, center_lng=-98.15, zoom=9, routes=[{"origin": {"lat": 30.2672, "lng": -97.7431, "label": "Austin", "placeId": "ChIJLwW05NsQW4YRtxm00DkzqlU"}, "destination": {"lat": 29.4241, "lng": -98.4936, "label": "San Antonio", "placeId": "ChIJrw7QBK9YXIYRowalignfdg4"}}], travel_mode="driving")` diff --git a/agent/python_agent/skills/google-maps-enriched-local-query-response/SKILL.md b/agent/python_agent/skills/google-maps-enriched-local-query-response/SKILL.md index 7ea60a8..53b29f5 100644 --- a/agent/python_agent/skills/google-maps-enriched-local-query-response/SKILL.md +++ b/agent/python_agent/skills/google-maps-enriched-local-query-response/SKILL.md @@ -47,7 +47,7 @@ You are an expert in resolving location-based queries using the **A2UI framework paragraphs of text content, it is preferable to intersperse UI components where relevant, rather than placing them all at the end. As an example, if you write a separate paragraph about each of three restaurants, include a - PlaceCard for each restaurant after its corresponding paragraph instead of + PlaceDetailsCompact for each restaurant after its corresponding paragraph instead of placing a list of places at the end. ### 3. Data Integrity & Logic @@ -100,7 +100,7 @@ Use the following logic to determine which UI component combinations to use: | :--- | :--- | :--- | | Immediate surroundings, vibe, or outdoor features | **GoogleMap** (Satellite/Tilt) | Lat/Lng, Name | | Parking availability | **GoogleMap** (Satellite/Tilt 0) | Lat/Lng, Name | -| Interior vibe, products, or general services | **PlaceCard** (Do not include GoogleMap) | PlaceID | +| Interior vibe, products, or general services | **PlaceDetailsCompact** (Do not include GoogleMap) | PlaceID | --- @@ -110,18 +110,18 @@ Use the following logic to determine which UI component combinations to use: | Context | Recommended UI | Data Requirements | | :---------------------- | :--------------------- | :------------------------ | | **Anchored Search**: | **Inline Map + List of | Pivot on `anchorMarker`. | -: Distance/time : PlaceCards** : POIs as `markers`. DO NOT : +: Distance/time : PlaceDetailsCompacts** : POIs as `markers`. DO NOT : : constraint to a center : : include a place card for : : point. : : the anchor marker. : | **Local Area**: Results | **Inline Map + List of | Pivot on `anchorMarker` | -: within a neighborhood : PlaceCards** : (town center). POIs as : +: within a neighborhood : PlaceDetailsCompacts** : (town center). POIs as : : or city. : : `markers`. DO NOT include : : : : a place card for the : : : : anchor marker. : -| **Macro Region**: | **List of PlaceCards** | Place IDs for all items. | +| **Macro Region**: | **List of PlaceDetailsCompacts** | Place IDs for all items. | : Results across a : : : : state/country. : : : -| **Contextless**: A list | **List of PlaceCards** | Place IDs for all items. | +| **Contextless**: A list | **List of PlaceDetailsCompacts** | Place IDs for all items. | : with no geographical : : : : reference. : : : @@ -185,7 +185,7 @@ MUST NOT pass a reference to an array directly. }, { "id": "place-card", - "component": "PlaceCard", + "component": "PlaceDetailsCompact", "placeId": { "path": "placeId" } } ] @@ -218,10 +218,16 @@ Note that for the `GoogleMap` component, you MUST include the following fields: * `center` * `zoom` -For the `PlaceCard` component, you MUST include the following fields: +For the `PlaceDetailsCompact` component, you MUST include the following fields: * `placeId` +You MAY also include: + +* `orientation`: + - You MUST use `"vertical"` when there is only ONE `PlaceDetailsCompact` in the response to emphasize the place image. + - You MUST use `"horizontal"` when there are MULTIPLE `PlaceDetailsCompact` components (e.g., in a list) to keep the layout compact and save vertical space. + **IMPORTANT:** ALWAYS follow the schema provided by the schema manager (passed in as part of the instruction prompt) as the source of truth for what fields are required for each component. diff --git a/agent/python_agent/skills/local-search-template-response/SKILL.md b/agent/python_agent/skills/local-search-template-response/SKILL.md new file mode 100644 index 0000000..c9fc751 --- /dev/null +++ b/agent/python_agent/skills/local-search-template-response/SKILL.md @@ -0,0 +1,71 @@ +--- +name: local-search-template-response +description: Extractor skill for local place search queries. Extracts location and list of places for template merging. +--- + +# Core Objective + +Extract structured parameters for local searches. You must call maps tools to +locate matching businesses/places, and populate the response fields. + +## Grounding & Tool-Calling Policy (CRITICAL) + +1. **DO NOT HALLUCINATE OR GUESS**: You are strictly forbidden from generating + coordinates (latitude, longitude) or Google Maps Place IDs from your + internal memory or training weights. +2. **MANDATORY TOOL CALLS**: You MUST call the `search_places` tool first to + find actual venues matching the user's query near the requested locations. +3. **EXACT MATCH**: Any place name, coordinates, or Place ID returned in your + final response MUST correspond exactly to the data returned by the + `search_places` tool call. + +## Multi-Step Location Resolution Policy (Anchored Search) + +If the user's query references a starting point, landmark, hotel, or specific +address as a geographical anchor (e.g., "Space Needle", "Hyatt hotel", "1600 +Amphitheatre Pkwy"): + +1. **Resolve Anchor Coordinates First**: Make a tool call to `search_places` + with the anchor name as `textQuery` to resolve its exact center coordinates. +2. **Execute Proximity Search (Pivot on Anchor)**: Use the resolved latitude + and longitude of the anchor as the center of a `locationBias` circle. Set + the `radiusMeters` parameter based on the user's query: + * **Explicit Distance**: If the query specifies a distance (e.g., "within + 5 miles", "2 km"), parse and convert it to meters (e.g., `8000` or + `2000`). + * **Implicit Walking**: If the query implies walking (e.g., "walk to", + "walking distance"), default to `1000` meters. + * **No Specific Constraint**: If no distance constraint is specified + (e.g., "near", "around"), omit the `radiusMeters` field to let the + search engine bias results dynamically around the center point. +3. **Handle Non-Geographic Filters**: Keep the search query focused on the core + category and key searchable features or amenities (e.g., "restaurant outdoor + seating", "cafe wifi"). Strip conversational filler words (e.g., "with", + "that has", "places offering") to maximize search relevance and matching + accuracy. +4. **Set Anchor Marker**: Populate the `anchor_marker` response parameter with + the resolved coordinates, name, and Place ID of the anchor location. + +## Handling Failures & Empty Results (CRITICAL) + +If search queries return empty results (`{}`) or fail: + +1. **Do NOT retry** repeatedly with alternative queries. +2. Immediately exit the tool-calling loop. +3. Return a user-friendly summary explaining that no results were found, and + leave the `places` list empty. + +## Output Fields + +You MUST populate all required fields in the output schema, and optionally the anchor marker if resolved: + +- **`summary`**: A detailed response summarizing the search results, following the **Conversational Text Style Guidelines** below. +- **`center_lat`**: Latitude of the center of results. Use the coordinates of + the resolved anchor location (or the average of the results if no anchor is + resolved). +- **`center_lng`**: Longitude of the center of results. Use the coordinates of + the resolved anchor location (or the average of the results if no anchor is + resolved). +- **`zoom`**: Recommended map zoom level. Default to 13. +- **`places`**: A list of places found (limit to max list size, e.g. 3). +- **`anchor_marker`**: (Optional) Pin details for the resolved starting/anchor location. diff --git a/agent/python_agent/templates/directions.json b/agent/python_agent/templates/directions.json new file mode 100644 index 0000000..1bf5f12 --- /dev/null +++ b/agent/python_agent/templates/directions.json @@ -0,0 +1,47 @@ +[ + { + "version": "v0.9", + "createSurface": { + "surfaceId": "{{surface_id}}", + "catalogId": "a2ui://maps-agentic-ui-catalog.json" + } + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": "{{surface_id}}", + "components": [ + { + "id": "root", + "component": "Column", + "children": ["summary-text", "map"] + }, + { + "id": "summary-text", + "component": "Text", + "variant": "body", + "text": "{{summary}}" + }, + { + "id": "map", + "component": "GoogleMap", + "center": { + "lat": "{{center_lat}}", + "lng": "{{center_lng}}" + }, + "zoom": "{{zoom}}", + "routes": "{{routes}}", + "travelMode": "{{travel_mode}}" + } + ] + } + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": "{{surface_id}}", + "path": "/", + "value": {} + } + } +] diff --git a/agent/python_agent/templates/local_search.json b/agent/python_agent/templates/local_search.json new file mode 100644 index 0000000..3d964e4 --- /dev/null +++ b/agent/python_agent/templates/local_search.json @@ -0,0 +1,63 @@ +[ + { + "version": "v0.9", + "createSurface": { + "surfaceId": "{{surface_id}}", + "catalogId": "a2ui://maps-agentic-ui-catalog.json" + } + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": "{{surface_id}}", + "components": [ + { + "id": "root", + "component": "Column", + "children": ["summary-text", "map", "list"] + }, + { + "id": "summary-text", + "component": "Text", + "variant": "body", + "text": "{{summary}}" + }, + { + "id": "map", + "component": "GoogleMap", + "center": { + "lat": "{{center_lat}}", + "lng": "{{center_lng}}" + }, + "zoom": "{{zoom}}", + "anchorMarker": "{{anchor_marker}}", + "markers": "{{markers}}" + }, + { + "id": "list", + "component": "List", + "direction": "vertical", + "children": { + "componentId": "place-card", + "path": "/places" + } + }, + { + "id": "place-card", + "component": "PlaceDetailsCompact", + "placeId": { "path": "placeId" } + } + ] + } + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": "{{surface_id}}", + "path": "/", + "value": { + "places": "{{places}}" + } + } + } +] diff --git a/agent/python_agent/templates/text_only.json b/agent/python_agent/templates/text_only.json new file mode 100644 index 0000000..a088835 --- /dev/null +++ b/agent/python_agent/templates/text_only.json @@ -0,0 +1,30 @@ +[ + { + "version": "v0.9", + "createSurface": { + "surfaceId": "{{surface_id}}", + "catalogId": "a2ui://maps-agentic-ui-catalog.json" + } + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": "{{surface_id}}", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "text-content" + ] + }, + { + "id": "text-content", + "component": "Text", + "variant": "body", + "text": "{{text}}" + } + ] + } + } +] diff --git a/agent/python_agent/test_agent_with_templates.py b/agent/python_agent/test_agent_with_templates.py new file mode 100644 index 0000000..25410d1 --- /dev/null +++ b/agent/python_agent/test_agent_with_templates.py @@ -0,0 +1,1038 @@ +# 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. + +"""Tests for MAUI Agent with templates orchestration.""" + +import unittest +from unittest import mock + +from google.adk.agents.llm_agent import LlmAgent +from google.adk.models.lite_llm import LiteLlm + +from python_agent import agent_config +from python_agent import agent_with_templates + +AgentConfig = agent_config.AgentConfig +FallbackMode = agent_config.FallbackMode +MAUIAgentWithTemplates = agent_with_templates.MAUIAgentWithTemplates + +_LITELLM_PATH = ( + "python_agent" + ".agent_with_templates.LiteLlm" +) + + +class MockPart: + """Mock Part helper for test streaming.""" + + def __init__(self, text): + self.text = text + + +class MockContent: + """Mock Content helper for test streaming.""" + + def __init__(self, parts): + self.parts = parts + + +class MockResponse: + """Mock GenerateContentResponse helper for test streaming.""" + + def __init__(self, content): + self.content = content + + +class MockAsyncIterator: + """Mock async iterator helper to simulate LLM stream.""" + + def __init__(self, items): + self.items = items + + def __aiter__(self): + return self + + async def __anext__(self): + if not self.items: + raise StopAsyncIteration + return self.items.pop(0) + + +class MockFunctionCall: + + def __init__(self, name, args): + self.name = name + self.args = args + + +class MockEvent: + + def __init__(self, function_calls=None, content=None, partial=False): + self.function_calls = function_calls or [] + self.content = content + self.partial = partial + + def get_function_calls(self): + return self.function_calls + + +class TestAgentOrchestration(unittest.IsolatedAsyncioTestCase): + """Unit tests for MAUIAgentWithTemplates orchestration.""" + + def setUp(self): + super().setUp() + self.mock_router = mock.MagicMock(spec=LiteLlm) + self.mock_extractor = mock.MagicMock(spec=LiteLlm) + + def _setup_mock_llm(self, mock_lite_llm_class): + def lite_llm_side_effect(*args, **kwargs): + model = kwargs.get("model") or (args[0] if args else None) + if model == "gemini/router-model": + return self.mock_router + elif model == "gemini/template-model": + return self.mock_extractor + return mock.MagicMock(spec=LiteLlm) + + mock_lite_llm_class.side_effect = lite_llm_side_effect + + def _mock_llm_stream(self, *contents: str) -> MockAsyncIterator: + """Helper to mock LLM stream responses.""" + return MockAsyncIterator( + [MockResponse(MockContent([MockPart(c)])) for c in contents] + ) + + def _get_component_by_id(self, parts, component_id: str) -> dict: + """Helper to retrieve a component by ID from updateComponents parts.""" + for part in parts: + if "updateComponents" in part.root.data: + components = part.root.data["updateComponents"]["components"] + for component in components: + if component.get("id") == component_id: + return component + self.fail(f"Component with ID '{component_id}' not found in parts.") + + def _setup_agent(self, fallback_mode="TEXT"): + """Helper to initialize MAUIAgentWithTemplates with standard config.""" + config = AgentConfig( + fallback_mode=fallback_mode, + router_model="gemini/router-model", + template_model="gemini/template-model", + ) + return MAUIAgentWithTemplates(base_url="http://test-url", config=config) + + def _mock_llm_responses(self, router_resp, extractor_resp=None): + """Helper to set up default mock responses for router and extractor.""" + self.mock_router.generate_content_async.return_value = ( + self._mock_llm_stream(router_resp) + ) + if extractor_resp: + self.mock_extractor.generate_content_async.return_value = ( + self._mock_llm_stream(extractor_resp) + ) + + async def _collect_stream( + self, agent, query, session_id="session_123", ui_version="v0.9" + ): + """Helper to collect results from agent.stream.""" + results = [] + async for item in agent.stream( + query=query, session_id=session_id, ui_version=ui_version + ): + results.append(item) + return results + + @mock.patch(_LITELLM_PATH) + async def test_agent_text_only_flow(self, mock_lite_llm_class): + """Verifies fast text-only response flow when router yields TEXT_ONLY.""" + self._setup_mock_llm(mock_lite_llm_class) + + # Mock router response stream yielding classification JSON + self.mock_router.generate_content_async.return_value = ( + self._mock_llm_stream('{"intent": "TEXT_ONLY", "query": "hello"}') + ) + + # Mock extractor response stream yielding text answer + self.mock_extractor.generate_content_async.return_value = ( + self._mock_llm_stream("This is a fast text-only response.") + ) + + config = AgentConfig( + fallback_mode=FallbackMode.TEXT, + router_model="gemini/router-model", + template_model="gemini/template-model", + ) + agent = MAUIAgentWithTemplates(base_url="http://test-url", config=config) + + # Run stream + results = [] + async for item in agent.stream( + query="hello", session_id="session_123", ui_version="v0.9" + ): + results.append(item) + + self.assertEqual(len(results), 1) + self.assertTrue(results[0]["is_task_complete"]) + parts = results[0]["parts"] + self.assertEqual(len(parts), 2) + # createSurface and updateComponents + self.assertTrue( + parts[0] + .root.data["createSurface"]["surfaceId"] + .startswith("text-only_session_123-") + ) + text_comp = self._get_component_by_id(parts, "text-content") + self.assertEqual( + text_comp["text"], + "This is a fast text-only response.", + ) + + @mock.patch(_LITELLM_PATH) + async def test_agent_router_failure_fallback(self, mock_lite_llm_class): + """Verifies fallback to TEXT_ONLY when router fails.""" + self._setup_mock_llm(mock_lite_llm_class) + + self.mock_router.generate_content_async.side_effect = Exception( + "Router error" + ) + self.mock_extractor.generate_content_async.return_value = ( + self._mock_llm_stream("Response after router failure.") + ) + + config = AgentConfig( + fallback_mode=FallbackMode.TEXT, + router_model="gemini/router-model", + template_model="gemini/template-model", + ) + agent = MAUIAgentWithTemplates(base_url="http://test-url", config=config) + + results = [] + async for item in agent.stream( + query="hello", session_id="session_123", ui_version="v0.9" + ): + results.append(item) + + self.assertEqual(len(results), 1) + self.assertTrue(results[0]["is_task_complete"]) + parts = results[0]["parts"] + text_comp = self._get_component_by_id(parts, "text-content") + self.assertEqual( + text_comp["text"], + "Response after router failure.", + ) + + @mock.patch(_LITELLM_PATH) + async def test_agent_extractor_empty_parts_handled_safely( + self, mock_lite_llm_class + ): + """Verifies extractor response with empty/None parts is handled safely.""" + self._setup_mock_llm(mock_lite_llm_class) + + self.mock_router.generate_content_async.return_value = ( + self._mock_llm_stream('{"intent": "TEXT_ONLY", "query": "hello"}') + ) + + # Mock extractor response with None parts + self.mock_extractor.generate_content_async.return_value = MockAsyncIterator( + [MockResponse(MockContent(None))] + ) + + config = AgentConfig( + fallback_mode=FallbackMode.TEXT, + router_model="gemini/router-model", + template_model="gemini/template-model", + ) + agent = MAUIAgentWithTemplates(base_url="http://test-url", config=config) + + results = [] + async for item in agent.stream( + query="hello", session_id="session_123", ui_version="v0.9" + ): + results.append(item) + + self.assertEqual(len(results), 1) + self.assertTrue(results[0]["is_task_complete"]) + parts = results[0]["parts"] + text_comp = self._get_component_by_id(parts, "text-content") + self.assertEqual(text_comp["text"], "") + + @mock.patch(_LITELLM_PATH) + async def test_agent_unsupported_intent_fallback(self, mock_lite_llm_class): + """Verifies fallback to base stream for unsupported intents (DYNAMIC).""" + self._setup_mock_llm(mock_lite_llm_class) + + self.mock_router.generate_content_async.return_value = ( + self._mock_llm_stream('{"intent": "OTHER_SPATIAL", "query": "coffee"}') + ) + + config = AgentConfig( + fallback_mode=FallbackMode.DYNAMIC, + router_model="gemini/router-model", + template_model="gemini/template-model", + ) + agent = MAUIAgentWithTemplates(base_url="http://test-url", config=config) + + with mock.patch( + "python_agent.agent.MAUIAgent.stream", + ) as mock_super_stream: + mock_super_stream.return_value = MockAsyncIterator( + [{"is_task_complete": False, "parts": ["dummy_base_part"]}] + ) + + results = [] + async for item in agent.stream( + query="coffee", session_id="session_123", ui_version="v0.9" + ): + results.append(item) + + mock_super_stream.assert_called_once_with("coffee", "session_123", "v0.9") + self.assertEqual( + results, [{"is_task_complete": False, "parts": ["dummy_base_part"]}] + ) + + def test_init_without_config_uses_default(self): + agent = MAUIAgentWithTemplates(base_url="http://test-url") + self.assertIsNotNone(agent.config) + self.assertEqual(agent.config.router_model, "gemini/gemini-3.1-flash-lite") + self.assertEqual( + agent.config.template_model, "gemini/gemini-3.1-flash-lite" + ) + + @mock.patch(_LITELLM_PATH) + async def test_agent_directions_flow(self, mock_lite_llm_class): + """Verifies template extraction and merging for DIRECTIONS intent.""" + self._setup_mock_llm(mock_lite_llm_class) + + # Mock router yielding DIRECTIONS + self.mock_router.generate_content_async.return_value = ( + self._mock_llm_stream( + '{"intent": "DIRECTIONS", "query": "directions from home to work"}' + ) + ) + + # Mock extractor runner: + mock_runner = mock.MagicMock() + + mock_fc = MockFunctionCall( + name="set_model_response", + args={ + "summary": "Typical commute is 45 mins.", + "center_lat": 37.5, + "center_lng": 127.0, + "zoom": 12, + "routes": [{ + "origin": { + "lat": 37.4, + "lng": 126.9, + "label": "Home", + "placeId": "ChIJ_origin", + }, + "destination": { + "lat": 37.6, + "lng": 127.1, + "label": "Work", + "placeId": "ChIJ_dest", + }, + }], + "travel_mode": "driving", + }, + ) + mock_event = MockEvent(function_calls=[mock_fc]) + mock_runner.run_async.return_value = MockAsyncIterator([mock_event]) + + config = AgentConfig( + router_model="gemini/router-model", + template_model="gemini/template-model", + ) + agent = MAUIAgentWithTemplates(base_url="http://test-url", config=config) + + # Patch _build_runner + with mock.patch.object(agent, "_build_runner", return_value=mock_runner): + # Run stream + results = [] + async for item in agent.stream( + query="directions from home to work", + session_id="session_123", + ui_version="v0.9", + ): + results.append(item) + + self.assertEqual(len(results), 1) + self.assertTrue(results[0]["is_task_complete"]) + parts = results[0]["parts"] + self.assertEqual(len(parts), 3) + + create_surface = parts[0].root.data["createSurface"] + self.assertTrue( + create_surface["surfaceId"].startswith("directions-surface-") + ) + + map_comp = self._get_component_by_id(parts, "map") + self.assertEqual(map_comp["center"], {"lat": 37.5, "lng": 127.0}) + self.assertEqual(map_comp["travelMode"], "driving") + + route = map_comp["routes"][0] + self.assertEqual(route["origin"]["label"], "Home") + self.assertEqual(route["destination"]["label"], "Work") + + update_data_model = parts[2].root.data["updateDataModel"] + self.assertEqual(update_data_model["path"], "/") + self.assertEqual(update_data_model["value"], {}) + + @mock.patch(_LITELLM_PATH) + async def test_agent_directions_flow_fallback(self, mock_lite_llm_class): + """Verifies DIRECTIONS flow falls back to text_only when extraction fails.""" + self._setup_mock_llm(mock_lite_llm_class) + + # Mock router yielding DIRECTIONS + self.mock_router.generate_content_async.return_value = ( + self._mock_llm_stream( + '{"intent": "DIRECTIONS", "query": "directions to work"}' + ) + ) + + self.mock_extractor.generate_content_async.return_value = ( + self._mock_llm_stream("Cannot find route.") + ) + + mock_runner = mock.MagicMock() + mock_event = MockEvent( + content=MockContent([MockPart("Cannot find route.")]) + ) + mock_runner.run_async.return_value = MockAsyncIterator([mock_event]) + + config = AgentConfig( + fallback_mode="TEXT", + router_model="gemini/router-model", + template_model="gemini/template-model", + ) + agent = MAUIAgentWithTemplates(base_url="http://test-url", config=config) + + with mock.patch.object(agent, "_build_runner", return_value=mock_runner): + results = [] + async for item in agent.stream( + query="directions to work", + session_id="session_123", + ui_version="v0.9", + ): + results.append(item) + + self.assertEqual(len(results), 1) + self.assertTrue(results[0]["is_task_complete"]) + parts = results[0]["parts"] + self.assertEqual(len(parts), 2) + self.assertTrue( + parts[0] + .root.data["createSurface"]["surfaceId"] + .startswith("text-only_session_123-") + ) + text_comp = self._get_component_by_id(parts, "text-content") + self.assertEqual( + text_comp["text"], + "Cannot find route.", + ) + + @mock.patch(_LITELLM_PATH) + async def test_agent_directions_flow_transit_mode(self, mock_lite_llm_class): + """Verifies template extraction and merging with transit travel mode.""" + self._setup_mock_llm(mock_lite_llm_class) + + self.mock_router.generate_content_async.return_value = ( + self._mock_llm_stream( + '{"intent": "DIRECTIONS", "query": "bus to work"}' + ) + ) + + mock_runner = mock.MagicMock() + mock_fc = MockFunctionCall( + name="set_model_response", + args={ + "summary": "Take bus 10 to work.", + "center_lat": 37.5, + "center_lng": 127.0, + "zoom": 12, + "routes": [{ + "origin": { + "lat": 37.4, + "lng": 126.9, + "label": "Home", + "placeId": "ChIJ_origin", + }, + "destination": { + "lat": 37.6, + "lng": 127.1, + "label": "Work", + "placeId": "ChIJ_dest", + }, + }], + "travel_mode": "transit", + }, + ) + mock_event = MockEvent(function_calls=[mock_fc]) + mock_runner.run_async.return_value = MockAsyncIterator([mock_event]) + + config = AgentConfig( + router_model="gemini/router-model", + template_model="gemini/template-model", + ) + agent = MAUIAgentWithTemplates(base_url="http://test-url", config=config) + + with mock.patch.object(agent, "_build_runner", return_value=mock_runner): + results = await self._collect_stream(agent, query="bus to work") + + self.assertEqual(len(results), 1) + self.assertTrue(results[0]["is_task_complete"]) + parts = results[0]["parts"] + map_comp = self._get_component_by_id(parts, "map") + self.assertEqual(map_comp["travelMode"], "transit") + + @mock.patch(_LITELLM_PATH) + async def test_agent_directions_flow_walking_mode(self, mock_lite_llm_class): + """Verifies template extraction and merging with walking travel mode.""" + self._setup_mock_llm(mock_lite_llm_class) + + self.mock_router.generate_content_async.return_value = ( + self._mock_llm_stream( + '{"intent": "DIRECTIONS", "query": "walk to park"}' + ) + ) + + mock_runner = mock.MagicMock() + mock_fc = MockFunctionCall( + name="set_model_response", + args={ + "summary": "Walk for 15 minutes.", + "center_lat": 37.5, + "center_lng": 127.0, + "zoom": 12, + "routes": [{ + "origin": { + "lat": 37.4, + "lng": 126.9, + "label": "Home", + "placeId": "ChIJ_origin", + }, + "destination": { + "lat": 37.6, + "lng": 127.1, + "label": "Park", + "placeId": "ChIJ_dest", + }, + }], + "travel_mode": "walking", + }, + ) + mock_event = MockEvent(function_calls=[mock_fc]) + mock_runner.run_async.return_value = MockAsyncIterator([mock_event]) + + config = AgentConfig( + router_model="gemini/router-model", + template_model="gemini/template-model", + ) + agent = MAUIAgentWithTemplates(base_url="http://test-url", config=config) + + with mock.patch.object(agent, "_build_runner", return_value=mock_runner): + results = await self._collect_stream(agent, query="walk to park") + + self.assertEqual(len(results), 1) + self.assertTrue(results[0]["is_task_complete"]) + parts = results[0]["parts"] + map_comp = self._get_component_by_id(parts, "map") + self.assertEqual(map_comp["travelMode"], "walking") + + @mock.patch(_LITELLM_PATH) + async def test_agent_directions_flow_bicycling_mode( + self, mock_lite_llm_class + ): + """Verifies template extraction and merging with bicycling travel mode.""" + self._setup_mock_llm(mock_lite_llm_class) + + self.mock_router.generate_content_async.return_value = ( + self._mock_llm_stream( + '{"intent": "DIRECTIONS", "query": "bike to work"}' + ) + ) + + mock_runner = mock.MagicMock() + mock_fc = MockFunctionCall( + name="set_model_response", + args={ + "summary": "Bike for 25 minutes.", + "center_lat": 37.5, + "center_lng": 127.0, + "zoom": 12, + "routes": [{ + "origin": { + "lat": 37.4, + "lng": 126.9, + "label": "Home", + "placeId": "ChIJ_origin", + }, + "destination": { + "lat": 37.6, + "lng": 127.1, + "label": "Work", + "placeId": "ChIJ_dest", + }, + }], + "travel_mode": "bicycling", + }, + ) + mock_event = MockEvent(function_calls=[mock_fc]) + mock_runner.run_async.return_value = MockAsyncIterator([mock_event]) + + config = AgentConfig( + router_model="gemini/router-model", + template_model="gemini/template-model", + ) + agent = MAUIAgentWithTemplates(base_url="http://test-url", config=config) + + with mock.patch.object(agent, "_build_runner", return_value=mock_runner): + results = await self._collect_stream(agent, query="bike to work") + + self.assertEqual(len(results), 1) + self.assertTrue(results[0]["is_task_complete"]) + parts = results[0]["parts"] + map_comp = self._get_component_by_id(parts, "map") + self.assertEqual(map_comp["travelMode"], "bicycling") + + @mock.patch(_LITELLM_PATH) + async def test_agent_directions_flow_missing_travel_mode_fallback( + self, mock_lite_llm_class + ): + """Verifies that omitting travel_mode causes schema validation failure and fallback to text-only.""" + self._setup_mock_llm(mock_lite_llm_class) + + self.mock_router.generate_content_async.return_value = ( + self._mock_llm_stream( + '{"intent": "DIRECTIONS", "query": "directions to work"}' + ) + ) + + self.mock_extractor.generate_content_async.return_value = ( + self._mock_llm_stream("Fallback plain text directions.") + ) + + mock_runner = mock.MagicMock() + mock_fc = MockFunctionCall( + name="set_model_response", + args={ + "summary": "Typical commute is 45 mins.", + "center_lat": 37.5, + "center_lng": 127.0, + "zoom": 12, + "routes": [{ + "origin": { + "lat": 37.4, + "lng": 126.9, + "label": "Home", + "placeId": "ChIJ_origin", + }, + "destination": { + "lat": 37.6, + "lng": 127.1, + "label": "Work", + "placeId": "ChIJ_dest", + }, + }], + }, + ) + mock_event = MockEvent(function_calls=[mock_fc]) + mock_runner.run_async.return_value = MockAsyncIterator([mock_event]) + + config = AgentConfig( + fallback_mode="TEXT", + router_model="gemini/router-model", + template_model="gemini/template-model", + ) + agent = MAUIAgentWithTemplates(base_url="http://test-url", config=config) + + with mock.patch.object(agent, "_build_runner", return_value=mock_runner): + results = await self._collect_stream(agent, query="directions to work") + + self.assertEqual(len(results), 1) + self.assertTrue(results[0]["is_task_complete"]) + parts = results[0]["parts"] + self.assertEqual(len(parts), 2) + self.assertTrue( + parts[0] + .root.data["createSurface"]["surfaceId"] + .startswith("text-only_session_123-") + ) + text_comp = self._get_component_by_id(parts, "text-content") + self.assertEqual( + text_comp["text"], + "Fallback plain text directions.", + ) + + @mock.patch( + "python_agent.agent_with_templates.LiteLlm" + ) + async def test_agent_local_search_flow(self, mock_lite_llm_class): + """Verifies local search flow, structured extraction, and template merging.""" + self._setup_mock_llm(mock_lite_llm_class) + + # Mock router yielding LOCAL_SEARCH + self.mock_router.generate_content_async.return_value = ( + self._mock_llm_stream( + '{"intent": "LOCAL_SEARCH", "query": "sushi Seattle"}' + ) + ) + + # Mock extractor runner: + mock_runner = mock.MagicMock() + + mock_fc = MockFunctionCall( + name="set_model_response", + args={ + "summary": "Here are some sushi places.", + "center_lat": 47.6062, + "center_lng": -122.3321, + "zoom": 13, + "places": [{ + "placeId": "ChIJ111", + "name": "Shiki Sushi", + "lat": 47.6200, + "lng": -122.3200, + }], + }, + ) + mock_event = MockEvent(function_calls=[mock_fc]) + mock_runner.run_async.return_value = MockAsyncIterator([mock_event]) + + config = AgentConfig( + fallback_mode="DYNAMIC", + router_model="gemini/router-model", + template_model="gemini/template-model", + ) + agent = MAUIAgentWithTemplates(base_url="http://test-url", config=config) + + # Patch _build_runner + with mock.patch.object(agent, "_build_runner", return_value=mock_runner): + results = [] + async for item in agent.stream( + query="sushi Seattle", session_id="session_123", ui_version="v0.9" + ): + results.append(item) + + self.assertEqual(len(results), 1) + self.assertTrue(results[0]["is_task_complete"]) + parts = results[0]["parts"] + self.assertEqual(len(parts), 3) + + create_surface = parts[0].root.data["createSurface"] + self.assertTrue( + create_surface["surfaceId"].startswith("local-search-surface-") + ) + + update_data_model = parts[2].root.data["updateDataModel"] + # Verify places array was successfully populated in data model + self.assertEqual(update_data_model["path"], "/") + places = update_data_model["value"]["places"] + self.assertEqual(len(places), 1) + self.assertEqual(places[0]["name"], "Shiki Sushi") + + @mock.patch(_LITELLM_PATH) + async def test_agent_local_search_flow_validation_failure_fallback( + self, mock_lite_llm_class + ): + """Verifies LOCAL_SEARCH fallback when extraction validation fails.""" + self._setup_mock_llm(mock_lite_llm_class) + + # Mock router yielding LOCAL_SEARCH + self.mock_router.generate_content_async.return_value = ( + self._mock_llm_stream('{"intent": "LOCAL_SEARCH", "query": "coffee"}') + ) + + self.mock_extractor.generate_content_async.return_value = ( + self._mock_llm_stream("Fallback text here.") + ) + + mock_runner = mock.MagicMock() + + # Mock invalid set_model_response arguments (missing required center_lat) + invalid_args = {"summary": "Invalid data", "places": []} + mock_fc = MockFunctionCall("set_model_response", invalid_args) + mock_event_fc = MockEvent(function_calls=[mock_fc]) + mock_event_text = MockEvent( + content=MockContent([MockPart("Fallback text here.")]) + ) + + mock_runner.run_async.return_value = MockAsyncIterator( + [mock_event_fc, mock_event_text] + ) + + config = AgentConfig( + fallback_mode="TEXT", + router_model="gemini/router-model", + template_model="gemini/template-model", + ) + agent = MAUIAgentWithTemplates(base_url="http://test-url", config=config) + + # Patch _build_runner + with mock.patch.object(agent, "_build_runner", return_value=mock_runner): + results = [] + async for item in agent.stream( + query="coffee", session_id="session_123", ui_version="v0.9" + ): + results.append(item) + + self.assertEqual(len(results), 1) + self.assertTrue(results[0]["is_task_complete"]) + parts = results[0]["parts"] + self.assertEqual(len(parts), 2) + self.assertTrue( + parts[0] + .root.data["createSurface"]["surfaceId"] + .startswith("text-only_session_123-") + ) + text_comp = self._get_component_by_id(parts, "text-content") + self.assertEqual( + text_comp["text"], + "Fallback text here.", + ) + + @mock.patch(_LITELLM_PATH) + async def test_agent_local_search_flow_catalog_validation_failure_fallback( + self, mock_lite_llm_class + ): + """Verifies LOCAL_SEARCH flow falls back to text_only when catalog validation fails.""" + self._setup_mock_llm(mock_lite_llm_class) + self._mock_llm_responses( + router_resp='{"intent": "LOCAL_SEARCH", "query": "coffee"}', + extractor_resp="Fallback text from LLM.", + ) + + mock_runner = mock.MagicMock() + mock_fc = MockFunctionCall( + "set_model_response", + {"summary": "Coffee", "places": [{"name": "Starbucks"}]}, + ) + mock_runner.run_async.return_value = MockAsyncIterator( + [MockEvent(function_calls=[mock_fc])] + ) + + agent = self._setup_agent(fallback_mode="TEXT") + + # Mock schema manager to return a catalog that fails validation (generic Exception) + mock_catalog = mock.MagicMock() + mock_catalog.validator.validate.side_effect = Exception( + "Mock validation error" + ) + mock_schema_manager = mock.MagicMock() + mock_schema_manager.get_catalog.return_value = mock_catalog + agent._schema_managers = {"v0.9": mock_schema_manager} + + with mock.patch.object(agent, "_build_runner", return_value=mock_runner): + results = await self._collect_stream(agent, "coffee") + + self.assertEqual(len(results), 1) + self.assertTrue(results[0]["is_task_complete"]) + text_comp = self._get_component_by_id(results[0]["parts"], "text-content") + self.assertEqual(text_comp["text"], "Fallback text from LLM.") + + @mock.patch(_LITELLM_PATH) + async def test_agent_fallback_mode_text_on_extractor_failure( + self, mock_lite_llm_class + ): + """Verifies TEXT fallback mode when template extractor fails.""" + self._setup_mock_llm(mock_lite_llm_class) + self._mock_llm_responses( + router_resp='{"intent": "LOCAL_SEARCH", "query": "sushi Seattle"}', + extractor_resp="I could not search places right now.", + ) + mock_runner = mock.MagicMock() + mock_runner.run_async.return_value = MockAsyncIterator([MockEvent()]) + + agent = self._setup_agent(fallback_mode="TEXT") + with mock.patch.object(agent, "_build_runner", return_value=mock_runner): + results = await self._collect_stream(agent, "sushi Seattle") + + self.assertEqual(len(results), 1) + self.assertTrue(results[0]["is_task_complete"]) + parts = results[0]["parts"] + self.assertEqual(len(parts), 2) + text_comp = self._get_component_by_id(parts, "text-content") + self.assertEqual(text_comp["text"], "I could not search places right now.") + + @mock.patch(_LITELLM_PATH) + async def test_agent_fallback_mode_dynamic_on_extractor_failure( + self, mock_lite_llm_class + ): + """Verifies extractor failure always falls back to TEXT response, even in DYNAMIC mode.""" + self._setup_mock_llm(mock_lite_llm_class) + self._mock_llm_responses( + router_resp='{"intent": "LOCAL_SEARCH", "query": "sushi Seattle"}', + extractor_resp="Fast response after extraction failure.", + ) + mock_runner = mock.MagicMock() + mock_runner.run_async.return_value = MockAsyncIterator([MockEvent()]) + + agent = self._setup_agent(fallback_mode="DYNAMIC") + with mock.patch.object(agent, "_build_runner", return_value=mock_runner): + with mock.patch( + "python_agent.agent.MAUIAgent.stream", + ) as mock_super_stream: + results = await self._collect_stream(agent, "sushi Seattle") + + mock_super_stream.assert_not_called() + self.assertEqual(len(results), 1) + self.assertTrue(results[0]["is_task_complete"]) + parts = results[0]["parts"] + self.assertEqual(len(parts), 2) + text_comp = self._get_component_by_id(parts, "text-content") + self.assertEqual( + text_comp["text"], "Fast response after extraction failure." + ) + + @mock.patch(_LITELLM_PATH) + async def test_agent_other_spatial_fallback_mode_text( + self, mock_lite_llm_class + ): + """Verifies OTHER_SPATIAL intent routes to TEXT fallback mode.""" + self._setup_mock_llm(mock_lite_llm_class) + self._mock_llm_responses( + router_resp='{"intent": "OTHER_SPATIAL", "query": "weather Yosemite"}', + extractor_resp="The weather in Yosemite is sunny, 75 degrees.", + ) + agent = self._setup_agent(fallback_mode="TEXT") + results = await self._collect_stream(agent, "weather Yosemite") + + self.assertEqual(len(results), 1) + self.assertTrue(results[0]["is_task_complete"]) + parts = results[0]["parts"] + self.assertEqual(len(parts), 2) + text_comp = self._get_component_by_id(parts, "text-content") + self.assertEqual( + text_comp["text"], "The weather in Yosemite is sunny, 75 degrees." + ) + + @mock.patch(_LITELLM_PATH) + async def test_agent_other_spatial_fallback_mode_dynamic( + self, mock_lite_llm_class + ): + """Verifies OTHER_SPATIAL intent routes to DYNAMIC fallback mode.""" + self._setup_mock_llm(mock_lite_llm_class) + self._mock_llm_responses( + router_resp='{"intent": "OTHER_SPATIAL", "query": "weather Yosemite"}' + ) + agent = self._setup_agent(fallback_mode="DYNAMIC") + + mock_part = mock.MagicMock() + mock_part.root.text = "base_agent_dynamic_ui" + + async def mock_super_stream_gen(*_args, **_kwargs): + yield { + "is_task_complete": True, + "parts": [mock_part], + } + + # Patch MAUIAgent.stream + with mock.patch( + "python_agent.agent.MAUIAgent.stream", + side_effect=mock_super_stream_gen, + ) as mock_super_stream: + results = await self._collect_stream(agent, "weather Yosemite") + + mock_super_stream.assert_called_once_with( + "weather Yosemite", "session_123", "v0.9" + ) + self.assertEqual(len(results), 1) + self.assertTrue(results[0]["is_task_complete"]) + self.assertEqual(results[0]["parts"][0].root.text, "base_agent_dynamic_ui") + + @mock.patch( + "python_agent.agent_with_templates.LiteLlm" + ) + async def test_extractor_duplication_prevented(self, mock_lite_llm_class): + """Verifies that text is not duplicated in fallback if extractor yields partial and final events.""" + self._setup_mock_llm(mock_lite_llm_class) + + # Mock router yielding LOCAL_SEARCH + self.mock_router.generate_content_async.return_value = ( + self._mock_llm_stream('{"intent": "LOCAL_SEARCH", "query": "sushi"}') + ) + + # Mock extractor runner yielding partials and then final consolidated event + mock_runner = mock.MagicMock() + mock_runner.run_async.return_value = MockAsyncIterator([ + MockEvent(content=MockContent([MockPart("I'")]), partial=True), + MockEvent(content=MockContent([MockPart("m sorry")]), partial=True), + MockEvent(content=MockContent([MockPart("I'm sorry")]), partial=False), + ]) + + config = AgentConfig( + fallback_mode="TEXT", + router_model="gemini/router-model", + template_model="gemini/template-model", + ) + agent = MAUIAgentWithTemplates(base_url="http://test-url", config=config) + + with mock.patch.object(agent, "_build_runner", return_value=mock_runner): + results = await self._collect_stream(agent, "sushi") + + self.assertEqual(len(results), 1) + self.assertTrue(results[0]["is_task_complete"]) + parts = results[0]["parts"] + text_comp = self._get_component_by_id(parts, "text-content") + self.assertEqual(text_comp["text"], "I'm sorry") + + def test_build_runner_sets_auto_create_session(self): + agent = MAUIAgentWithTemplates(base_url="http://test-url") + mock_agent = mock.MagicMock(spec=LlmAgent) + runner = agent._build_runner(mock_agent) # pylint: disable=protected-access + self.assertTrue(runner.auto_create_session) + + def test_build_dynamic_extractor_agent_appends_shared_guidelines(self): + """Verifies that shared guidelines are appended to skill instructions.""" + agent = MAUIAgentWithTemplates(base_url="http://test-url") + with mock.patch( + "builtins.open", mock.mock_open(read_data="Shared guidelines content") + ) as mock_file: + with mock.patch( + "google.adk.skills.load_skill_from_dir" + ) as mock_load_skill: + mock_skill = mock.MagicMock() + mock_skill.instructions = "Base skill instructions" + mock_load_skill.return_value = mock_skill + + extractor_agent = agent._build_dynamic_extractor_agent( # pylint: disable=protected-access + "local-search-template-response" + ) + self.assertIn("Shared guidelines content", extractor_agent.instruction) + self.assertIn("Base skill instructions", extractor_agent.instruction) + mock_file.assert_called_once() + + def test_build_dynamic_extractor_agent_handles_file_read_error(self): + """Verifies that file read errors are handled gracefully when loading guidelines.""" + agent = MAUIAgentWithTemplates(base_url="http://test-url") + with mock.patch("builtins.open", side_effect=OSError("Read error")): + with mock.patch( + "google.adk.skills.load_skill_from_dir" + ) as mock_load_skill: + mock_skill = mock.MagicMock() + mock_skill.instructions = "Base skill instructions" + mock_load_skill.return_value = mock_skill + + # Check that it handles OSError gracefully and proceeds + extractor_agent = agent._build_dynamic_extractor_agent( # pylint: disable=protected-access + "local-search-template-response" + ) + self.assertNotIn( + "Shared guidelines content", extractor_agent.instruction + ) + self.assertIn("Base skill instructions", extractor_agent.instruction) + + +if __name__ == "__main__": + unittest.main() diff --git a/agent/python_agent/test_extractor.py b/agent/python_agent/test_extractor.py new file mode 100644 index 0000000..1a322d4 --- /dev/null +++ b/agent/python_agent/test_extractor.py @@ -0,0 +1,211 @@ +# 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 +# +# https://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. + +"""Tests for extractor.py.""" + +import unittest +import pydantic +from python_agent.extractor import ( + DirectionsExtractorSchema, + LocalSearchExtractorSchema, + Pin, + PlacePin, +) + + +class TestExtractor(unittest.TestCase): + """Unit tests for extractor schemas and data normalization.""" + + def test_pin_normalize_label_copies_name(self): + data = {"lat": 1.0, "lng": 2.0, "name": "My Place"} + pin = Pin(**data) + self.assertEqual(pin.label, "My Place") + + def test_pin_normalize_label_defaults_to_location(self): + data = {"lat": 1.0, "lng": 2.0} + pin = Pin(**data) + self.assertEqual(pin.label, "Location") + + def test_pin_normalize_label_preserves_existing(self): + data = { + "lat": 1.0, + "lng": 2.0, + "label": "Custom Label", + "name": "Ignored Name", + } + pin = Pin(**data) + self.assertEqual(pin.label, "Custom Label") + + def test_directions_extractor_schema_normalize_travel_mode(self): + """Verifies that travel mode is normalized to lowercase.""" + data = { + "summary": "Commute is 1h.", + "center_lat": 37.5, + "center_lng": 127.0, + "routes": [{ + "origin": {"lat": 37.4, "lng": 126.9, "label": "Start"}, + "destination": {"lat": 37.6, "lng": 127.1, "label": "End"}, + }], + "travel_mode": "WALK", + } + schema = DirectionsExtractorSchema(**data) + self.assertEqual(schema.travel_mode, "walking") + + def test_directions_extractor_schema_with_routes(self): + """Verifies that DirectionsExtractorSchema can be initialized with routes.""" + data = { + "summary": "Scenic route.", + "center_lat": 37.5, + "center_lng": 127.0, + "travel_mode": "driving", + "routes": [ + { + "origin": {"lat": 37.4, "lng": 126.9, "label": "A"}, + "destination": {"lat": 37.5, "lng": 127.0, "label": "B"}, + }, + { + "origin": {"lat": 37.5, "lng": 127.0, "label": "B"}, + "destination": {"lat": 37.6, "lng": 127.1, "label": "C"}, + }, + ], + } + schema = DirectionsExtractorSchema(**data) + self.assertEqual(len(schema.routes), 2) + self.assertEqual(schema.routes[0].origin.label, "A") + self.assertEqual(schema.routes[1].destination.label, "C") + self.assertEqual(schema.travel_mode, "driving") + + def test_directions_extractor_schema_missing_travel_mode_fails_validation( + self, + ): + """Verifies that omitting travel_mode raises ValidationError.""" + data = { + "summary": "Directions summary", + "center_lat": 37.5, + "center_lng": 127.0, + "routes": [{ + "origin": {"lat": 37.4, "lng": 126.9, "label": "Start"}, + "destination": {"lat": 37.6, "lng": 127.1, "label": "End"}, + }], + } + with self.assertRaises(pydantic.ValidationError): + DirectionsExtractorSchema(**data) + + def test_directions_extractor_schema_invalid_travel_mode_fails_validation( + self, + ): + """Verifies that invalid travel_mode values raise ValidationError.""" + for invalid_mode in ["flying", "", None, "scooter", 123]: + with self.subTest(invalid_mode=invalid_mode): + data = { + "summary": "Directions summary", + "center_lat": 37.5, + "center_lng": 127.0, + "routes": [], + "travel_mode": invalid_mode, + } + with self.assertRaises(pydantic.ValidationError): + DirectionsExtractorSchema(**data) + + def test_directions_extractor_schema_all_valid_modes(self): + """Verifies all valid travel modes are accepted.""" + for mode in ["driving", "walking", "transit", "bicycling"]: + with self.subTest(mode=mode): + data = { + "summary": f"Going via {mode}", + "center_lat": 37.5, + "center_lng": 127.0, + "routes": [], + "travel_mode": mode, + } + schema = DirectionsExtractorSchema(**data) + self.assertEqual(schema.travel_mode, mode) + + def test_directions_extractor_schema_normalize_all_synonyms(self): + """Verifies all synonyms and case/whitespace variations normalize cleanly.""" + synonym_cases = { + "transit": [ + "bus", + "train", + "subway", + "tube", + "metro", + "tram", + "rail", + "light rail", + "ferry", + "public transit", + "public_transit", + "public transport", + "public_transport", + "transit", + " BUS ", + "Train", + " Metro ", + "SUBWAY", + "Public Transit", + " public_transport ", + " Light Rail ", + ], + "walking": [ + "walk", + "walking", + "pedestrian", + "foot", + "on foot", + "on_foot", + " WALK ", + "Foot", + " pedestrian ", + "On Foot", + " on_foot ", + ], + "bicycling": [ + "bike", + "biking", + "bicycling", + "cycling", + "bicycle", + " BIKE ", + "Bicycle", + " Cycling ", + ], + "driving": [ + "car", + "drive", + "driving", + "auto", + "automobile", + " CAR ", + "Drive", + " Auto ", + " Automobile ", + ], + } + for expected_mode, synonyms in synonym_cases.items(): + for synonym in synonyms: + with self.subTest(synonym=synonym, expected=expected_mode): + data = { + "summary": "Commute", + "center_lat": 37.5, + "center_lng": 127.0, + "routes": [], + "travel_mode": synonym, + } + schema = DirectionsExtractorSchema(**data) + self.assertEqual(schema.travel_mode, expected_mode) + + +if __name__ == "__main__": + unittest.main() diff --git a/agent/python_agent/test_merger.py b/agent/python_agent/test_merger.py new file mode 100644 index 0000000..2e66745 --- /dev/null +++ b/agent/python_agent/test_merger.py @@ -0,0 +1,709 @@ +# 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. + +"""Tests for A2UI Layout Template Merger (`merger.py`).""" + +import pathlib +import re +import unittest + +from google3.third_party.a2ui.agent_sdks.python.a2ui_agent.src import a2ui +from python_agent import agent +from python_agent import merger + +merge_template = merger.merge_template + + +class TestMerger(unittest.TestCase): + """Tests for A2UI Layout Template Merger.""" + + def _validate_schema(self, result): + """Validates the merged result against the Maps Catalog Extension schema.""" + extension_path = ( + pathlib.Path(__file__).parent + / "shared" + / "schema" + / "maps_catalog_extension.json" + ) + schema_manager = a2ui.schema.manager.A2uiSchemaManager( + version=a2ui.schema.constants.VERSION_0_9, + catalogs=[ + a2ui.schema.catalog.CatalogConfig( + name="maps-agentic-ui-catalog", + provider=agent.MergedCatalogProvider( + a2ui.schema.constants.VERSION_0_9, str(extension_path) + ), + ) + ], + schema_modifiers=[ + a2ui.schema.common_modifiers.remove_strict_validation + ], + ) + selected_catalog = schema_manager.get_selected_catalog() + selected_catalog.validator.validate(result) + + def test_merge_unknown_template_raises_error(self): + """Verifies that an unknown template raises FileNotFoundError.""" + with self.assertRaises(FileNotFoundError): + merge_template("non_existent_template", {"text": "hello"}) + + def test_merge_text_only_full_json(self): + """Verifies that text-only response is merged correctly.""" + data = { + "surface_id": "text-only-surface-efg", + "text": "Google Maps directions are not supported in South Korea.", + } + + expected = [ + { + "version": "v0.9", + "createSurface": { + "surfaceId": "text-only-surface-efg", + "catalogId": "a2ui://maps-agentic-ui-catalog.json", + }, + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": "text-only-surface-efg", + "components": [ + { + "id": "root", + "component": "Column", + "children": ["text-content"], + }, + { + "id": "text-content", + "component": "Text", + "variant": "body", + "text": ( + "Google Maps directions are not supported in South" + " Korea." + ), + }, + ], + }, + }, + ] + result = merge_template("text_only", data) + self.assertEqual(result, expected) + + def test_validate_merged_output_with_schema(self): + """Validates the merged output against maps catalog extension schema.""" + data = { + "surface_id": "text-only-surface-efg", + "text": "Google Maps directions are not supported in South Korea.", + } + result = merge_template("text_only", data) + + self._validate_schema(result) + + def test_merge_omitted_surface_id_generates_random_suffix(self): + """Verifies that omitting surface_id generates a random dynamic ID.""" + data = { + "text": "Hello world", + } + result = merge_template("text_only", data) + # createSurface is the first event in the list + surface_id = result[0]["createSurface"]["surfaceId"] + self.assertTrue(re.fullmatch(r"text_only_surface_[a-f0-9]{6}", surface_id)) + + def test_merge_generic_surface_id_generates_random_suffix(self): + """Verifies that a generic default surface_id gets a random suffix.""" + data = { + "surface_id": "text-only-surface", + "text": "Hello world", + } + result = merge_template("text_only", data) + surface_id = result[0]["createSurface"]["surfaceId"] + self.assertTrue(re.fullmatch(r"text-only-surface_[a-f0-9]{6}", surface_id)) + + def test_merge_custom_surface_id_remains_intact(self): + """Verifies that a custom unique surface_id is preserved exactly.""" + data = { + "surface_id": "my-special-surface-123", + "text": "Hello world", + } + result = merge_template("text_only", data) + surface_id = result[0]["createSurface"]["surfaceId"] + self.assertEqual(surface_id, "my-special-surface-123") + + def test_merge_local_search_full_json(self): + """Verifies merging a complete local search payload.""" + data = { + "surface_id": "local-search-surface-abc", + "summary": "Here are 3 highly-rated coffee shops in Seattle.", + "center_lat": "47.6062", + "center_lng": -122.3321, + "zoom": "14", + "places": [ + { + "placeId": "ChIJ111", + "name": "Espresso Vivace", + "lat": "47.6200", + "lng": "-122.3200", + }, + { + "placeId": "ChIJ222", + "name": "Milstead & Co.", + "lat": 47.6400, + "lng": -122.3500, + }, + { + "placeId": "ChIJ333", + "name": "Victrola Coffee", + "lat": 47.6100, + "lng": -122.3200, + }, + ], + } + + expected = [ + { + "version": "v0.9", + "createSurface": { + "surfaceId": "local-search-surface-abc", + "catalogId": "a2ui://maps-agentic-ui-catalog.json", + }, + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": "local-search-surface-abc", + "components": [ + { + "id": "root", + "component": "Column", + "children": ["summary-text", "map", "list"], + }, + { + "id": "summary-text", + "component": "Text", + "variant": "body", + "text": ( + "Here are 3 highly-rated coffee shops in Seattle." + ), + }, + { + "id": "map", + "component": "GoogleMap", + "center": {"lat": 47.6062, "lng": -122.3321}, + "zoom": 14, + "markers": [ + { + "lat": 47.62, + "lng": -122.32, + "label": "Espresso Vivace", + "placeId": "ChIJ111", + }, + { + "lat": 47.64, + "lng": -122.35, + "label": "Milstead & Co.", + "placeId": "ChIJ222", + }, + { + "lat": 47.61, + "lng": -122.32, + "label": "Victrola Coffee", + "placeId": "ChIJ333", + }, + ], + }, + { + "id": "list", + "component": "List", + "direction": "vertical", + "children": { + "componentId": "place-card", + "path": "/places", + }, + }, + { + "id": "place-card", + "component": "PlaceDetailsCompact", + "placeId": {"path": "placeId"}, + }, + ], + }, + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": "local-search-surface-abc", + "path": "/", + "value": { + "places": [ + { + "placeId": "ChIJ111", + "name": "Espresso Vivace", + "lat": 47.62, + "lng": -122.32, + }, + { + "placeId": "ChIJ222", + "name": "Milstead & Co.", + "lat": 47.64, + "lng": -122.35, + }, + { + "placeId": "ChIJ333", + "name": "Victrola Coffee", + "lat": 47.61, + "lng": -122.32, + }, + ] + }, + }, + }, + ] + + result = merge_template("local_search", data, max_list_size=3) + self.assertEqual(result, expected) + + def test_validate_local_search_output_with_schema(self): + """Validates the merged local search output against maps catalog extension schema.""" + data = { + "surface_id": "local-search-surface-abc", + "summary": "Here are coffee shops.", + "center_lat": 47.6062, + "center_lng": -122.3321, + "zoom": 14, + "places": [{ + "placeId": "ChIJ111", + "name": "Espresso Vivace", + "lat": 47.62, + "lng": -122.32, + }], + } + result = merge_template("local_search", data) + + self._validate_schema(result) + + def test_merge_max_list_size_slicing(self): + """Verifies that max_list_size parameter slices the places and markers list.""" + data = { + "surface_id": "test-surface", + "summary": "Here are some places.", + "center_lat": 47.6062, + "center_lng": -122.3321, + "zoom": 14, + "places": [ + {"placeId": "1", "name": "P1", "lat": 47.61, "lng": -122.31}, + {"placeId": "2", "name": "P2", "lat": 47.62, "lng": -122.32}, + {"placeId": "3", "name": "P3", "lat": 47.63, "lng": -122.33}, + ], + } + result = merge_template("local_search", data, max_list_size=2) + # Check that updateComponents has only 2 markers + components = result[1]["updateComponents"]["components"] + map_comp = next(c for c in components if c["id"] == "map") + self.assertEqual(len(map_comp["markers"]), 2) + + # Check that updateDataModel has only 2 places + places = result[2]["updateDataModel"]["value"]["places"] + self.assertEqual(len(places), 2) + self.assertEqual(places[0]["placeId"], "1") + self.assertEqual(places[1]["placeId"], "2") + + def test_merge_directions_full_json(self): + """Verifies complete end-to-end directions template merging, placeholder replacement, and travel mode normalization.""" + data = { + "surface_id": "directions-surface-xyz", + "summary": "Typical commute is 1h 15m.", + "center_lat": "37.5665", + "center_lng": 126.9780, + "zoom": 12, + "routes": [{ + "origin": {"lat": "37.6700", "lng": "127.0400", "label": "Dobong"}, + "destination": { + "lat": 37.4900, + "lng": 127.0200, + "label": "Gangnam", + }, + }], + "travel_mode": "WALK", + } + + expected = [ + { + "version": "v0.9", + "createSurface": { + "surfaceId": "directions-surface-xyz", + "catalogId": "a2ui://maps-agentic-ui-catalog.json", + }, + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": "directions-surface-xyz", + "components": [ + { + "id": "root", + "component": "Column", + "children": ["summary-text", "map"], + }, + { + "id": "summary-text", + "component": "Text", + "variant": "body", + "text": "Typical commute is 1h 15m.", + }, + { + "id": "map", + "component": "GoogleMap", + "center": {"lat": 37.5665, "lng": 126.978}, + "zoom": 12, + "routes": [{ + "origin": { + "lat": 37.67, + "lng": 127.04, + "label": "Dobong", + }, + "destination": { + "lat": 37.49, + "lng": 127.02, + "label": "Gangnam", + }, + }], + "travelMode": "walking", + }, + ], + }, + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": "directions-surface-xyz", + "path": "/", + "value": {}, + }, + }, + ] + + result = merge_template("directions", data, max_list_size=3) + self.assertEqual(result, expected) + + def test_validate_directions_output_with_schema(self): + """Verifies merged directions output passes schema validation.""" + data = { + "surface_id": "directions-surface-xyz", + "summary": "Typical commute is 1h 15m.", + "center_lat": "37.5665", + "center_lng": 126.9780, + "zoom": 12, + "routes": [{ + "origin": {"lat": "37.6700", "lng": "127.0400", "label": "Dobong"}, + "destination": { + "lat": 37.4900, + "lng": 127.0200, + "label": "Gangnam", + }, + }], + "travel_mode": "WALK", + } + result = merge_template("directions", data) + + self._validate_schema(result) + + +class TestMergerEdgeCases(unittest.TestCase): + """Edge case tests for A2UI Layout Template Merger.""" + + def test_missing_keys_no_crash_local_search(self): + """Verifies that merging local_search template with empty/missing places falls back to text_only.""" + data = { + "surface_id": "test-surface", + "summary": "Short response without places.", + } + result = merge_template("local_search", data, max_list_size=3) + self.assertEqual(len(result), 2) + self.assertEqual( + result[1]["updateComponents"]["components"][1]["text"], + "Short response without places.", + ) + + def test_invalid_places_with_valid_center_fallback(self): + """Verifies that local_search fallback to text_only if places is invalid but center is valid.""" + data = { + "surface_id": "test-surface", + "summary": "Short response.", + "center_lat": 37.0, + "center_lng": 127.0, + "zoom": 10, + "places": "NOT_A_LIST", # Invalid places + } + result = merge_template("local_search", data, max_list_size=3) + self.assertEqual(len(result), 2) + self.assertEqual( + result[1]["updateComponents"]["components"][1]["text"], + "Short response.", + ) + + def test_missing_keys_no_crash_directions(self): + """Verifies that merging directions template with missing or empty routes gracefully falls back to a text-only representation.""" + # Test missing routes + data_no_routes = { + "surface_id": "test-surface", + "summary": "Cannot compute directions.", + } + result = merge_template("directions", data_no_routes, max_list_size=3) + self.assertEqual(len(result), 2) + self.assertEqual( + result[1]["updateComponents"]["components"][1]["text"], + "Cannot compute directions.", + ) + + # Test empty routes + data_empty_routes = { + "surface_id": "test-surface", + "summary": "Cannot compute directions.", + "routes": [], + } + result = merge_template("directions", data_empty_routes, max_list_size=3) + self.assertEqual(len(result), 2) + self.assertEqual( + result[1]["updateComponents"]["components"][1]["text"], + "Cannot compute directions.", + ) + + def test_unrecognized_travel_mode(self): + """Verifies that unrecognized travel modes are ignored and not passed to output.""" + data = { + "surface_id": "test-surface", + "summary": "Short response.", + "center_lat": 37.0, + "center_lng": 127.0, + "zoom": 10, + "routes": [{ + "origin": {"lat": 37.0, "lng": 127.0, "label": "Start"}, + "destination": {"lat": 37.1, "lng": 127.1, "label": "End"}, + }], + "travel_mode": "TELEPORT", + } + result = merge_template("directions", data, max_list_size=3) + map_comp = next( + c + for c in result[1]["updateComponents"]["components"] + if c["component"] == "GoogleMap" + ) + self.assertNotIn("travelMode", map_comp) + + def test_travel_mode_synonyms_normalization(self): + """Verifies that various travel mode synonyms normalize properly in merger.""" + test_cases = [ + ("public transit", "transit"), + ("on foot", "walking"), + ("auto", "driving"), + ("cycling", "bicycling"), + ("subway", "transit"), + ("pedestrian", "walking"), + ] + for raw_mode, expected_mode in test_cases: + with self.subTest(raw_mode=raw_mode, expected_mode=expected_mode): + data = { + "surface_id": "test-surface", + "summary": "Commute.", + "center_lat": 37.0, + "center_lng": 127.0, + "zoom": 10, + "routes": [{ + "origin": {"lat": 37.0, "lng": 127.0, "label": "Start"}, + "destination": {"lat": 37.1, "lng": 127.1, "label": "End"}, + }], + "travel_mode": raw_mode, + } + result = merge_template("directions", data, max_list_size=3) + map_comp = next( + c + for c in result[1]["updateComponents"]["components"] + if c["component"] == "GoogleMap" + ) + self.assertEqual(map_comp.get("travelMode"), expected_mode) + + def test_malformed_coordinate_types(self): + """Verifies that coordinates parsing fallback triggers text_only fallback.""" + data = { + "surface_id": "test-surface", + "summary": "Short response.", + "center_lat": "invalid-lat-string", # Malformed float + "center_lng": None, # Malformed type + "zoom": "invalid-zoom", # Malformed int + "places": [{"name": "Dummy Place", "lat": 47.6, "lng": -122.3}], + } + result = merge_template("local_search", data, max_list_size=3) + # Validation failure in mandatory fields must trigger fallback to text_only + # (2 parts) + self.assertEqual(len(result), 2) + self.assertEqual( + result[1]["updateComponents"]["components"][1]["text"], + "Short response.", + ) + + def test_places_not_list(self): + """Verifies type fallback when places is string.""" + data = { + "surface_id": "test-surface", + "summary": "Short response.", + "places": "this is a string, not a list", # Invalid type for places + } + result = merge_template("local_search", data, max_list_size=3) + # Non-list places must trigger fallback to text_only (2 parts) + self.assertEqual(len(result), 2) + self.assertEqual( + result[1]["updateComponents"]["components"][1]["text"], + "Short response.", + ) + + def test_missing_optional_placeholders_are_stripped(self): + """Verifies that optional placeholders are omitted when missing from input data.""" + data = { + "surface_id": "test-surface", + "summary": "Results for sushi.", + "center_lat": 47.6062, + "center_lng": -122.3321, + "zoom": 14, + "places": [{ + "placeId": "ChIJ111", + "name": "Espresso Vivace", + "lat": 47.6200, + "lng": -122.3200, + }], + } + result = merge_template("local_search", data, max_list_size=3) + update_components = result[1]["updateComponents"] + map_comp = next( + c for c in update_components["components"] if c["id"] == "map" + ) + # Verify anchorMarker key is NOT in map component (cleanly stripped) + self.assertNotIn("anchorMarker", map_comp) + + def test_markers_explicitly_provided_and_sanitized(self): + """Verifies that explicitly provided markers are used and sanitized.""" + data = { + "surface_id": "test-surface", + "summary": "Results with custom markers.", + "center_lat": 47.6062, + "center_lng": -122.3321, + "zoom": 14, + "places": [{ + "placeId": "ChIJ111", + "name": "Espresso Vivace", + "lat": 47.62, + "lng": -122.32, + }], + "markers": [ + {"lat": "47.63", "lng": "-122.33", "label": "Custom 1"}, + {"lat": 47.64, "lng": -122.34, "label": None}, + {"invalid_marker": "yes"}, + ], + } + result = merge_template("local_search", data, max_list_size=3) + update_components = result[1]["updateComponents"] + map_comp = next( + c for c in update_components["components"] if c["id"] == "map" + ) + expected_markers = [ + {"lat": 47.63, "lng": -122.33, "label": "Custom 1"}, + {"lat": 47.64, "lng": -122.34, "label": ""}, + ] + self.assertEqual(map_comp["markers"], expected_markers) + + def test_fallback_preserves_surface_id(self): + """Verifies that text_only fallback preserves the provided surface_id.""" + data = { + "surface_id": "my-custom-fallback-surface", + "summary": "Short response.", + "places": "invalid", + } + result = merge_template("local_search", data, max_list_size=3) + self.assertEqual(len(result), 2) + self.assertEqual( + result[1]["updateComponents"]["surfaceId"], + "my-custom-fallback-surface", + ) + + def test_malformed_coordinate_types_directions(self): + """Verifies that malformed coordinates in directions trigger text_only fallback.""" + data = { + "surface_id": "test-surface", + "summary": "Cannot compute directions.", + "center_lat": "invalid-lat", + "center_lng": 127.0, + "zoom": 10, + "routes": [{ + "origin": {"lat": 37.0, "lng": 127.0, "label": "Start"}, + "destination": {"lat": 37.1, "lng": 127.1, "label": "End"}, + }], + } + result = merge_template("directions", data, max_list_size=3) + self.assertEqual(len(result), 2) + self.assertEqual( + result[1]["updateComponents"]["components"][1]["text"], + "Cannot compute directions.", + ) + + def test_merge_directions_with_direct_routes(self): + """Verifies directions template merging when routes are directly passed.""" + data = { + "surface_id": "directions-surface-xyz", + "summary": "Commute is 1h.", + "center_lat": 37.5665, + "center_lng": 126.9780, + "zoom": 12, + "routes": [ + { + "origin": {"lat": 37.6700, "lng": 127.0400, "label": "A"}, + "destination": {"lat": 37.5000, "lng": 127.0000, "label": "B"}, + }, + { + "origin": {"lat": 37.5000, "lng": 127.0000, "label": "B"}, + "destination": {"lat": 37.4900, "lng": 127.0200, "label": "C"}, + }, + ], + } + result = merge_template("directions", data) + # The merged updateComponents should contain routes in GoogleMap + map_comp = next( + c + for c in result[1]["updateComponents"]["components"] + if c["component"] == "GoogleMap" + ) + self.assertEqual(len(map_comp["routes"]), 2) + self.assertEqual(map_comp["routes"][0]["origin"]["label"], "A") + self.assertEqual(map_comp["routes"][1]["destination"]["label"], "C") + + def test_merge_directions_malformed_routes_fallback(self): + """Verifies that malformed routes trigger text_only fallback.""" + data = { + "surface_id": "test-surface", + "summary": "Fallback text.", + "center_lat": 37.0, + "center_lng": 127.0, + "zoom": 10, + "routes": [{ + "origin": "not-a-dict", + "destination": {"lat": 37.1, "lng": 127.1, "label": "End"}, + }], + } + result = merge_template("directions", data) + self.assertEqual(len(result), 2) + self.assertEqual( + result[1]["updateComponents"]["components"][1]["text"], + "Fallback text.", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/agent/python_agent/test_router_config.py b/agent/python_agent/test_router_config.py new file mode 100644 index 0000000..20a6164 --- /dev/null +++ b/agent/python_agent/test_router_config.py @@ -0,0 +1,42 @@ +# 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 +# +# https://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. + +"""Tests for Router Configuration.""" + +import unittest +from python_agent.router_config import IntentClass +from python_agent.router_config import ROUTER_SYSTEM_INSTRUCTION +from python_agent.router_config import RouterClassification + + +class TestRouterConfig(unittest.TestCase): + """Unit tests for Intent Router configuration schema and prompt.""" + + def test_schema_instantiation(self): + data = {"intent": "LOCAL_SEARCH", "query": "coffee near me"} + classification = RouterClassification(**data) + self.assertEqual(classification.intent, IntentClass.LOCAL_SEARCH) + self.assertEqual(classification.query, "coffee near me") + + def test_schema_validation_error(self): + data = {"intent": "INVALID_INTENT", "query": "coffee near me"} + with self.assertRaises(ValueError): + RouterClassification(**data) + + def test_instruction_not_empty(self): + self.assertGreater(len(ROUTER_SYSTEM_INSTRUCTION), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/client/android/GoogleMapsA2UI/src/main/assets/index.html b/client/android/GoogleMapsA2UI/src/main/assets/index.html index 3643909..e1e9678 100644 --- a/client/android/GoogleMapsA2UI/src/main/assets/index.html +++ b/client/android/GoogleMapsA2UI/src/main/assets/index.html @@ -1,1182 +1,7299 @@ - + + + + - - - - AI Kit React Reference Implementation - + + -
+ + - \ No newline at end of file + diff --git a/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/A2AResponseParser.kt b/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/A2AResponseParser.kt index 632b7de..56d43bc 100644 --- a/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/A2AResponseParser.kt +++ b/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/A2AResponseParser.kt @@ -20,289 +20,304 @@ import org.json.JSONObject data class ParsedA2AEventMetadata(val mimeType: String?) sealed interface ParsedA2AEvent { - data class Text(val text: String) : ParsedA2AEvent - data class Data(val data: String, val metadata: ParsedA2AEventMetadata? = null) : ParsedA2AEvent + data class Text(val text: String) : ParsedA2AEvent + + data class Data(val data: String, val metadata: ParsedA2AEventMetadata? = null) : ParsedA2AEvent } object A2AResponseParser { - private const val KEY_TEXT = "text" - private const val KEY_KIND = "kind" - private const val KEY_DATA = "data" - private const val KEY_HISTORY = "history" - private const val KEY_ROLE = "role" - private const val VAL_USER = "user" - private const val VAL_AGENT = "agent" - private const val KEY_PARTS = "parts" - private const val KEY_CONTENT = "content" - private const val KEY_STATUS = "status" - private const val KEY_MESSAGE = "message" - private const val KEY_RESULT = "result" - - private const val KEY_CREATE_SURFACE = "createSurface" - private const val KEY_UPDATE_COMPONENTS = "updateComponents" - private const val KEY_UPDATE_DATA_MODEL = "updateDataModel" - private const val KEY_DELETE_SURFACE = "deleteSurface" - private const val KEY_SURFACE_ID = "surfaceId" - - fun parse(rawJson: JSONObject): List { - val partsList = mutableListOf() - val partsArray = extractPartsArray(rawJson) - - if (partsArray != null) { - var currentTextBuilder = java.lang.StringBuilder() - var currentUiElements = JSONArray() - - for (i in 0 until partsArray.length()) { - val part = partsArray.getJSONObject(i) - val textPart = if (part.has(KEY_TEXT)) part.optString(KEY_TEXT) else if (part.optString(KEY_KIND) == KEY_TEXT) part.optString(KEY_TEXT) else null - - if (textPart != null) { - if (currentUiElements.length() > 0) { - partsList.add(ParsedA2AEvent.Data(currentUiElements.toString())) - currentUiElements = JSONArray() - } - - if (textPart.contains("---a2ui_JSON---") || textPart.contains("```json")) { - extractJsonBlocks(textPart, currentTextBuilder, partsList) - } else if (textPart.isNotEmpty()) { - if (currentTextBuilder.isNotEmpty()) currentTextBuilder.append("\n") - currentTextBuilder.append(textPart) - } - } - - val dataPayload = if (part.has(KEY_DATA)) part.optJSONObject(KEY_DATA) else if (part.optString(KEY_KIND) == KEY_DATA) part.optJSONObject(KEY_DATA) else null - if (dataPayload != null && isUiElement(dataPayload)) { - if (currentTextBuilder.isNotEmpty()) { - partsList.add(ParsedA2AEvent.Text(currentTextBuilder.toString())) - currentTextBuilder = java.lang.StringBuilder() - } - currentUiElements.put(dataPayload) - } - } - - if (currentTextBuilder.isNotEmpty()) { - partsList.add(ParsedA2AEvent.Text(currentTextBuilder.toString())) - } - if (currentUiElements.length() > 0) { - partsList.add(ParsedA2AEvent.Data(currentUiElements.toString())) - } - } else { - try { - val resultObj = rawJson.opt(KEY_RESULT) - if (resultObj is String) { - if (resultObj.isNotEmpty()) { - val firstChar = resultObj.trim().firstOrNull() - if (firstChar == '[') { - val array = JSONArray(resultObj) - val uiElements = JSONArray() - for (j in 0 until array.length()) { - val item = array.optJSONObject(j) - if (item != null && isUiElement(item)) { - uiElements.put(item) - } - } - if (uiElements.length() > 0) { - partsList.add(ParsedA2AEvent.Data(uiElements.toString())) - } - } - } - } else if (resultObj is JSONArray) { - var currentTextBuilder = java.lang.StringBuilder() - val uiElements = JSONArray() - for (j in 0 until resultObj.length()) { - val item = resultObj.optJSONObject(j) - if (item != null) { - if (item.has(KEY_TEXT)) { - val textPart = item.optString(KEY_TEXT) - if (textPart.isNotEmpty()) { - if (currentTextBuilder.isNotEmpty()) currentTextBuilder.append("\n") - currentTextBuilder.append(textPart) - } - } - if (isUiElement(item)) { - uiElements.put(item) - } - } - } - if (currentTextBuilder.isNotEmpty()) { - partsList.add(ParsedA2AEvent.Text(currentTextBuilder.toString())) - } - if (uiElements.length() > 0) { - partsList.add(ParsedA2AEvent.Data(uiElements.toString())) - } - } - } catch (e: Exception) {} + private const val KEY_TEXT = "text" + private const val KEY_KIND = "kind" + private const val KEY_DATA = "data" + private const val KEY_HISTORY = "history" + private const val KEY_ROLE = "role" + private const val VAL_USER = "user" + private const val VAL_AGENT = "agent" + private const val KEY_PARTS = "parts" + private const val KEY_CONTENT = "content" + private const val KEY_STATUS = "status" + private const val KEY_MESSAGE = "message" + private const val KEY_RESULT = "result" + + private const val KEY_CREATE_SURFACE = "createSurface" + private const val KEY_UPDATE_COMPONENTS = "updateComponents" + private const val KEY_UPDATE_DATA_MODEL = "updateDataModel" + private const val KEY_DELETE_SURFACE = "deleteSurface" + private const val KEY_SURFACE_ID = "surfaceId" + + fun parse(rawJson: JSONObject): List { + val partsList = mutableListOf() + val partsArray = extractPartsArray(rawJson) + + if (partsArray != null) { + var currentTextBuilder = java.lang.StringBuilder() + var currentUiElements = JSONArray() + + for (i in 0 until partsArray.length()) { + val part = partsArray.getJSONObject(i) + val textPart = + if (part.has(KEY_TEXT)) part.optString(KEY_TEXT) + else if (part.optString(KEY_KIND) == KEY_TEXT) part.optString(KEY_TEXT) else null + + if (textPart != null) { + if (currentUiElements.length() > 0) { + partsList.add(ParsedA2AEvent.Data(currentUiElements.toString())) + currentUiElements = JSONArray() + } + + if (textPart.contains("---a2ui_JSON---") || textPart.contains("```json")) { + extractJsonBlocks(textPart, currentTextBuilder, partsList) + } else if (textPart.isNotEmpty()) { + if (currentTextBuilder.isNotEmpty()) currentTextBuilder.append("\n") + currentTextBuilder.append(textPart) + } } - val deduplicatedParts = mutableListOf() - val seenSurfaces = mutableSetOf() - var lastSeenText: String? = null - - for (part in partsList) { - val finalText: String? = if (part is ParsedA2AEvent.Text) { - part.text.replace("```json", "").replace("```", "").trim().takeIf { it.isNotEmpty() } - } else null - - // Deduplicate consecutive identical text blocks - if (finalText != null && finalText != lastSeenText) { - deduplicatedParts.add(ParsedA2AEvent.Text(finalText)) - lastSeenText = finalText - } - - if (part is ParsedA2AEvent.Data && part.data != "[]") { - try { - val array = JSONArray(part.data) - val newArray = JSONArray() - for (i in 0 until array.length()) { - val obj = array.getJSONObject(i) - val sid = obj.optJSONObject(KEY_CREATE_SURFACE)?.optString(KEY_SURFACE_ID) - if (sid != null) { - if (seenSurfaces.contains(sid)) { - continue - } - seenSurfaces.add(sid) - } - newArray.put(obj) - } - if (newArray.length() > 0) { - deduplicatedParts.add(ParsedA2AEvent.Data(newArray.toString(), part.metadata)) - } - } catch (e: Exception) { - if (part.data.isNotEmpty()) { - deduplicatedParts.add(part) - } - } - } + val dataPayload = + if (part.has(KEY_DATA)) part.optJSONObject(KEY_DATA) + else if (part.optString(KEY_KIND) == KEY_DATA) part.optJSONObject(KEY_DATA) else null + if (dataPayload != null && isUiElement(dataPayload)) { + if (currentTextBuilder.isNotEmpty()) { + partsList.add(ParsedA2AEvent.Text(currentTextBuilder.toString())) + currentTextBuilder = java.lang.StringBuilder() + } + currentUiElements.put(dataPayload) } - - return deduplicatedParts - } - - private fun isUiElement(obj: JSONObject): Boolean { - return obj.has(KEY_CREATE_SURFACE) || - obj.has(KEY_UPDATE_COMPONENTS) || - obj.has(KEY_UPDATE_DATA_MODEL) || - obj.has(KEY_DELETE_SURFACE) - } - - private fun extractPartsArray(rawJson: JSONObject): JSONArray? { - val finalParts = JSONArray() - - if (rawJson.has(KEY_HISTORY)) { - val history = rawJson.optJSONArray(KEY_HISTORY) - if (history != null) { - var lastUserIndex = -1 - for (i in 0 until history.length()) { - val msg = history.optJSONObject(i) - if (msg?.optString(KEY_ROLE) == VAL_USER) { - lastUserIndex = i - } - } - - for (i in (lastUserIndex + 1) until history.length()) { - val msg = history.optJSONObject(i) - if (msg?.optString(KEY_ROLE) == VAL_AGENT) { - val parts = msg.optJSONArray(KEY_PARTS) - if (parts != null) { - for (j in 0 until parts.length()) { - finalParts.put(parts.getJSONObject(j)) - } - } - } + } + + if (currentTextBuilder.isNotEmpty()) { + partsList.add(ParsedA2AEvent.Text(currentTextBuilder.toString())) + } + if (currentUiElements.length() > 0) { + partsList.add(ParsedA2AEvent.Data(currentUiElements.toString())) + } + } else { + try { + val resultObj = rawJson.opt(KEY_RESULT) + if (resultObj is String) { + if (resultObj.isNotEmpty()) { + val firstChar = resultObj.trim().firstOrNull() + if (firstChar == '[') { + val array = JSONArray(resultObj) + val uiElements = JSONArray() + for (j in 0 until array.length()) { + val item = array.optJSONObject(j) + if (item != null && isUiElement(item)) { + uiElements.put(item) } + } + if (uiElements.length() > 0) { + partsList.add(ParsedA2AEvent.Data(uiElements.toString())) + } } - } - - val additionalParts = when { - rawJson.has(KEY_PARTS) -> rawJson.optJSONArray(KEY_PARTS) - rawJson.has(KEY_CONTENT) -> rawJson.optJSONObject(KEY_CONTENT)?.optJSONArray(KEY_PARTS) - rawJson.has(KEY_STATUS) -> rawJson.optJSONObject(KEY_STATUS)?.optJSONObject(KEY_MESSAGE)?.optJSONArray(KEY_PARTS) - rawJson.has(KEY_RESULT) -> { - val resultObj = rawJson.opt(KEY_RESULT) - if (resultObj is String) { - try { - val innerJson = JSONObject(resultObj) - innerJson.optJSONObject(KEY_STATUS)?.optJSONObject(KEY_MESSAGE)?.optJSONArray(KEY_PARTS) - } catch (e: Exception) { - null - } - } else if (resultObj is JSONObject) { - resultObj.optJSONObject(KEY_STATUS)?.optJSONObject(KEY_MESSAGE)?.optJSONArray(KEY_PARTS) - } else { - null + } + } else if (resultObj is JSONArray) { + var currentTextBuilder = java.lang.StringBuilder() + val uiElements = JSONArray() + for (j in 0 until resultObj.length()) { + val item = resultObj.optJSONObject(j) + if (item != null) { + if (item.has(KEY_TEXT)) { + val textPart = item.optString(KEY_TEXT) + if (textPart.isNotEmpty()) { + if (currentTextBuilder.isNotEmpty()) currentTextBuilder.append("\n") + currentTextBuilder.append(textPart) } + } + if (isUiElement(item)) { + uiElements.put(item) + } } - else -> null + } + if (currentTextBuilder.isNotEmpty()) { + partsList.add(ParsedA2AEvent.Text(currentTextBuilder.toString())) + } + if (uiElements.length() > 0) { + partsList.add(ParsedA2AEvent.Data(uiElements.toString())) + } } + } catch (e: Exception) {} + } - if (additionalParts != null) { - for (i in 0 until additionalParts.length()) { - finalParts.put(additionalParts.getJSONObject(i)) + val deduplicatedParts = mutableListOf() + val seenSurfaces = mutableSetOf() + var lastSeenText: String? = null + + for (part in partsList) { + val finalText: String? = + if (part is ParsedA2AEvent.Text) { + part.text.replace("```json", "").replace("```", "").trim().takeIf { it.isNotEmpty() } + } else null + + // Deduplicate consecutive identical text blocks + if (finalText != null && finalText != lastSeenText) { + deduplicatedParts.add(ParsedA2AEvent.Text(finalText)) + lastSeenText = finalText + } + + if (part is ParsedA2AEvent.Data && part.data != "[]") { + try { + val array = JSONArray(part.data) + val newArray = JSONArray() + for (i in 0 until array.length()) { + val obj = array.getJSONObject(i) + val sid = obj.optJSONObject(KEY_CREATE_SURFACE)?.optString(KEY_SURFACE_ID) + if (sid != null) { + if (seenSurfaces.contains(sid)) { + continue + } + seenSurfaces.add(sid) } + newArray.put(obj) + } + if (newArray.length() > 0) { + deduplicatedParts.add(ParsedA2AEvent.Data(newArray.toString(), part.metadata)) + } + } catch (e: Exception) { + if (part.data.isNotEmpty()) { + deduplicatedParts.add(part) + } } - - return if (finalParts.length() > 0) finalParts else null + } } - private fun extractJsonBlocks(textPart: String, textBuilder: java.lang.StringBuilder, partsList: MutableList) { - val jsonPattern = "```json(.*?)```".toRegex(RegexOption.DOT_MATCHES_ALL) - val a2uiPattern = "---a2ui_JSON---(.*?)---a2ui_JSON_END---".toRegex(RegexOption.DOT_MATCHES_ALL) - - val allMatches = mutableListOf() - allMatches.addAll(jsonPattern.findAll(textPart)) - allMatches.addAll(a2uiPattern.findAll(textPart)) - - if (allMatches.isEmpty()) { - if (textBuilder.isNotEmpty()) textBuilder.append("\n") - textBuilder.append(textPart) - return + return deduplicatedParts + } + + private fun isUiElement(obj: JSONObject): Boolean { + return obj.has(KEY_CREATE_SURFACE) || + obj.has(KEY_UPDATE_COMPONENTS) || + obj.has(KEY_UPDATE_DATA_MODEL) || + obj.has(KEY_DELETE_SURFACE) + } + + private fun extractPartsArray(rawJson: JSONObject): JSONArray? { + val finalParts = JSONArray() + + if (rawJson.has(KEY_HISTORY)) { + val history = rawJson.optJSONArray(KEY_HISTORY) + if (history != null) { + var lastUserIndex = -1 + for (i in 0 until history.length()) { + val msg = history.optJSONObject(i) + if (msg?.optString(KEY_ROLE) == VAL_USER) { + lastUserIndex = i + } } - allMatches.sortBy { it.range.first } - - var lastEnd = 0 - for (match in allMatches) { - val beforeText = textPart.substring(lastEnd, match.range.first).trim() - if (beforeText.isNotEmpty()) { - if (textBuilder.isNotEmpty()) textBuilder.append("\n") - textBuilder.append(beforeText) + for (i in (lastUserIndex + 1) until history.length()) { + val msg = history.optJSONObject(i) + if (msg?.optString(KEY_ROLE) == VAL_AGENT) { + val parts = msg.optJSONArray(KEY_PARTS) + if (parts != null) { + for (j in 0 until parts.length()) { + finalParts.put(parts.getJSONObject(j)) + } } + } + } + } + } - val jsonString = match.groupValues[1].trim() + val additionalParts = + when { + rawJson.has(KEY_PARTS) -> rawJson.optJSONArray(KEY_PARTS) + rawJson.has(KEY_CONTENT) -> rawJson.optJSONObject(KEY_CONTENT)?.optJSONArray(KEY_PARTS) + rawJson.has(KEY_STATUS) -> + rawJson.optJSONObject(KEY_STATUS)?.optJSONObject(KEY_MESSAGE)?.optJSONArray(KEY_PARTS) + rawJson.has(KEY_RESULT) -> { + val resultObj = rawJson.opt(KEY_RESULT) + if (resultObj is String) { try { - val firstChar = jsonString.firstOrNull() - if (firstChar == '[') { - if (textBuilder.isNotEmpty()) { - partsList.add(ParsedA2AEvent.Text(textBuilder.toString())) - textBuilder.clear() - } - val array = JSONArray(jsonString) - val localUiElements = JSONArray() - for (i in 0 until array.length()) { - localUiElements.put(array.getJSONObject(i)) - } - partsList.add(ParsedA2AEvent.Data(localUiElements.toString())) - } else if (firstChar == '{') { - if (textBuilder.isNotEmpty()) { - partsList.add(ParsedA2AEvent.Text(textBuilder.toString())) - textBuilder.clear() - } - val localUiElements = JSONArray() - localUiElements.put(JSONObject(jsonString)) - partsList.add(ParsedA2AEvent.Data(localUiElements.toString())) - } + val innerJson = JSONObject(resultObj) + innerJson + .optJSONObject(KEY_STATUS) + ?.optJSONObject(KEY_MESSAGE) + ?.optJSONArray(KEY_PARTS) } catch (e: Exception) { - if (textBuilder.isNotEmpty()) textBuilder.append("\n") - textBuilder.append(match.value) + null } - lastEnd = match.range.last + 1 + } else if (resultObj is JSONObject) { + resultObj.optJSONObject(KEY_STATUS)?.optJSONObject(KEY_MESSAGE)?.optJSONArray(KEY_PARTS) + } else { + null + } } + else -> null + } + + if (additionalParts != null) { + for (i in 0 until additionalParts.length()) { + finalParts.put(additionalParts.getJSONObject(i)) + } + } - val remainingText = textPart.substring(lastEnd).trim() - if (remainingText.isNotEmpty()) { - if (textBuilder.isNotEmpty()) textBuilder.append("\n") - textBuilder.append(remainingText) + return if (finalParts.length() > 0) finalParts else null + } + + private fun extractJsonBlocks( + textPart: String, + textBuilder: java.lang.StringBuilder, + partsList: MutableList, + ) { + val jsonPattern = "```json(.*?)```".toRegex(RegexOption.DOT_MATCHES_ALL) + val a2uiPattern = "---a2ui_JSON---(.*?)---a2ui_JSON_END---".toRegex(RegexOption.DOT_MATCHES_ALL) + + val allMatches = mutableListOf() + allMatches.addAll(jsonPattern.findAll(textPart)) + allMatches.addAll(a2uiPattern.findAll(textPart)) + + if (allMatches.isEmpty()) { + if (textBuilder.isNotEmpty()) textBuilder.append("\n") + textBuilder.append(textPart) + return + } + + allMatches.sortBy { it.range.first } + + var lastEnd = 0 + for (match in allMatches) { + val beforeText = textPart.substring(lastEnd, match.range.first).trim() + if (beforeText.isNotEmpty()) { + if (textBuilder.isNotEmpty()) textBuilder.append("\n") + textBuilder.append(beforeText) + } + + val jsonString = match.groupValues[1].trim() + try { + val firstChar = jsonString.firstOrNull() + if (firstChar == '[') { + if (textBuilder.isNotEmpty()) { + partsList.add(ParsedA2AEvent.Text(textBuilder.toString())) + textBuilder.clear() + } + val array = JSONArray(jsonString) + val localUiElements = JSONArray() + for (i in 0 until array.length()) { + localUiElements.put(array.getJSONObject(i)) + } + partsList.add(ParsedA2AEvent.Data(localUiElements.toString())) + } else if (firstChar == '{') { + if (textBuilder.isNotEmpty()) { + partsList.add(ParsedA2AEvent.Text(textBuilder.toString())) + textBuilder.clear() + } + val localUiElements = JSONArray() + localUiElements.put(JSONObject(jsonString)) + partsList.add(ParsedA2AEvent.Data(localUiElements.toString())) } + } catch (e: Exception) { + if (textBuilder.isNotEmpty()) textBuilder.append("\n") + textBuilder.append(match.value) + } + lastEnd = match.range.last + 1 + } + + val remainingText = textPart.substring(lastEnd).trim() + if (remainingText.isNotEmpty()) { + if (textBuilder.isNotEmpty()) textBuilder.append("\n") + textBuilder.append(remainingText) } + } } diff --git a/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/A2UIServices.kt b/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/A2UIServices.kt index 4f2027c..b5caf2d 100644 --- a/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/A2UIServices.kt +++ b/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/A2UIServices.kt @@ -15,10 +15,10 @@ package com.google.android.libraries.mapsplatform.a2ui object A2UIServices { - var apiKey: String = "" - private set + var apiKey: String = "" + private set - fun provideAPIKey(key: String) { - apiKey = key - } -} \ No newline at end of file + fun provideAPIKey(key: String) { + apiKey = key + } +} diff --git a/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/A2UIView.kt b/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/A2UIView.kt index 7b8b6f5..5043f39 100644 --- a/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/A2UIView.kt +++ b/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/A2UIView.kt @@ -27,110 +27,117 @@ import android.webkit.WebViewClient import java.io.BufferedReader import java.io.InputStreamReader -class A2UIView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : WebView(context, attrs, defStyleAttr) { - - private val A2UI_DEBUG_TAG = "A2UIViewDebug" - private val A2UI_CONSOLE_TAG = "A2UIViewConsole" - private val A2UI_ERROR_TAG = "A2UIViewError" - private val MAPS_HOST = "maps.google.com" - private val MAPS_PATH = "google.com/maps" - private val MAPS_PACKAGE = "com.google.android.apps.maps" - private val HTTP_SCHEME = "http://" - private val HTTPS_SCHEME = "https://" - - private var indexHtml: String = "" - var a2uiJson: String = "" - private var startTime: Long? = null - var onRenderComplete: ((latencyMs: Long, status: String) -> Unit)? = null - var onUserAction: ((actionJson: String) -> Unit)? = null - - private var isJsReady: Boolean = false - private var lastInjectedElementCount: Int = 0 - - private val webAppInterface = WebAppInterface(this, this) - - init { - indexHtml = loadIndexHtml(context) - setupWebView() +class A2UIView +@JvmOverloads +constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + WebView(context, attrs, defStyleAttr) { + + private val A2UI_DEBUG_TAG = "A2UIViewDebug" + private val A2UI_CONSOLE_TAG = "A2UIViewConsole" + private val A2UI_ERROR_TAG = "A2UIViewError" + private val MAPS_HOST = "maps.google.com" + private val MAPS_PATH = "google.com/maps" + private val MAPS_PACKAGE = "com.google.android.apps.maps" + private val HTTP_SCHEME = "http://" + private val HTTPS_SCHEME = "https://" + + private var indexHtml: String = "" + var a2uiJson: String = "" + private var startTime: Long? = null + var onRenderComplete: ((latencyMs: Long, status: String) -> Unit)? = null + var onUserAction: ((actionJson: String) -> Unit)? = null + + private var isJsReady: Boolean = false + private var lastInjectedElementCount: Int = 0 + + private val webAppInterface = WebAppInterface(this, this) + + init { + indexHtml = loadIndexHtml(context) + setupWebView() + } + + private fun setupWebView() { + settings.javaScriptEnabled = true + settings.allowFileAccess = true + settings.allowContentAccess = true + settings.allowFileAccessFromFileURLs = true + settings.allowUniversalAccessFromFileURLs = true + + addJavascriptInterface(webAppInterface, "Android") + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + setWebContentsDebuggingEnabled(true) } - private fun setupWebView() { - settings.javaScriptEnabled = true - settings.allowFileAccess = true - settings.allowContentAccess = true - settings.allowFileAccessFromFileURLs = true - settings.allowUniversalAccessFromFileURLs = true - - addJavascriptInterface(webAppInterface, "Android") - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { - setWebContentsDebuggingEnabled(true) - } - - webViewClient = object : WebViewClient() { - override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { - val url = request?.url?.toString() ?: return false - if (url.startsWith(HTTP_SCHEME) || url.startsWith(HTTPS_SCHEME)) { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) - if (url.contains(MAPS_HOST) || url.contains(MAPS_PATH)) { - intent.setPackage(MAPS_PACKAGE) - if (intent.resolveActivity(context.packageManager) == null) { - intent.setPackage(null) - } - } - context.startActivity(intent) - return true - } - return super.shouldOverrideUrlLoading(view, request) - } - - override fun onReceivedError( - view: WebView?, - request: WebResourceRequest?, - error: WebResourceError?, - ) { - super.onReceivedError(view, request, error) - Log.e(A2UI_ERROR_TAG, "Error loading WebView: ${error?.description}, URL: ${request?.url}") + webViewClient = + object : WebViewClient() { + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest?, + ): Boolean { + val url = request?.url?.toString() ?: return false + if (url.startsWith(HTTP_SCHEME) || url.startsWith(HTTPS_SCHEME)) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + if (url.contains(MAPS_HOST) || url.contains(MAPS_PATH)) { + intent.setPackage(MAPS_PACKAGE) + if (intent.resolveActivity(context.packageManager) == null) { + intent.setPackage(null) + } } + context.startActivity(intent) + return true + } + return super.shouldOverrideUrlLoading(view, request) } - } - private fun loadIndexHtml(context: Context): String { - return try { - val inputStream = context.assets.open("index.html") - val reader = BufferedReader(InputStreamReader(inputStream)) - reader.readText() - } catch (e: Exception) { - Log.e(A2UI_ERROR_TAG, "Failed to load index.html from assets: ${e.message}") - "" + override fun onReceivedError( + view: WebView?, + request: WebResourceRequest?, + error: WebResourceError?, + ) { + super.onReceivedError(view, request, error) + Log.e( + A2UI_ERROR_TAG, + "Error loading WebView: ${error?.description}, URL: ${request?.url}", + ) } + } + } + + private fun loadIndexHtml(context: Context): String { + return try { + val inputStream = context.assets.open("index.html") + val reader = BufferedReader(InputStreamReader(inputStream)) + reader.readText() + } catch (e: Exception) { + Log.e(A2UI_ERROR_TAG, "Failed to load index.html from assets: ${e.message}") + "" } - - fun render(json: String, startTimeMs: Long? = null) { - Log.d(A2UI_DEBUG_TAG, "Rendering A2UI JSON") - this.startTime = startTimeMs ?: System.currentTimeMillis() - this.a2uiJson = json - this.isJsReady = false - this.lastInjectedElementCount = 0 - val apiKey = A2UIServices.apiKey - val htmlToLoad = indexHtml.replace("\$GOOGLE_MAPS_API_KEY", apiKey) - loadDataWithBaseURL("file:///android_asset/", htmlToLoad, "text/html", "UTF-8", null) - } - - fun updateA2uiJson(newJson: String) { - this.a2uiJson = newJson - if (!isJsReady) return - - webAppInterface.resized = false - - post { - try { - val escapedJson = org.json.JSONObject.quote(newJson) - val script = """ + } + + fun render(json: String, startTimeMs: Long? = null) { + Log.d(A2UI_DEBUG_TAG, "Rendering A2UI JSON") + this.startTime = startTimeMs ?: System.currentTimeMillis() + this.a2uiJson = json + this.isJsReady = false + this.lastInjectedElementCount = 0 + val apiKey = A2UIServices.apiKey + val htmlToLoad = indexHtml.replace("\$GOOGLE_MAPS_API_KEY", apiKey) + loadDataWithBaseURL("file:///android_asset/", htmlToLoad, "text/html", "UTF-8", null) + } + + fun updateA2uiJson(newJson: String) { + this.a2uiJson = newJson + if (!isJsReady) return + + webAppInterface.resized = false + + post { + try { + val escapedJson = org.json.JSONObject.quote(newJson) + val script = + """ try { const shell = document.querySelector('a2ui-shell'); if (shell) { @@ -143,27 +150,28 @@ class A2UIView @JvmOverloads constructor( } catch (e) { console.error('Error in evaluateJavascript: ' + e); } - """.trimIndent() - evaluateJavascript(script, null) - } catch (e: Exception) { - Log.e(A2UI_ERROR_TAG, "Error processing a2ui update", e) - } - } + """ + .trimIndent() + evaluateJavascript(script, null) + } catch (e: Exception) { + Log.e(A2UI_ERROR_TAG, "Error processing a2ui update", e) + } } + } - internal fun onJsReadyInternal() { - Log.d(A2UI_DEBUG_TAG, "a2ui-shell is fully ready!") - isJsReady = true - if (a2uiJson.isNotEmpty()) { - updateA2uiJson(a2uiJson) - } + internal fun onJsReadyInternal() { + Log.d(A2UI_DEBUG_TAG, "a2ui-shell is fully ready!") + isJsReady = true + if (a2uiJson.isNotEmpty()) { + updateA2uiJson(a2uiJson) } + } - internal fun onRenderCompleteInternal() { - startTime?.let { - val latency = System.currentTimeMillis() - it - onRenderComplete?.invoke(latency, "A2UI Render Complete") - startTime = null - } + internal fun onRenderCompleteInternal() { + startTime?.let { + val latency = System.currentTimeMillis() - it + onRenderComplete?.invoke(latency, "A2UI Render Complete") + startTime = null } + } } diff --git a/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/WebAppInterface.kt b/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/WebAppInterface.kt index 391d17b..87c400f 100644 --- a/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/WebAppInterface.kt +++ b/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/WebAppInterface.kt @@ -20,23 +20,20 @@ import android.util.Log import android.webkit.JavascriptInterface import android.webkit.ValueCallback import android.webkit.WebView -import org.json.JSONException import org.json.JSONObject -class WebAppInterface( - private val webView: WebView, - private val a2uiView: A2UIView -) { - private val TAG = "A2UIWebAppInterface" - var resized = false +class WebAppInterface(private val webView: WebView, private val a2uiView: A2UIView) { + private val TAG = "A2UIWebAppInterface" + var resized = false - @JavascriptInterface - fun sendA2uiMessages(jsonMessages: String) { - Log.d(TAG, "sendA2uiMessages called with: $jsonMessages") - resized = false - webView.post { - val escapedJson = JSONObject.quote(jsonMessages) - val script = """ + @JavascriptInterface + fun sendA2uiMessages(jsonMessages: String) { + Log.d(TAG, "sendA2uiMessages called with: $jsonMessages") + resized = false + webView.post { + val escapedJson = JSONObject.quote(jsonMessages) + val script = + """ try { const shell = document.querySelector('a2ui-shell'); if (shell) { @@ -48,44 +45,44 @@ class WebAppInterface( } catch (e) { console.error('WebAppInterface: Error in evaluateJavascript: ' + e.message); } - """.trimIndent() - webView.evaluateJavascript(script, ValueCallback { value -> - Log.d(TAG, "JavaScript evaluation result: $value") - }) - } + """ + .trimIndent() + webView.evaluateJavascript( + script, + ValueCallback { value -> Log.d(TAG, "JavaScript evaluation result: $value") }, + ) } + } - @JavascriptInterface - fun onGetDirections(jsonString: String) { - Log.d(TAG, "onGetDirections: $jsonString") - a2uiView.onUserAction?.invoke(jsonString) - } + @JavascriptInterface + fun onGetDirections(jsonString: String) { + Log.d(TAG, "onGetDirections: $jsonString") + a2uiView.onUserAction?.invoke(jsonString) + } - @JavascriptInterface - fun onWebpageResized(height: Int) { - Log.d(TAG, "onWebpageResized: $height") - if (!resized) { - webView.post { - val layoutParams = webView.layoutParams - if (layoutParams != null) { - val newHeight = (height * webView.resources.displayMetrics.density).toInt() - layoutParams.height = newHeight - webView.layoutParams = layoutParams - resized = true - Log.d("A2UIViewDebug", "WebView height updated to: $newHeight") - a2uiView.onRenderCompleteInternal() - } else { - Log.e("A2UIViewDebug", "WebView LayoutParams is null.") - } - } + @JavascriptInterface + fun onWebpageResized(height: Int) { + Log.d(TAG, "onWebpageResized: $height") + if (!resized) { + webView.post { + val layoutParams = webView.layoutParams + if (layoutParams != null) { + val newHeight = (height * webView.resources.displayMetrics.density).toInt() + layoutParams.height = newHeight + webView.layoutParams = layoutParams + resized = true + Log.d("A2UIViewDebug", "WebView height updated to: $newHeight") + a2uiView.onRenderCompleteInternal() + } else { + Log.e("A2UIViewDebug", "WebView LayoutParams is null.") } + } } + } - @JavascriptInterface - fun onJsReady() { - Log.d(TAG, "onJsReady") - Handler(Looper.getMainLooper()).post { - a2uiView.onJsReadyInternal() - } - } + @JavascriptInterface + fun onJsReady() { + Log.d(TAG, "onJsReady") + Handler(Looper.getMainLooper()).post { a2uiView.onJsReadyInternal() } + } } diff --git a/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/A2AResponseParserTest.kt b/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/A2AResponseParserTest.kt index 9bef328..4f91ed0 100644 --- a/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/A2AResponseParserTest.kt +++ b/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/A2AResponseParserTest.kt @@ -25,98 +25,110 @@ import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class A2AResponseParserTest { - @Test - fun testParse_InvalidPayloadStructure() { - // Test that an unexpected payload format returns an empty event list safely - val payloadWithNoParts = JSONObject().apply { - put("status", "ok") + @Test + fun testParse_InvalidPayloadStructure() { + // Test that an unexpected payload format returns an empty event list safely + val payloadWithNoParts = JSONObject().apply { put("status", "ok") } + val events = A2AResponseParser.parse(payloadWithNoParts) + assertEquals(0, events.size) + } + + @Test + fun testParse_SimpleTextPart() { + // Test standard text extraction from a standard payload structure + val payload = + JSONObject( + """ + { + "parts": [ + {"text": "Show me some good sushi in Seattle"} + ] + } + """ + .trimIndent() + ) + + val events = A2AResponseParser.parse(payload) + assertEquals(1, events.size) + val textEvent = events[0] as ParsedA2AEvent.Text + assertEquals("Show me some good sushi in Seattle", textEvent.text) + } + + @Test + fun testParse_TextConcatenation() { + // Android parser concatenates all text elements within a JSONArray into a single text event + val payload = + JSONObject( + """ + { + "result": [ + {"text": "Hello Seattle!"}, + {"text": "Hello Seattle!"}, + {"text": "Different text."} + ] } - val events = A2AResponseParser.parse(payloadWithNoParts) - assertEquals(0, events.size) - } - - @Test - fun testParse_SimpleTextPart() { - // Test standard text extraction from a standard payload structure - val payload = JSONObject(""" + """ + .trimIndent() + ) + + val events = A2AResponseParser.parse(payload) + assertEquals(1, events.size) // Expecting 1 because texts are concatenated + assertEquals( + "Hello Seattle!\nHello Seattle!\nDifferent text.", + (events[0] as ParsedA2AEvent.Text).text, + ) + } + + @Test + fun testParse_StringifiedJsonResultArray() { + // Tests the scenario where 'result' contains a stringified JSON array starting with '[' + val stringifiedArray = """[{"createSurface": {"surfaceId": "sushi-seattle"}}]""" + val payload = JSONObject().apply { put("result", stringifiedArray) } + + val events = A2AResponseParser.parse(payload) + assertEquals(1, events.size) + + val dataEvent = events[0] as ParsedA2AEvent.Data + val a2uiArray = JSONArray(dataEvent.data) + assertEquals(1, a2uiArray.length()) + assertTrue(a2uiArray.getJSONObject(0).has("createSurface")) + } + + @Test + fun testParse_NativeJsonResultArray() { + // Tests the newly added support for native JSONArray inside the 'result' key (from PR #311 + // fixes) + val payload = + JSONObject( + """ + { + "result": [ { - "parts": [ - {"text": "Show me some good sushi in Seattle"} - ] - } - """.trimIndent()) - - val events = A2AResponseParser.parse(payload) - assertEquals(1, events.size) - val textEvent = events[0] as ParsedA2AEvent.Text - assertEquals("Show me some good sushi in Seattle", textEvent.text) - } - - @Test - fun testParse_TextConcatenation() { - // Android parser concatenates all text elements within a JSONArray into a single text event - val payload = JSONObject(""" + "text": "Here is your native array map" + }, { - "result": [ - {"text": "Hello Seattle!"}, - {"text": "Hello Seattle!"}, - {"text": "Different text."} - ] + "updateComponents": { + "surfaceId": "sushi-seattle", + "components": [] + } } - """.trimIndent()) - - val events = A2AResponseParser.parse(payload) - assertEquals(1, events.size) // Expecting 1 because texts are concatenated - assertEquals("Hello Seattle!\nHello Seattle!\nDifferent text.", (events[0] as ParsedA2AEvent.Text).text) - } - - @Test - fun testParse_StringifiedJsonResultArray() { - // Tests the scenario where 'result' contains a stringified JSON array starting with '[' - val stringifiedArray = """[{"createSurface": {"surfaceId": "sushi-seattle"}}]""" - val payload = JSONObject().apply { - put("result", stringifiedArray) + ] } + """ + .trimIndent() + ) - val events = A2AResponseParser.parse(payload) - assertEquals(1, events.size) + val events = A2AResponseParser.parse(payload) - val dataEvent = events[0] as ParsedA2AEvent.Data - val a2uiArray = JSONArray(dataEvent.data) - assertEquals(1, a2uiArray.length()) - assertTrue(a2uiArray.getJSONObject(0).has("createSurface")) - } + // We expect one Text event and one Data event + assertEquals(2, events.size) - @Test - fun testParse_NativeJsonResultArray() { - // Tests the newly added support for native JSONArray inside the 'result' key (from PR #311 fixes) - val payload = JSONObject(""" - { - "result": [ - { - "text": "Here is your native array map" - }, - { - "updateComponents": { - "surfaceId": "sushi-seattle", - "components": [] - } - } - ] - } - """.trimIndent()) - - val events = A2AResponseParser.parse(payload) - - // We expect one Text event and one Data event - assertEquals(2, events.size) - - val textEvent = events[0] as ParsedA2AEvent.Text - assertEquals("Here is your native array map", textEvent.text) - - val dataEvent = events[1] as ParsedA2AEvent.Data - val a2uiArray = JSONArray(dataEvent.data) - assertEquals(1, a2uiArray.length()) - assertTrue(a2uiArray.getJSONObject(0).has("updateComponents")) - } -} \ No newline at end of file + val textEvent = events[0] as ParsedA2AEvent.Text + assertEquals("Here is your native array map", textEvent.text) + + val dataEvent = events[1] as ParsedA2AEvent.Data + val a2uiArray = JSONArray(dataEvent.data) + assertEquals(1, a2uiArray.length()) + assertTrue(a2uiArray.getJSONObject(0).has("updateComponents")) + } +} diff --git a/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/A2UIAttributionIdTests.kt b/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/A2UIAttributionIdTests.kt new file mode 100644 index 0000000..7c916f8 --- /dev/null +++ b/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/A2UIAttributionIdTests.kt @@ -0,0 +1,48 @@ +// 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.android.libraries.mapsplatform.a2ui + +import java.io.InputStreamReader +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class A2UIAttributionIdTests { + + @Test + fun testAndroidAttributionIdIsGenerated() { + // Read index.html from Android assets + val context = org.robolectric.RuntimeEnvironment.getApplication() + val inputStream = context.assets.open("index.html") + requireNotNull(inputStream) { "Failed to load local index.html" } + + val htmlContent = InputStreamReader(inputStream).readText() + + assertTrue( + "Expected the generated attribution ID to contain the Web identifier 'gmp_web_maui_v0.1.7_exp'", + htmlContent.contains("gmp_web_maui_v0.1.7_exp"), + ) + assertTrue( + "Expected the generated attribution ID to contain the Android identifier 'gmp_android_maui_v0.1.7_exp'", + htmlContent.contains("gmp_android_maui_v0.1.7_exp"), + ) + assertTrue( + "Expected the generated attribution ID to contain the combined Web and Android identifier string 'gmp_web_maui_v0.1.7_exp,gmp_android_maui_v0.1.7_exp'", + htmlContent.contains("gmp_web_maui_v0.1.7_exp,gmp_android_maui_v0.1.7_exp"), + ) + } +} diff --git a/client/android/README.md b/client/android/README.md index a00df07..a811721 100644 --- a/client/android/README.md +++ b/client/android/README.md @@ -8,12 +8,12 @@ The **GoogleMapsA2UI** library is an Android SDK designed to encapsulate the par It makes use of the following technologies: * **Google Maps Platform** for rendering maps and places. * **A2UI** for the Agent-driven dynamic UI protocol. -* **React** for the underlying web-based rendering engine. +* **Lit** for the underlying web-based rendering engine. ## Prerequisites * **Protocol Version:** This library is built based on the **v0.9 A2UI protocol** and is **not backward compatible** with the v0.8 protocol. Ensure your backend server uses the v0.9 protocol format. * **Android Studio:** Koala (2024.1.1) or later. -* **Android SDK:** +* **Android SDK:** * **Library:** API Level 24 or later. * **Sample App:** API Level 26 or later. * **Java:** JDK 17. @@ -104,13 +104,13 @@ The SDK encapsulates all complex parsing and rendering logic into four core comp 4. **JS Communication Bridge (`WebAppInterface`)**: Manages bidirectional communication. It sends data from Android to JS and receives callbacks for events like webpage resizing and user actions. -## Updating the React Frontend (React Renderer Updates) +## Customizing the Web Components -The `GoogleMapsA2UI` library relies on a pre-built React web frontend bundle (`index.html`) which is shipped inside its `assets` folder. +The `GoogleMapsA2UI` library relies on a pre-built HTML bundle (`index.html`) which is shipped inside its `assets` folder. This bundle compiles the core A2UI web components together with the Android platform-specific shell logic at `a2ui/client/android/web_build/`. -If you have customized your `internal-usage-attribution-ids` or modified the underlying web components, you must recompile the frontend and bundle it back into this Android Library. +If you have customized your `internal-usage-attribution-ids` or modified the underlying web components, you must recompile the HTML payload and bundle it back into this Android Library. -Steps to update the React renderer with your customizations: +Steps to update the web components with your customizations: 1. **Build the local A2UI web library:** ```bash @@ -118,18 +118,14 @@ Steps to update the React renderer with your customizations: npm run build-and-link ``` -2. **Rebuild the React app and bundle it into a single HTML file:** +2. **Rebuild the web components and bundle it into a single HTML file:** ```bash - cd ~/ai-kit/a2ui-samples/client/web/react + cd ~/ai-kit/a2ui/client/android/web_build npm install npm link @googlemaps/a2ui - npm run build:mobile + npm run build ``` + *(Note: The `build` script compiles the payload and automatically copies it into the Android Library's assets folder)* -3. **Copy the compiled `index.html` into the Android Library's assets folder:** - ```bash - cp ~/ai-kit/a2ui-samples/client/web/react/dist/index.html ~/ai-kit/a2ui/client/android/GoogleMapsA2UI/src/main/assets/ - ``` - -4. **Re-publish the Library:** +3. **Re-publish the Library:** Finally, re-publish the SDK to Maven Local (Step 2 above) and reinstall your Android application to see the changes. \ No newline at end of file diff --git a/client/android/web_build/package.json b/client/android/web_build/package.json new file mode 100644 index 0000000..9a2de2c --- /dev/null +++ b/client/android/web_build/package.json @@ -0,0 +1,17 @@ +{ + "name": "@googlemaps/a2ui-android-web", + "version": "0.1.7", + "type": "module", + "scripts": { + "build": "vite build && node -e \"const fs=require('fs');const p='dist/index.html';fs.writeFileSync(p,fs.readFileSync(p,'utf8').replace(/type=\\\"module\\\" crossorigin/g,'defer')); fs.copyFileSync(p, '../GoogleMapsA2UI/src/main/assets/index.html'); console.log('Successfully copied to native assets!');\"" + }, + "dependencies": { + "@googlemaps/a2ui": "^0.1.7", + "lit": "^3.3.1" + }, + "devDependencies": { + "typescript": "^5.8.3", + "vite": "^8.0.1", + "vite-plugin-singlefile": "^2.3.3" + } +} diff --git a/client/android/web_build/src/globals.d.ts b/client/android/web_build/src/globals.d.ts new file mode 100644 index 0000000..e31283a --- /dev/null +++ b/client/android/web_build/src/globals.d.ts @@ -0,0 +1,30 @@ +// +// 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. + + +import {AndroidA2UIShell} from './main'; + +declare global { + interface Window { + Android?: { + onJsReady?: () => void; + onWebpageResized?: (height: number) => void; + }; + } + + interface HTMLElementTagNameMap { + 'a2ui-shell': AndroidA2UIShell; + } +} diff --git a/client/android/web_build/src/main.ts b/client/android/web_build/src/main.ts new file mode 100644 index 0000000..9f520c9 --- /dev/null +++ b/client/android/web_build/src/main.ts @@ -0,0 +1,37 @@ +// +// 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. + + +import {customElement} from 'lit/decorators.js'; +import {A2UICoreShell} from '../../../mobile_core/web_build/src/core-shell'; + +(window as any)['A2UI_ATTRIBUTION_ID'] = 'gmp_web_maui_v0.1.7_exp,gmp_android_maui_v0.1.7_exp'; + +@customElement('a2ui-shell') +export class AndroidA2UIShell extends A2UICoreShell { + + protected override notifyWebpageResized(height: number): void { + window.Android?.onWebpageResized?.(height); + } + + protected override notifyJsReady(): void { + window.Android?.onJsReady?.(); + } + + override connectedCallback() { + super.connectedCallback(); + } +} + diff --git a/client/ios/GoogleMapsA2UI/Package.swift b/client/ios/GoogleMapsA2UI/Package.swift index b207e23..1cd990d 100644 --- a/client/ios/GoogleMapsA2UI/Package.swift +++ b/client/ios/GoogleMapsA2UI/Package.swift @@ -1,6 +1,6 @@ // swift-tools-version: 5.9 // -// Copyright 2026 Google Inc. +// 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. @@ -26,7 +26,7 @@ let package = Package( .library( name: "GoogleMapsA2UI", targets: ["GoogleMapsA2UI"] - ), + ) ], dependencies: [], targets: [ @@ -43,4 +43,3 @@ let package = Package( ), ] ) - diff --git a/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/A2AResponseParser.swift b/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/A2AResponseParser.swift index 5477fb3..f8650cc 100644 --- a/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/A2AResponseParser.swift +++ b/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/A2AResponseParser.swift @@ -1,5 +1,5 @@ // -// Copyright 2026 Google Inc. +// 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. @@ -33,10 +33,10 @@ public enum A2AResponseParser { /// Parses a raw server response dictionary into a flat list of `ParsedA2AEvent`s. /// - /// The parser checks multiple possible JSON paths (`parts`, `content.parts`, `status.message.parts`) + /// The parser checks multiple possible JSON paths (`parts`, `content.parts`, `status.message.parts`) /// to support varying response structures from different server backends (e.g. standalone JSON-RPC vs. ADK Web Server). /// - /// Note: Any extracted A2UI JSON payloads (mime type `application/json+a2ui`) will always be batched + /// Note: Any extracted A2UI JSON payloads (mime type `application/json+a2ui`) will always be batched /// and returned as an array (`[Any]`) inside `ParsedA2AEvent.data`, providing a consistent format. /// /// - Parameter rawJSON: The raw JSON dictionary received from the server. @@ -51,11 +51,13 @@ public enum A2AResponseParser { if let parts = rawJSON["parts"] as? [[String: Any]] { partsArray = parts } else if let content = rawJSON["content"] as? [String: Any], - let parts = content["parts"] as? [[String: Any]] { + let parts = content["parts"] as? [[String: Any]] + { partsArray = parts } else if let status = rawJSON["status"] as? [String: Any], - let message = status["message"] as? [String: Any], - let parts = message["parts"] as? [[String: Any]] { + let message = status["message"] as? [String: Any], + let parts = message["parts"] as? [[String: Any]] + { partsArray = parts } else { partsArray = nil @@ -68,7 +70,7 @@ public enum A2AResponseParser { var sdkParts: [ParsedA2AEvent] = [] var a2uiPayloads: [Any] = [] - // flushA2UI() batches consecutive A2UI JSON payloads together into a single ParsedA2AEvent. + // flushA2UI() batches consecutive A2UI JSON payloads together into a single ParsedA2AEvent. func flushA2UI() { if !a2uiPayloads.isEmpty { let event = ParsedA2AEvent.data( @@ -88,7 +90,8 @@ public enum A2AResponseParser { } else if let dataPayload = part["data"] as? [String: Any] { let metadata = part["metadata"] as? [String: Any] let mimeType = part["mimeType"] as? String ?? metadata?["mimeType"] as? String - let resolvedMimeType = mimeType + let resolvedMimeType = + mimeType ?? (isA2UIPayload(dataPayload) ? a2uiJsonMimeType : nil) if resolvedMimeType == a2uiJsonMimeType { @@ -111,7 +114,7 @@ public enum A2AResponseParser { private static let a2uiKeys: Set = [ "createSurface", "updateComponents", "updateDataModel", - "beginRendering", "surfaceUpdate", "dataModelUpdate" + "beginRendering", "surfaceUpdate", "dataModelUpdate", ] /// Checks if a given dictionary represents an A2UI payload. diff --git a/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/A2UIServices.swift b/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/A2UIServices.swift index ce02dba..5428e1d 100644 --- a/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/A2UIServices.swift +++ b/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/A2UIServices.swift @@ -1,5 +1,5 @@ // -// Copyright 2026 Google Inc. +// 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. @@ -33,7 +33,8 @@ public enum A2UIServices { /// /// - Parameter apiKey: The Google Maps API key string. public static func provideApiKey(_ apiKey: String) { - assert(Thread.isMainThread, "A2UIServices.provideApiKey(_:) must be called from the main thread.") + assert( + Thread.isMainThread, "A2UIServices.provideApiKey(_:) must be called from the main thread.") self.apiKey = apiKey } @@ -44,7 +45,9 @@ public enum A2UIServices { /// /// - Returns: A tuple containing the HTML string and the base URL, or `nil` on failure. static func getLocalHTMLContent() -> (html: String, baseURL: URL?)? { - assert(Thread.isMainThread, "A2UIServices.getLocalHTMLContent() must be called from the main thread.") + assert( + Thread.isMainThread, "A2UIServices.getLocalHTMLContent() must be called from the main thread." + ) let currentKey = self.apiKey ?? "" // Return the cached HTML if we've already resolved it for the current API key if let cached = self.cachedContent, self.cachedForApiKey == currentKey { @@ -74,4 +77,3 @@ public enum A2UIServices { return (html: resolvedHtml, baseURL: Bundle.module.resourceURL) } } - diff --git a/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/A2UIView.swift b/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/A2UIView.swift index 806c92d..005e9b2 100644 --- a/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/A2UIView.swift +++ b/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/A2UIView.swift @@ -1,5 +1,5 @@ // -// Copyright 2026 Google Inc. +// 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. @@ -46,7 +46,7 @@ public struct A2UIView: View { } public var body: some View { - if case let .data(payloadData, _) = part { + if case .data(let payloadData, _) = part { A2UIMessageInnerWrapper( webViewID: id, payload: payloadData, @@ -59,7 +59,6 @@ public struct A2UIView: View { } } - /// An internal wrapper View that manages the dynamic height state of the WKWebView /// and applies standard styling like shadows and rounded corners to the message container. struct A2UIMessageInnerWrapper: View { @@ -68,7 +67,7 @@ struct A2UIMessageInnerWrapper: View { let onUserAction: (String) -> Void let onRenderComplete: ((String, Double, String) -> Void)? - @State private var height: CGFloat = 100 // Default height + @State private var height: CGFloat = 100 // Default height var body: some View { A2UIMessageRepresentableView( @@ -93,7 +92,7 @@ struct A2UIMessageRepresentableView: UIViewRepresentable { @Binding var dynamicHeight: CGFloat let onUserAction: (String) -> Void let onRenderComplete: ((String, Double, String) -> Void)? - + /// Creates the WKWebView instance and configures its bridge to the web component. /// - Parameter context: The SwiftUI context. /// - Returns: A configured WKWebView. @@ -104,7 +103,7 @@ struct A2UIMessageRepresentableView: UIViewRepresentable { // Expose iOS bridge to JS (window.webkit.messageHandlers.iOS) // This allows the web component to communicate user interactions (like "get_directions") back to Swift. contentController.add(context.coordinator, name: "iOS") - + // Allows the JS ResizeObserver to notify Swift when the content height changes contentController.add(context.coordinator, name: "heightObserver") @@ -125,6 +124,7 @@ struct A2UIMessageRepresentableView: UIViewRepresentable { }; window.addEventListener('error', function(e) { window.webkit.messageHandlers.iOS.postMessage({action: 'error', data: 'Global Error: ' + e.message + ' at line ' + e.lineno}); + }); """ let consoleScript = WKUserScript( source: consoleScriptSource, injectionTime: .atDocumentStart, forMainFrameOnly: true) @@ -147,7 +147,7 @@ struct A2UIMessageRepresentableView: UIViewRepresentable { webView.navigationDelegate = context.coordinator webView.uiDelegate = context.coordinator webView.scrollView.isScrollEnabled = false // Prevent double scrolling inside the chat list - + // Fix for the gray background sometimes seen at the boundaries of WKWebViews. // Setting the view and its scroll view to clear ensures our SwiftUI styling (shadows/corners) looks correct. webView.isOpaque = false @@ -243,7 +243,8 @@ struct A2UIMessageRepresentableView: UIViewRepresentable { // Use JSONSerialization to safely escape the native Swift object for inclusion in JavaScript. let jsonString: String if let jsonData = try? JSONSerialization.data(withJSONObject: payload, options: []), - let str = String(data: jsonData, encoding: .utf8) { + let str = String(data: jsonData, encoding: .utf8) + { jsonString = str } else { jsonString = "[]" @@ -256,7 +257,8 @@ struct A2UIMessageRepresentableView: UIViewRepresentable { // the JSON string as a JavaScript string literal. let encodedString: String if let encodedData = try? JSONEncoder().encode(jsonString), - let str = String(data: encodedData, encoding: .utf8) { + let str = String(data: encodedData, encoding: .utf8) + { encodedString = str } else { encodedString = "\"[]\"" @@ -319,4 +321,4 @@ struct A2UIMessageRepresentableView: UIViewRepresentable { } } } -} \ No newline at end of file +} diff --git a/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/Models.swift b/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/Models.swift index 4ed0cf3..eb70cd8 100644 --- a/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/Models.swift +++ b/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/Models.swift @@ -1,5 +1,5 @@ // -// Copyright 2026 Google Inc. +// 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. diff --git a/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/Resources/index.html b/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/Resources/index.html index 3643909..1a7babc 100644 --- a/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/Resources/index.html +++ b/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/Resources/index.html @@ -1,1182 +1,7302 @@ - + + + + - - - - AI Kit React Reference Implementation - + + -
+ + - \ No newline at end of file + diff --git a/client/ios/GoogleMapsA2UI/Tests/A2AResponseParserTests.swift b/client/ios/GoogleMapsA2UI/Tests/A2AResponseParserTests.swift index 67ae7ab..4bc57f8 100644 --- a/client/ios/GoogleMapsA2UI/Tests/A2AResponseParserTests.swift +++ b/client/ios/GoogleMapsA2UI/Tests/A2AResponseParserTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2026 Google Inc. +// 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. @@ -15,13 +15,14 @@ // import XCTest + @testable import GoogleMapsA2UI final class A2AResponseParserTests: XCTestCase { /// Tests that an error is thrown when the input payload is not a valid JSON object. func testParse_InvalidJSONFormat() { - let invalidPayload: [String: Any] = ["key": Date()] // Date is not valid JSON + let invalidPayload: [String: Any] = ["key": Date()] // Date is not valid JSON XCTAssertThrowsError(try A2AResponseParser.parse(invalidPayload)) { error in XCTAssertEqual(error as? A2AParserError, .invalidJSONFormat) } @@ -46,7 +47,7 @@ final class A2AResponseParserTests: XCTestCase { let events = try A2AResponseParser.parse(payload) XCTAssertEqual(events.count, 1) - guard case let .text(text) = events[0] else { + guard case .text(let text) = events[0] else { XCTFail("Expected text event") return } @@ -59,7 +60,7 @@ final class A2AResponseParserTests: XCTestCase { "content": [ "parts": [ ["kind": "text", "text": "Show me some good sushi in Seattle"], - ["kind": "text", "text": "What are their ratings?"] + ["kind": "text", "text": "What are their ratings?"], ] ] ] @@ -67,13 +68,13 @@ final class A2AResponseParserTests: XCTestCase { let events = try A2AResponseParser.parse(payload) XCTAssertEqual(events.count, 2) - if case let .text(text1) = events[0] { + if case .text(let text1) = events[0] { XCTAssertEqual(text1, "Show me some good sushi in Seattle") } else { XCTFail("Expected first event to be text") } - if case let .text(text2) = events[1] { + if case .text(let text2) = events[1] { XCTAssertEqual(text2, "What are their ratings?") } else { XCTFail("Expected second event to be text") @@ -82,7 +83,8 @@ final class A2AResponseParserTests: XCTestCase { /// Tests that an A2UI JSON payload embedded inside a text part using `` tags is extracted. func testParse_EmbeddedA2UIJSON() throws { - let textWithJSON = "Here is the Seattle map {\"createSurface\": {\"surfaceId\": \"sushi-seattle\"}} Hope you like it!" + let textWithJSON = + "Here is the Seattle map {\"createSurface\": {\"surfaceId\": \"sushi-seattle\"}} Hope you like it!" let payload: [String: Any] = [ "parts": [ ["kind": "text", "text": textWithJSON] @@ -92,20 +94,20 @@ final class A2AResponseParserTests: XCTestCase { let events = try A2AResponseParser.parse(payload) XCTAssertEqual(events.count, 3) - guard case let .text(prefix) = events[0] else { + guard case .text(let prefix) = events[0] else { return XCTFail("Expected text event") } XCTAssertEqual(prefix, "Here is the Seattle map") - guard case let .data(data, metadata) = events[1] else { + guard case .data(let data, let metadata) = events[1] else { return XCTFail("Expected data event") } XCTAssertEqual(metadata?.mimeType, "application/json+a2ui") let array = data as? [Any] let dict = array?.first as? [String: Any] XCTAssertNotNil(dict?["createSurface"]) - - guard case let .text(suffix) = events[2] else { + + guard case .text(let suffix) = events[2] else { return XCTFail("Expected text event") } XCTAssertEqual(suffix, "Hope you like it!") @@ -121,10 +123,10 @@ final class A2AResponseParserTests: XCTestCase { "version": "v0.9", "updateComponents": [ "surfaceId": "sushi-seattle", - "components": [] - ] + "components": [], + ], ], - "metadata": ["mimeType": "application/json+a2ui"] + "metadata": ["mimeType": "application/json+a2ui"], ] ] ] @@ -132,11 +134,11 @@ final class A2AResponseParserTests: XCTestCase { let events = try A2AResponseParser.parse(payload) XCTAssertEqual(events.count, 1) - guard case let .data(data, metadata) = events[0] else { + guard case .data(let data, let metadata) = events[0] else { return XCTFail("Expected data event") } XCTAssertEqual(metadata?.mimeType, "application/json+a2ui") - + let a2uiArray = data as? [Any] XCTAssertNotNil(a2uiArray, "A2UI payload should be batched into an array") XCTAssertEqual(a2uiArray?.count, 1) @@ -151,9 +153,9 @@ final class A2AResponseParserTests: XCTestCase { "data": [ "createSurface": [ "surfaceId": "sushi-seattle", - "catalogId": "a2ui://maps-agentic-ui-catalog.json" + "catalogId": "a2ui://maps-agentic-ui-catalog.json", ] - ] + ], ] ] ] @@ -161,7 +163,7 @@ final class A2AResponseParserTests: XCTestCase { let events = try A2AResponseParser.parse(payload) XCTAssertEqual(events.count, 1) - guard case let .data(data, metadata) = events[0] else { + guard case .data(let data, let metadata) = events[0] else { return XCTFail("Expected data event") } XCTAssertEqual(metadata?.mimeType, "application/json+a2ui") @@ -185,7 +187,7 @@ final class A2AResponseParserTests: XCTestCase { let events = try A2AResponseParser.parse(payload) XCTAssertEqual(events.count, 1) - guard case let .text(text) = events[0] else { + guard case .text(let text) = events[0] else { XCTFail("Expected text event") return } @@ -201,28 +203,28 @@ final class A2AResponseParserTests: XCTestCase { "data": [ "createSurface": [ "surfaceId": "sushi-seattle", - "catalogId": "a2ui://maps-agentic-ui-catalog.json" + "catalogId": "a2ui://maps-agentic-ui-catalog.json", ] ], - "metadata": ["mimeType": "application/json+a2ui"] + "metadata": ["mimeType": "application/json+a2ui"], ], [ "kind": "data", "data": [ "updateComponents": [ "surfaceId": "sushi-seattle", - "components": [] + "components": [], ] ], - "metadata": ["mimeType": "application/json+a2ui"] - ] + "metadata": ["mimeType": "application/json+a2ui"], + ], ] ] let events = try A2AResponseParser.parse(payload) XCTAssertEqual(events.count, 1) - guard case let .data(data, metadata) = events[0] else { + guard case .data(let data, let metadata) = events[0] else { XCTFail("Expected data event") return } @@ -233,9 +235,10 @@ final class A2AResponseParserTests: XCTestCase { return } XCTAssertEqual(a2uiArray.count, 2) - + guard let dict1 = a2uiArray[0] as? [String: Any], - let dict2 = a2uiArray[1] as? [String: Any] else { + let dict2 = a2uiArray[1] as? [String: Any] + else { XCTFail("Expected array elements to be dictionaries") return } @@ -250,21 +253,21 @@ final class A2AResponseParserTests: XCTestCase { [ "kind": "data", "data": ["createSurface": ["surfaceId": "sushi-seattle"]], - "metadata": ["mimeType": "application/json+a2ui"] + "metadata": ["mimeType": "application/json+a2ui"], ], ["kind": "text", "text": "Middle Text explaining the surface"], [ "kind": "data", "data": ["updateComponents": ["surfaceId": "sushi-seattle"]], - "metadata": ["mimeType": "application/json+a2ui"] - ] + "metadata": ["mimeType": "application/json+a2ui"], + ], ] ] let events = try A2AResponseParser.parse(payload) XCTAssertEqual(events.count, 3) - guard case let .data(data1, metadata1) = events[0] else { + guard case .data(let data1, let metadata1) = events[0] else { XCTFail("Expected first event to be data") return } @@ -272,13 +275,13 @@ final class A2AResponseParserTests: XCTestCase { let batch1 = data1 as? [Any] XCTAssertEqual(batch1?.count, 1) - guard case let .text(text) = events[1] else { + guard case .text(let text) = events[1] else { XCTFail("Expected second event to be text") return } XCTAssertEqual(text, "Middle Text explaining the surface") - guard case let .data(data2, metadata2) = events[2] else { + guard case .data(let data2, let metadata2) = events[2] else { XCTFail("Expected third event to be data") return } @@ -289,7 +292,8 @@ final class A2AResponseParserTests: XCTestCase { /// Tests that multiple `` tags within a single text part are all extracted sequentially. func testParse_MultipleEmbeddedA2UITags() throws { - let textWithMultipleTags = "First map: {\"createSurface\": {\"surfaceId\": \"sushi\"}} Then: {\"updateComponents\": {\"surfaceId\": \"sushi\"}} Done." + let textWithMultipleTags = + "First map: {\"createSurface\": {\"surfaceId\": \"sushi\"}} Then: {\"updateComponents\": {\"surfaceId\": \"sushi\"}} Done." let payload: [String: Any] = [ "parts": [ ["kind": "text", "text": textWithMultipleTags] @@ -299,11 +303,12 @@ final class A2AResponseParserTests: XCTestCase { let events = try A2AResponseParser.parse(payload) XCTAssertEqual(events.count, 5) - guard case let .text(t1) = events[0], - case let .data(d1, m1) = events[1], - case let .text(t2) = events[2], - case let .data(d2, m2) = events[3], - case let .text(t3) = events[4] else { + guard case .text(let t1) = events[0], + case .data(let d1, let m1) = events[1], + case .text(let t2) = events[2], + case .data(let d2, let m2) = events[3], + case .text(let t3) = events[4] + else { XCTFail("Expected sequence: [text, data, text, data, text]") return } @@ -319,4 +324,3 @@ final class A2AResponseParserTests: XCTestCase { XCTAssertEqual(t3, "Done.") } } - diff --git a/client/ios/GoogleMapsA2UI/Tests/A2UIAttributionIdTests.swift b/client/ios/GoogleMapsA2UI/Tests/A2UIAttributionIdTests.swift new file mode 100644 index 0000000..aa71876 --- /dev/null +++ b/client/ios/GoogleMapsA2UI/Tests/A2UIAttributionIdTests.swift @@ -0,0 +1,40 @@ +// 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. + +import XCTest + +@testable import GoogleMapsA2UI + +final class A2UIAttributionIdTests: XCTestCase { + + func testIOSAttributionIdIsGenerated() throws { + let localContent = try XCTUnwrap( + A2UIServices.getLocalHTMLContent(), "Failed to load local index.html") + + let htmlString = localContent.html + + XCTAssertTrue( + htmlString.contains("gmp_web_maui_v0.1.7_exp"), + "Expected the generated attribution ID to contain the Web identifier 'gmp_web_maui_v0.1.7_exp'" + ) + XCTAssertTrue( + htmlString.contains("gmp_ios_maui_v0.1.7_exp"), + "Expected the generated attribution ID to contain the iOS identifier 'gmp_ios_maui_v0.1.7_exp'" + ) + XCTAssertTrue( + htmlString.contains("gmp_web_maui_v0.1.7_exp,gmp_ios_maui_v0.1.7_exp"), + "Expected the generated attribution ID to contain the combined Web and iOS identifier string 'gmp_web_maui_v0.1.7_exp,gmp_ios_maui_v0.1.7_exp'" + ) + } +} diff --git a/client/ios/web_build/package.json b/client/ios/web_build/package.json new file mode 100644 index 0000000..e316561 --- /dev/null +++ b/client/ios/web_build/package.json @@ -0,0 +1,17 @@ +{ + "name": "@googlemaps/a2ui-ios-web", + "version": "0.1.7", + "type": "module", + "scripts": { + "build": "vite build && node -e \"const fs=require('fs');const p='dist/index.html';fs.writeFileSync(p,fs.readFileSync(p,'utf8').replace(/type=\\\"module\\\" crossorigin/g,'defer')); fs.copyFileSync(p, '../GoogleMapsA2UI/Sources/GoogleMapsA2UI/Resources/index.html'); console.log('Successfully copied to native resources!');\"" + }, + "dependencies": { + "@googlemaps/a2ui": "^0.1.7", + "lit": "^3.3.1" + }, + "devDependencies": { + "typescript": "^5.8.3", + "vite": "^8.0.1", + "vite-plugin-singlefile": "^2.3.3" + } +} diff --git a/client/ios/web_build/src/globals.d.ts b/client/ios/web_build/src/globals.d.ts new file mode 100644 index 0000000..77b6288 --- /dev/null +++ b/client/ios/web_build/src/globals.d.ts @@ -0,0 +1,32 @@ +// +// 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. + + +import {IOSA2UIShell} from './main'; + +declare global { + interface Window { + webkit?: { + messageHandlers?: { + iOS?: { postMessage: (msg: unknown) => void }; + heightObserver?: { postMessage: (height: number) => void }; + }; + }; + } + + interface HTMLElementTagNameMap { + 'a2ui-shell': IOSA2UIShell; + } +} diff --git a/client/ios/web_build/src/main.ts b/client/ios/web_build/src/main.ts new file mode 100644 index 0000000..88812eb --- /dev/null +++ b/client/ios/web_build/src/main.ts @@ -0,0 +1,96 @@ +// +// 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. + + +import {customElement} from 'lit/decorators.js'; +import {type PropertyValues} from 'lit'; +import {A2UICoreShell} from '../../../mobile_core/web_build/src/core-shell'; + +(window as any)['A2UI_ATTRIBUTION_ID'] = 'gmp_web_maui_v0.1.7_exp,gmp_ios_maui_v0.1.7_exp'; + +const PLACE_CARD_THUMBNAIL_MIN_WIDTH = 350; + +@customElement('a2ui-shell') +export class IOSA2UIShell extends A2UICoreShell { + + protected override notifyWebpageResized(height: number): void { + window.webkit?.messageHandlers?.heightObserver?.postMessage(height); + } + + protected override notifyJsReady(): void { + window.webkit?.messageHandlers?.iOS?.postMessage({ action: 'onJsReady', data: '' }); + } + + override connectedCallback() { + super.connectedCallback(); + this.setupGoogleMapIOSFix(); + this.setupPlaceCardIOSFix(); + } + + private setupGoogleMapIOSFix() { + customElements.whenDefined('a2ui-googlemap').then(() => { + const GoogleMap = customElements.get('a2ui-googlemap'); + if (!GoogleMap) return; + + const origUpdated = GoogleMap.prototype.updated; + GoogleMap.prototype.updated = function (changedProperties: PropertyValues) { + // Fix for iOS crash: Wait for map element to be defined before running updated() + customElements.whenDefined('gmp-map-3d').then(() => { + if (origUpdated) origUpdated.call(this, changedProperties); + }); + }; + }); + } + + private setupPlaceCardIOSFix() { + customElements.whenDefined('a2ui-placedetailscompact').then(() => { + const PlaceCard = customElements.get('a2ui-placedetailscompact'); + if (!PlaceCard) return; + const orig = PlaceCard.prototype.firstUpdated; + + PlaceCard.prototype.firstUpdated = function (changedProperties: PropertyValues) { + if (orig) orig.call(this, changedProperties); + + const compact = this.renderRoot?.querySelector('gmp-place-details-compact') as HTMLElement | null; + if (!compact) return; + + new ResizeObserver(() => { + const parentWidth = this.clientWidth; + const targetWidth = PLACE_CARD_THUMBNAIL_MIN_WIDTH; + + if (parentWidth > 0 && parentWidth < targetWidth) { + compact.style.setProperty('width', targetWidth + 'px', 'important'); + compact.style.setProperty('min-width', targetWidth + 'px', 'important'); + + const scale = parentWidth / targetWidth; + compact.style.setProperty('transform-origin', 'top left', 'important'); + compact.style.setProperty('transform', `scale(${scale})`, 'important'); + + const height = compact.offsetHeight; + if (height > 0) { + compact.style.setProperty('margin-bottom', `-${height * (1 - scale)}px`, 'important'); + } + } else { + compact.style.removeProperty('width'); + compact.style.removeProperty('min-width'); + compact.style.removeProperty('transform'); + compact.style.removeProperty('margin-bottom'); + } + }).observe(this); + }; + }); + } +} + diff --git a/client/mobile_core/web_build/build_defs.bzl b/client/mobile_core/web_build/build_defs.bzl new file mode 100644 index 0000000..228dbd7 --- /dev/null +++ b/client/mobile_core/web_build/build_defs.bzl @@ -0,0 +1,37 @@ +# Copyright 2026 Google LLC + +"""Shared build definitions for A2UI mobile web builds.""" + +def generate_mobile_index_html(name, html_template, js_bundle, out_html): + """Takes a base HTML template and inline-injects a compiled JS bundle. + + Args: + name: Name of the generated rule. + html_template: The base index.html target to use as a shell. + js_bundle: The compiled JS bundle target (e.g. from closure_js_binary). + out_html: The filename of the resulting self-contained HTML file. + """ + native.genrule( + name = name, + srcs = [html_template, js_bundle], + outs = [out_html], + cmd = """ + # Extract the .js file from the bundle outputs. + for f in $(locations {js_bundle}); do + case $$f in *.js) JS_FILE=$$f ;; esac + done + + # Wrap the raw JS inside " + ) > tmp_inject_js.js + + # Replace the old module + + + + + + + diff --git a/client/mobile_core/web_build/src/core-shell.ts b/client/mobile_core/web_build/src/core-shell.ts new file mode 100644 index 0000000..ea3d5a2 --- /dev/null +++ b/client/mobile_core/web_build/src/core-shell.ts @@ -0,0 +1,310 @@ +// +// 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. + + +import {css, html, LitElement, nothing, type PropertyValues} from 'lit'; +import {state} from 'lit/decorators.js'; + +import {A2UIRenderer, type TimelineItem, themeStyleSheet} from '@googlemaps/a2ui/lit'; + +export interface A2UIComponentNode { + id?: string; + component: string; + child?: string; + children?: string[] | A2UIComponentNode[] | { componentId: string }; + center?: { path?: string; lat?: number; lng?: number }; + [key: string]: unknown; +} + +export interface A2UIMessage { + createSurface?: { surfaceId: string; catalogId: string }; + updateComponents?: { surfaceId?: string; components: A2UIComponentNode[] }; + updateDataModel?: { surfaceId?: string; data?: Record; value?: Record }; + version?: string; + [key: string]: unknown; +} + +export abstract class A2UICoreShell extends LitElement { + @state() + protected timeline: TimelineItem[] = []; + + protected rendererRef = new A2UIRenderer(); + protected globalDataModelRef: Record = {}; + protected resizeObserver!: ResizeObserver; + protected timeoutId: ReturnType | null = null; + + static override styles = [ + themeStyleSheet, + css` + :host { + display: flex; + flex-direction: column; + width: 100%; + height: 100vh; + overflow-y: auto; + overflow-x: hidden; + background: var(--social-bg, #f1f3f4); + } + .chat-messages { + height: auto; + padding: 16px; + overflow: visible; + display: block; + } + .surface-message { + margin-bottom: 16px; + } + .loading { + opacity: 0.5; + text-align: center; + margin-top: 20px; + font-family: sans-serif; + } + `]; + + override connectedCallback() { + super.connectedCallback(); + + // Ensure global typography and Material theme definitions are present on the document + if (!document.adoptedStyleSheets.includes(themeStyleSheet)) { + document.adoptedStyleSheets = [...document.adoptedStyleSheets, themeStyleSheet]; + } + + this.setupResizer(); + this.notifyJsReady(); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + } + if (this.timeoutId) { + clearTimeout(this.timeoutId); + } + } + + protected abstract notifyWebpageResized(height: number): void; + protected abstract notifyJsReady(): void; + + processA2uiMessages(json: unknown) { + try { + let messages: A2UIMessage[] = (typeof json === 'string' ? JSON.parse(json) : json) as A2UIMessage[]; + if (typeof messages === 'string') { + messages = JSON.parse(messages as string) as A2UIMessage[]; + } + + if (!Array.isArray(messages)) { + messages = [messages]; + } + + // 1. Auto-fix common LLM hallucinated keys ('latitude' -> 'lat', + // 'title' -> 'label'). + const fixKeys = (obj: any) => { + if (Array.isArray(obj)) { + obj.forEach(fixKeys); + } else if (obj !== null && typeof obj === 'object') { + if (obj.latitude !== undefined) { + obj.lat = obj.latitude; + delete obj.latitude; + } + if (obj.longitude !== undefined) { + obj.lng = obj.longitude; + delete obj.longitude; + } + if (obj.title !== undefined && obj.label === undefined) { + obj.label = obj.title; + delete obj.title; + } + Object.values(obj).forEach(fixKeys); + } + }; + fixKeys(messages); + + + // 2. Track global data model and resolve 'path' references (e.g., Paris + // map bug). We must track the model globally because components and + // data often arrive in separate SSE chunks. + let hasUiInstructions = false; + const UI_KEYS = ['createSurface', 'updateComponents', 'updateDataModel', 'deleteSurface', 'beginRendering', 'surfaceUpdate']; + + messages.forEach((item) => { + if (UI_KEYS.some(key => Object.prototype.hasOwnProperty.call(item, key))) { + hasUiInstructions = true; + } + if (item.updateDataModel) { + const payload = item.updateDataModel.data || item.updateDataModel.value; + if (payload) { + this.globalDataModelRef = { ...this.globalDataModelRef, ...payload }; + } + } + }); + + // 3. Deduplication: Only process this chunk in the WebView IF it + // contains actual UI instructions. If it's just pure conversational + // text, we ignore it here because Android's native bubble handles it. + if (!hasUiInstructions) { + return; + } + + const resolvePath = (pathStr: string) => { + if (!pathStr || !pathStr.startsWith('/')) return null; + let parts = pathStr.split('/').filter(Boolean); + let curr: any = this.globalDataModelRef; + for (let p of parts) { + if (curr && Object.prototype.hasOwnProperty.call(curr, p)) + curr = curr[p]; + else + return null; + } + return curr; + }; + + const fixGoogleMap = (comp: any) => { + if (comp.component === 'GoogleMap') { + if (comp.center && comp.center.path) { + let resolved = resolvePath(comp.center.path); + if (resolved) comp.center = resolved; + } + } + if (comp.children && Array.isArray(comp.children)) { + comp.children.forEach((c: any) => { + if (typeof c === 'object') fixGoogleMap(c); + }); + } + }; + + messages.forEach((item: any) => { + if (item.updateComponents && item.updateComponents.components) { + let comps = item.updateComponents.components; + comps.forEach(fixGoogleMap); + + // Ensure 'root' Column exists for the A2UI Renderer + let hasRoot = comps.some((c: any) => c.id === 'root'); + if (!hasRoot && comps.length > 0) { + // If no "root" exists, generating a new "root" container. + let referencedChildIds = new Set(); + comps.forEach((c: any) => { + if (typeof c.child === 'string') + referencedChildIds.add(c.child); + if (c.children) { + if (Array.isArray(c.children)) { + c.children.forEach((child: any) => { + if (typeof child === 'string') + referencedChildIds.add(child); + else if (child && typeof child === 'object' && child.id) + referencedChildIds.add(child.id); + }); + } else if ( + typeof c.children === 'object' && + c.children.componentId) { + referencedChildIds.add(c.children.componentId); + } + } + }); + + // Filter the components that are not claimed as a child by anyone + let rootChildren = + comps + .filter((c: any) => c.id && !referencedChildIds.has(c.id)) + .map((c: any) => c.id); + + comps.unshift( + {id: 'root', component: 'Column', children: rootChildren}); + } + } + }); + + // Injects a mandatory createSurface command if missing so isolated + // WebView chunks won't render blank. + const hasCreate = messages.some((item) => item.createSurface); + if (!hasCreate) { + let surfaceId: string | undefined = undefined; + for (const m of messages) { + if (m.updateComponents) surfaceId = m.updateComponents.surfaceId; + if (m.updateDataModel) surfaceId = m.updateDataModel.surfaceId; + if (surfaceId) break; + } + if (surfaceId) { + messages.unshift({ + createSurface: { + surfaceId, + catalogId: 'a2ui://maps-agentic-ui-catalog.json' + }, + version: 'v0.9' + }); + } + } + + this.rendererRef.processResponse(messages.map((msg) => ({ type: "a2ui", message: msg }))); + this.timeline = [...this.rendererRef.timeline]; + } catch (e) { + console.error("Failed to process A2UI JSON:", e); + } + } + + private setupResizer() { + this.resizeObserver = new ResizeObserver(() => { + if (this.timeoutId) { + clearTimeout(this.timeoutId); + } + this.timeoutId = setTimeout(() => { + const rootWrapper = this.shadowRoot?.querySelector('.chat-messages'); + if (rootWrapper) { + const newHeight = rootWrapper.scrollHeight; + this.notifyWebpageResized(newHeight); + } + }, 100); + }); + + + const chatMessagesEl = this.shadowRoot?.querySelector('.chat-messages'); + if (chatMessagesEl) { + this.resizeObserver.observe(chatMessagesEl); + } + } + + protected override updated(changedProperties: PropertyValues) { + super.updated(changedProperties); + + // Fallback: If setupResizer ran before shadow DOM rendered chat-messages, observe it now. + const chatMessagesEl = this.shadowRoot?.querySelector('.chat-messages'); + if (chatMessagesEl && this.resizeObserver) { + this.resizeObserver.observe(chatMessagesEl); + } + } + + override render() { + return html` +
+ + ${this.timeline.length === 0 ? html`

Waiting for payload...

` : nothing} + ${this.timeline.map((item) => { + if (item.type === 'surface') { + const surface = this.rendererRef.getSurface(item.surfaceId); + if (!surface) return nothing; + return html` +
+ +
+ `; + } + return nothing; + })} +
+
+ `; + } +} diff --git a/client/mobile_core/web_build/tsconfig.json b/client/mobile_core/web_build/tsconfig.json new file mode 100644 index 0000000..398398c --- /dev/null +++ b/client/mobile_core/web_build/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "esnext", + "lib": ["es2023", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "experimentalDecorators": true, + "useDefineForClassFields": false, + "rootDir": ".", + "outDir": "dist", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts"] +} diff --git a/client/mobile_core/web_build/vite.config.ts b/client/mobile_core/web_build/vite.config.ts new file mode 100644 index 0000000..83ce639 --- /dev/null +++ b/client/mobile_core/web_build/vite.config.ts @@ -0,0 +1,33 @@ +// +// 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. + + +import {defineConfig} from 'vite'; +import {viteSingleFile} from 'vite-plugin-singlefile'; + +export default defineConfig({ + plugins: [viteSingleFile()], + resolve: { + dedupe: ['lit', '@lit/context', '@lit-labs/signals'], + }, + build: { + outDir: 'dist', + rollupOptions: { + input: { + app: 'index.html', + }, + }, + }, +}); diff --git a/client/web/package-lock.json b/client/web/package-lock.json index 8db21e0..b042a11 100644 --- a/client/web/package-lock.json +++ b/client/web/package-lock.json @@ -22,6 +22,7 @@ "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", "@types/google.maps": "^3.64.0", + "@types/jasmine": "^5.1.4", "@types/markdown-it": "^14.1.2", "@types/node": "^24.10.1", "google-artifactregistry-auth": "^3.5.0", @@ -1090,6 +1091,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jasmine": { + "version": "5.1.15", + "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-5.1.15.tgz", + "integrity": "sha512-ZAC8KjmV2MJxbNTrwXFN+HKeajpXQZp6KpPiR6Aa4XvaEnjP6qh23lL/Rqb7AYzlp3h/rcwDrQ7Gg7q28cQTQg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", diff --git a/client/web/package.json b/client/web/package.json index 83eda75..a9bd3d8 100644 --- a/client/web/package.json +++ b/client/web/package.json @@ -77,6 +77,7 @@ }, "devDependencies": { "@types/google.maps": "^3.64.0", + "@types/jasmine": "^5.1.4", "@types/markdown-it": "^14.1.2", "@types/node": "^24.10.1", "google-artifactregistry-auth": "^3.5.0", @@ -90,6 +91,7 @@ "@a2a-js/sdk": "^0.3.8", "@a2ui/lit": "^0.9.3", "@a2ui/markdown-it": "^0.0.3", + "@a2ui/web_core": "^0.9.2", "@lit-labs/signals": "^0.1.3", "@lit/context": "^1.1.4", "lit": "^3.3.1", diff --git a/client/web/src/lit/catalog.ts b/client/web/src/lit/catalog.ts index 94096b8..70eec33 100644 --- a/client/web/src/lit/catalog.ts +++ b/client/web/src/lit/catalog.ts @@ -1,6 +1,19 @@ +// 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 +// +// https://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. import { basicCatalog } from "@a2ui/lit/v0_9"; import { A2uiGoogleMap } from "./custom-components/google_map"; -import { A2uiPlaceCard } from "./custom-components/place_card"; +import { A2uiPlaceDetailsCompact } from "./custom-components/place_details_compact"; import { Catalog } from "@a2ui/web_core/v0_9"; // import { Column, Row } from "@a2ui/lit/ui"; // import { css } from "lit"; @@ -9,7 +22,7 @@ const mapsAgenticUICatalog = new Catalog( 'a2ui://maps-agentic-ui-catalog.json', [ A2uiGoogleMap, - A2uiPlaceCard, + A2uiPlaceDetailsCompact, ...basicCatalog.components.values(), ], Array.from(basicCatalog.functions.values()) diff --git a/client/web/src/lit/custom-components/google_map.ts b/client/web/src/lit/custom-components/google_map.ts index cb011c0..ba3a1f8 100644 --- a/client/web/src/lit/custom-components/google_map.ts +++ b/client/web/src/lit/custom-components/google_map.ts @@ -1,3 +1,4 @@ +/// /* Copyright 2026 Google LLC @@ -25,6 +26,8 @@ import {z} from 'zod'; const sheet = new CSSStyleSheet(); sheet.replaceSync(structuralStyles); +let nextMarkerId = 0; + const LatLngSchema = z.object({ lat: DynamicNumberSchema, lng: DynamicNumberSchema, @@ -42,6 +45,14 @@ const MapPinSchema = z.object({ placeId: DynamicStringSchema.optional(), }).strict(); +interface MarkerInput { + position?: google.maps.LatLngLiteral; + placeId?: string|null; + label?: string|null; + zIndex?: number|null; + collisionBehavior?: google.maps.CollisionBehavior; +} + /** A2UI GoogleMap interface. */ export const GoogleMapApi = { name: 'GoogleMap', @@ -115,6 +126,10 @@ export class GoogleMap extends A2uiLitElement { Map3DElement; } + get routeElements(): NodeListOf { + return this.renderRoot.querySelectorAll('gmp-route-3d'); + } + protected override createController() { return new A2uiController(this, GoogleMapApi); } @@ -129,11 +144,10 @@ export class GoogleMap extends A2uiLitElement { css` :host { display: block; - height: 400px; width: 100%; } gmp-map-3d { - height: 400px; + height: 100%; display: block; width: 100%; } @@ -174,23 +188,26 @@ export class GoogleMap extends A2uiLitElement { return []; } - private create3DMarkerElement({ position, placeId, label, zIndex, collisionBehavior }: { - position?: google.maps.LatLngLiteral, - placeId?: string | null, - label?: string | null, - zIndex?: number | null, - collisionBehavior?: google.maps.CollisionBehavior, - }) { - const marker = document.createElement("gmp-marker-3d") as any; - marker.autofitsCamera = true; - - position && (marker.position = position); - placeId && (marker.placeId = placeId); - label && (marker.label = label); - collisionBehavior && (marker.collisionBehavior = collisionBehavior); - (zIndex != null) && (marker.zIndex = zIndex); - - return marker; + private createMarkerAndLabel( + {position, placeId, label, zIndex, collisionBehavior}: MarkerInput): + {markerEl: HTMLElement, labelEl: HTMLElement} { + const markerId = `marker-${nextMarkerId++}`; + const markerEl = document.createElement('gmp-marker-3d') as any; + markerEl.autofitsCamera = true; + markerEl.id = markerId; + position && (markerEl.position = position); + placeId && (markerEl.placeId = placeId); + collisionBehavior && (markerEl.collisionBehavior = collisionBehavior); + (zIndex != null) && (markerEl.zIndex = zIndex); + + const labelEl = document.createElement('gmp-label-3d') as any; + labelEl.id = `${markerId}-label`; + labelEl.for = markerId; + labelEl.collisionBehavior = + google.maps.CollisionBehavior.OPTIONAL_AND_HIDES_LOWER_PRIORITY; + labelEl.textContent = label; + + return {markerEl, labelEl}; } override updated(changedProperties: PropertyValues): void { @@ -237,29 +254,33 @@ export class GoogleMap extends A2uiLitElement { // Add markers from props.markers for (const { lat, lng, label, placeId } of markers) { - const marker = this.create3DMarkerElement({ - position: { lat, lng }, + const {markerEl, labelEl} = this.createMarkerAndLabel({ + position: {lat, lng}, placeId, label, }); - this.map3dElement.appendChild(marker); - this.markers.push(marker); + this.map3dElement.appendChild(markerEl); + this.map3dElement.appendChild(labelEl); + this.markers.push(markerEl); } // Add destination marker if available if (destination) { - const marker = this.create3DMarkerElement({ - position: { lat: destination.lat as number, lng: destination.lng as number }, + const {markerEl, labelEl} = this.createMarkerAndLabel({ + position: + {lat: destination.lat as number, lng: destination.lng as number}, label: 'Destination', }); - this.map3dElement.appendChild(marker); - this.markers.push(marker); + this.map3dElement.appendChild(markerEl); + this.map3dElement.appendChild(labelEl); + this.markers.push(markerEl); } // Add anchor marker if available and no routes if (anchorMarker && !routes.length) { - const marker = this.create3DMarkerElement({ - position: { lat: anchorMarker.lat as number, lng: anchorMarker.lng as number }, + const {markerEl, labelEl} = this.createMarkerAndLabel({ + position: + {lat: anchorMarker.lat as number, lng: anchorMarker.lng as number}, placeId: anchorMarker.placeId as string, label: anchorMarker.label as string, zIndex: 1, @@ -270,30 +291,43 @@ export class GoogleMap extends A2uiLitElement { borderColor: "#2f79e8ff", glyphColor: "#ffffff" }); - marker.append(pin as any); + markerEl.append(pin as any); } - this.map3dElement.appendChild(marker); - this.markers.push(marker); + this.map3dElement.appendChild(markerEl); + this.map3dElement.appendChild(labelEl); + this.markers.push(markerEl); } // Add pins for each route origin and destination for (const route of routes) { - const originMarker = this.create3DMarkerElement({ - position: { lat: route.origin.lat as number, lng: route.origin.lng as number }, - label: route.origin.label as string || "Origin", - collisionBehavior: google.maps.CollisionBehavior.OPTIONAL_AND_HIDES_LOWER_PRIORITY, + const { + markerEl: originMarker, + labelEl: originLabel + } = this.createMarkerAndLabel({ + position: + {lat: route.origin.lat as number, lng: route.origin.lng as number}, + label: route.origin.label as string || 'Origin', + collisionBehavior: + google.maps.CollisionBehavior.OPTIONAL_AND_HIDES_LOWER_PRIORITY, placeId: route.origin.placeId as string, }); this.map3dElement.appendChild(originMarker); + this.map3dElement.appendChild(originLabel); this.markers.push(originMarker); - const destMarker = this.create3DMarkerElement({ - position: { lat: route.destination.lat as number, lng: route.destination.lng as number }, - label: route.destination.label as string || "Destination", - collisionBehavior: google.maps.CollisionBehavior.OPTIONAL_AND_HIDES_LOWER_PRIORITY, - placeId: route.destination.placeId as string, - }); + const {markerEl: destMarker, labelEl: destLabel} = + this.createMarkerAndLabel({ + position: { + lat: route.destination.lat as number, + lng: route.destination.lng as number + }, + label: route.destination.label as string || 'Destination', + collisionBehavior: + google.maps.CollisionBehavior.OPTIONAL_AND_HIDES_LOWER_PRIORITY, + placeId: route.destination.placeId as string, + }); this.map3dElement.appendChild(destMarker); + this.map3dElement.appendChild(destLabel); this.markers.push(destMarker); } } @@ -311,18 +345,18 @@ export class GoogleMap extends A2uiLitElement { zoom = 16; } const heading = props.heading ?? 0; - const mode = props.mode ?? 'roadmap'; + const mode = (props.mode ?? 'roadmap').toUpperCase() as google.maps.maps3d.MapModeString; let tilt = props.tilt ?? 0; - if (mode !== 'satellite') { + if (mode !== 'SATELLITE') { tilt = 0; } const routes = props.routes || []; const style = { - "height": "400px", "width": "100%", + "aspect-ratio": "8 / 5", "margin-bottom": "16px", "border-radius": "16px", "overflow": "hidden", @@ -335,16 +369,17 @@ export class GoogleMap extends A2uiLitElement { center="${lat},${lng},0" tilt="${tilt}" mode="${mode}" - max-tilt=${mode === 'roadmap' ? '0' : nothing} + max-tilt=${mode === 'ROADMAP' ? '0' : nothing} heading="${heading}" map-id="2d6e1a27a57efe3c9479f6fc" - internal-usage-attribution-ids="${(window as any).A2UI_ATTRIBUTION_ID || 'gmp_web_maui_v0.1.7_exp'}" + internal-usage-attribution-ids="${(window as any)['A2UI_ATTRIBUTION_ID'] || 'gmp_web_maui_v0.1.7_exp'}" >${routes.map((route: any) => html` `)} diff --git a/client/web/src/lit/custom-components/google_map_test.ts b/client/web/src/lit/custom-components/google_map_test.ts new file mode 100644 index 0000000..a2b0c41 --- /dev/null +++ b/client/web/src/lit/custom-components/google_map_test.ts @@ -0,0 +1,218 @@ +// 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. + +import './google_map'; +import type {GoogleMap} from './google_map'; + +interface GoogleMapInternals { + controller: { + props: { + center: { lat: number; lng: number }; + markers?: unknown[]; + travelMode?: string | null; + routes?: Array<{ + origin: { lat: number; lng: number; label: string }; + destination: { lat: number; lng: number; label: string }; + }>; + }; + }; + prevCenter: { lat: number; lng: number } | null; +} + +describe('GoogleMap Component', () => { + let originalGoogle: unknown; + let originalAttributionId: unknown; + + beforeEach(() => { + const windowWithGlobals = window as unknown as Record; + originalGoogle = windowWithGlobals['google']; + originalAttributionId = windowWithGlobals['A2UI_ATTRIBUTION_ID']; + + // Common google mock setup + windowWithGlobals['google'] = { + maps: { + CollisionBehavior: { + OPTIONAL_AND_HIDES_LOWER_PRIORITY: 'OPTIONAL_AND_HIDES_LOWER_PRIORITY' + }, + maps3d: { AltitudeMode: { RELATIVE_TO_GROUND: 1 } } + } + }; + }); + + afterEach(() => { + const windowWithGlobals = window as unknown as Record; + windowWithGlobals['google'] = originalGoogle; + windowWithGlobals['A2UI_ATTRIBUTION_ID'] = originalAttributionId; + }); + + it('uses a fallback attribution ID when the global one is missing', async () => { + // 1. Explicitly remove any global attribution ID + delete (window as unknown as Record)['A2UI_ATTRIBUTION_ID']; + + // 2. Render the component with empty props so it falls back to defaults + const element = document.createElement('a2ui-googlemap') as GoogleMap; + const internals = element as unknown as GoogleMapInternals; + internals.controller = { props: { markers: [], center: { lat: 0, lng: 0 } } }; + internals.prevCenter = { lat: 0, lng: 0 }; + document.body.appendChild(element); + + // Wait for lit to finish initial render + await element.updateComplete; + + // 3. Query the rendered map using renderRoot (since shadowRoot is closed) + const gmpMap3d = element.renderRoot.querySelector('gmp-map-3d'); + + // 4. Assert that the attribute has the correct fallback ID + const attrId = gmpMap3d!.getAttribute('internal-usage-attribution-ids'); + expect(attrId).toBe('gmp_web_maui_v0.1.7_exp'); + + // Cleanup + document.body.removeChild(element); + }); + + it('propagates travelMode to gmp-route-3d', async () => { + // 1. Render the component with travelMode and routes + const element = document.createElement('a2ui-googlemap') as GoogleMap; + const internals = element as unknown as GoogleMapInternals; + internals.controller = { + props: { + center: { lat: 0, lng: 0 }, + travelMode: 'driving', + routes: [ + { + origin: { lat: 1, lng: 1, label: 'Origin' }, + destination: { lat: 2, lng: 2, label: 'Destination' }, + } + ] + } + }; + internals.prevCenter = { lat: 0, lng: 0 }; + document.body.appendChild(element); + + // Wait for lit to finish initial render + await element.updateComplete; + + // 2. Query the rendered route + const gmpRoute3d = element.renderRoot.querySelector('gmp-route-3d'); + expect(gmpRoute3d).not.toBeNull(); + + // 3. Assert that travel-mode attribute is set to 'driving' + const travelModeAttr = gmpRoute3d!.getAttribute('travel-mode'); + expect(travelModeAttr).toBe('driving'); + + // Cleanup + document.body.removeChild(element); + }); + + it('does not propagate travelMode if it is not provided', async () => { + // 1. Render the component without travelMode but with routes + const element = document.createElement('a2ui-googlemap') as GoogleMap; + const internals = element as unknown as GoogleMapInternals; + internals.controller = { + props: { + center: { lat: 0, lng: 0 }, + routes: [ + { + origin: { lat: 1, lng: 1, label: 'Origin' }, + destination: { lat: 2, lng: 2, label: 'Destination' }, + } + ] + } + }; + internals.prevCenter = { lat: 0, lng: 0 }; + document.body.appendChild(element); + + // Wait for lit to finish initial render + await element.updateComplete; + + // 2. Query the rendered route + const gmpRoute3d = element.renderRoot.querySelector('gmp-route-3d'); + expect(gmpRoute3d).not.toBeNull(); + + // 3. Assert that travel-mode attribute is not set + const travelModeAttr = gmpRoute3d!.getAttribute('travel-mode'); + expect(travelModeAttr).toBeNull(); + + // Cleanup + document.body.removeChild(element); + }); + + it('does not propagate travelMode if it is null', async () => { + // 1. Render the component with travelMode set to null + const element = document.createElement('a2ui-googlemap') as GoogleMap; + const internals = element as unknown as GoogleMapInternals; + internals.controller = { + props: { + center: { lat: 0, lng: 0 }, + travelMode: null, + routes: [ + { + origin: { lat: 1, lng: 1, label: 'Origin' }, + destination: { lat: 2, lng: 2, label: 'Destination' }, + } + ] + } + }; + internals.prevCenter = { lat: 0, lng: 0 }; + document.body.appendChild(element); + + // Wait for lit to finish initial render + await element.updateComplete; + + // 2. Query the rendered route + const gmpRoute3d = element.renderRoot.querySelector('gmp-route-3d'); + expect(gmpRoute3d).not.toBeNull(); + + // 3. Assert that travel-mode attribute is not set + const travelModeAttr = gmpRoute3d!.getAttribute('travel-mode'); + expect(travelModeAttr).toBeNull(); + + // Cleanup + document.body.removeChild(element); + }); + + it('does not propagate travelMode if it is an empty string', async () => { + // 1. Render the component with travelMode set to empty string + const element = document.createElement('a2ui-googlemap') as GoogleMap; + const internals = element as unknown as GoogleMapInternals; + internals.controller = { + props: { + center: { lat: 0, lng: 0 }, + travelMode: '', + routes: [ + { + origin: { lat: 1, lng: 1, label: 'Origin' }, + destination: { lat: 2, lng: 2, label: 'Destination' }, + } + ] + } + }; + internals.prevCenter = { lat: 0, lng: 0 }; + document.body.appendChild(element); + + // Wait for lit to finish initial render + await element.updateComplete; + + // 2. Query the rendered route + const gmpRoute3d = element.renderRoot.querySelector('gmp-route-3d'); + expect(gmpRoute3d).not.toBeNull(); + + // 3. Assert that travel-mode attribute is not set + const travelModeAttr = gmpRoute3d!.getAttribute('travel-mode'); + expect(travelModeAttr).toBeNull(); + + // Cleanup + document.body.removeChild(element); + }); +}); diff --git a/client/web/src/lit/custom-components/index.ts b/client/web/src/lit/custom-components/index.ts index 0cfa148..c08806b 100644 --- a/client/web/src/lit/custom-components/index.ts +++ b/client/web/src/lit/custom-components/index.ts @@ -15,4 +15,4 @@ */ export { A2uiGoogleMap, GoogleMap } from './google_map.js'; -export { A2uiPlaceCard, PlaceCard } from './place_card.js'; +export { A2uiPlaceDetailsCompact, PlaceDetailsCompact } from './place_details_compact.js'; diff --git a/client/web/src/lit/custom-components/place_card.ts b/client/web/src/lit/custom-components/place_details_compact.ts similarity index 65% rename from client/web/src/lit/custom-components/place_card.ts rename to client/web/src/lit/custom-components/place_details_compact.ts index 55df3df..20a79e2 100644 --- a/client/web/src/lit/custom-components/place_card.ts +++ b/client/web/src/lit/custom-components/place_details_compact.ts @@ -25,12 +25,16 @@ import {z} from 'zod' const sheet = new CSSStyleSheet(); sheet.replaceSync(structuralStyles); - -export const PlaceCardApi = { - name: 'PlaceCard', +export const PlaceDetailsCompactApi = { + name: 'PlaceDetailsCompact', schema: z .object({ placeId: DynamicStringSchema.describe('The ID of the place to display.'), + orientation: z + .enum(['horizontal', 'vertical']) + .optional() + .default('horizontal') + .describe('The orientation of the place card.'), }) .strict(), } satisfies ComponentApi; @@ -39,20 +43,22 @@ declare global { interface HTMLElementTagNameMap { "gmpx-place-details-compact": HTMLElement & { place: string | object | null; + orientation: "horizontal" | "vertical"; }; } } -/** A2UI Custom Component for PlaceCard */ -@customElement('a2ui-placecard') -export class PlaceCard extends A2uiLitElement { +/** A2UI Custom Component for PlaceDetailsCompact */ +@customElement('a2ui-placedetailscompact') +export class PlaceDetailsCompact extends + A2uiLitElement { static override shadowRootOptions: ShadowRootInit = { ...LitElement.shadowRootOptions, mode: 'closed', }; protected override createController() { - return new A2uiController(this, PlaceCardApi); + return new A2uiController(this, PlaceDetailsCompactApi); } static override styles = [ @@ -75,8 +81,16 @@ export class PlaceCard extends A2uiLitElement { const placeId = props.placeId; + // Default to 'vertical' if this is the only a2ui-placedetailscompact component among its siblings, + // otherwise default to 'horizontal'. AI can still override this. + const siblingCards = Array.from(this.parentElement?.children || []) + .filter(c => c.tagName.toLowerCase() === 'a2ui-placedetailscompact'); + const autoOrientation = siblingCards.length === 1 ? 'vertical' : 'horizontal'; + + const orientation = (props.orientation ?? autoOrientation).toUpperCase() as google.maps.places.PlaceDetailsOrientationString; + const style = { - "width": "100%", + 'width': '100%', }; if (!placeId) { @@ -85,9 +99,10 @@ export class PlaceCard extends A2uiLitElement { return html`
- + internal-usage-attribution-ids="${ + (window as any)['A2UI_ATTRIBUTION_ID'] || 'gmp_web_maui_v0.1.7_exp'}"> @@ -106,8 +121,8 @@ export class PlaceCard extends A2uiLitElement { } } -/** A2UI Definition for PlaceCard component */ -export const A2uiPlaceCard = { - ...PlaceCardApi, - tagName: "a2ui-placecard", +/** A2UI Definition for PlaceDetailsCompact component */ +export const A2uiPlaceDetailsCompact = { + ...PlaceDetailsCompactApi, + tagName: 'a2ui-placedetailscompact', }; diff --git a/client/web/src/lit/custom-components/place_details_compact_test.ts b/client/web/src/lit/custom-components/place_details_compact_test.ts new file mode 100644 index 0000000..700f29d --- /dev/null +++ b/client/web/src/lit/custom-components/place_details_compact_test.ts @@ -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. + +import './place_details_compact'; + +import type {PlaceDetailsCompact} from './place_details_compact'; + +describe('PlaceDetailsCompact Component', () => { + it('uses a fallback attribution ID when the global one is missing', + async () => { + // 1. Explicitly remove any global attribution ID + delete (window as any).A2UI_ATTRIBUTION_ID; + + // 2. Render the component with a place ID + const element = document.createElement('a2ui-placedetailscompact') as PlaceDetailsCompact; + (element as any).controller = { + props: {placeId: 'ChIJN1t_tDeuEmsRUsoyG83frY4'} + }; + document.body.appendChild(element); + + // Wait for lit to finish initial render + await element.updateComplete; + + // 3. Query the rendered place details using renderRoot + const detailsCompact = + element.renderRoot.querySelector('gmp-place-details-compact'); + + // 4. Assert that the attribute has the correct fallback ID + const attrId = + detailsCompact!.getAttribute('internal-usage-attribution-ids'); + expect(attrId).toBe('gmp_web_maui_v0.1.7_exp'); + + // Cleanup + document.body.removeChild(element); + }); +}); diff --git a/client/web/tsconfig.json b/client/web/tsconfig.json index 3d284b0..790ff27 100644 --- a/client/web/tsconfig.json +++ b/client/web/tsconfig.json @@ -24,12 +24,14 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, + "types": ["google.maps", "node"], /* Linting */ "strict": true, "noUnusedLocals": false, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + "types": ["jasmine"] }, "include": ["**/*.ts", "**/*.json"], "exclude": ["dist", "node_modules"]