From dd8d04432c86144b4a1711f9b6b68544b1ab084f Mon Sep 17 00:00:00 2001 From: Google Maps SDK Team Date: Mon, 14 Sep 2026 14:55:34 -0700 Subject: [PATCH] No public description PiperOrigin-RevId: 981377695 --- .github/workflows/android-ci.yml | 48 + .github/workflows/cleanup-stale-prs.yml | 84 + .github/workflows/ios-ci.yml | 45 + .github/workflows/python-ci.yml | 3 + .github/workflows/release.yml | 19 +- .github/workflows/web-ci.yml | 1 + .github/workflows/zizmor.yml | 17 + .releaserc.json | 29 +- README.md | 1 - agent/python_agent/README.md | 3 + agent/python_agent/__init__.py | 22 +- agent/python_agent/after_tools_callback.py | 100 + agent/python_agent/agent.py | 102 +- agent/python_agent/agent_with_grounding.py | 54 +- agent/python_agent/agent_with_templates.py | 197 +- agent/python_agent/extractor.py | 80 +- agent/python_agent/grounding_sources.py | 416 + agent/python_agent/merger.py | 55 +- .../instructions/shared_style_guidelines.md | 55 +- .../shared/schema/maps_catalog_extension.json | 65 +- .../directions-template-response/SKILL.md | 99 +- .../SKILL.md | 20 +- .../local-search-template-response/SKILL.md | 17 +- agent/python_agent/template_tool.py | 360 + agent/python_agent/templates/directions.json | 12 +- .../python_agent/templates/local_search.json | 10 +- .../python_agent/test_after_tools_callback.py | 307 + agent/python_agent/test_agent.py | 44 + .../python_agent/test_agent_with_templates.py | 87 +- agent/python_agent/test_extractor.py | 133 + agent/python_agent/test_grounding_sources.py | 277 + agent/python_agent/test_merger.py | 125 +- agent/python_agent/test_template_tool.py | 323 + client/android/GoogleMapsA2UI/build.gradle | 27 + .../GoogleMapsA2UI/src/main/assets/index.html | 8664 ++++++++++++++--- .../mapsplatform/a2ui/A2AResponseParser.kt | 177 +- .../libraries/mapsplatform/a2ui/A2UIView.kt | 4 +- .../a2ui/A2AResponseParserTest.kt | 532 +- .../mapsplatform/a2ui/A2UIServicesTest.kt | 74 + .../mapsplatform/a2ui/A2UIViewTest.kt | 320 + .../mapsplatform/a2ui/WebAppInterfaceTest.kt | 174 + client/android/web_build/src/core-shell.ts | 2 +- client/ios/GoogleMapsA2UI/Package.swift | 4 +- .../GoogleMapsA2UI/A2AResponseParser.swift | 36 +- .../Sources/GoogleMapsA2UI/A2UIView.swift | 36 +- .../GoogleMapsA2UI/Resources/index.html | 8607 +++++++++++++--- .../Tests/A2AResponseParserTests.swift | 276 +- .../Tests/A2UIServicesTests.swift | 74 + .../GoogleMapsA2UI/Tests/A2UIViewTests.swift | 522 + client/ios/web_build/src/core-shell.ts | 2 +- client/web/src/lit/a2ui_client.ts | 90 +- client/web/src/lit/a2ui_client_test.ts | 92 + client/web/src/lit/a2ui_renderer.ts | 141 +- .../src/lit/custom-components/3d_marker.ts | 104 + .../lit/custom-components/3d_marker_test.ts | 54 + .../lit/custom-components/anchor_marker.ts | 84 + .../anchor_marker_constants.ts | 48 + .../custom-components/anchor_marker_test.ts | 45 + .../src/lit/custom-components/google_map.ts | 284 +- .../lit/custom-components/google_map_test.ts | 261 +- .../custom-components/grounding_sources.ts | 457 + .../grounding_sources_test.ts | 76 + client/web/src/lit/custom-components/index.ts | 30 +- .../place_details_compact.ts | 52 +- .../lit/custom-components/place_pin_marker.ts | 169 + .../place_pin_marker_constants.ts | 240 + .../place_pin_marker_test.ts | 64 + client/web/src/lit/index.ts | 8 +- 68 files changed, 21823 insertions(+), 3217 deletions(-) create mode 100644 .github/workflows/android-ci.yml create mode 100644 .github/workflows/cleanup-stale-prs.yml create mode 100644 .github/workflows/ios-ci.yml create mode 100644 agent/python_agent/after_tools_callback.py create mode 100644 agent/python_agent/grounding_sources.py create mode 100644 agent/python_agent/template_tool.py create mode 100644 agent/python_agent/test_after_tools_callback.py create mode 100644 agent/python_agent/test_agent.py create mode 100644 agent/python_agent/test_grounding_sources.py create mode 100644 agent/python_agent/test_template_tool.py create mode 100644 client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/A2UIServicesTest.kt create mode 100644 client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/A2UIViewTest.kt create mode 100644 client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/WebAppInterfaceTest.kt create mode 100644 client/ios/GoogleMapsA2UI/Tests/A2UIServicesTests.swift create mode 100644 client/ios/GoogleMapsA2UI/Tests/A2UIViewTests.swift create mode 100644 client/web/src/lit/a2ui_client_test.ts create mode 100644 client/web/src/lit/custom-components/3d_marker.ts create mode 100644 client/web/src/lit/custom-components/3d_marker_test.ts create mode 100644 client/web/src/lit/custom-components/anchor_marker.ts create mode 100644 client/web/src/lit/custom-components/anchor_marker_constants.ts create mode 100644 client/web/src/lit/custom-components/anchor_marker_test.ts create mode 100644 client/web/src/lit/custom-components/grounding_sources.ts create mode 100644 client/web/src/lit/custom-components/grounding_sources_test.ts create mode 100644 client/web/src/lit/custom-components/place_pin_marker.ts create mode 100644 client/web/src/lit/custom-components/place_pin_marker_constants.ts create mode 100644 client/web/src/lit/custom-components/place_pin_marker_test.ts diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml new file mode 100644 index 0000000..2905bd5 --- /dev/null +++ b/.github/workflows/android-ci.yml @@ -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. + +name: Android CI + +on: + pull_request: + branches: [ main ] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Android CI / build + # zizmor: ignore[unpinned-images] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Set up JDK 17 + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 + with: + distribution: 'temurin' + java-version: '17' + cache: 'gradle' + + - name: Build and test library + run: | + chmod +x ./gradlew + ./gradlew test assembleRelease --no-daemon + working-directory: client/android/GoogleMapsA2UI diff --git a/.github/workflows/cleanup-stale-prs.yml b/.github/workflows/cleanup-stale-prs.yml new file mode 100644 index 0000000..d238b98 --- /dev/null +++ b/.github/workflows/cleanup-stale-prs.yml @@ -0,0 +1,84 @@ +# 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. + +name: Cleanup Stale Draft PRs + +on: + schedule: + - cron: '0 2 * * *' # Daily at 02:00 UTC + workflow_dispatch: + inputs: + older_than_days: + description: 'Close draft PRs older than N days' + required: false + default: '3' + type: string + dry_run: + description: 'Dry run (simulate without closing PRs or deleting branches)' + required: false + default: false + type: boolean + +permissions: + pull-requests: write + contents: write + +jobs: + cleanup: + name: Cleanup Draft PRs + # zizmor: ignore[unpinned-images] + runs-on: ubuntu-latest + steps: + - name: Close stale draft PRs and delete branches + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + INPUT_DAYS: ${{ inputs.older_than_days }} + INPUT_DRY_RUN: ${{ inputs.dry_run }} + run: | + DAYS="${INPUT_DAYS:-3}" + DRY_RUN="${INPUT_DRY_RUN:-false}" + + echo "Searching for open draft PRs with head branch matching 'test_*' older than $DAYS day(s)..." + + CUTOFF_EPOCH=$(date -d "$DAYS days ago" +%s) + echo "Cutoff timestamp: $CUTOFF_EPOCH ($(date -d "@$CUTOFF_EPOCH" --utc --iso-8601=seconds))" + + PRS_JSON=$(gh pr list --repo "$GH_REPO" --state open --draft --json number,headRefName,updatedAt) + + echo "$PRS_JSON" | jq -c '.[]' | while read -r pr; do + PR_NUMBER=$(echo "$pr" | jq -r '.number') + HEAD_REF=$(echo "$pr" | jq -r '.headRefName') + UPDATED_AT=$(echo "$pr" | jq -r '.updatedAt') + + # Only target Copybara presubmit branches (prefix test_) + if [[ ! "$HEAD_REF" =~ ^test_ ]]; then + echo "Skipping PR #$PR_NUMBER (head branch '$HEAD_REF' does not match 'test_*')" + continue + fi + + PR_EPOCH=$(date -d "$UPDATED_AT" +%s) + if [ "$PR_EPOCH" -lt "$CUTOFF_EPOCH" ]; then + echo "PR #$PR_NUMBER ($HEAD_REF, updated at $UPDATED_AT) is older than $DAYS day(s)." + if [ "$DRY_RUN" = "true" ]; then + echo "[DRY RUN] Would close PR #$PR_NUMBER and delete branch '$HEAD_REF'" + else + echo "Closing PR #$PR_NUMBER and deleting branch '$HEAD_REF'..." + gh pr close "$PR_NUMBER" --repo "$GH_REPO" --comment "Automatically closing stale presubmit draft PR and cleaning up branch." --delete-branch || \ + gh pr close "$PR_NUMBER" --repo "$GH_REPO" --comment "Automatically closing stale presubmit draft PR." + fi + else + echo "Keeping PR #$PR_NUMBER ($HEAD_REF, updated at $UPDATED_AT) - active within $DAYS day(s)." + fi + done diff --git a/.github/workflows/ios-ci.yml b/.github/workflows/ios-ci.yml new file mode 100644 index 0000000..6c8352a --- /dev/null +++ b/.github/workflows/ios-ci.yml @@ -0,0 +1,45 @@ +# 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. + +name: iOS CI + +on: + pull_request: + branches: [ main ] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: iOS CI / build + # Pinned to macos-15 so the bundled Xcode and iOS Simulator lineup stay stable. + # zizmor: ignore[unpinned-images] + runs-on: macos-15 + timeout-minutes: 30 + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Build and test package + run: | + xcodebuild test \ + -scheme GoogleMapsA2UI \ + -destination 'platform=iOS Simulator,name=iPhone 16' \ + -skipPackagePluginValidation \ + CODE_SIGNING_ALLOWED=NO + working-directory: client/ios/GoogleMapsA2UI diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 8ee8f3b..4693853 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -22,7 +22,10 @@ on: jobs: build: + name: Python CI / build runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fc62731..165d705 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,6 +18,11 @@ on: workflow_dispatch: + inputs: + dry_run: + description: "Run in dry-run mode (no tags, no publish)" + type: boolean + default: true permissions: contents: write @@ -45,7 +50,7 @@ jobs: - name: Install dependencies working-directory: client/web - run: npm ci + run: npm install - name: Setup Node for Publishing uses: actions/setup-node@b39b52d1213e96004bfcb1c61a8a6fa8ab84f3e8 # v4.0.1 @@ -60,5 +65,15 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_WOMBAT_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_WOMBAT_TOKEN }} NODE_PATH: ${{ github.workspace }}/client/web/node_modules - run: npx --prefix client/web semantic-release + DRY_RUN: ${{ inputs.dry_run }} + REF_NAME: ${{ github.ref_name }} + run: | + EXTRA_ARGS="" + if [ "$DRY_RUN" != "false" ]; then + EXTRA_ARGS="--dry-run" + fi + if [ "$REF_NAME" != "main" ]; then + EXTRA_ARGS="$EXTRA_ARGS --branches $REF_NAME" + fi + npx --prefix client/web semantic-release $EXTRA_ARGS diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml index a55bc4d..5272949 100644 --- a/.github/workflows/web-ci.yml +++ b/.github/workflows/web-ci.yml @@ -25,6 +25,7 @@ permissions: jobs: build: + name: Web CI / build # zizmor: ignore[unpinned-images] runs-on: ubuntu-24.04 steps: diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 8c4f481..5f48f4e 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -1,3 +1,17 @@ +# 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. + name: Zizmor on: @@ -24,3 +38,6 @@ jobs: - name: Run zizmor uses: zizmorcore/zizmor-action@195d10ad90f31d8cd6ea1efd6ecc12969ddbe73f # v0.5.1 + with: + args: --ignore insufficient-cooldown + diff --git a/.releaserc.json b/.releaserc.json index 089c315..15c8354 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -3,7 +3,34 @@ "main" ], "plugins": [ - "@semantic-release/commit-analyzer", + [ + "@semantic-release/commit-analyzer", + { + "preset": "angular", + "releaseRules": [ + { + "breaking": true, + "release": "patch" + }, + { + "type": "feat", + "release": "patch" + }, + { + "type": "fix", + "release": "patch" + }, + { + "type": "perf", + "release": "patch" + }, + { + "type": "refactor", + "release": "patch" + } + ] + } + ], "@semantic-release/release-notes-generator", "@semantic-release/changelog", [ diff --git a/README.md b/README.md index a153511..fad53d2 100644 --- a/README.md +++ b/README.md @@ -297,7 +297,6 @@ Agentic UI Toolkit requires an API Key to use Google Maps Platform products. To Your API Key must have the following APIs enabled in the [Google Cloud Console](https://console.cloud.google.com/apis/credentials): -* Geocoding API * Maps JavaScript API * Places UI Kit * Routes API diff --git a/agent/python_agent/README.md b/agent/python_agent/README.md index a56a9c1..57f5d65 100644 --- a/agent/python_agent/README.md +++ b/agent/python_agent/README.md @@ -14,6 +14,9 @@ AI Maps Grounding. `DIRECTIONS`) and structured parameter extraction for low latency. * `agent_with_grounding.py`: Contains `MAUIAgentWithGrounding`, extending the base agent with Vertex AI Grounding capabilities. +* `template_tool.py`: Contains standard ADK `BaseTool` implementations + (`RenderLocalSearchTemplateTool`, `RenderDirectionsTemplateTool`, + `RenderTextOnlyTemplateTool`, and `TemplateToolset`) for template rendering. * `agent_config.py`: Contains `AgentConfig` and `FallbackMode` configurations (`TEXT` vs `DYNAMIC`). * `extractor.py` & `merger.py`: Parameter extraction schemas and template diff --git a/agent/python_agent/__init__.py b/agent/python_agent/__init__.py index 11eecd8..067827d 100644 --- a/agent/python_agent/__init__.py +++ b/agent/python_agent/__init__.py @@ -1 +1,21 @@ -# GMP A2UI Python Agent Package +# 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. + +from template_tool import ( + BaseTemplateTool, + RenderDirectionsTemplateTool, + RenderLocalSearchTemplateTool, + RenderTextOnlyTemplateTool, + TemplateToolset, +) diff --git a/agent/python_agent/after_tools_callback.py b/agent/python_agent/after_tools_callback.py new file mode 100644 index 0000000..ee46ef7 --- /dev/null +++ b/agent/python_agent/after_tools_callback.py @@ -0,0 +1,100 @@ +# 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. + +"""After-tool callback for grounding tools in MAUI Agent.""" + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + +# Maximum number of recent content tokens to retain in session state. +# +# Trade-offs / Considerations: +# - Pros of larger values: +# - Retains grounding tokens across longer multi-turn conversations where +# older tool calls returned entities that are still referenced or +# rendered in UI widgets. +# - Prevents premature eviction of valid tokens, ensuring Place Widget +# requests can successfully waive billing even after multiple subsequent +# tool turns. +# - Cons of larger values: +# - Increases session state size and payload memory footprint across +# requests. +# - Increases serialized metadata size attached to message parts and RPCs. +# - Adds backend processing overhead when downstream services must decrypt +# and validate a larger list of candidate tokens. +# - Since tokens have an expiration TTL (e.g. 30 minutes), retaining too +# many historical tokens increases stale/expired tokens in the payload. +MAX_CONTENT_TOKENS: int = 10 + + +def after_tools_callback( + tool: Any, + args: dict[str, Any], + tool_context: Any, + tool_response: Any, + **kwargs: Any, +) -> Any: + """Callback to aggregate grounding_content_token into session state.""" + # pylint: disable=unused-argument + if not tool_response or not isinstance(tool_response, dict): + return None + + after_maps_tools_callback(tool_context, tool_response) + + return None + + +def after_maps_tools_callback( + tool_context: Any, + tool_response: Any, +) -> Any: + """Callback to aggregate content_token from Maps Tools into session state.""" + # pylint: disable=unused-argument + if tool_context is None or getattr(tool_context, "state", None) is None: + return None + + token = tool_response.get("content_token") + if isinstance(token, str) and token: + content_tokens = tool_context.state.get("maps_tools_content_tokens", []) + # If content_tokens is not a list, initialize it to an empty list. + if not isinstance(content_tokens, list): + content_tokens = [] + if token not in content_tokens: + content_tokens.append(token) + # Keep only the last MAX_CONTENT_TOKENS tokens. + if len(content_tokens) > MAX_CONTENT_TOKENS: + content_tokens = content_tokens[-MAX_CONTENT_TOKENS:] + tool_context.state["maps_tools_content_tokens"] = content_tokens + logger.info( + "--- after_maps_tools_callback: Aggregated content token into" + " content_tokens. ---" + ) + + return None + + +def _add_maps_tools_tokens_to_part(part: Any, session: Any) -> None: + """Adds maps_tools_content_tokens from session state to part metadata.""" + if session is None or getattr(session, "state", None) is None: + return + maps_tools_content_tokens = session.state.get("maps_tools_content_tokens") + if maps_tools_content_tokens: + if getattr(part, "root", None) is not None: + if getattr(part.root, "metadata", None) is None: + part.root.metadata = {} + part.root.metadata["maps_tools_content_tokens"] = ( + maps_tools_content_tokens + ) diff --git a/agent/python_agent/agent.py b/agent/python_agent/agent.py index 19182fb..3431e4f 100644 --- a/agent/python_agent/agent.py +++ b/agent/python_agent/agent.py @@ -51,9 +51,22 @@ from a2ui.schema.catalog import CatalogConfig from a2ui.schema.catalog_provider import A2uiCatalogProvider 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.parser.constants import ( + MSG_TYPE_CREATE_SURFACE, + MSG_TYPE_DELETE_SURFACE, + MSG_TYPE_UPDATE_COMPONENTS, + MSG_TYPE_UPDATE_DATA_MODEL, +) +from a2ui.schema.constants import ( + A2UI_CLOSE_TAG, + A2UI_OPEN_TAG, + A2UI_SURFACE_ID_KEY, + VERSION_0_9, +) from a2ui.schema.manager import A2uiSchemaManager +from .after_tools_callback import _add_maps_tools_tokens_to_part, after_tools_callback + logger = logging.getLogger(__name__) InMemorySessionService = in_memory_session_service.InMemorySessionService @@ -92,6 +105,8 @@ **Important**: When answering a location-based question, you may need to find up-to-date information about places or routes. Use your skills or tools to answer the user. When returning information for places, always fetch the place's name, address, lat, lng, and place id. + When returning places in `updateDataModel` or components, every place object MUST include `name`, `address` (or street/vicinity), `lat`, `lng`, and `placeId`. + In the `GoogleMap` component, the `markers` property MUST ALWAYS be an explicit array of marker objects (e.g. `[{"lat": ..., "lng": ..., "label": ..., "placeId": ...}]`). NEVER use data binding like `{"path": "/markers"}` for the markers property. **Important**: Consider that subsequent requests are likely to be part of the same "user journey", and keep track of any context that you may need to provide to the user. Examples: @@ -104,6 +119,7 @@ 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. + """ @@ -143,6 +159,24 @@ def load(self) -> dict[str, Any]: return catalog +def extract_surface_id(data: Any) -> str | None: + """Extracts the surface ID from an A2UI payload dictionary or part.""" + if not isinstance(data, dict): + return None + for key in ( + MSG_TYPE_CREATE_SURFACE, + MSG_TYPE_UPDATE_COMPONENTS, + MSG_TYPE_UPDATE_DATA_MODEL, + MSG_TYPE_DELETE_SURFACE, + ): + target = data.get(key) + if isinstance(target, dict): + surface_id = target.get(A2UI_SURFACE_ID_KEY) + if surface_id: + return str(surface_id) + return None + + class MAUIAgent: """An agent that finds restaurants based on user criteria.""" @@ -159,6 +193,7 @@ def __init__( self._model_name = model_name self._user_id = "remote_agent" self._shared_session_service = InMemorySessionService() + self._after_tool_callback = after_tools_callback self._text_runner: Runner | None = self._build_runner( self._build_llm_agent() ) @@ -303,6 +338,7 @@ def _build_llm_agent( ), instruction=instruction, tools=[grounding_lite_mcp, skill_manager_tool], + after_tool_callback=self._after_tool_callback, ) async def stream( @@ -414,15 +450,38 @@ async def 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("-- MAUIAgent.stream: Streamed part: %s ---", part) - yield { - "is_task_complete": False, - "parts": [part], - } + session_surface_id = None + # Wrap stream parsing in try/except to prevent A2uiValidatorError from crashing the ASGI app. + # This ensures execution falls through to the deleteSurface/retry loop below. + token_gen = token_stream() + try: + async for part in stream_response_to_parts( + self._parsers[session_id], + token_gen, + ): + _add_maps_tools_tokens_to_part(part, session) + logger.info("-- MAUIAgent.stream: Streamed part: %s ---", part) + # TODO(b/553539577): Remove this workaround once A2UI fixes the stream parser state issue. + if isinstance(part.root, DataPart): + s_id = extract_surface_id(part.root.data) + if s_id: + session_surface_id = s_id + logger.info("[WORKAROUND] Sniffed surfaceId '%s' from streamed part", session_surface_id) + yield { + "is_task_complete": False, + "parts": [part], + } + except Exception as e: + logger.warning("--- MAUIAgent.stream: Error during stream parsing (will fall through to retry loop): %s ---", e) + # Drain remaining tokens from the same generator so full_content_list is complete and runner finishes cleanly + try: + async for _ in token_gen: + pass + except Exception as drain_err: + logger.debug( + "--- MAUIAgent.stream: Error draining token stream: %s ---", + drain_err, + ) else: async for token in token_stream(): yield { @@ -528,6 +587,9 @@ async def token_stream(): filtered_parts.append(p) final_parts = filtered_parts + for p in final_parts: + _add_maps_tools_tokens_to_part(p, session) + yield { "is_task_complete": True, "parts": final_parts, @@ -542,6 +604,26 @@ async def token_stream(): attempt, max_retries + 1, ) + + # Extract surfaceId to clear the failed UI card on the client + surface_id = session_surface_id or getattr(self._parsers.get(session_id), "surface_id", None) + + if surface_id: + logger.info("--- MAUIAgent.stream: Sending deleteSurface for '%s' to clear failed attempt ---", surface_id) + yield { + "is_task_complete": False, + "parts": [ + Part( + root=DataPart( + data={ + "version": "v0.9", + "deleteSurface": {"surfaceId": surface_id}, + } + ) + ) + ], + } + # Prepare the query for the retry current_query_text = ( f"Your previous response was invalid. {error_message} You MUST" diff --git a/agent/python_agent/agent_with_grounding.py b/agent/python_agent/agent_with_grounding.py index 3d38e6a..4f38308 100644 --- a/agent/python_agent/agent_with_grounding.py +++ b/agent/python_agent/agent_with_grounding.py @@ -14,11 +14,13 @@ """MAUI Agent with Grounding implementation.""" +import json import logging import os import pathlib -from typing import Optional +from typing import Any, AsyncIterable, Optional +from a2a.types import DataPart, Part from google import genai from google.adk import skills as adk_skills from google.adk.agents.llm_agent import LlmAgent @@ -33,6 +35,7 @@ from a2ui.schema.manager import A2uiSchemaManager # Import MAUIAgent to inherit from it from agent import AGENT_INSTRUCTION, MAUIAgent, MergedCatalogProvider +from grounding_sources import enrich_grounding_sources_with_a2ui_payload, extract_sources_from_grounding_chunks logger = logging.getLogger(__name__) @@ -54,6 +57,7 @@ async def query_vertex_map( query: str, model_id: str = "gemini-3-flash-preview", + sources_out: list[dict[str, str]] | None = None, ) -> str: """Query Google Maps via Vertex Grounding and return cleaned response. @@ -117,8 +121,9 @@ async def query_vertex_map( ) 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. + CRITICAL: Before generating the JSON, you MUST write a short plain-text summary of the places you found, listing their exact names and street addresses (e.g., "1. The Pink Door: 1919 Post Alley, Seattle, WA"). 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: Every place object in the A2UI JSON (e.g., in updateDataModel or markers) MUST include an "address" field containing its street address (e.g., "1919 Post Alley"). 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. PLACE ID GENERATION RULES: @@ -170,6 +175,13 @@ async def query_vertex_map( title_counts[title] = title_counts.get(title, 0) + 1 count = title_counts[title] grounding_map[f"PLACE_ID_FOR_{count}_{title}"] = place_id + + if sources_out is not None: + sources_out.extend( + extract_sources_from_grounding_chunks( + meta.grounding_chunks, query=query + ) + ) else: logger.warning("No grounding chunks found") else: @@ -187,17 +199,32 @@ async def query_vertex_map( if "PLACE_ID_FOR_" in final_response_content: logger.warning("Place ID placeholder found in response.") + plain_text_before_json = final_response_content + parsed_json_for_sources = None # Final safety check: Extract JSON array if marker is present if "" in final_response_content: marker_idx = final_response_content.find("") + plain_text_before_json = final_response_content[:marker_idx].strip() 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] + try: + parsed_json_for_sources = json.loads(json_only) + except Exception: # pylint: disable=broad-exception-caught + parsed_json_for_sources = None final_response_content = "" + json_only + "" + if sources_out is not None: + enrich_grounding_sources_with_a2ui_payload( + sources_out, + parsed_json_for_sources, + query=query, + plain_text=plain_text_before_json, + ) + return final_response_content @@ -214,6 +241,7 @@ def __init__( agent_name="MAUI Agent with Grounding", model_name=model_name, ) + self._current_sources: list[dict[str, str]] = [] async def query_vertex_map(self, query: str) -> str: """Query Google Maps via Vertex Grounding and return cleaned response. @@ -227,7 +255,26 @@ async def query_vertex_map(self, query: str) -> str: model_id = ( self._model_name.removeprefix("gemini/").removeprefix("models/") ) - return await query_vertex_map(query, model_id=model_id) + self._current_sources = [] + return await query_vertex_map( + query, model_id=model_id, sources_out=self._current_sources + ) + + async def stream( + self, query: str, session_id: str, ui_version: str | None = None + ) -> AsyncIterable[dict[str, Any]]: + """Streams responses from base agent and attaches groundingSources to final parts.""" + self._current_sources = [] + async for item in super().stream(query, session_id, ui_version): + if item.get("is_task_complete") and self._current_sources: + parts = list(item.get("parts", [])) + parts.append( + Part( + root=DataPart(data={"groundingSources": self._current_sources}) + ) + ) + item["parts"] = parts + yield item def _build_llm_agent( self, schema_manager: A2uiSchemaManager | None = None @@ -276,4 +323,5 @@ def _build_llm_agent( ), instruction=instruction, tools=[grounding_tool, skill_manager_tool], + after_tool_callback=self._after_tool_callback, ) diff --git a/agent/python_agent/agent_with_templates.py b/agent/python_agent/agent_with_templates.py index 0b2ec0a..4afee30 100644 --- a/agent/python_agent/agent_with_templates.py +++ b/agent/python_agent/agent_with_templates.py @@ -15,6 +15,7 @@ """MAUI Agent with template-based latency optimization.""" import asyncio +import inspect import json import logging import pathlib @@ -31,7 +32,6 @@ 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 @@ -42,12 +42,22 @@ from agent import MAUIAgent from agent_config import AgentConfig from agent_config import FallbackMode -from extractor import DirectionsExtractorSchema -from extractor import LocalSearchExtractorSchema +from grounding_sources import ( + extract_sources_from_a2ui_payload, + extract_sources_from_places_data, +) from merger import merge_template from router_config import IntentClass from router_config import ROUTER_SYSTEM_INSTRUCTION from router_config import RouterClassification +from template_tool import ( + BaseTemplateTool, + RenderDirectionsTemplateTool, + RenderLocalSearchTemplateTool, + RenderTextOnlyTemplateTool, + STATE_RENDERED_A2UI_DATA, + STATE_RENDERED_A2UI_PARTS, +) logger = logging.getLogger(__name__) _SKILL_BASE_PATH = pathlib.Path(__file__).parent / "skills" @@ -62,10 +72,6 @@ _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} _GROUNDED_TEXT_BASE_INSTRUCTION = """\ @@ -107,9 +113,12 @@ def _on_tool_error( ) -> 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 - ): + if tool.name in ( + "render_local_search_template", + "render_directions_template", + "render_text_only_template", + "set_model_response", + ) and isinstance(error, pydantic.ValidationError): logger.warning( "Extractor tool '%s' failed validation: %s. " "Returning error to model for self-correction.", @@ -161,21 +170,28 @@ def _build_dynamic_extractor_agent( ) tools = [self.make_grounding_lite_mcp()] - output_schema = _EXTRACTOR_SCHEMAS.get(skill_name) + target_tool = None + if skill_name == _LOCAL_SEARCH_SKILL_NAME: + target_tool = RenderLocalSearchTemplateTool( + schema_manager=schema_manager, + max_list_size=self.config.max_list_size, + surface_id_prefix=_LOCAL_SEARCH_SURFACE_PREFIX, + ) + elif skill_name == _DIRECTIONS_SKILL_NAME: + target_tool = RenderDirectionsTemplateTool( + schema_manager=schema_manager, + max_list_size=self.config.max_list_size, + surface_id_prefix=_DIRECTIONS_SURFACE_PREFIX, + ) generate_content_config = None - if output_schema: - # Manually inject SetModelResponseTool - set_response_tool = SetModelResponseTool(output_schema) - tools.append(set_response_tool) + if target_tool: + tools.append(target_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." + "IMPORTANT: After using any other tools needed to complete the task," + f" you MUST call {target_tool.name} to render the final response" + " interface." ) if skill_name == _LOCAL_SEARCH_SKILL_NAME: workaround_instruction += ( @@ -183,7 +199,7 @@ def _build_dynamic_extractor_agent( 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." + f" {target_tool.name} tool call." ) skill_instructions = f"{skill_instructions}\n\n{workaround_instruction}" @@ -217,6 +233,7 @@ def _build_dynamic_extractor_agent( output_schema=None, # Keep output_schema as None in LlmAgent generate_content_config=generate_content_config, on_tool_error_callback=self._on_tool_error, + after_tool_callback=self._after_tool_callback, ) async def _run_extractor( @@ -225,9 +242,10 @@ async def _run_extractor( 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 + ) -> tuple[list[Part] | None, list[str], dict[str, Any] | None]: + """Runs the extractor agent and collects its output (rendered parts or text).""" + rendered_parts: list[Part] | None = None + rendered_data: dict[str, Any] | None = None full_content_list = [] async for event in runner.run_async( @@ -238,10 +256,6 @@ async def _run_extractor( ), 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, @@ -249,51 +263,49 @@ async def _run_extractor( ): if hasattr(event, "get_function_calls"): for fc in event.get_function_calls(): - if fc.name == "set_model_response": + if fc.name in ( + "render_local_search_template", + "render_directions_template", + "render_text_only_template", + "set_model_response", + ): logger.info( - "Intercepted set_model_response tool call with args: %s", + "--- AGENT_WITH_TEMPLATES: Observed %s tool call with args:" + " %s ---", + fc.name, fc.args, ) - # Find SetModelResponseTool in agent tools target_tool = None for t in agent.tools: - if getattr(t, "name", None) == "set_model_response": + if getattr(t, "name", None) == fc.name: target_tool = t break if target_tool and hasattr(target_tool, "run_async"): + tool_ctx = SimpleNamespace(state={}) try: - noop_tool_context = SimpleNamespace( - actions=SimpleNamespace(set_model_response=None) + tool_result = await target_tool.run_async( + args=fc.args, tool_context=tool_ctx ) - 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 + isinstance(tool_result, dict) + and "error" not in tool_result + and STATE_RENDERED_A2UI_PARTS in tool_ctx.state ): - logger.warning( - "Local Pydantic validation failed: %s. Continuing.", - validated_data["error"], - ) - else: - parsed_json_data = validated_data + rendered_parts = tool_ctx.state[STATE_RENDERED_A2UI_PARTS] + rendered_data = tool_ctx.state.get(STATE_RENDERED_A2UI_DATA) logger.info( - "Local Pydantic validation passed! Short-circuiting." + "--- AGENT_WITH_TEMPLATES: Template tool %s succeeded!" + " Captured %d rendered parts. ---", + fc.name, + len(rendered_parts), ) break - except pydantic.ValidationError as e: + except Exception as e: # pylint: disable=broad-exception-caught logger.warning( - "Local Pydantic validation failed: %s. Continuing.", - e, + "--- AGENT_WITH_TEMPLATES: Tool execution error: %s ---", e ) - else: - parsed_json_data = fc.args - break if event.content and event.content.parts: if event.partial: @@ -306,7 +318,24 @@ async def _run_extractor( if p.text: full_content_list.append(p.text) - return parsed_json_data, full_content_list + if rendered_parts is None and getattr(runner, "session_service", None): + get_session_fn = getattr(runner.session_service, "get_session", None) + if callable(get_session_fn): + try: + res = get_session_fn( + app_name=getattr(runner, "app_name", ""), + user_id=self._user_id, + session_id=session_id, + ) + if inspect.isawaitable(res): + session = await res + if session and getattr(session, "state", None): + rendered_parts = session.state.get(STATE_RENDERED_A2UI_PARTS) + rendered_data = session.state.get(STATE_RENDERED_A2UI_DATA) + except Exception as e: # pylint: disable=broad-exception-caught + logger.debug("Could not retrieve session from session_service: %s", e) + + return rendered_parts, full_content_list, rendered_data async def _run_extractor_and_merge( self, @@ -317,15 +346,10 @@ async def _run_extractor_and_merge( 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 + """Runs the dynamic extractor agent and returns rendered template parts.""" + del template_name, surface_id_prefix + # 1. Resolve catalog schema manager 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( @@ -340,35 +364,12 @@ async def _run_extractor_and_merge( ) # 4. Run extractor runner, collecting output - parsed_json_data, full_content_list = await self._run_extractor( - runner, agent, current_message, session_id + rendered_parts, full_content_list, rendered_data = ( + 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 + if rendered_parts is not None: + return rendered_parts, None, rendered_data else: raw_text = "".join(full_content_list) return None, raw_text, None @@ -638,6 +639,20 @@ async def _handle_extracted_intent( ) if merged_parts is not None: + sources = [] + if parsed_json_data and "places" in parsed_json_data: + sources = extract_sources_from_places_data( + parsed_json_data["places"], query=query + ) + if not sources and merged_parts: + sources = extract_sources_from_a2ui_payload( + [p.root.data for p in merged_parts if isinstance(p.root, DataPart)], + query=query, + ) + if sources: + merged_parts.append( + Part(root=DataPart(data={"groundingSources": sources})) + ) yield { "is_task_complete": True, "parts": merged_parts, diff --git a/agent/python_agent/extractor.py b/agent/python_agent/extractor.py index 53749a0..f4995e6 100644 --- a/agent/python_agent/extractor.py +++ b/agent/python_agent/extractor.py @@ -21,6 +21,22 @@ Field = pydantic.Field +PlacePrimaryType = Literal[ + "food_and_drink", + "retail", + "outdoor", + "service", + "lodging", + "emergency", + "entertainment", + "ev", + "airport", + "parking", + "closed", + "generic", +] + + class Pin(BaseModel): """Representation of a Map Pin.""" @@ -36,6 +52,13 @@ class Pin(BaseModel): placeId: str | None = Field( # pylint: disable=invalid-name default=None, description="Optional Google Maps Place ID" ) + placePrimaryType: PlacePrimaryType | None = Field( # pylint: disable=invalid-name + default=None, + description="Optional primary POI category type string", + ) + address: str | None = Field( + default=None, description="Optional address, vicinity, or street name" + ) @pydantic.model_validator(mode="before") @classmethod @@ -73,19 +96,48 @@ class PlacePin(BaseModel): name: str = Field(description="Name of the place") lat: float = Field(description="Latitude coordinates") lng: float = Field(description="Longitude coordinates") + placePrimaryType: PlacePrimaryType | None = Field( # pylint: disable=invalid-name + default=None, + description="Optional primary POI category type string", + ) + address: str = Field( + default="", + description=( + "Street address (first line or vicinity, e.g. '23 Commerce St' or" + " 'Harry Thomas Way NE') of the place from Google Maps search." + ), + ) class LocalSearchExtractorSchema(BaseModel): """Structured parameters to render a local search UI update.""" + heading: str = Field( + description=( + "A concise, constraint-confirming primary heading in sentence case" + " that starts with or includes the exact number of places provided" + " in the UI response, reflecting the prompt and primary reference" + " location (e.g. '5 vegetarian restaurants near The Plaza Hotel'," + " '5 transit stops near Seattle Center'). Plain text only; do" + " NOT include markdown hashtags or conversational filler." + ), + ) summary: str = Field( description=( - "A detailed response summarizing the search results that fully and" - " clearly answers all aspects of the user's prompt (including" - " qualitative criteria, preferences, and comparisons). Use markdown" - " formatting (bullet points, bolding, tables) and break into" - " paragraphs as needed. Bold place names." - ) + "A concise 1-paragraph overview that covers all returned places by" + " weaving them into natural, contrasting groups (e.g., pairing" + " lively group-friendly spots vs. intimate neighborhood bistros)" + " rather than listing them one by one. Broadly characterize the" + " dining or activity landscape near the reference location using" + " concrete, sensory details, bolding every place name (e.g.," + " **Carmine's** and **Tony's Di Napoli**), and directly addressing" + " any prompt constraints. For nearby places, never describe" + " distances as numbers (e.g., do not say '0.3 miles' or '500" + " meters'); instead generalize (e.g., 'a short walk', 'just steps" + " away', 'a quick stroll'). Plain text with markdown bolding only;" + " do NOT include conversational greetings ('Sure!', 'Here are...')" + " and do NOT list place names in bullet points." + ), ) center_lat: float = Field(description="Latitude of the center of results") center_lng: float = Field(description="Longitude of the center of results") @@ -93,7 +145,7 @@ class LocalSearchExtractorSchema(BaseModel): 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)" + description="A list of places found (limit to max list size, e.g. 5)" ) anchor_marker: Pin | None = Field( default=None, @@ -156,12 +208,18 @@ def normalize_travel_mode(mode: Any) -> str | None: class DirectionsExtractorSchema(BaseModel): """Structured parameters to render a directions UI update.""" + heading: str = Field( + description=( + "A concise, constraint-confirming primary heading for the response." + " Plain text only (e.g., 'Walking route from Seattle Center to Pike" + " Place Market', 'Driving directions to JFK Airport')." + ) + ) summary: str = Field( description=( - "A detailed response summarizing the travel directions and route" - " options that fully answers all user questions, route comparisons," - " and travel context requested in the prompt. Use markdown formatting" - " and break into paragraphs if helpful." + "A natural, direct resolution of the route prompt describing" + " approximate travel duration and distance (e.g. 'Driving from" + " [Origin] to [Destination] takes about 19 minutes (14 miles).')." ) ) center_lat: float = Field( diff --git a/agent/python_agent/grounding_sources.py b/agent/python_agent/grounding_sources.py new file mode 100644 index 0000000..6da3337 --- /dev/null +++ b/agent/python_agent/grounding_sources.py @@ -0,0 +1,416 @@ +# 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. + +"""Utility functions for extracting and formatting Google Maps Grounding Sources.""" + +import re +from typing import Any +import urllib.parse + + +def extract_location_from_query(query: str | None) -> str | None: + """Extracts city, neighborhood, or region name from user query string.""" + if not query: + return None + cleaned = re.sub(r"^\[.*?\]\s*", "", query).strip() + first_clause = re.split(r"[.!?\n]", cleaned)[0].strip() + patterns = [ + r"\b(?:in|near|around|at)\s+([A-Za-z0-9\s,-]+?)(?:\s+(?:with|that|for|and|or|please)|\?|$)", + r"\bto\s+(?:the\s+)?([A-Za-z0-9\s,-]+?)(?:\s+(?:from|with|for)|\?|$)", + ] + for pat in patterns: + m = re.search(pat, first_clause, re.IGNORECASE) + if m: + loc = m.group(1).strip() + loc = re.sub(r"[,.!?]+$", "", loc).strip() + if loc.lower().startswith("the "): + loc = loc[4:].strip() + if ( + loc + and len(loc) > 1 + and loc.lower() + not in ( + "the area", + "town", + "my area", + "here", + ) + ): + return loc.title() if loc.islower() else loc + return None + + +def simplify_address(address: str | None) -> str | None: + """Extracts the first line / street address from a full address string.""" + if not address or not isinstance(address, str): + return None + first_part = re.split(r"[\n,]", address)[0].strip() + return first_part if first_part else address.strip() + + +def format_maps_place_url(place_id: str, query: str | None = None) -> str: + """Formats a direct canonical URL to a Google Maps place with optional query fallback.""" + clean_place_id = str(place_id).strip() + if clean_place_id.startswith("places/"): + clean_place_id = clean_place_id[len("places/") :] + + if query: + query_for_search = query.replace(" · ", ", ").strip() + return ( + "https://www.google.com/maps/search/?api=1" + f"&query={urllib.parse.quote(query_for_search)}" + f"&query_place_id={urllib.parse.quote(clean_place_id)}" + ) + return f"https://www.google.com/maps/place/?q=place_id:{clean_place_id}" + + +def format_maps_search_url(query: str) -> str: + """Formats a Google Maps search URL for a query string.""" + return f"https://www.google.com/maps/search/?api=1&query={urllib.parse.quote(query)}" + + +def extract_sources_from_grounding_chunks( + grounding_chunks: list[Any], + query: str | None = None, +) -> list[dict[str, str]]: + """Extracts structured sources from Vertex AI Grounding chunks.""" + sources: list[dict[str, str]] = [] + seen_urls: set[str] = set() + ignore_title_suffix = " - Google Maps" + loc_from_query = extract_location_from_query(query) + + for chunk in grounding_chunks: + if hasattr(chunk, "maps") and chunk.maps: + title = getattr(chunk.maps, "title", None) or "Google Maps Place" + place_id = getattr(chunk.maps, "place_id", None) + uri = getattr(chunk.maps, "uri", None) + if title.endswith(ignore_title_suffix): + title = title[: -len(ignore_title_suffix)].strip() + + if place_id and str(place_id).startswith("places/"): + place_id = str(place_id)[len("places/") :] + + if " · " not in title and loc_from_query: + display_title = f"{title} · {loc_from_query}" + else: + display_title = title + + if uri and ("maps.google." in uri or "google.com/maps" in uri): + url = uri + elif place_id: + url = format_maps_place_url( + place_id, query=display_title if query else None + ) + else: + url = format_maps_search_url(display_title) + + if url not in seen_urls: + seen_urls.add(url) + source_entry = { + "title": display_title, + "url": url, + "type": "place", + } + if place_id: + source_entry["placeId"] = place_id + sources.append(source_entry) + + elif hasattr(chunk, "web") and chunk.web: + web_title = getattr(chunk.web, "title", None) or "Web Source" + web_uri = getattr(chunk.web, "uri", None) + if web_uri and web_uri not in seen_urls: + seen_urls.add(web_uri) + sources.append({ + "title": web_title, + "url": web_uri, + "type": "web", + }) + + return sources + + +def extract_sources_from_places_data( + places: list[dict[str, Any]], + query: str | None = None, +) -> list[dict[str, str]]: + """Extracts structured sources from a list of Place objects (e.g. + + from templates). + """ + sources: list[dict[str, str]] = [] + seen_urls: set[str] = set() + loc_from_query = extract_location_from_query(query) + + for p in places: + if not isinstance(p, dict): + continue + name = ( + p.get("name") or p.get("title") or p.get("label") or "Google Maps Place" + ) + address = ( + p.get("formatted_address") + or p.get("address") + or p.get("vicinity") + or p.get("short_formatted_address") + or p.get("street") + or p.get("location") + ) + if not isinstance(address, str): + address = None + + clean_address = simplify_address(address) + if clean_address and clean_address not in name: + display_title = f"{name} · {clean_address}" + elif loc_from_query and " · " not in name: + display_title = f"{name} · {loc_from_query}" + else: + display_title = name + + place_id = p.get("placeId") or p.get("place_id") + if place_id and not str(place_id).startswith("PLACE_ID_FOR_"): + url = format_maps_place_url(str(place_id), query=display_title) + else: + url = format_maps_search_url(display_title) + + if url not in seen_urls: + seen_urls.add(url) + source_entry = { + "title": display_title, + "url": url, + "type": "place", + } + if place_id and not str(place_id).startswith("PLACE_ID_FOR_"): + source_entry["placeId"] = str(place_id) + sources.append(source_entry) + + return sources + + +def extract_sources_from_a2ui_payload( + payload: Any, + query: str | None = None, +) -> list[dict[str, str]]: + """Recursively scans an A2UI message payload or data structure for place sources.""" + sources_by_id: dict[str, dict[str, str]] = {} + sources_by_name: dict[str, dict[str, str]] = {} + loc_from_query = extract_location_from_query(query) + + def _scan(obj: Any): + if isinstance(obj, dict): + place_id = obj.get("placeId") or obj.get("place_id") + name = obj.get("name") or obj.get("title") or obj.get("label") + address = ( + obj.get("formatted_address") + or obj.get("address") + or obj.get("vicinity") + or obj.get("short_formatted_address") + or obj.get("street") + or obj.get("streetAddress") + or obj.get("street_address") + or obj.get("location") + ) + if not address and isinstance(obj.get("subtitle"), str): + # Use subtitle if it looks like a street address (contains digits or street suffix) + sub = obj.get("subtitle", "") + if re.search(r"\b\d+\s+[A-Za-z]", sub): + address = sub + if not isinstance(address, str): + address = None + + clean_name = (name or "").strip() if isinstance(name, str) else "" + clean_address = simplify_address(address) + + if ( + place_id + and isinstance(place_id, str) + and not place_id.startswith("PLACE_ID_FOR_") + ): + display_name = clean_name or "Google Maps Place" + if clean_address and clean_address not in display_name: + display_title = f"{display_name} · {clean_address}" + elif loc_from_query and " · " not in display_name: + display_title = f"{display_name} · {loc_from_query}" + else: + display_title = display_name + + url = format_maps_place_url(place_id, query=display_title) + + if place_id not in sources_by_id: + entry = { + "title": display_title, + "url": url, + "type": "place", + "placeId": place_id, + } + if clean_address: + entry["streetAddress"] = clean_address + sources_by_id[place_id] = entry + else: + existing = sources_by_id[place_id] + # If current object has real street address, unconditionally enrich the existing entry! + if clean_address: + existing_name = display_name + if ( + existing["title"].split(" · ")[0] != "Google Maps Place" + and display_name == "Google Maps Place" + ): + existing_name = existing["title"].split(" · ")[0] + new_title = f"{existing_name} · {clean_address}" + existing["title"] = new_title + existing["url"] = format_maps_place_url(place_id, query=new_title) + existing["streetAddress"] = clean_address + elif ( + existing["title"] == "Google Maps Place" + and display_name != "Google Maps Place" + ): + existing["title"] = display_title + existing["url"] = url + elif clean_name and clean_address: + # Also record name -> streetAddress even if placeId wasn't attached on this inner node + norm_name = clean_name.lower() + sources_by_name[norm_name] = { + "name": clean_name, + "streetAddress": clean_address, + } + + for v in obj.values(): + _scan(v) + elif isinstance(obj, list): + for item in obj: + _scan(item) + + _scan(payload) + + # Apply any name-based street addresses to entries in sources_by_id that lacked streetAddress + for entry in sources_by_id.values(): + if "streetAddress" not in entry: + base_name = entry["title"].split(" · ")[0].strip().lower() + if base_name in sources_by_name: + street_addr = sources_by_name[base_name]["streetAddress"] + orig_name = entry["title"].split(" · ")[0].strip() + entry["title"] = f"{orig_name} · {street_addr}" + entry["url"] = format_maps_place_url( + entry["placeId"], query=entry["title"] + ) + entry["streetAddress"] = street_addr + + return list(sources_by_id.values()) + + +def extract_addresses_from_plain_text( + plain_text: str | None, + place_names: list[str], +) -> dict[str, str]: + """Extracts street addresses for specific place names from LLM plain-text summary.""" + result: dict[str, str] = {} + if not plain_text or not isinstance(plain_text, str): + return result + + for raw_name in place_names: + name = raw_name.strip() + if not name or name == "Google Maps Place": + continue + escaped = re.escape(name) + # Match patterns like: "1. The Pink Door: 1919 Post Alley, Seattle" or "**The Pink Door** - 1919 Post Alley" + patterns = [ + ( + rf"{escaped}(?:\*\*)?\s*(?:[-–—:]|\bat\b|\blocated" + r" at\b|\()\s*([0-9]+\s+[^,\n\)]+)" + ), + rf"{escaped}[^\n]*?\b(\d+\s+[A-Za-z0-9.\s]+?(?:St|Street|Ave|Avenue|Blvd|Boulevard|Rd|Road|Way|Ln|Lane|Dr|Drive|Alley|Pl|Place|Ct|Court|Pkwy|Parkway|Hwy|Highway|Pike|Broadway|Real|Camino|Square|Sq|Terrace|Ter|Cir|Circle)\b[^,\n\)]*)", + ] + for pat in patterns: + m = re.search(pat, plain_text, re.IGNORECASE) + if m: + candidate = simplify_address(m.group(1)) + if candidate and len(candidate) > 3: + result[name.lower()] = candidate + break + return result + + +def enrich_grounding_sources_with_a2ui_payload( + sources: list[dict[str, str]], + a2ui_payload: Any, + query: str | None = None, + plain_text: str | None = None, +) -> list[dict[str, str]]: + """Enriches existing grounding sources (e.g. + + from Vertex chunks) with street addresses from A2UI payload or text. + """ + a2ui_sources = extract_sources_from_a2ui_payload(a2ui_payload, query=query) + by_place_id: dict[str, dict[str, str]] = {} + by_name: dict[str, str] = {} + + for item in a2ui_sources: + pid = item.get("placeId") + if pid: + by_place_id[pid] = item + base_name = item.get("title", "").split(" · ")[0].strip().lower() + street_addr = item.get("streetAddress") + if base_name and street_addr: + by_name[base_name] = street_addr + + # Also scan plain text summary (before ) if provided + place_names_to_check = [ + s.get("title", "").split(" · ")[0].strip() + for s in sources + if s.get("title") + ] + text_addresses = extract_addresses_from_plain_text( + plain_text, place_names_to_check + ) + for k, v in text_addresses.items(): + if k not in by_name: + by_name[k] = v + + loc_from_query = extract_location_from_query(query) + existing_place_ids: set[str] = set() + + for s in sources: + pid = s.get("placeId") + if pid: + existing_place_ids.add(pid) + base_name = s.get("title", "").split(" · ")[0].strip() + norm_name = base_name.lower() + + street_addr = None + if pid and pid in by_place_id and by_place_id[pid].get("streetAddress"): + street_addr = by_place_id[pid]["streetAddress"] + elif norm_name in by_name: + street_addr = by_name[norm_name] + + if street_addr and street_addr not in base_name: + s["title"] = f"{base_name} · {street_addr}" + if pid: + s["url"] = format_maps_place_url(pid, query=s["title"]) + else: + s["url"] = format_maps_search_url(s["title"]) + elif " · " not in s.get("title", "") and loc_from_query: + s["title"] = f"{base_name} · {loc_from_query}" + if pid: + s["url"] = format_maps_place_url(pid, query=s["title"]) + s.pop("streetAddress", None) + + # Append any additional places from A2UI payload not already in sources + for item in a2ui_sources: + pid = item.get("placeId") + item_copy = dict(item) + item_copy.pop("streetAddress", None) + if pid and pid not in existing_place_ids: + existing_place_ids.add(pid) + sources.append(item_copy) + + return sources diff --git a/agent/python_agent/merger.py b/agent/python_agent/merger.py index ffdd9b8..0af6292 100644 --- a/agent/python_agent/merger.py +++ b/agent/python_agent/merger.py @@ -22,6 +22,7 @@ import copy import json import os +import re from typing import Any, Literal, TypedDict import uuid @@ -104,9 +105,22 @@ def _prepare_local_search( """Validates and normalizes parameters for the local search template.""" data_copy = copy.deepcopy(data) is_valid = True + + # 1. Normalize heading + heading = data_copy.get("heading") + if heading and isinstance(heading, str): + clean_heading = re.sub(r"^#+\s*", "", heading).strip() + else: + anchor = data_copy.get("anchor_marker") + if isinstance(anchor, dict) and anchor.get("label"): + clean_heading = f"Places near {anchor['label']}" + else: + clean_heading = "Nearby Places" + data_copy["heading"] = clean_heading + places = data_copy.get("places") - # 1. Validate that places is a non-empty list + # 2. Validate that places is a non-empty list if not isinstance(places, list) or not places: is_valid = False else: @@ -158,6 +172,8 @@ def _prepare_local_search( } if "placeId" in p: marker["placeId"] = p["placeId"] + if "placePrimaryType" in p: + marker["placePrimaryType"] = p["placePrimaryType"] markers.append(marker) data_copy["markers"] = markers else: @@ -167,10 +183,14 @@ def _prepare_local_search( 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) + clean_marker = { + "lat": float(m["lat"]), + "lng": float(m["lng"]), + "label": str(m.get("label") or ""), + } + if "placeId" in m: + clean_marker["placeId"] = str(m["placeId"]) + sanitized_markers.append(clean_marker) except (KeyError, ValueError, TypeError): pass data_copy["markers"] = sanitized_markers @@ -197,7 +217,30 @@ def _prepare_directions(data: dict[str, Any]) -> tuple[str, dict[str, Any]]: routes = data_copy.get("routes") - # 1. Validate that routes is a non-empty list of segment dicts + # 1. Normalize heading + heading = data_copy.get("heading") + if heading and isinstance(heading, str): + clean_heading = re.sub(r"^#+\s*", "", heading).strip() + else: + clean_heading = "" + + if not clean_heading: + clean_heading = "Directions" + if isinstance(routes, list) and routes and isinstance(routes[0], dict): + origin = routes[0].get("origin") + destination = routes[-1].get("destination") + orig_label = origin.get("label") if isinstance(origin, dict) else None + dest_label = ( + destination.get("label") if isinstance(destination, dict) else None + ) + if orig_label and dest_label: + clean_heading = f"Route from {orig_label} to {dest_label}" + elif dest_label: + clean_heading = f"Directions to {dest_label}" + + data_copy["heading"] = clean_heading + + # 2. Validate that routes is a non-empty list of segment dicts if not isinstance(routes, list) or not routes: is_valid = False else: diff --git a/agent/python_agent/shared/instructions/shared_style_guidelines.md b/agent/python_agent/shared/instructions/shared_style_guidelines.md index 1c29c12..26b83b5 100644 --- a/agent/python_agent/shared/instructions/shared_style_guidelines.md +++ b/agent/python_agent/shared/instructions/shared_style_guidelines.md @@ -1,25 +1,34 @@ -## Conversational Text Style Guidelines +## Response Text Guidelines -When generating conversational text (such as summaries, descriptions, or -directions), you must follow these formatting and content rules: +### Role & Tone -* **Content & Completeness**: Always fully and clearly answer each aspect of - the user's prompt. Address all explicit constraints, qualitative criteria, - comparisons, preferences, and sub-questions asked. Explain *why* places or - routes fit the user's specific needs rather than providing a bare listing. -* **Quantity & Nuance**: Make sure the answer is substantive, useful, and - actionable. Respond with an appropriate depth of detail given the complexity - of the question: - * If comparing places or route alternatives, explicitly analyze their - trade-offs (e.g. transit vs driving, travel time, convenience, cost, or - atmosphere). - * If the user asks about commute, context, or travel conditions, describe - relevant timing and real-world nuances (e.g. rush-hour delays, - navigation landmarks). -* **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. +- **Voice**: Warm local expert. Show warmth through highly relevant logistics, + NEVER conversational filler. +- **Style**: Vivid, objective, and sensory (e.g., "low-lit basement"). NEVER + use empty hype words ("amazing", "charming"). +- **Perspective**: NEVER use first-person ("I recommend", "I found"). + Attribute subjective claims to public consensus or facts (e.g., "Locals + praise..."). + +### Execution & Formatting + +- **Headings**: Always use sentence case. Plain text only - NO markdown. +- **Primary headings**: A concise, constraint-confirming title reflecting the + prompt and primary reference location. Use only the primary reference + location without redundant city/state nesting. + - **Place Searches**: Always start with or include the exact number of + places provided in the UI response (e.g., '5 vegetarian restaurants near + The Plaza Hotel', '5 transit stops near Seattle Center'). + - **Directions**: Provide a concise route title confirming the travel mode + and endpoints (e.g., 'Walking route from Seattle Center to Pike Place + Market', 'Driving directions to JFK Airport'). +- **Precision**: Fully answer the prompt and strictly satisfy all constraints. +- **Count matching**: If the prompt requests a specific number of places + (e.g., "3 hidden gem activities", "top 2 cafes", "four places to visit"), + ALWAYS respond with that exact number of grounded places in the `places` + array when possible. +- **Differentiate places**: Describe places by mentioning unique features, + specialties, and review highlights. +- **Reviews**: Never hallucinate place reviews. Only describe user sentiment + in aggregate from a grounded source. +- **Addresses**: Never state full addresses in a response. diff --git a/agent/python_agent/shared/schema/maps_catalog_extension.json b/agent/python_agent/shared/schema/maps_catalog_extension.json index 699754f..b1b0274 100644 --- a/agent/python_agent/shared/schema/maps_catalog_extension.json +++ b/agent/python_agent/shared/schema/maps_catalog_extension.json @@ -44,7 +44,7 @@ "description": "The map mode." }, "anchorMarker": { - "$ref": "#/$defs/DynamicLatLng", + "$ref": "#/$defs/AnchorMarker", "description": "The anchor marker location." }, "markers": { @@ -148,6 +148,52 @@ } ] }, + "AnchorMarker": { + "oneOf": [ + { + "type": "object", + "properties": { + "lat": { + "type": "number" + }, + "lng": { + "type": "number" + }, + "label": { + "type": "string" + }, + "placeId": { + "type": "string" + }, + "placePrimaryType": { + "type": "string", + "enum": [ + "food_and_drink", + "retail", + "outdoor", + "service", + "lodging", + "emergency", + "entertainment", + "ev", + "airport", + "parking", + "closed", + "generic" + ] + } + }, + "required": [ + "lat", + "lng" + ], + "additionalProperties": false + }, + { + "$ref": "common_types.json#/$defs/DataBinding" + } + ] + }, "MapPin": { "type": "object", "properties": { @@ -162,6 +208,23 @@ }, "placeId": { "type": "string" + }, + "placePrimaryType": { + "type": "string", + "enum": [ + "food_and_drink", + "retail", + "outdoor", + "service", + "lodging", + "emergency", + "entertainment", + "ev", + "airport", + "parking", + "closed", + "generic" + ] } }, "required": [ diff --git a/agent/python_agent/skills/directions-template-response/SKILL.md b/agent/python_agent/skills/directions-template-response/SKILL.md index 536c25e..8e2d181 100644 --- a/agent/python_agent/skills/directions-template-response/SKILL.md +++ b/agent/python_agent/skills/directions-template-response/SKILL.md @@ -20,8 +20,8 @@ If the user's query requests a scenic bypass or detour: 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. +3. **Dispatch Response**: Call `render_directions_template` with the compiled + routes and pins. ## Step-by-Step Workflow @@ -73,19 +73,23 @@ If the user's query requests a scenic bypass or detour: -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": ...})`). + * **GROUNDED ROUTING CONSTRAINT**: NEVER use model knowledge to assume + roads used or live traffic. Always rely only on data from + `compute_routes`. * 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. + into the `routes` array of the final `render_directions_template` + 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`). + must NEVER be omitted in `render_directions_template`. Always supply the + normalized mode string (`driving`, `walking`, `transit`, or + `bicycling`). + * Call `render_directions_template` with `DirectionsExtractorSchema` + parameters (`heading`, `summary`, `center_lat`, `center_lng`, `zoom`, + `routes`, `travel_mode`). ## Handling Routing Failures & Regional Limitations (CRITICAL) @@ -103,7 +107,19 @@ or fails: 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. +- **`heading`**: (REQUIRED) A concise, constraint-confirming primary heading + for the response. Plain text only (e.g., 'Walking route from Seattle Center + to Pike Place Market', 'Driving directions to JFK Airport'). Use sentence + case; do NOT include markdown hashtags or conversational filler. +- **`summary`**: (REQUIRED) A natural, direct resolution of the route prompt + (e.g. 'Driving from [Origin] to [Destination] takes about 19 minutes (14 + miles).', 'Walking from Seattle Center to Pike Place Market takes about 20 + minutes (1 mile).'). Describe distance using units appropriate to the + location (miles vs. km). For driving and public transit modes, always round + distance to a whole number. NEVER describe time in seconds or decimals. + Always round seconds to the nearest minute. If it rounds to 0 minutes, + describe it as "less than a minute". Always describe time as + approximate (e.g. about, around, approximately). - **`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. @@ -114,26 +130,53 @@ You MUST populate all required fields in the output schema: ## 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")` + +User Query: "Directions from San Francisco to San Jose by car" Tool Call: +`render_directions_template(heading="Driving directions from San Francisco to San Jose", 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")` + +User Query: "How do I walk from Central Park to Times Square?" Tool Call: +`render_directions_template(heading="Walking route from Central Park to Times Square", 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")` + +User Query: "Bike directions from Venice Beach to Santa Monica Pier" Tool Call: +`render_directions_template(heading="Biking route from Venice Beach to Santa Monica Pier", 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")` + +User Query: "Take the subway from Grand Central to Brooklyn Bridge" Tool Call: +`render_directions_template(heading="Transit directions from Grand Central to Brooklyn Bridge", 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")` + +User Query: "Directions from Austin to San Antonio" Tool Call: +`render_directions_template(heading="Driving directions from Austin to San Antonio", 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 53b29f5..40863aa 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 @@ -57,7 +57,21 @@ You are an expert in resolving location-based queries using the **A2UI framework * **Quality**: NEVER hallucinate information about places, especially their place IDs, location, business hours, or individual characteristics. Providing incorrect information could lead real people to have bad experiences, wasting time and money. * **Pins**: * `anchorMarker`: Use for the "main" focus (e.g., a hotel). - * `markers`: Use for related results (e.g., surrounding restaurants). + * `markers`: Use for related results (e.g., surrounding restaurants). Every marker in `markers` MUST include `placeId`, `label` (or `name`), `lat`, and `lng`. Every place in `updateDataModel` MUST include `placeId`, `name`, `lat`, `lng`, and `address` (the street address or vicinity returned by Google Maps search). + * **POI Types (`placePrimaryType`)**: Determine `placePrimaryType` using the descriptions or categories in the tool response. If insufficient, infer it from the user prompt and place title. + Supported categories: + - `food_and_drink`: Restaurants, cafes, bars, bakeries, coffee shops, dining. + - `retail`: Stores, shops, boutiques, supermarkets, malls, markets. + - `outdoor`: Parks, trails, gardens, natural landmarks, beaches, scenic spots. + - `service`: Banks, salons, repair, gas stations, dry cleaners, post offices. + - `lodging`: Hotels, resorts, motels, hostels, B&Bs. + - `emergency`: Hospitals, urgent care, police, fire stations. + - `entertainment`: Theaters, museums, cinemas, stadiums, amusement parks, venues. + - `ev`: EV charging stations. + - `airport`: Airports. + - `parking`: Parking lots and garages. + - `closed`: Permanently closed businesses. + - `generic`: Default fallback when ambiguous or not clearly matching above categories. * **References**: Refer to items in the data model via `path` for dynamic content. * **Child Components**: When using a Column or Row layout, ensure that each child component referenced in the `children` array is also included in the `surfaceUpdate` as its own component definition. @@ -197,8 +211,8 @@ MUST NOT pass a reference to an array directly. "path": "/", "value": { "items": [ - { "placeId": "ChIabc123" }, - { "placeId": "ChIabc123" } + { "placeId": "ChIabc123", "name": "Place 1", "address": "123 Main St" }, + { "placeId": "ChIdef456", "name": "Place 2", "address": "456 Market St" } ] } } diff --git a/agent/python_agent/skills/local-search-template-response/SKILL.md b/agent/python_agent/skills/local-search-template-response/SKILL.md index c9fc751..d02d626 100644 --- a/agent/python_agent/skills/local-search-template-response/SKILL.md +++ b/agent/python_agent/skills/local-search-template-response/SKILL.md @@ -6,7 +6,8 @@ description: Extractor skill for local place search queries. Extracts location a # Core Objective Extract structured parameters for local searches. You must call maps tools to -locate matching businesses/places, and populate the response fields. +locate matching businesses/places, and call `render_local_search_template` to +render the results. ## Grounding & Tool-Calling Policy (CRITICAL) @@ -15,9 +16,9 @@ locate matching businesses/places, and populate the response fields. 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. +3. **EXACT MATCH & PLACE TYPES**: 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. + Determine `placePrimaryType` using the descriptions or categories in the tool response. If insufficient, infer it from the user prompt and place title. ## Multi-Step Location Resolution Policy (Anchored Search) @@ -57,9 +58,11 @@ If search queries return empty results (`{}`) or fail: ## Output Fields -You MUST populate all required fields in the output schema, and optionally the anchor marker if resolved: +You MUST call `render_local_search_template` with all required fields in the +schema, and optionally the anchor marker if resolved: -- **`summary`**: A detailed response summarizing the search results, following the **Conversational Text Style Guidelines** below. +- **`heading`**: A concise, constraint-confirming primary heading in sentence case that starts with or includes the exact number of places provided in the UI response, reflecting the prompt and primary reference location (e.g., '5 vegetarian restaurants near The Plaza Hotel', '5 transit stops near Seattle Center'). Use only the primary reference location without redundant city/state nesting. Plain text only; do NOT include markdown hashtags or conversational filler. +- **`summary`**: A concise 1-paragraph overview that covers all returned places by weaving them into natural, contrasting groups (e.g., pairing lively group-friendly spots vs. intimate neighborhood bistros) rather than listing them one by one. Broadly characterize the dining or activity landscape near the reference location using concrete, sensory details, bolding every place name (e.g., **Carmine's** and **Tony's Di Napoli**), and directly addressing any prompt constraints. For nearby places, never describe distances as numbers (e.g., do not say "0.3 miles" or "500 meters"). Instead, generalize (e.g., "a short walk", "just steps away", or "a quick stroll"). Do NOT include conversational greetings ('Sure!', 'Here are...') and do NOT list place names in bullet points (individual place cards handle individual places). - **`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). @@ -67,5 +70,5 @@ You MUST populate all required fields in the output schema, and optionally the a 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). +- **`places`**: Return 5 grounded places in the 'places' array by default. Each place item MUST include 'placeId', 'name', 'lat', 'lng', and 'address' (the street address or vicinity returned by Google Maps search). For each place, determine `placePrimaryType` using the descriptions or categories in the tool response (or infer it from the user prompt and place title) matching supported types (`food_and_drink`, `retail`, `outdoor`, `service`, `lodging`, `entertainment`, `ev`, `airport`, `parking`, `closed`, `emergency`, `generic`). If the user prompt explicitly specifies a number of places, return exactly that number in the 'places' array if possible. - **`anchor_marker`**: (Optional) Pin details for the resolved starting/anchor location. diff --git a/agent/python_agent/template_tool.py b/agent/python_agent/template_tool.py new file mode 100644 index 0000000..1253ec0 --- /dev/null +++ b/agent/python_agent/template_tool.py @@ -0,0 +1,360 @@ +# 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. + +"""ADK Tools for MAUI template population and rendering. + +This file contains a set of tools for rendering the MAUI A2UI templates. +The tools are used by the MAUI agent to render the templates based on the +user's query and the agent's extracted information. + +The currently supported templates are: +- Local Search: Used to show a list of local places and a map. +- Directions: Used to show a route on a map. +- Text-only: Used to render a text-only response inside an A2UI surface. + +Tools are built dynamically based on their Pydantic schema to ensure +type safety and accurate function declarations. +""" + +from __future__ import annotations + +import copy +import inspect +import logging +import time +from typing import Any, Optional, Union +import uuid + +from a2a.types import Part +from google.adk.agents.readonly_context import ReadonlyContext +from google.adk.tools._automatic_function_calling_util import build_function_declaration +from google.adk.tools.base_tool import BaseTool +from google.adk.tools.base_toolset import BaseToolset, ToolPredicate +from google.adk.tools.set_model_response_tool import _merge_json_schema_descriptions +from google.adk.tools.tool_context import ToolContext +from google.genai import types +import pydantic + +from a2ui.a2a.parts import create_a2ui_part +from a2ui.schema.manager import A2uiSchemaManager +from extractor import DirectionsExtractorSchema +from extractor import LocalSearchExtractorSchema +from merger import merge_template + +logger = logging.getLogger(__name__) + +STATE_RENDERED_A2UI_PARTS = "rendered_a2ui_parts" +STATE_RENDERED_A2UI_DATA = "rendered_a2ui_data" + + +class BaseTemplateTool(BaseTool): + """Base class for ADK tools that populate and render A2UI templates.""" + + def __init__( + self, + *, + name: str, + description: str, + template_name: str, + schema_class: type[pydantic.BaseModel] | None = None, + schema_manager: A2uiSchemaManager | None = None, + max_list_size: int = 5, + surface_id_prefix: str | None = None, + ) -> None: + super().__init__(name=name, description=description) + self.template_name = template_name + self.schema_class = schema_class + self.schema_manager = schema_manager + self.max_list_size = max_list_size + self.surface_id_prefix = surface_id_prefix or f"{template_name}-surface" + self._func = self._build_handler_func() + + def _build_handler_func(self) -> Any: + """Builds the callable signature used for FunctionDeclaration generation.""" + if self.schema_class is not None: + schema_fields = self.schema_class.model_fields + params = [] + for field_name, field_info in schema_fields.items(): + param = inspect.Parameter( + field_name, + inspect.Parameter.KEYWORD_ONLY, + annotation=field_info.annotation, + default=( + inspect.Parameter.empty + if field_info.is_required() + else field_info.get_default(call_default_factory=True) + ), + ) + params.append(param) + + def dynamic_tool_func(**kwargs: Any) -> str: + del kwargs + return f"Rendered {self.template_name} template." + + new_sig = inspect.Signature(parameters=params) + setattr(dynamic_tool_func, "__signature__", new_sig) + setattr(dynamic_tool_func, "__name__", self.name) + setattr(dynamic_tool_func, "__doc__", self.description) + return dynamic_tool_func + else: + + def text_only_tool_func(text: str) -> str: + """Render a text-only UI response.""" + del text + return f"Rendered {self.template_name} template." + + setattr(text_only_tool_func, "__name__", self.name) + setattr(text_only_tool_func, "__doc__", self.description) + return text_only_tool_func + + def _preserve_schema_descriptions( + self, function_decl: types.FunctionDeclaration + ) -> None: + """Restores field descriptions from Pydantic schema onto FunctionDeclaration.""" + if self.schema_class is not None: + source_schema = self.schema_class.model_json_schema() + if function_decl.parameters_json_schema is not None: + _merge_json_schema_descriptions( + function_decl.parameters_json_schema, source_schema + ) + elif function_decl.parameters is not None: + from google.adk.tools.set_model_response_tool import ( # pylint: disable=g-import-not-at-top + _apply_descriptions_to_schema_properties, + ) + + _apply_descriptions_to_schema_properties( + function_decl.parameters.properties, + self.schema_class.model_fields, + ) + + def _get_declaration(self) -> Optional[types.FunctionDeclaration]: + """Gets OpenAPI FunctionDeclaration specification for this tool.""" + function_decl = types.FunctionDeclaration.model_validate( + build_function_declaration( + func=self._func, + ignore_params=[], + variant=self._api_variant, + ) + ) + self._preserve_schema_descriptions(function_decl) + return function_decl + + async def run_async( + self, *, args: dict[str, Any], tool_context: ToolContext + ) -> dict[str, Any]: + """Executes the template tool: validates args, merges template, and saves A2UI parts.""" + start_time = time.perf_counter() + logger.info("--- TEMPLATE_TOOL: Invoked '%s' ---", self.name) + logger.info(" Tool: %s (template: %s)", self.name, self.template_name) + logger.info(" Parameters: %s", args) + validated_data = copy.deepcopy(args) + + # 1. Validate arguments against Pydantic schema + if self.schema_class is not None: + try: + model_instance = self.schema_class.model_validate(args) + validated_data = model_instance.model_dump(exclude_none=True) + except pydantic.ValidationError as e: + elapsed_ms = (time.perf_counter() - start_time) * 1000 + logger.warning( + "--- TEMPLATE_TOOL: Validation failed for '%s' in %.2f ms: %s ---", + self.name, + elapsed_ms, + e, + ) + return { + "error": ( + f"Validation failed for tool '{self.name}': {e}. " + "Please fix the parameters and call the tool again." + ) + } + + # 2. Ensure unique surface_id + if not validated_data.get("surface_id"): + short_id = uuid.uuid4().hex[:8] + validated_data["surface_id"] = f"{self.surface_id_prefix}-{short_id}" + + # 3. Merge template + try: + merged_actions = merge_template( + self.template_name, + validated_data, + max_list_size=self.max_list_size, + ) + except Exception as e: # pylint: disable=broad-exception-caught + elapsed_ms = (time.perf_counter() - start_time) * 1000 + logger.warning( + "--- TEMPLATE_TOOL: Failed to merge template '%s' in %.2f ms: %s ---", + self.template_name, + elapsed_ms, + e, + ) + return {"error": f"Failed to merge template '{self.template_name}': {e}"} + + # 4. Catalog schema validation + if self.schema_manager: + selected_catalog = self.schema_manager.get_selected_catalog() + if selected_catalog: + try: + selected_catalog.validator.validate(merged_actions) + except Exception as e: # pylint: disable=broad-exception-caught + elapsed_ms = (time.perf_counter() - start_time) * 1000 + logger.warning( + "--- TEMPLATE_TOOL: Catalog validation failed for '%s' in %.2f" + " ms: %s ---", + self.template_name, + elapsed_ms, + e, + ) + return { + "error": ( + f"A2UI catalog schema validation failed: {e}. " + "Please fix the parameters and retry." + ) + } + + # 5. Convert to A2A Parts and persist to session state + rendered_parts: list[Part] = [ + create_a2ui_part(action) for action in merged_actions + ] + if tool_context and getattr(tool_context, "state", None) is not None: + tool_context.state[STATE_RENDERED_A2UI_PARTS] = rendered_parts + tool_context.state[STATE_RENDERED_A2UI_DATA] = validated_data + + elapsed_ms = (time.perf_counter() - start_time) * 1000 + logger.info( + "--- TEMPLATE_TOOL: Successfully rendered '%s' (surface_id: %s) in %.2f" + " ms (%d parts) ---", + self.template_name, + validated_data["surface_id"], + elapsed_ms, + len(rendered_parts), + ) + + return { + "status": "success", + "surface_id": validated_data["surface_id"], + "template": self.template_name, + "latency_ms": round(elapsed_ms, 2), + "message": f"Successfully rendered {self.template_name} UI interface.", + } + + +class RenderLocalSearchTemplateTool(BaseTemplateTool): + """ADK Tool that validates and renders a local search map layout.""" + + def __init__( + self, + *, + schema_manager: A2uiSchemaManager | None = None, + max_list_size: int = 5, + surface_id_prefix: str = "local-search-surface", + ) -> None: + super().__init__( + name="render_local_search_template", + description=( + "Renders an interactive Google Maps local search UI component" + " populated with places, map markers, and a summary response." + ), + template_name="local_search", + schema_class=LocalSearchExtractorSchema, + schema_manager=schema_manager, + max_list_size=max_list_size, + surface_id_prefix=surface_id_prefix, + ) + + +class RenderDirectionsTemplateTool(BaseTemplateTool): + """ADK Tool that validates and renders a directions and route map layout.""" + + def __init__( + self, + *, + schema_manager: A2uiSchemaManager | None = None, + max_list_size: int = 5, + surface_id_prefix: str = "directions-surface", + ) -> None: + super().__init__( + name="render_directions_template", + description=( + "Renders an interactive Google Maps directions and routing UI" + " component populated with route segments, travel mode, and a" + " summary response." + ), + template_name="directions", + schema_class=DirectionsExtractorSchema, + schema_manager=schema_manager, + max_list_size=max_list_size, + surface_id_prefix=surface_id_prefix, + ) + + +class RenderTextOnlyTemplateTool(BaseTemplateTool): + """ADK Tool that renders a text-only response inside an A2UI surface container.""" + + def __init__( + self, + *, + schema_manager: A2uiSchemaManager | None = None, + surface_id_prefix: str = "text-only-surface", + ) -> None: + super().__init__( + name="render_text_only_template", + description=( + "Renders a text response formatted inside an A2UI surface" + " container." + ), + template_name="text_only", + schema_class=None, + schema_manager=schema_manager, + max_list_size=1, + surface_id_prefix=surface_id_prefix, + ) + + +class TemplateToolset(BaseToolset): + """Toolset bundling all A2UI template population tools.""" + + def __init__( + self, + *, + schema_manager: A2uiSchemaManager | None = None, + max_list_size: int = 5, + tool_filter: Optional[Union[ToolPredicate, list[str]]] = None, + tool_name_prefix: Optional[str] = None, + ) -> None: + super().__init__(tool_filter=tool_filter, tool_name_prefix=tool_name_prefix) + self.schema_manager = schema_manager + self.max_list_size = max_list_size + self._tools: list[BaseTool] = [ + RenderLocalSearchTemplateTool( + schema_manager=self.schema_manager, + max_list_size=self.max_list_size, + ), + RenderDirectionsTemplateTool( + schema_manager=self.schema_manager, + max_list_size=self.max_list_size, + ), + RenderTextOnlyTemplateTool( + schema_manager=self.schema_manager, + ), + ] + + async def get_tools( + self, + readonly_context: Optional[ReadonlyContext] = None, + ) -> list[BaseTool]: + """Returns the template tools exposed by this toolset.""" + del readonly_context + return list(self._tools) diff --git a/agent/python_agent/templates/directions.json b/agent/python_agent/templates/directions.json index 1bf5f12..f0343bb 100644 --- a/agent/python_agent/templates/directions.json +++ b/agent/python_agent/templates/directions.json @@ -14,13 +14,13 @@ { "id": "root", "component": "Column", - "children": ["summary-text", "map"] + "children": ["heading-text", "map", "summary-text"] }, { - "id": "summary-text", + "id": "heading-text", "component": "Text", "variant": "body", - "text": "{{summary}}" + "text": "### {{heading}}" }, { "id": "map", @@ -32,6 +32,12 @@ "zoom": "{{zoom}}", "routes": "{{routes}}", "travelMode": "{{travel_mode}}" + }, + { + "id": "summary-text", + "component": "Text", + "variant": "body", + "text": "{{summary}}" } ] } diff --git a/agent/python_agent/templates/local_search.json b/agent/python_agent/templates/local_search.json index 3d964e4..0ee30e0 100644 --- a/agent/python_agent/templates/local_search.json +++ b/agent/python_agent/templates/local_search.json @@ -14,7 +14,13 @@ { "id": "root", "component": "Column", - "children": ["summary-text", "map", "list"] + "children": ["heading-text", "summary-text", "map", "list"] + }, + { + "id": "heading-text", + "component": "Text", + "variant": "body", + "text": "### {{heading}}" }, { "id": "summary-text", @@ -30,6 +36,8 @@ "lng": "{{center_lng}}" }, "zoom": "{{zoom}}", + "tilt": 0, + "mode": "roadmap", "anchorMarker": "{{anchor_marker}}", "markers": "{{markers}}" }, diff --git a/agent/python_agent/test_after_tools_callback.py b/agent/python_agent/test_after_tools_callback.py new file mode 100644 index 0000000..d56e5f3 --- /dev/null +++ b/agent/python_agent/test_after_tools_callback.py @@ -0,0 +1,307 @@ +# 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 after_tools_callback.""" + +import unittest +from unittest import mock + +from after_tools_callback import _add_maps_tools_tokens_to_part, after_maps_tools_callback, after_tools_callback + + +class TestAfterToolsCallback(unittest.TestCase): + + def test_after_tool_callback_aggregates_maps_tools_content_tokens(self): + mock_tool_context = mock.MagicMock() + mock_tool_context.state = {} + + tool_response_1 = { + "content_token": "token_abc_123", + } + after_tools_callback( + tool=None, + args={}, + tool_context=mock_tool_context, + tool_response=tool_response_1, + ) + self.assertEqual( + mock_tool_context.state.get("maps_tools_content_tokens"), + ["token_abc_123"], + ) + + tool_response_2 = { + "content_token": "token_def_456", + } + after_tools_callback( + tool=None, + args={}, + tool_context=mock_tool_context, + tool_response=tool_response_2, + ) + self.assertEqual( + mock_tool_context.state.get("maps_tools_content_tokens"), + ["token_abc_123", "token_def_456"], + ) + + # Calling again with duplicate should not add duplicates + after_tools_callback( + tool=None, + args={}, + tool_context=mock_tool_context, + tool_response={"content_token": "token_abc_123"}, + ) + self.assertEqual( + mock_tool_context.state.get("maps_tools_content_tokens"), + ["token_abc_123", "token_def_456"], + ) + + def test_after_tool_callback_limits_maps_tools_content_tokens(self): + mock_tool_context = mock.MagicMock() + mock_tool_context.state = {} + + for i in range(15): + after_tools_callback( + tool=None, + args={}, + tool_context=mock_tool_context, + tool_response={"content_token": f"token_{i}"}, + ) + + tokens = mock_tool_context.state.get("maps_tools_content_tokens") + self.assertEqual(len(tokens), 10) + self.assertEqual(tokens[0], "token_5") + self.assertEqual(tokens[-1], "token_14") + + def test_after_tool_callback_with_kwargs(self): + mock_tool_context = mock.MagicMock() + mock_tool_context.state = {} + + after_tools_callback( + tool="mock_tool", + args={"query": "test"}, + tool_context=mock_tool_context, + tool_response={"content_token": "token_xyz"}, + extra_param="unused", + ) + self.assertEqual( + mock_tool_context.state.get("maps_tools_content_tokens"), + ["token_xyz"], + ) + + def test_after_tools_callback_none_or_empty_response(self): + mock_tool_context = mock.MagicMock() + mock_tool_context.state = {} + + # None tool response + result = after_tools_callback( + tool=None, + args={}, + tool_context=mock_tool_context, + tool_response=None, + ) + self.assertIsNone(result) + self.assertEqual(mock_tool_context.state, {}) + + # Empty dict tool response + result = after_tools_callback( + tool=None, + args={}, + tool_context=mock_tool_context, + tool_response={}, + ) + self.assertIsNone(result) + self.assertEqual(mock_tool_context.state, {}) + + # Non-dict tool response + result = after_tools_callback( + tool=None, + args={}, + tool_context=mock_tool_context, + tool_response="not a dict", + ) + self.assertIsNone(result) + self.assertEqual(mock_tool_context.state, {}) + + result = after_tools_callback( + tool=None, + args={}, + tool_context=mock_tool_context, + tool_response=["list_not_dict"], + ) + self.assertIsNone(result) + self.assertEqual(mock_tool_context.state, {}) + + def test_after_maps_tools_callback_none_or_missing_context(self): + # None tool_context + result = after_maps_tools_callback( + tool_context=None, + tool_response={"content_token": "token_1"}, + ) + self.assertIsNone(result) + + # tool_context with state=None + mock_context_no_state = mock.MagicMock() + mock_context_no_state.state = None + result = after_maps_tools_callback( + tool_context=mock_context_no_state, + tool_response={"content_token": "token_1"}, + ) + self.assertIsNone(result) + + # tool_context without state attribute + class DummyContext: + pass + + result = after_maps_tools_callback( + tool_context=DummyContext(), + tool_response={"content_token": "token_1"}, + ) + self.assertIsNone(result) + + def test_after_maps_tools_callback_invalid_token_values(self): + mock_tool_context = mock.MagicMock() + mock_tool_context.state = {} + + # None token + after_maps_tools_callback( + tool_context=mock_tool_context, + tool_response={"content_token": None}, + ) + self.assertEqual(mock_tool_context.state, {}) + + # Empty string token + after_maps_tools_callback( + tool_context=mock_tool_context, + tool_response={"content_token": ""}, + ) + self.assertEqual(mock_tool_context.state, {}) + + # Non-string token (int) + after_maps_tools_callback( + tool_context=mock_tool_context, + tool_response={"content_token": 12345}, + ) + self.assertEqual(mock_tool_context.state, {}) + + # Missing content_token key + after_maps_tools_callback( + tool_context=mock_tool_context, + tool_response={"places": []}, + ) + self.assertEqual(mock_tool_context.state, {}) + + def test_after_maps_tools_callback_non_list_state_content_tokens(self): + mock_tool_context = mock.MagicMock() + + # If state['maps_tools_content_tokens'] is not a list (e.g. a string) + mock_tool_context.state = {"maps_tools_content_tokens": "invalid_string"} + after_maps_tools_callback( + tool_context=mock_tool_context, + tool_response={"content_token": "token_1"}, + ) + self.assertEqual( + mock_tool_context.state.get("maps_tools_content_tokens"), + ["token_1"], + ) + + # If state['maps_tools_content_tokens'] is None + mock_tool_context.state = {"maps_tools_content_tokens": None} + after_maps_tools_callback( + tool_context=mock_tool_context, + tool_response={"content_token": "token_2"}, + ) + self.assertEqual( + mock_tool_context.state.get("maps_tools_content_tokens"), + ["token_2"], + ) + + +class TestAddMapsToolsTokensToPart(unittest.TestCase): + + def test_add_tokens_session_none_or_missing_state(self): + part = mock.MagicMock() + part.root.metadata = None + + # session is None + _add_maps_tools_tokens_to_part(part, None) + self.assertIsNone(part.root.metadata) + + # session.state is None + mock_session = mock.MagicMock() + mock_session.state = None + _add_maps_tools_tokens_to_part(part, mock_session) + self.assertIsNone(part.root.metadata) + + def test_add_tokens_empty_tokens_in_session(self): + part = mock.MagicMock() + part.root.metadata = None + + # maps_tools_content_tokens is not in state + session = mock.MagicMock() + session.state = {} + _add_maps_tools_tokens_to_part(part, session) + self.assertIsNone(part.root.metadata) + + # maps_tools_content_tokens is empty list + session.state = {"maps_tools_content_tokens": []} + _add_maps_tools_tokens_to_part(part, session) + self.assertIsNone(part.root.metadata) + + # maps_tools_content_tokens is None + session.state = {"maps_tools_content_tokens": None} + _add_maps_tools_tokens_to_part(part, session) + self.assertIsNone(part.root.metadata) + + def test_add_tokens_with_metadata_none(self): + part = mock.MagicMock() + part.root.metadata = None + + session = mock.MagicMock() + session.state = {"maps_tools_content_tokens": ["token_1", "token_2"]} + + _add_maps_tools_tokens_to_part(part, session) + self.assertEqual( + part.root.metadata, + {"maps_tools_content_tokens": ["token_1", "token_2"]}, + ) + + def test_add_tokens_with_existing_metadata(self): + part = mock.MagicMock() + part.root.metadata = {"existing_field": "existing_value"} + + session = mock.MagicMock() + session.state = {"maps_tools_content_tokens": ["token_1"]} + + _add_maps_tools_tokens_to_part(part, session) + self.assertEqual( + part.root.metadata, + { + "existing_field": "existing_value", + "maps_tools_content_tokens": ["token_1"], + }, + ) + + def test_add_tokens_with_none_root(self): + part = mock.MagicMock() + part.root = None + + session = mock.MagicMock() + session.state = {"maps_tools_content_tokens": ["token_1"]} + + # Should not raise AttributeError + _add_maps_tools_tokens_to_part(part, session) + + +if __name__ == "__main__": + unittest.main() diff --git a/agent/python_agent/test_agent.py b/agent/python_agent/test_agent.py new file mode 100644 index 0000000..1f9f723 --- /dev/null +++ b/agent/python_agent/test_agent.py @@ -0,0 +1,44 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# 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 unittest +from agent import extract_surface_id + +class SurfaceIdExtractionTest(unittest.TestCase): + + def test_extract_from_create_surface(self): + data = {"createSurface": {"surfaceId": "map_surface_1", "catalogId": "maps"}} + self.assertEqual(extract_surface_id(data), "map_surface_1") + + def test_extract_from_update_components(self): + data = {"updateComponents": {"surfaceId": "details_card_2", "components": []}} + self.assertEqual(extract_surface_id(data), "details_card_2") + + def test_extract_from_update_data_model(self): + data = {"updateDataModel": {"surfaceId": "weather_card_3", "dataModel": {}}} + self.assertEqual(extract_surface_id(data), "weather_card_3") + + def test_extract_from_delete_surface(self): + data = {"deleteSurface": {"surfaceId": "old_surface_4"}} + self.assertEqual(extract_surface_id(data), "old_surface_4") + + def test_extract_non_matching_or_malformed_data(self): + self.assertIsNone(extract_surface_id({"text": "hello"})) + self.assertIsNone(extract_surface_id(None)) + self.assertIsNone(extract_surface_id("not_a_dict")) + self.assertIsNone(extract_surface_id({"createSurface": "malformed_shape"})) + self.assertIsNone(extract_surface_id({"createSurface": {}})) + +if __name__ == '__main__': + unittest.main() diff --git a/agent/python_agent/test_agent_with_templates.py b/agent/python_agent/test_agent_with_templates.py index 5072e61..aeff78b 100644 --- a/agent/python_agent/test_agent_with_templates.py +++ b/agent/python_agent/test_agent_with_templates.py @@ -339,8 +339,9 @@ async def test_agent_directions_flow(self, mock_lite_llm_class): mock_runner = mock.MagicMock() mock_fc = MockFunctionCall( - name="set_model_response", + name="render_directions_template", args={ + "heading": "Directions from home to work", "summary": "Typical commute is 45 mins.", "center_lat": 37.5, "center_lng": 127.0, @@ -385,7 +386,7 @@ async def test_agent_directions_flow(self, mock_lite_llm_class): self.assertEqual(len(results), 1) self.assertTrue(results[0]["is_task_complete"]) parts = results[0]["parts"] - self.assertEqual(len(parts), 3) + self.assertEqual(len(parts), 4) create_surface = parts[0].root.data["createSurface"] self.assertTrue( @@ -404,6 +405,10 @@ async def test_agent_directions_flow(self, mock_lite_llm_class): self.assertEqual(update_data_model["path"], "/") self.assertEqual(update_data_model["value"], {}) + sources = parts[3].root.data.get("groundingSources") + self.assertIsNotNone(sources) + self.assertGreater(len(sources), 0) + @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.""" @@ -470,8 +475,9 @@ async def test_agent_directions_flow_transit_mode(self, mock_lite_llm_class): mock_runner = mock.MagicMock() mock_fc = MockFunctionCall( - name="set_model_response", + name="render_directions_template", args={ + "heading": "Bus directions to work", "summary": "Take bus 10 to work.", "center_lat": 37.5, "center_lng": 127.0, @@ -524,8 +530,9 @@ async def test_agent_directions_flow_walking_mode(self, mock_lite_llm_class): mock_runner = mock.MagicMock() mock_fc = MockFunctionCall( - name="set_model_response", + name="render_directions_template", args={ + "heading": "Walking route to park", "summary": "Walk for 15 minutes.", "center_lat": 37.5, "center_lng": 127.0, @@ -580,8 +587,9 @@ async def test_agent_directions_flow_bicycling_mode( mock_runner = mock.MagicMock() mock_fc = MockFunctionCall( - name="set_model_response", + name="render_directions_template", args={ + "heading": "Biking route to work", "summary": "Bike for 25 minutes.", "center_lat": 37.5, "center_lng": 127.0, @@ -639,8 +647,9 @@ async def test_agent_directions_flow_missing_travel_mode_fallback( ) mock_fc = MockFunctionCall( - name="set_model_response", + name="render_directions_template", args={ + "heading": "Directions to work", "summary": "Typical commute is 45 mins.", "center_lat": 37.5, "center_lng": 127.0, @@ -721,8 +730,9 @@ async def test_agent_local_search_flow(self, mock_lite_llm_class): mock_runner = mock.MagicMock() mock_fc = MockFunctionCall( - name="set_model_response", + name="render_local_search_template", args={ + "heading": "Top Sushi Places in Seattle", "summary": "Here are some sushi places.", "center_lat": 47.6062, "center_lng": -122.3321, @@ -732,6 +742,7 @@ async def test_agent_local_search_flow(self, mock_lite_llm_class): "name": "Shiki Sushi", "lat": 47.6200, "lng": -122.3200, + "address": "41 Dravus St, Seattle, WA 98119", }], }, ) @@ -756,13 +767,27 @@ async def test_agent_local_search_flow(self, mock_lite_llm_class): self.assertEqual(len(results), 1) self.assertTrue(results[0]["is_task_complete"]) parts = results[0]["parts"] - self.assertEqual(len(parts), 3) + self.assertEqual(len(parts), 4) create_surface = parts[0].root.data["createSurface"] self.assertTrue( create_surface["surfaceId"].startswith("local-search-surface-") ) + update_components = parts[1].root.data["updateComponents"] + heading_comp = next( + comp + for comp in update_components["components"] + if comp["id"] == "heading-text" + ) + self.assertEqual(heading_comp["text"], "### Top Sushi Places in Seattle") + + map_comp = next( + comp for comp in update_components["components"] if comp["id"] == "map" + ) + self.assertEqual(map_comp["tilt"], 0) + self.assertEqual(map_comp["mode"], "roadmap") + update_data_model = parts[2].root.data["updateDataModel"] # Verify places array was successfully populated in data model self.assertEqual(update_data_model["path"], "/") @@ -770,6 +795,10 @@ async def test_agent_local_search_flow(self, mock_lite_llm_class): self.assertEqual(len(places), 1) self.assertEqual(places[0]["name"], "Shiki Sushi") + sources = parts[3].root.data.get("groundingSources") + self.assertIsNotNone(sources) + self.assertEqual(sources[0]["title"], "Shiki Sushi · 41 Dravus St") + @mock.patch(_LITELLM_PATH) async def test_agent_local_search_flow_validation_failure_fallback( self, mock_lite_llm_class @@ -788,9 +817,9 @@ async def test_agent_local_search_flow_validation_failure_fallback( mock_runner = mock.MagicMock() - # Mock invalid set_model_response arguments (missing required center_lat) + # Mock invalid render_local_search_template arguments (missing required center_lat) invalid_args = {"summary": "Invalid data", "places": []} - mock_fc = MockFunctionCall("set_model_response", invalid_args) + mock_fc = MockFunctionCall("render_local_search_template", invalid_args) mock_event_fc = MockEvent(function_calls=[mock_fc]) mock_event_text = MockEvent( content=MockContent([MockPart("Fallback text here.")]) @@ -843,8 +872,19 @@ async def test_agent_local_search_flow_catalog_validation_failure_fallback( mock_runner = mock.MagicMock() mock_fc = MockFunctionCall( - "set_model_response", - {"summary": "Coffee", "places": [{"name": "Starbucks"}]}, + "render_local_search_template", + { + "heading": "Coffee Shops", + "summary": "Coffee", + "center_lat": 47.6, + "center_lng": -122.3, + "places": [{ + "placeId": "1", + "name": "Starbucks", + "lat": 47.6, + "lng": -122.3, + }], + }, ) mock_runner.run_async.return_value = MockAsyncIterator( [MockEvent(function_calls=[mock_fc])] @@ -858,7 +898,7 @@ async def test_agent_local_search_flow_catalog_validation_failure_fallback( "Mock validation error" ) mock_schema_manager = mock.MagicMock() - mock_schema_manager.get_catalog.return_value = mock_catalog + mock_schema_manager.get_selected_catalog.return_value = mock_catalog agent._schema_managers = {"v0.9": mock_schema_manager} mock_fallback_runner = mock.MagicMock() @@ -1097,10 +1137,23 @@ def test_build_dynamic_extractor_agent_handles_file_read_error(self): 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) + + def test_build_dynamic_extractor_agent_directions_loads_skill_instructions( + self, + ): + """Verifies that directions skill instructions from disk are loaded into the extractor agent.""" + agent = MAUIAgentWithTemplates(base_url="http://test-url") + extractor_agent = agent._build_dynamic_extractor_agent( # pylint: disable=protected-access + "directions-template-response" + ) + self.assertIn("less than a minute", extractor_agent.instruction) + self.assertIn( + "Always round seconds to the nearest minute", + extractor_agent.instruction, + ) + tool_names = [t.name for t in extractor_agent.tools if hasattr(t, "name")] + self.assertIn("render_directions_template", tool_names) + if __name__ == "__main__": unittest.main() diff --git a/agent/python_agent/test_extractor.py b/agent/python_agent/test_extractor.py index f4d77b9..8ac5df0 100644 --- a/agent/python_agent/test_extractor.py +++ b/agent/python_agent/test_extractor.py @@ -37,6 +37,27 @@ def test_pin_normalize_label_defaults_to_location(self): pin = Pin(**data) self.assertEqual(pin.label, "Location") + def test_pin_with_place_primary_type(self): + data = { + "lat": 1.0, + "lng": 2.0, + "label": "Coffee Shop", + "placePrimaryType": "food_and_drink", + } + pin = Pin(**data) + self.assertEqual(pin.placePrimaryType, "food_and_drink") + + def test_place_pin_with_place_primary_type(self): + data = { + "placeId": "ChIJ123", + "name": "Coffee Shop", + "lat": 1.0, + "lng": 2.0, + "placePrimaryType": "food_and_drink", + } + pin = PlacePin(**data) + self.assertEqual(pin.placePrimaryType, "food_and_drink") + def test_pin_normalize_label_preserves_existing(self): data = { "lat": 1.0, @@ -50,6 +71,7 @@ def test_pin_normalize_label_preserves_existing(self): def test_directions_extractor_schema_normalize_travel_mode(self): """Verifies that travel mode is normalized to lowercase.""" data = { + "heading": "Commute Route", "summary": "Commute is 1h.", "center_lat": 37.5, "center_lng": 127.0, @@ -65,6 +87,7 @@ def test_directions_extractor_schema_normalize_travel_mode(self): def test_directions_extractor_schema_with_routes(self): """Verifies that DirectionsExtractorSchema can be initialized with routes.""" data = { + "heading": "Scenic Route", "summary": "Scenic route.", "center_lat": 37.5, "center_lng": 127.0, @@ -91,6 +114,7 @@ def test_directions_extractor_schema_missing_travel_mode_fails_validation( ): """Verifies that omitting travel_mode raises ValidationError.""" data = { + "heading": "Directions Route", "summary": "Directions summary", "center_lat": 37.5, "center_lng": 127.0, @@ -109,6 +133,7 @@ def test_directions_extractor_schema_invalid_travel_mode_fails_validation( for invalid_mode in ["flying", "", None, "scooter", 123]: with self.subTest(invalid_mode=invalid_mode): data = { + "heading": "Directions Route", "summary": "Directions summary", "center_lat": 37.5, "center_lng": 127.0, @@ -123,6 +148,7 @@ def test_directions_extractor_schema_all_valid_modes(self): for mode in ["driving", "walking", "transit", "bicycling"]: with self.subTest(mode=mode): data = { + "heading": f"Going via {mode}", "summary": f"Going via {mode}", "center_lat": 37.5, "center_lng": 127.0, @@ -197,6 +223,7 @@ def test_directions_extractor_schema_normalize_all_synonyms(self): for synonym in synonyms: with self.subTest(synonym=synonym, expected=expected_mode): data = { + "heading": "Commute", "summary": "Commute", "center_lat": 37.5, "center_lng": 127.0, @@ -206,6 +233,112 @@ def test_directions_extractor_schema_normalize_all_synonyms(self): schema = DirectionsExtractorSchema(**data) self.assertEqual(schema.travel_mode, expected_mode) + def test_directions_extractor_schema_with_heading(self): + """Verifies that DirectionsExtractorSchema validates with heading.""" + data = { + "heading": "Walking route from Seattle Center to Pike Place Market", + "summary": "Walking takes about 25 minutes (1 mile).", + "center_lat": 47.6205, + "center_lng": -122.3493, + "travel_mode": "walking", + "routes": [{ + "origin": { + "lat": 47.6205, + "lng": -122.3493, + "label": "Seattle Center", + }, + "destination": { + "lat": 47.6097, + "lng": -122.3422, + "label": "Pike Place Market", + }, + }], + } + schema = DirectionsExtractorSchema(**data) + self.assertEqual( + schema.heading, "Walking route from Seattle Center to Pike Place Market" + ) + + def test_directions_extractor_schema_missing_heading_fails_validation(self): + """Verifies that omitting heading raises ValidationError.""" + data = { + "summary": "Walking takes about 25 minutes (1 mile).", + "center_lat": 47.6205, + "center_lng": -122.3493, + "travel_mode": "walking", + "routes": [{ + "origin": { + "lat": 47.6205, + "lng": -122.3493, + "label": "Seattle Center", + }, + "destination": { + "lat": 47.6097, + "lng": -122.3422, + "label": "Pike Place Market", + }, + }], + } + with self.assertRaises(pydantic.ValidationError): + DirectionsExtractorSchema(**data) + + def test_local_search_extractor_schema_with_heading(self): + """Verifies that LocalSearchExtractorSchema validates with heading.""" + data = { + "heading": "5 Transit Stops Near Seattle Center", + "summary": "Here are 5 transit stops.", + "center_lat": 47.6205, + "center_lng": -122.3493, + "places": [{ + "placeId": "ChIJ111", + "name": "Stop 1", + "lat": 47.62, + "lng": -122.35, + "address": "400 Broad St, Seattle, WA 98109", + }], + } + schema = LocalSearchExtractorSchema(**data) + self.assertEqual(schema.heading, "5 Transit Stops Near Seattle Center") + self.assertEqual( + schema.places[0].address, "400 Broad St, Seattle, WA 98109" + ) + + def test_local_search_extractor_schema_missing_heading_fails_validation(self): + """Verifies that omitting heading raises ValidationError.""" + data = { + "summary": "Here are 5 transit stops.", + "center_lat": 47.6205, + "center_lng": -122.3493, + "places": [{ + "placeId": "ChIJ111", + "name": "Stop 1", + "lat": 47.62, + "lng": -122.35, + "address": "400 Broad St", + }], + } + with self.assertRaises(pydantic.ValidationError): + LocalSearchExtractorSchema(**data) + + def test_local_search_extractor_schema_omitted_address_defaults_to_empty( + self, + ): + """Verifies that omitting place address defaults to empty string.""" + data = { + "heading": "5 Transit Stops Near Seattle Center", + "summary": "Here are 5 transit stops.", + "center_lat": 47.6205, + "center_lng": -122.3493, + "places": [{ + "placeId": "ChIJ111", + "name": "Stop 1", + "lat": 47.62, + "lng": -122.35, + }], + } + schema = LocalSearchExtractorSchema(**data) + self.assertEqual(schema.places[0].address, "") + if __name__ == "__main__": unittest.main() diff --git a/agent/python_agent/test_grounding_sources.py b/agent/python_agent/test_grounding_sources.py new file mode 100644 index 0000000..94adf5a --- /dev/null +++ b/agent/python_agent/test_grounding_sources.py @@ -0,0 +1,277 @@ +# 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. + +"""Unit tests for grounding_sources utility functions.""" + +from types import SimpleNamespace +import unittest + +from grounding_sources import ( + enrich_grounding_sources_with_a2ui_payload, + extract_location_from_query, + extract_sources_from_a2ui_payload, + extract_sources_from_grounding_chunks, + extract_sources_from_places_data, + format_maps_place_url, + format_maps_search_url, + simplify_address, +) + + +class TestGroundingSources(unittest.TestCase): + + def test_format_maps_place_url(self): + url = format_maps_place_url("ChIJ12345") + self.assertEqual( + url, "https://www.google.com/maps/place/?q=place_id:ChIJ12345" + ) + + def test_format_maps_search_url(self): + url = format_maps_search_url("Pike Place Market") + self.assertEqual( + url, + "https://www.google.com/maps/search/?api=1&query=Pike%20Place%20Market", + ) + + def test_extract_sources_from_grounding_chunks(self): + chunks = [ + SimpleNamespace( + maps=SimpleNamespace( + title="Pike Place Chowder - Google Maps", + place_id="places/ChIJ-02xI_NqkFQR97b5eH101oY", + ) + ), + SimpleNamespace( + maps=SimpleNamespace( + title="Beecher's Handmade Cheese", + place_id="ChIJN1t_tDeuEmsRUsoyG83frY4", + ) + ), + SimpleNamespace( + web=SimpleNamespace( + title="Seattle Dining Guide", + uri="https://example.com/seattle-guide", + ) + ), + ] + + sources = extract_sources_from_grounding_chunks(chunks) + self.assertEqual(len(sources), 3) + + self.assertEqual(sources[0]["title"], "Pike Place Chowder") + self.assertEqual(sources[0]["placeId"], "ChIJ-02xI_NqkFQR97b5eH101oY") + self.assertEqual( + sources[0]["url"], + "https://www.google.com/maps/place/?q=place_id:ChIJ-02xI_NqkFQR97b5eH101oY", + ) + self.assertEqual(sources[0]["type"], "place") + + self.assertEqual(sources[1]["title"], "Beecher's Handmade Cheese") + self.assertEqual(sources[1]["placeId"], "ChIJN1t_tDeuEmsRUsoyG83frY4") + + self.assertEqual(sources[2]["title"], "Seattle Dining Guide") + self.assertEqual(sources[2]["url"], "https://example.com/seattle-guide") + self.assertEqual(sources[2]["type"], "web") + + def test_extract_sources_from_places_data(self): + places = [ + { + "name": "Canlis", + "placeId": "ChIJxyz789", + "address": "2576 Aurora Ave N", + }, + { + "name": "Space Needle", + "place_id": "ChIJabc123", + }, + ] + + sources = extract_sources_from_places_data(places) + self.assertEqual(len(sources), 2) + self.assertEqual(sources[0]["title"], "Canlis · 2576 Aurora Ave N") + self.assertEqual(sources[0]["placeId"], "ChIJxyz789") + self.assertEqual( + sources[0]["url"], + "https://www.google.com/maps/search/?api=1&query=Canlis%2C%202576%20Aurora%20Ave%20N&query_place_id=ChIJxyz789", + ) + self.assertEqual(sources[1]["title"], "Space Needle") + self.assertEqual(sources[1]["placeId"], "ChIJabc123") + self.assertEqual( + sources[1]["url"], + "https://www.google.com/maps/search/?api=1&query=Space%20Needle&query_place_id=ChIJabc123", + ) + + def test_extract_sources_from_a2ui_payload(self): + payload = { + "surface": { + "components": [{ + "type": "PlaceCard", + "props": { + "name": "The Pink Door", + "placeId": "ChIJpink123", + }, + }] + } + } + + sources = extract_sources_from_a2ui_payload(payload) + self.assertEqual(len(sources), 1) + self.assertEqual(sources[0]["title"], "The Pink Door") + self.assertEqual(sources[0]["placeId"], "ChIJpink123") + self.assertEqual( + sources[0]["url"], + "https://www.google.com/maps/search/?api=1&query=The%20Pink%20Door&query_place_id=ChIJpink123", + ) + + def test_extract_sources_from_a2ui_payload_enrichment(self): + # markers array comes first without address, then restaurants comes with address + payload = [ + { + "updateComponents": { + "components": [{ + "component": "GoogleMap", + "markers": [{ + "lat": 47.608, + "lng": -122.34, + "label": "Sushi Kashiba", + "placeId": "ChIJsushi1", + }], + }] + } + }, + { + "updateDataModel": { + "restaurants": [{ + "name": "Sushi Kashiba", + "address": "86 Pine St, Seattle", + "placeId": "ChIJsushi1", + }] + } + }, + ] + + sources = extract_sources_from_a2ui_payload(payload) + self.assertEqual(len(sources), 1) + self.assertEqual(sources[0]["title"], "Sushi Kashiba · 86 Pine St") + self.assertEqual(sources[0]["placeId"], "ChIJsushi1") + self.assertEqual( + sources[0]["url"], + "https://www.google.com/maps/search/?api=1&query=Sushi%20Kashiba%2C%2086%20Pine%20St&query_place_id=ChIJsushi1", + ) + + def test_simplify_address(self): + self.assertEqual( + simplify_address("23 Commerce St, New York, NY 10014"), "23 Commerce St" + ) + self.assertEqual( + simplify_address("173 Hester St, New York, NY 10013"), "173 Hester St" + ) + self.assertEqual(simplify_address("3rd & L St NE"), "3rd & L St NE") + self.assertIsNone(simplify_address(None)) + + def test_extract_location_from_query(self): + self.assertEqual( + extract_location_from_query("sushi restaurants in Seattle"), "Seattle" + ) + self.assertEqual( + extract_location_from_query("Where can I get a beer in Ballard?"), + "Ballard", + ) + self.assertEqual( + extract_location_from_query( + "find hotels near Central Park, NY with pool" + ), + "Central Park, NY", + ) + self.assertIsNone(extract_location_from_query("tell me a joke")) + + def test_extract_sources_from_grounding_chunks_with_query(self): + chunks = [ + SimpleNamespace( + maps=SimpleNamespace( + title="Sushi Kashiba - Google Maps", + place_id="places/ChIJsushi1", + ) + ) + ] + sources = extract_sources_from_grounding_chunks( + chunks, query="Show me sushi in Seattle" + ) + self.assertEqual(len(sources), 1) + self.assertEqual(sources[0]["title"], "Sushi Kashiba · Seattle") + self.assertEqual(sources[0]["placeId"], "ChIJsushi1") + self.assertEqual( + sources[0]["url"], + "https://www.google.com/maps/search/?api=1&query=Sushi%20Kashiba%2C%20Seattle&query_place_id=ChIJsushi1", + ) + + def test_extract_sources_with_canonical_uri(self): + canonical_maps_url = ( + "https://www.google.com/maps/place/data=!4m2!3m1!1s0x54906ab2d385158b" + ) + chunks = [ + SimpleNamespace( + maps=SimpleNamespace( + title="Sushi Kashiba", + place_id="ChIJsushi1", + uri=canonical_maps_url, + ) + ) + ] + sources = extract_sources_from_grounding_chunks(chunks) + self.assertEqual(len(sources), 1) + self.assertEqual(sources[0]["url"], canonical_maps_url) + + def test_enrich_grounding_sources_with_a2ui_payload(self): + sources = [{ + "title": "The Pink Door · Seattle", + "url": "https://www.google.com/maps/place/?q=place_id:ChIJpink123", + "type": "place", + "placeId": "ChIJpink123", + }] + a2ui_payload = [{ + "updateDataModel": { + "value": { + "items": [{ + "placeId": "ChIJpink123", + "name": "The Pink Door", + "address": "1919 Post Alley, Seattle, WA 98101", + }] + } + } + }] + enrich_grounding_sources_with_a2ui_payload( + sources, a2ui_payload, query="italian in Seattle" + ) + self.assertEqual(sources[0]["title"], "The Pink Door · 1919 Post Alley") + self.assertIn("1919%20Post%20Alley", sources[0]["url"]) + + def test_enrich_grounding_sources_from_plain_text(self): + sources = [{ + "title": "Canlis · Seattle", + "url": "https://www.google.com/maps/place/?q=place_id:ChIJcanlis", + "type": "place", + "placeId": "ChIJcanlis", + }] + plain_text = ( + "Here is what I found:\n1. Canlis: 2576 Aurora Ave N, Seattle, WA 98109" + ) + enrich_grounding_sources_with_a2ui_payload( + sources, None, query="fine dining in Seattle", plain_text=plain_text + ) + self.assertEqual(sources[0]["title"], "Canlis · 2576 Aurora Ave N") + + +if __name__ == "__main__": + unittest.main() diff --git a/agent/python_agent/test_merger.py b/agent/python_agent/test_merger.py index f5e4f01..d17a31b 100644 --- a/agent/python_agent/test_merger.py +++ b/agent/python_agent/test_merger.py @@ -143,6 +143,7 @@ def test_merge_local_search_full_json(self): """Verifies merging a complete local search payload.""" data = { "surface_id": "local-search-surface-abc", + "heading": "Top Coffee Shops in Seattle", "summary": "Here are 3 highly-rated coffee shops in Seattle.", "center_lat": "47.6062", "center_lng": -122.3321, @@ -185,7 +186,18 @@ def test_merge_local_search_full_json(self): { "id": "root", "component": "Column", - "children": ["summary-text", "map", "list"], + "children": [ + "heading-text", + "summary-text", + "map", + "list", + ], + }, + { + "id": "heading-text", + "component": "Text", + "variant": "body", + "text": "### Top Coffee Shops in Seattle", }, { "id": "summary-text", @@ -200,6 +212,8 @@ def test_merge_local_search_full_json(self): "component": "GoogleMap", "center": {"lat": 47.6062, "lng": -122.3321}, "zoom": 14, + "tilt": 0, + "mode": "roadmap", "markers": [ { "lat": 47.62, @@ -308,7 +322,7 @@ def test_merge_max_list_size_slicing(self): 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") + map_comp = next(comp for comp in components if comp["id"] == "map") self.assertEqual(len(map_comp["markers"]), 2) # Check that updateDataModel has only 2 places @@ -317,10 +331,57 @@ def test_merge_max_list_size_slicing(self): self.assertEqual(places[0]["placeId"], "1") self.assertEqual(places[1]["placeId"], "2") + def test_merge_local_search_heading_normalization(self): + """Verifies that heading is cleaned of markdown headers or synthesized from anchor.""" + # Case 1: Heading with leading markdown hashtags + data_with_hash = { + "surface_id": "test-surface", + "heading": "### Best Bakeries", + "summary": "Here are bakeries.", + "center_lat": 47.6, + "center_lng": -122.3, + "zoom": 13, + "places": [{"placeId": "p1", "name": "B1", "lat": 47.6, "lng": -122.3}], + } + result = merge_template("local_search", data_with_hash) + comps = result[1]["updateComponents"]["components"] + heading_comp = next(comp for comp in comps if comp["id"] == "heading-text") + self.assertEqual(heading_comp["text"], "### Best Bakeries") + + # Case 2: Missing heading with anchor marker + data_with_anchor = { + "surface_id": "test-surface", + "summary": "Here are bakeries.", + "center_lat": 47.6, + "center_lng": -122.3, + "zoom": 13, + "anchor_marker": {"lat": 47.6, "lng": -122.3, "label": "Space Needle"}, + "places": [{"placeId": "p1", "name": "B1", "lat": 47.6, "lng": -122.3}], + } + result = merge_template("local_search", data_with_anchor) + comps = result[1]["updateComponents"]["components"] + heading_comp = next(comp for comp in comps if comp["id"] == "heading-text") + self.assertEqual(heading_comp["text"], "### Places near Space Needle") + + # Case 3: Missing heading and no anchor + data_no_heading = { + "surface_id": "test-surface", + "summary": "Here are bakeries.", + "center_lat": 47.6, + "center_lng": -122.3, + "zoom": 13, + "places": [{"placeId": "p1", "name": "B1", "lat": 47.6, "lng": -122.3}], + } + result = merge_template("local_search", data_no_heading) + comps = result[1]["updateComponents"]["components"] + heading_comp = next(comp for comp in comps if comp["id"] == "heading-text") + self.assertEqual(heading_comp["text"], "### Nearby Places") + 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", + "heading": "Walking Route from Dobong to Gangnam", "summary": "Typical commute is 1h 15m.", "center_lat": "37.5665", "center_lng": 126.9780, @@ -352,13 +413,13 @@ def test_merge_directions_full_json(self): { "id": "root", "component": "Column", - "children": ["summary-text", "map"], + "children": ["heading-text", "map", "summary-text"], }, { - "id": "summary-text", + "id": "heading-text", "component": "Text", "variant": "body", - "text": "Typical commute is 1h 15m.", + "text": "### Walking Route from Dobong to Gangnam", }, { "id": "map", @@ -379,6 +440,12 @@ def test_merge_directions_full_json(self): }], "travelMode": "walking", }, + { + "id": "summary-text", + "component": "Text", + "variant": "body", + "text": "Typical commute is 1h 15m.", + }, ], }, }, @@ -395,6 +462,50 @@ def test_merge_directions_full_json(self): result = merge_template("directions", data, max_list_size=3) self.assertEqual(result, expected) + def test_merge_directions_heading_fallback(self): + """Verifies that missing heading is synthesized from route endpoints.""" + # Case 1: Heading with leading markdown hashtags + data_with_hash = { + "surface_id": "test-surface", + "heading": "### Driving Route", + "summary": "About 15 minutes.", + "center_lat": 37.5, + "center_lng": 127.0, + "zoom": 12, + "routes": [{ + "origin": {"lat": 37.5, "lng": 127.0, "label": "Origin"}, + "destination": {"lat": 37.6, "lng": 127.1, "label": "Dest"}, + }], + } + result = merge_template("directions", data_with_hash) + comps = result[1]["updateComponents"]["components"] + heading_comp = next(c for c in comps if c["id"] == "heading-text") + self.assertEqual(heading_comp["text"], "### Driving Route") + + # Case 2: Missing heading with origin and destination labels + data_missing = { + "surface_id": "test-surface", + "summary": "About 15 minutes.", + "center_lat": 37.5, + "center_lng": 127.0, + "zoom": 12, + "routes": [{ + "origin": {"lat": 37.5, "lng": 127.0, "label": "Seattle Center"}, + "destination": { + "lat": 37.6, + "lng": 127.1, + "label": "Pike Place Market", + }, + }], + } + result = merge_template("directions", data_missing) + comps = result[1]["updateComponents"]["components"] + heading_comp = next(c for c in comps if c["id"] == "heading-text") + self.assertEqual( + heading_comp["text"], + "### Route from Seattle Center to Pike Place Market", + ) + def test_validate_directions_output_with_schema(self): """Verifies merged directions output passes schema validation.""" data = { @@ -584,7 +695,7 @@ def test_missing_optional_placeholders_are_stripped(self): 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" + comp for comp in update_components["components"] if comp["id"] == "map" ) # Verify anchorMarker key is NOT in map component (cleanly stripped) self.assertNotIn("anchorMarker", map_comp) @@ -612,7 +723,7 @@ def test_markers_explicitly_provided_and_sanitized(self): 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" + comp for comp in update_components["components"] if comp["id"] == "map" ) expected_markers = [ {"lat": 47.63, "lng": -122.33, "label": "Custom 1"}, diff --git a/agent/python_agent/test_template_tool.py b/agent/python_agent/test_template_tool.py new file mode 100644 index 0000000..486fd8c --- /dev/null +++ b/agent/python_agent/test_template_tool.py @@ -0,0 +1,323 @@ +# 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. + +"""Unit tests for template_tool.py ADK tools.""" + +import pathlib +from types import SimpleNamespace +import unittest + +from a2a.types import DataPart +from google.adk.tools.tool_context import ToolContext + +import a2ui +import agent +import template_tool + +BaseTemplateTool = template_tool.BaseTemplateTool +RenderLocalSearchTemplateTool = template_tool.RenderLocalSearchTemplateTool +RenderDirectionsTemplateTool = template_tool.RenderDirectionsTemplateTool +RenderTextOnlyTemplateTool = template_tool.RenderTextOnlyTemplateTool +TemplateToolset = template_tool.TemplateToolset +STATE_RENDERED_A2UI_PARTS = template_tool.STATE_RENDERED_A2UI_PARTS + + +def _create_schema_manager(): + extension_path = ( + pathlib.Path(__file__).parent + / "shared" + / "schema" + / "maps_catalog_extension.json" + ) + return 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], + ) + + +class MockToolContext: + + def __init__(self): + self.state = {} + self.actions = SimpleNamespace() + + +class TestTemplateTools(unittest.IsolatedAsyncioTestCase): + """Unit tests for ADK template tools.""" + + def setUp(self): + super().setUp() + self.schema_manager = _create_schema_manager() + self.tool_context = MockToolContext() + + async def test_render_local_search_template_success(self): + tool = RenderLocalSearchTemplateTool( + schema_manager=self.schema_manager, max_list_size=3 + ) + + args = { + "heading": "Nearby Places", + "summary": "Here are 2 coffee shops.", + "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, + }, + ], + } + + result = await tool.run_async(args=args, tool_context=self.tool_context) + + self.assertEqual(result["status"], "success") + self.assertEqual(result["template"], "local_search") + self.assertIn("surface_id", result) + + # Verify session state was populated with A2A parts + self.assertIn(STATE_RENDERED_A2UI_PARTS, self.tool_context.state) + parts = self.tool_context.state[STATE_RENDERED_A2UI_PARTS] + self.assertEqual(len(parts), 3) + + create_surface_data = parts[0].root.data["createSurface"] + self.assertTrue( + create_surface_data["surfaceId"].startswith("local-search-surface-") + ) + + update_components = parts[1].root.data["updateComponents"]["components"] + heading_comp = next( + comp for comp in update_components if comp["id"] == "heading-text" + ) + self.assertEqual(heading_comp["text"], "### Nearby Places") + map_comp = next(comp for comp in update_components if comp["id"] == "map") + self.assertEqual(len(map_comp["markers"]), 2) + + update_data_model = parts[2].root.data["updateDataModel"]["value"] + self.assertEqual(len(update_data_model["places"]), 2) + + async def test_render_local_search_template_with_heading(self): + tool = RenderLocalSearchTemplateTool( + schema_manager=self.schema_manager, max_list_size=3 + ) + + args = { + "heading": "Top Coffee Shops in Seattle", + "summary": "Here are 2 coffee shops.", + "center_lat": 47.6062, + "center_lng": -122.3321, + "zoom": 14, + "places": [ + { + "placeId": "ChIJ111", + "name": "Espresso Vivace", + "lat": 47.6200, + "lng": -122.3200, + }, + ], + } + + result = await tool.run_async(args=args, tool_context=self.tool_context) + self.assertEqual(result["status"], "success") + + parts = self.tool_context.state[STATE_RENDERED_A2UI_PARTS] + update_components = parts[1].root.data["updateComponents"]["components"] + heading_comp = next( + comp for comp in update_components if comp["id"] == "heading-text" + ) + self.assertEqual(heading_comp["text"], "### Top Coffee Shops in Seattle") + + async def test_render_local_search_template_validation_failure(self): + tool = RenderLocalSearchTemplateTool(schema_manager=self.schema_manager) + + # Missing mandatory center_lat and center_lng + args = { + "summary": "Places without center coordinates", + "places": [{"placeId": "1", "name": "P1", "lat": 1.0, "lng": 2.0}], + } + + result = await tool.run_async(args=args, tool_context=self.tool_context) + self.assertIn("error", result) + self.assertIn("Validation failed for tool", result["error"]) + + async def test_render_local_search_template_missing_heading_validation_failure( + self, + ): + tool = RenderLocalSearchTemplateTool(schema_manager=self.schema_manager) + + # Missing mandatory heading + args = { + "summary": "Places without heading", + "center_lat": 47.6062, + "center_lng": -122.3321, + "places": [{"placeId": "1", "name": "P1", "lat": 1.0, "lng": 2.0}], + } + + result = await tool.run_async(args=args, tool_context=self.tool_context) + self.assertIn("error", result) + self.assertIn("Validation failed for tool", result["error"]) + + async def test_render_directions_template_success(self): + tool = RenderDirectionsTemplateTool(schema_manager=self.schema_manager) + + args = { + "heading": "Driving directions from San Francisco to Oakland", + "summary": "Commute is 30 minutes.", + "center_lat": 37.7749, + "center_lng": -122.4194, + "zoom": 12, + "routes": [{ + "origin": { + "lat": 37.7749, + "lng": -122.4194, + "label": "San Francisco", + "placeId": "ChIJ_SF", + }, + "destination": { + "lat": 37.8044, + "lng": -122.2712, + "label": "Oakland", + "placeId": "ChIJ_OAK", + }, + }], + "travel_mode": "driving", + } + + result = await tool.run_async(args=args, tool_context=self.tool_context) + + self.assertEqual(result["status"], "success") + self.assertEqual(result["template"], "directions") + + self.assertIn(STATE_RENDERED_A2UI_PARTS, self.tool_context.state) + parts = self.tool_context.state[STATE_RENDERED_A2UI_PARTS] + self.assertEqual(len(parts), 3) + + update_components = parts[1].root.data["updateComponents"]["components"] + root_comp = next(comp for comp in update_components if comp["id"] == "root") + self.assertEqual( + root_comp["children"], ["heading-text", "map", "summary-text"] + ) + heading_comp = next( + comp for comp in update_components if comp["id"] == "heading-text" + ) + self.assertEqual( + heading_comp["text"], + "### Driving directions from San Francisco to Oakland", + ) + map_comp = next(comp for comp in update_components if comp["id"] == "map") + self.assertEqual(map_comp["travelMode"], "driving") + self.assertEqual(len(map_comp["routes"]), 1) + + async def test_render_directions_template_missing_heading_fails(self): + tool = RenderDirectionsTemplateTool(schema_manager=self.schema_manager) + + args = { + "summary": "Commute is 30 minutes.", + "center_lat": 37.7749, + "center_lng": -122.4194, + "zoom": 12, + "routes": [{ + "origin": {"lat": 37.7749, "lng": -122.4194, "label": "A"}, + "destination": {"lat": 37.8044, "lng": -122.2712, "label": "B"}, + }], + "travel_mode": "driving", + } + + result = await tool.run_async(args=args, tool_context=self.tool_context) + self.assertIn("error", result) + self.assertIn("Validation failed for tool", result["error"]) + + async def test_render_directions_template_invalid_travel_mode_fails(self): + tool = RenderDirectionsTemplateTool(schema_manager=self.schema_manager) + + args = { + "summary": "Commute", + "center_lat": 37.7, + "center_lng": -122.4, + "zoom": 12, + "routes": [{ + "origin": {"lat": 37.7, "lng": -122.4, "label": "A"}, + "destination": {"lat": 37.8, "lng": -122.3, "label": "B"}, + }], + "travel_mode": "ROCKET_SHIP", # Invalid mode + } + + result = await tool.run_async(args=args, tool_context=self.tool_context) + self.assertIn("error", result) + + async def test_render_text_only_template_success(self): + tool = RenderTextOnlyTemplateTool(schema_manager=self.schema_manager) + + args = { + "text": "Hello world from text-only template.", + } + + result = await tool.run_async(args=args, tool_context=self.tool_context) + + self.assertEqual(result["status"], "success") + self.assertEqual(result["template"], "text_only") + + self.assertIn(STATE_RENDERED_A2UI_PARTS, self.tool_context.state) + parts = self.tool_context.state[STATE_RENDERED_A2UI_PARTS] + self.assertEqual(len(parts), 2) + text_comp = parts[1].root.data["updateComponents"]["components"][1] + self.assertEqual(text_comp["text"], "Hello world from text-only template.") + + async def test_template_toolset_returns_tools(self): + toolset = TemplateToolset( + schema_manager=self.schema_manager, max_list_size=3 + ) + tools = await toolset.get_tools() + + self.assertEqual(len(tools), 3) + tool_names = [t.name for t in tools] + self.assertIn("render_local_search_template", tool_names) + self.assertIn("render_directions_template", tool_names) + self.assertIn("render_text_only_template", tool_names) + + def test_tool_declarations_valid(self): + tool_ls = RenderLocalSearchTemplateTool(schema_manager=self.schema_manager) + decl_ls = tool_ls._get_declaration() + self.assertIsNotNone(decl_ls) + self.assertEqual(decl_ls.name, "render_local_search_template") + + tool_dir = RenderDirectionsTemplateTool(schema_manager=self.schema_manager) + decl_dir = tool_dir._get_declaration() + self.assertIsNotNone(decl_dir) + self.assertEqual(decl_dir.name, "render_directions_template") + + tool_text = RenderTextOnlyTemplateTool(schema_manager=self.schema_manager) + decl_text = tool_text._get_declaration() + self.assertIsNotNone(decl_text) + self.assertEqual(decl_text.name, "render_text_only_template") + + +if __name__ == "__main__": + unittest.main() diff --git a/client/android/GoogleMapsA2UI/build.gradle b/client/android/GoogleMapsA2UI/build.gradle index 702c9a0..be243f9 100644 --- a/client/android/GoogleMapsA2UI/build.gradle +++ b/client/android/GoogleMapsA2UI/build.gradle @@ -1,3 +1,17 @@ +// 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. + plugins { id 'com.android.library' version '9.0.0' id 'maven-publish' @@ -31,6 +45,16 @@ android { } } + // Robolectric needs the AGP-merged manifest, resources and assets. Without + // this, `context.assets` is empty and Robolectric cannot read `targetSdk`, + // so it falls back to its minimum supported SDK where API 23/24 WebViewClient + // overloads do not exist. + testOptions { + unitTests { + includeAndroidResources = true + } + } + publishing { singleVariant('release') } @@ -52,6 +76,9 @@ dependencies { implementation 'androidx.appcompat:appcompat:1.6.1' testImplementation 'junit:junit:4.13.2' testImplementation 'org.robolectric:robolectric:4.11.1' + testImplementation 'com.google.truth:truth:1.4.2' + testImplementation 'org.mockito:mockito-core:5.11.0' + testImplementation 'org.mockito.kotlin:mockito-kotlin:5.2.1' } afterEvaluate { diff --git a/client/android/GoogleMapsA2UI/src/main/assets/index.html b/client/android/GoogleMapsA2UI/src/main/assets/index.html index 0f35689..3f48c3e 100644 --- a/client/android/GoogleMapsA2UI/src/main/assets/index.html +++ b/client/android/GoogleMapsA2UI/src/main/assets/index.html @@ -31,1202 +31,7478 @@ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; } - + 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 56d43bc..ed6ad25 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 @@ -17,6 +17,12 @@ package com.google.android.libraries.mapsplatform.a2ui import org.json.JSONArray import org.json.JSONObject +sealed class A2AParserException(message: String) : Exception(message) { + class InvalidPayloadStructure( + message: String = "The raw JSON response lacks a recognized message parts structure." + ) : A2AParserException(message) +} + data class ParsedA2AEventMetadata(val mimeType: String?) sealed interface ParsedA2AEvent { @@ -38,7 +44,6 @@ object A2AResponseParser { 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" @@ -46,98 +51,53 @@ object A2AResponseParser { private const val KEY_DELETE_SURFACE = "deleteSurface" private const val KEY_SURFACE_ID = "surfaceId" + private val A2UI_PATTERN = "(.*?)".toRegex(RegexOption.DOT_MATCHES_ALL) + 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() - } + val partsArray = + extractPartsArray(rawJson) ?: throw A2AParserException.InvalidPayloadStructure() - 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 partsList = mutableListOf() + 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() } - 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 (textPart.contains("")) { + extractJsonBlocks(textPart, currentTextBuilder, partsList) + } else if (textPart.isNotEmpty()) { + if (currentTextBuilder.isNotEmpty()) currentTextBuilder.append("\n") + currentTextBuilder.append(textPart) } } - 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())) - } + 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() } - } catch (e: Exception) {} + currentUiElements.put(dataPayload) + } + } + + if (currentTextBuilder.isNotEmpty()) { + partsList.add(ParsedA2AEvent.Text(currentTextBuilder.toString())) + } + if (currentUiElements.length() > 0) { + partsList.add(ParsedA2AEvent.Data(currentUiElements.toString())) } val deduplicatedParts = mutableListOf() @@ -147,7 +107,7 @@ object A2AResponseParser { for (part in partsList) { val finalText: String? = if (part is ParsedA2AEvent.Text) { - part.text.replace("```json", "").replace("```", "").trim().takeIf { it.isNotEmpty() } + part.text.trim().takeIf { it.isNotEmpty() } } else null // Deduplicate consecutive identical text blocks @@ -226,24 +186,6 @@ object A2AResponseParser { 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 -> null } @@ -253,7 +195,9 @@ object A2AResponseParser { } } - return if (finalParts.length() > 0) finalParts else null + val hasRecognizedStructure = + rawJson.optJSONArray(KEY_HISTORY) != null || additionalParts != null + return if (hasRecognizedStructure) finalParts else null } private fun extractJsonBlocks( @@ -261,12 +205,7 @@ object A2AResponseParser { 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)) + val allMatches = A2UI_PATTERN.findAll(textPart).toList() if (allMatches.isEmpty()) { if (textBuilder.isNotEmpty()) textBuilder.append("\n") @@ -274,8 +213,6 @@ object A2AResponseParser { return } - allMatches.sortBy { it.range.first } - var lastEnd = 0 for (match in allMatches) { val beforeText = textPart.substring(lastEnd, match.range.first).trim() @@ -288,24 +225,28 @@ object A2AResponseParser { 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)) } + if (textBuilder.isNotEmpty()) { + partsList.add(ParsedA2AEvent.Text(textBuilder.toString())) + textBuilder.clear() + } partsList.add(ParsedA2AEvent.Data(localUiElements.toString())) } else if (firstChar == '{') { + val jsonObj = JSONObject(jsonString) if (textBuilder.isNotEmpty()) { partsList.add(ParsedA2AEvent.Text(textBuilder.toString())) textBuilder.clear() } val localUiElements = JSONArray() - localUiElements.put(JSONObject(jsonString)) + localUiElements.put(jsonObj) partsList.add(ParsedA2AEvent.Data(localUiElements.toString())) + } else { + if (textBuilder.isNotEmpty()) textBuilder.append("\n") + textBuilder.append(match.value) } } catch (e: Exception) { if (textBuilder.isNotEmpty()) textBuilder.append("\n") 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 5043f39..6fe76f3 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 @@ -96,7 +96,9 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 request: WebResourceRequest?, error: WebResourceError?, ) { - super.onReceivedError(view, request, error) + if (request != null && error != null) { + super.onReceivedError(view, request, error) + } Log.e( A2UI_ERROR_TAG, "Error loading WebView: ${error?.description}, URL: ${request?.url}", 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 4f91ed0..7340d24 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 @@ -14,10 +14,10 @@ package com.google.android.libraries.mapsplatform.a2ui +import com.google.common.truth.Truth.assertThat import org.json.JSONArray import org.json.JSONObject -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue +import org.junit.Assert.assertThrows import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -25,17 +25,56 @@ import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class A2AResponseParserTest { + /** + * Verifies that parsing an unexpected payload structure without message parts or results throws + * [A2AParserException.InvalidPayloadStructure]. + */ @Test - fun testParse_InvalidPayloadStructure() { - // Test that an unexpected payload format returns an empty event list safely + fun parse_invalidPayloadStructure_throwsException() { val payloadWithNoParts = JSONObject().apply { put("status", "ok") } - val events = A2AResponseParser.parse(payloadWithNoParts) - assertEquals(0, events.size) + val thrown = + assertThrows(A2AParserException.InvalidPayloadStructure::class.java) { + A2AResponseParser.parse(payloadWithNoParts) + } + assertThat(thrown).hasMessageThat().contains("lacks a recognized message parts structure") } + /** + * Verifies that when "history" is present but not an array, the parser throws + * [A2AParserException.InvalidPayloadStructure]. + */ @Test - fun testParse_SimpleTextPart() { - // Test standard text extraction from a standard payload structure + fun parse_invalidHistoryType_throwsException() { + val payloadWithInvalidHistory = JSONObject().apply { put("history", "not_an_array") } + val thrown = + assertThrows(A2AParserException.InvalidPayloadStructure::class.java) { + A2AResponseParser.parse(payloadWithInvalidHistory) + } + assertThat(thrown).hasMessageThat().contains("lacks a recognized message parts structure") + } + + /** Verifies that when the parts array is empty, the parser returns an empty list. */ + @Test + fun parse_emptyParts_returnsEmptyList() { + val emptyPartsPayload = JSONObject("""{"parts": []}""") + val emptyEvents = A2AResponseParser.parse(emptyPartsPayload) + assertThat(emptyEvents).isEmpty() + } + + /** + * Verifies that when the parts array contains unknown types, the parser returns an empty list + * safely without throwing an exception. + */ + @Test + fun parse_unknownParts_returnsEmptyList() { + val unknownPartsPayload = JSONObject("""{"parts": [{"kind": "unsupported_media"}]}""") + val unknownEvents = A2AResponseParser.parse(unknownPartsPayload) + assertThat(unknownEvents).isEmpty() + } + + /** Verifies that a simple text part in the "parts" array is parsed into a single text event. */ + @Test + fun parse_simpleTextPart_returnsSingleTextEvent() { val payload = JSONObject( """ @@ -49,19 +88,126 @@ class A2AResponseParserTest { ) 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) + assertThat(events).containsExactly(ParsedA2AEvent.Text("Show me some good sushi in Seattle")) + } + + /** Verifies that the parser resolves message parts nested under "content.parts". */ + @Test + fun parse_contentPartsPath_resolvesTextEvent() { + val payload = + JSONObject( + """ + { + "content": { + "parts": [ + {"text": "Welcome to Seattle!"} + ] + } + } + """ + .trimIndent() + ) + + val events = A2AResponseParser.parse(payload) + assertThat(events).containsExactly(ParsedA2AEvent.Text("Welcome to Seattle!")) + } + + /** Verifies that the parser resolves message parts nested under "status.message.parts". */ + @Test + fun parse_statusMessagePartsPath_resolvesTextEvent() { + val payload = + JSONObject( + """ + { + "status": { + "message": { + "parts": [ + {"text": "Status message text"} + ] + } + } + } + """ + .trimIndent() + ) + + val events = A2AResponseParser.parse(payload) + assertThat(events).containsExactly(ParsedA2AEvent.Text("Status message text")) + } + + /** + * Verifies that the parser resolves message parts from the "history" array, extracting agent + * parts that appear after the last user message. + */ + @Test + fun parse_historyPayload_resolvesAgentPartsAfterLastUser() { + val payload = + JSONObject( + """ + { + "history": [ + { + "role": "user", + "parts": [{"text": "First query"}] + }, + { + "role": "agent", + "parts": [{"text": "First answer"}] + }, + { + "role": "user", + "parts": [{"text": "Show me Seattle maps"}] + }, + { + "role": "agent", + "parts": [{"text": "Here is Seattle!"}] + } + ] + } + """ + .trimIndent() + ) + + val events = A2AResponseParser.parse(payload) + assertThat(events).containsExactly(ParsedA2AEvent.Text("Here is Seattle!")) + } + + /** + * Verifies that when a history payload has only user messages and no agent reply yet, the parser + * returns an empty list. + */ + @Test + fun parse_historyPayloadWithNoAgentResponse_returnsEmptyList() { + val payload = + JSONObject( + """ + { + "history": [ + { + "role": "user", + "parts": [{"text": "Hello, agent!"}] + } + ] + } + """ + .trimIndent() + ) + + val events = A2AResponseParser.parse(payload) + assertThat(events).isEmpty() } + /** + * Verifies that multiple text parts in the payload are concatenated with newlines into a single + * text event. + */ @Test - fun testParse_TextConcatenation() { - // Android parser concatenates all text elements within a JSONArray into a single text event + fun parse_multipleTextParts_concatenatesIntoSingleTextEvent() { val payload = JSONObject( """ { - "result": [ + "parts": [ {"text": "Hello Seattle!"}, {"text": "Hello Seattle!"}, {"text": "Different text."} @@ -72,44 +218,278 @@ class A2AResponseParserTest { ) 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, - ) + assertThat(events) + .containsExactly(ParsedA2AEvent.Text("Hello Seattle!\nHello Seattle!\nDifferent text.")) + } + + /** Verifies that parts containing empty text strings produce an empty event list. */ + @Test + fun parse_emptyTextPart_returnsEmptyList() { + val payload = + JSONObject( + """ + { + "parts": [ + {"text": ""} + ] + } + """ + .trimIndent() + ) + + val events = A2AResponseParser.parse(payload) + assertThat(events).isEmpty() + } + + /** + * Verifies that non-array parts structure throws [A2AParserException.InvalidPayloadStructure]. + */ + @Test + fun parse_invalidPartsType_throwsException() { + val payload = JSONObject("""{"parts": "not_an_array"}""") + assertThrows(A2AParserException.InvalidPayloadStructure::class.java) { + A2AResponseParser.parse(payload) + } + } + + /** + * Verifies that embedded '' tags within a text body are extracted into discrete text + * and A2UI data events in sequential order. + */ + @Test + fun parse_embeddedA2UIJSON_extractsTextAndDataEventsSequentially() { + val textWithJson = + "Here is the map: [{\"createSurface\": {\"surfaceId\": \"seattle-map\"}}] Hope this helps!" + val payload = + JSONObject( + """ + { + "parts": [ + {"kind": "text", "text": ${JSONObject.quote(textWithJson)}} + ] + } + """ + .trimIndent() + ) + + val events = A2AResponseParser.parse(payload) + assertThat(events).hasSize(3) + assertThat(events[0]).isEqualTo(ParsedA2AEvent.Text("Here is the map:")) + + val dataEvent = events[1] as ParsedA2AEvent.Data + val arr = JSONArray(dataEvent.data) + assertThat(arr.getJSONObject(0).getJSONObject("createSurface").getString("surfaceId")) + .isEqualTo("seattle-map") + + assertThat(events[2]).isEqualTo(ParsedA2AEvent.Text("Hope this helps!")) + } + + /** + * Verifies that an A2UI JSON array embedded inside a text part is parsed into a flat array of + * components. + */ + @Test + fun parse_embeddedA2UIJSONArray_flattensToSingleArray() { + val textWithJsonArray = + "Here is the map: [{\"createSurface\": {\"surfaceId\": \"sushi-seattle\"}}, {\"updateComponents\": {\"surfaceId\": \"sushi-seattle\"}}] Done." + val payload = + JSONObject( + """ + { + "parts": [ + {"kind": "text", "text": ${JSONObject.quote(textWithJsonArray)}} + ] + } + """ + .trimIndent() + ) + + val events = A2AResponseParser.parse(payload) + assertThat(events).hasSize(3) + assertThat(events[0]).isEqualTo(ParsedA2AEvent.Text("Here is the map:")) + + val dataEvent = events[1] as ParsedA2AEvent.Data + val arr = JSONArray(dataEvent.data) + assertThat(arr.length()).isEqualTo(2) + assertThat(arr.getJSONObject(0).has("createSurface")).isTrue() + assertThat(arr.getJSONObject(1).has("updateComponents")).isTrue() + + assertThat(events[2]).isEqualTo(ParsedA2AEvent.Text("Done.")) + } + + /** + * Verifies that malformed JSON inside an embedded '' tag falls back gracefully to + * plain text without crashing. + */ + @Test + fun parse_malformedEmbeddedA2UITag_fallsBackToPlainText() { + val textWithMalformedJson = "Intro text {not_valid_json outro text" + val payload = + JSONObject( + """ + { + "parts": [ + {"kind": "text", "text": ${JSONObject.quote(textWithMalformedJson)}} + ] + } + """ + .trimIndent() + ) + + val events = A2AResponseParser.parse(payload) + assertThat(events) + .containsExactly( + ParsedA2AEvent.Text("Intro text\n{not_valid_json\noutro text") + ) } + /** + * Verifies that non-object/non-array JSON inside an embedded '' tag falls back to + * plain 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) } + fun parse_nonObjectOrArrayEmbeddedA2UITag_fallsBackToPlainText() { + val textWithPrimitiveJson = "Intro text 12345 outro text" + val payload = + JSONObject( + """ + { + "parts": [ + {"kind": "text", "text": ${JSONObject.quote(textWithPrimitiveJson)}} + ] + } + """ + .trimIndent() + ) val events = A2AResponseParser.parse(payload) - assertEquals(1, events.size) + assertThat(events) + .containsExactly(ParsedA2AEvent.Text("Intro text\n12345\noutro text")) + } + + /** Verifies that an unclosed '' tag remains as plain text. */ + @Test + fun parse_unclosedA2UITag_fallsBackToPlainText() { + val unclosedTagText = "Before {\"createSurface\": {}} without closing tag" + val payload = + JSONObject( + """ + { + "parts": [ + {"text": ${JSONObject.quote(unclosedTagText)}} + ] + } + """ + .trimIndent() + ) + + val events = A2AResponseParser.parse(payload) + assertThat(events) + .containsExactly( + ParsedA2AEvent.Text("Before {\"createSurface\": {}} without closing tag") + ) + } + + /** + * Verifies that duplicate surface creation definitions with identical surface IDs are + * deduplicated within the payload. + */ + @Test + fun parse_duplicateSurfaceIds_deduplicatesSurfaces() { + val payload = + JSONObject( + """ + { + "parts": [ + { + "data": { + "createSurface": {"surfaceId": "dup-surface"} + } + }, + { + "data": { + "createSurface": {"surfaceId": "dup-surface"} + } + } + ] + } + """ + .trimIndent() + ) + val events = A2AResponseParser.parse(payload) + assertThat(events).hasSize(1) val dataEvent = events[0] as ParsedA2AEvent.Data - val a2uiArray = JSONArray(dataEvent.data) - assertEquals(1, a2uiArray.length()) - assertTrue(a2uiArray.getJSONObject(0).has("createSurface")) + val arr = JSONArray(dataEvent.data) + assertThat(arr.length()).isEqualTo(1) + } + + /** + * Verifies that multiple '' tags within a single text part are all extracted + * sequentially. + */ + @Test + fun parse_multipleEmbeddedA2UITags_extractsAllSequentially() { + val textWithMultipleTags = + "First map: {\"createSurface\": {\"surfaceId\": \"sushi\"}} Then: {\"updateComponents\": {\"surfaceId\": \"sushi\"}} Done." + val payload = + JSONObject( + """ + { + "parts": [ + {"kind": "text", "text": ${JSONObject.quote(textWithMultipleTags)}} + ] + } + """ + .trimIndent() + ) + + val events = A2AResponseParser.parse(payload) + assertThat(events).hasSize(5) + assertThat(events[0]).isEqualTo(ParsedA2AEvent.Text("First map:")) + + val dataEvent1 = events[1] as ParsedA2AEvent.Data + val arr1 = JSONArray(dataEvent1.data) + assertThat(arr1.getJSONObject(0).getJSONObject("createSurface").getString("surfaceId")) + .isEqualTo("sushi") + + assertThat(events[2]).isEqualTo(ParsedA2AEvent.Text("Then:")) + + val dataEvent2 = events[3] as ParsedA2AEvent.Data + val arr2 = JSONArray(dataEvent2.data) + assertThat(arr2.getJSONObject(0).getJSONObject("updateComponents").getString("surfaceId")) + .isEqualTo("sushi") + + assertThat(events[4]).isEqualTo(ParsedA2AEvent.Text("Done.")) } + /** + * Verifies that consecutive data parts identified as A2UI payloads are batched together into a + * single data event. + */ @Test - fun testParse_NativeJsonResultArray() { - // Tests the newly added support for native JSONArray inside the 'result' key (from PR #311 - // fixes) + fun parse_consecutiveA2UIPayloads_batchesIntoSingleEvent() { val payload = JSONObject( """ { - "result": [ + "parts": [ { - "text": "Here is your native array map" + "kind": "data", + "data": { + "createSurface": { + "surfaceId": "sushi-seattle", + "catalogId": "a2ui://maps-agentic-ui-catalog.json" + } + } }, { - "updateComponents": { - "surfaceId": "sushi-seattle", - "components": [] + "kind": "data", + "data": { + "updateComponents": { + "surfaceId": "sushi-seattle", + "components": [] + } } } ] @@ -119,16 +499,82 @@ class A2AResponseParserTest { ) val events = A2AResponseParser.parse(payload) + assertThat(events).hasSize(1) + val dataEvent = events[0] as ParsedA2AEvent.Data + val arr = JSONArray(dataEvent.data) + assertThat(arr.length()).isEqualTo(2) + assertThat(arr.getJSONObject(0).has("createSurface")).isTrue() + assertThat(arr.getJSONObject(1).has("updateComponents")).isTrue() + } - // We expect one Text event and one Data event - assertEquals(2, events.size) + /** + * Verifies that an A2UI batch is finalized and a new one starts if interrupted by a text part. + */ + @Test + fun parse_a2uiBatchInterruptedByTextPart_createsSeparateBatches() { + val payload = + JSONObject( + """ + { + "parts": [ + { + "kind": "data", + "data": {"createSurface": {"surfaceId": "sushi-seattle"}} + }, + {"kind": "text", "text": "Middle Text explaining the surface"}, + { + "kind": "data", + "data": {"updateComponents": {"surfaceId": "sushi-seattle"}} + } + ] + } + """ + .trimIndent() + ) - val textEvent = events[0] as ParsedA2AEvent.Text - assertEquals("Here is your native array map", textEvent.text) + val events = A2AResponseParser.parse(payload) + assertThat(events).hasSize(3) - val dataEvent = events[1] as ParsedA2AEvent.Data - val a2uiArray = JSONArray(dataEvent.data) - assertEquals(1, a2uiArray.length()) - assertTrue(a2uiArray.getJSONObject(0).has("updateComponents")) + val data1 = events[0] as ParsedA2AEvent.Data + assertThat(JSONArray(data1.data).length()).isEqualTo(1) + + assertThat(events[1]).isEqualTo(ParsedA2AEvent.Text("Middle Text explaining the surface")) + + val data2 = events[2] as ParsedA2AEvent.Data + assertThat(JSONArray(data2.data).length()).isEqualTo(1) + } + + /** + * Verifies that a data part is recognized as an A2UI payload if it contains recognized keys, even + * without a mime type. + */ + @Test + fun parse_dataPartWithImplicitA2UIKey_returnsDataEvent() { + val payload = + JSONObject( + """ + { + "parts": [ + { + "kind": "data", + "data": { + "createSurface": { + "surfaceId": "sushi-seattle", + "catalogId": "a2ui://maps-agentic-ui-catalog.json" + } + } + } + ] + } + """ + .trimIndent() + ) + + val events = A2AResponseParser.parse(payload) + assertThat(events).hasSize(1) + val dataEvent = events[0] as ParsedA2AEvent.Data + val arr = JSONArray(dataEvent.data) + assertThat(arr.length()).isEqualTo(1) + assertThat(arr.getJSONObject(0).has("createSurface")).isTrue() } } diff --git a/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/A2UIServicesTest.kt b/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/A2UIServicesTest.kt new file mode 100644 index 0000000..306e93f --- /dev/null +++ b/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/A2UIServicesTest.kt @@ -0,0 +1,74 @@ +// 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 com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +/** + * Unit tests for [A2UIServices]. + * + * Verifies global configuration, including Google Maps API Key provisioning. + */ +@RunWith(JUnit4::class) +class A2UIServicesTest { + + @Before + fun setUp() { + A2UIServices.provideAPIKey("") + } + + @After + fun tearDown() { + A2UIServices.provideAPIKey("") + } + + /** Verifies that the default API key is empty when initialized/reset. */ + @Test + fun defaultState_apiKeyIsEmpty() { + assertThat(A2UIServices.apiKey).isEmpty() + } + + /** Verifies that provideAPIKey() correctly sets the static apiKey property. */ + @Test + fun provideAPIKey_setsApiKey() { + A2UIServices.provideAPIKey("AIzaSyTestApiKey") + assertThat(A2UIServices.apiKey).isEqualTo("AIzaSyTestApiKey") + } + + /** Verifies that subsequent calls to provideAPIKey() override previous keys. */ + @Test + fun provideAPIKey_consecutiveUpdates_overridesKey() { + A2UIServices.provideAPIKey("FirstKey") + assertThat(A2UIServices.apiKey).isEqualTo("FirstKey") + + A2UIServices.provideAPIKey("SecondKey") + assertThat(A2UIServices.apiKey).isEqualTo("SecondKey") + } + + /** Verifies that blank or special character keys are stored verbatim. */ + @Test + fun provideAPIKey_blankOrSpecialCharacters_storesExactString() { + A2UIServices.provideAPIKey("AIzaSyTest-Key_123$!@#") + assertThat(A2UIServices.apiKey).isEqualTo("AIzaSyTest-Key_123$!@#") + + A2UIServices.provideAPIKey("") + assertThat(A2UIServices.apiKey).isEmpty() + } +} diff --git a/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/A2UIViewTest.kt b/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/A2UIViewTest.kt new file mode 100644 index 0000000..eb08de7 --- /dev/null +++ b/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/A2UIViewTest.kt @@ -0,0 +1,320 @@ +// 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 android.app.Activity +import android.content.Intent +import android.net.Uri +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.shadows.ShadowLooper + +/** + * Unit tests for [A2UIView]. + * + * Verifies rendering configuration, HTML asset loading, JavaScript injection, deduplication, + * latency measurement, and URL navigation overriding. + */ +@RunWith(RobolectricTestRunner::class) +class A2UIViewTest { + + private lateinit var activity: Activity + private lateinit var a2uiView: A2UIView + + @Before + fun setUp() { + activity = Robolectric.buildActivity(Activity::class.java).setup().get() + A2UIServices.provideAPIKey("AIzaSyValidKey") + a2uiView = A2UIView(activity) + activity.setContentView(a2uiView) + } + + @After + fun tearDown() { + A2UIServices.provideAPIKey("") + } + + private fun createMockRequest(urlStr: String?, isMainFrame: Boolean = true): WebResourceRequest { + val request = mock() + whenever(request.url).thenReturn(urlStr?.let { Uri.parse(it) }) + whenever(request.isForMainFrame).thenReturn(isMainFrame) + whenever(request.method).thenReturn("GET") + whenever(request.requestHeaders).thenReturn(emptyMap()) + return request + } + + /** + * Verifies that render() stores the A2UI payload and loads the HTML asset with the API key + * injected. + */ + @Test + fun render_withApiKey_storesPayloadAndLoadsInjectedHtml() { + val payload = """[{"createSurface": {"surfaceId": "test"}}]""" + + a2uiView.render(payload) + assertThat(a2uiView.a2uiJson).isEqualTo(payload) + + val shadowWebView = shadowOf(a2uiView) + val lastLoadedData = shadowWebView.lastLoadDataWithBaseURL + assertThat(lastLoadedData).isNotNull() + assertThat(lastLoadedData.baseUrl).isEqualTo("file:///android_asset/") + assertThat(lastLoadedData.mimeType).isEqualTo("text/html") + assertThat(lastLoadedData.encoding).isEqualTo("UTF-8") + assertThat(lastLoadedData.data).contains("AIzaSyValidKey") + } + + /** + * Verifies that updateA2uiJson() properly updates the a2uiJson property and schedules JavaScript + * evaluation with safely escaped JSON when JS is ready. + */ + @Test + fun updateA2uiJson_complexJsonPayload_updatesPayloadAndExecutes() { + val complexJson = + """[{"text": "Line 1\nLine 2 with \"quotes\" and 'single' and \\backslash"}]""" + a2uiView.onJsReadyInternal() + a2uiView.updateA2uiJson(complexJson) + ShadowLooper.idleMainLooper() + + assertThat(a2uiView.a2uiJson).isEqualTo(complexJson) + val lastEvaluatedJs = shadowOf(a2uiView).lastEvaluatedJavascript + assertThat(lastEvaluatedJs).isNotNull() + assertThat(lastEvaluatedJs).contains("shell.processA2uiMessages") + assertThat(lastEvaluatedJs).contains("Line 1") + assertThat(lastEvaluatedJs).contains("quotes") + } + + /** + * Verifies that updateA2uiJson() when JS is not ready only stores the payload without executing + * JS. + */ + @Test + fun updateA2uiJson_whenJsNotReady_storesPayloadWithoutEvaluating() { + val payload = """[{"createSurface": {"surfaceId": "pending"}}]""" + + a2uiView.updateA2uiJson(payload) + assertThat(a2uiView.a2uiJson).isEqualTo(payload) + val lastEvaluatedJs = shadowOf(a2uiView).lastEvaluatedJavascript + assertThat(lastEvaluatedJs).isNull() + } + + /** Verifies that onJsReadyInternal() marks the view ready and pushes queued payloads. */ + @Test + fun onJsReadyInternal_withQueuedPayload_triggersUpdate() { + val payload = """[{"createSurface": {"surfaceId": "queued"}}]""" + a2uiView.a2uiJson = payload + + a2uiView.onJsReadyInternal() + ShadowLooper.idleMainLooper() + + assertThat(a2uiView.a2uiJson).isEqualTo(payload) + val lastEvaluatedJs = shadowOf(a2uiView).lastEvaluatedJavascript + assertThat(lastEvaluatedJs).isNotNull() + assertThat(lastEvaluatedJs).contains("queued") + } + + /** + * Verifies that onRenderComplete callback correctly computes latency and receives status, and + * resets startTime so subsequent calls are ignored. + */ + @Test + fun onRenderCompleteInternal_computesLatencyAndInvokesCallback() { + var callCount = 0 + var invokedLatency: Long? = null + var invokedStatus: String? = null + a2uiView.onRenderComplete = { latency, status -> + callCount++ + invokedLatency = latency + invokedStatus = status + } + + val pastStartTime = System.currentTimeMillis() - 150 + a2uiView.render("[]", startTimeMs = pastStartTime) + a2uiView.onRenderCompleteInternal() + + assertThat(invokedLatency).isNotNull() + val latency = checkNotNull(invokedLatency) + assertThat(latency).isAtLeast(150L) + assertThat(invokedStatus).isEqualTo("A2UI Render Complete") + assertThat(callCount).isEqualTo(1) + + // Second call should not invoke callback because startTime was reset to null + a2uiView.onRenderCompleteInternal() + assertThat(callCount).isEqualTo(1) + } + + /** Verifies that onRenderCompleteInternal() does not throw when no callback is registered. */ + @Test + fun onRenderCompleteInternal_withNullCallback_doesNotCrash() { + a2uiView.onRenderComplete = null + a2uiView.render("[]") + a2uiView.onRenderCompleteInternal() + } + + /** + * Verifies that shouldOverrideUrlLoading intercepts web and maps URLs to launch ACTION_VIEW + * intents. + */ + @Test + fun shouldOverrideUrlLoading_webUrl_startsActionViewIntent() { + val request = createMockRequest("https://www.google.com") + + val client = shadowOf(a2uiView).webViewClient + val handled = client.shouldOverrideUrlLoading(a2uiView, request) + assertThat(handled).isTrue() + + val startedIntent = shadowOf(activity).nextStartedActivity + assertThat(startedIntent).isNotNull() + assertThat(startedIntent.action).isEqualTo(Intent.ACTION_VIEW) + assertThat(startedIntent.data).isEqualTo(Uri.parse("https://www.google.com")) + } + + /** + * Verifies that shouldOverrideUrlLoading with Google Maps URLs falls back to browser (null + * package) when the Maps app is not installed on the device. + */ + @Test + fun shouldOverrideUrlLoading_mapsUrlWithoutMapsApp_fallsBackToBrowser() { + val request = createMockRequest("https://maps.google.com/?q=sushi") + + val client = shadowOf(a2uiView).webViewClient + val handled = client.shouldOverrideUrlLoading(a2uiView, request) + assertThat(handled).isTrue() + + val startedIntent = shadowOf(activity).nextStartedActivity + assertThat(startedIntent).isNotNull() + assertThat(startedIntent.action).isEqualTo(Intent.ACTION_VIEW) + assertThat(startedIntent.data).isEqualTo(Uri.parse("https://maps.google.com/?q=sushi")) + assertThat(startedIntent.`package`).isNull() + } + + /** + * Verifies that shouldOverrideUrlLoading also recognizes path-based maps URLs (google.com/maps). + */ + @Test + fun shouldOverrideUrlLoading_pathBasedMapsUrl_interceptsIntent() { + val request = createMockRequest("https://www.google.com/maps/search/pizza") + + val client = shadowOf(a2uiView).webViewClient + val handled = client.shouldOverrideUrlLoading(a2uiView, request) + assertThat(handled).isTrue() + + val startedIntent = shadowOf(activity).nextStartedActivity + assertThat(startedIntent).isNotNull() + assertThat(startedIntent.action).isEqualTo(Intent.ACTION_VIEW) + assertThat(startedIntent.data).isEqualTo(Uri.parse("https://www.google.com/maps/search/pizza")) + } + + /** + * Verifies that non-http and non-https schemes (e.g. javascript:, about:blank) are not handled. + */ + @Test + fun shouldOverrideUrlLoading_nonHttpScheme_returnsFalse() { + val request = createMockRequest("javascript:void(0)") + + val client = shadowOf(a2uiView).webViewClient + val handled = client.shouldOverrideUrlLoading(a2uiView, request) + assertThat(handled).isFalse() + } + + /** Verifies that onReceivedError handles resource loading errors gracefully without crashing. */ + @Test + fun onReceivedError_withNullRequestAndError_logsAndDoesNotCrash() { + val client = shadowOf(a2uiView).webViewClient + client.onReceivedError(a2uiView, null, null) + } + + @Test + fun onReceivedError_withValidRequestAndError_delegatesAndDoesNotCrash() { + val request = createMockRequest("https://maps.googleapis.com/test", isMainFrame = true) + val error = mock() + whenever(error.description).thenReturn("Connection failed") + whenever(error.errorCode).thenReturn(-2) + + val client = shadowOf(a2uiView).webViewClient + client.onReceivedError(a2uiView, request, error) + + verify(request).isForMainFrame + verify(error).errorCode + } + + /** Verifies that secondary constructors initialize without errors. */ + @Test + fun constructor_withAttributeSetAndDefStyle_initializesSuccessfully() { + val viewWithAttrs = A2UIView(activity, null) + val viewWithStyle = A2UIView(activity, null, 0) + assertThat(viewWithAttrs).isNotNull() + assertThat(viewWithStyle).isNotNull() + } + + /** Verifies that shouldOverrideUrlLoading returns false when request or URL is null. */ + @Test + fun shouldOverrideUrlLoading_nullRequestOrUrl_returnsFalse() { + val client = shadowOf(a2uiView).webViewClient + val requestWithNullUrl = createMockRequest(null) + + assertThat(client.shouldOverrideUrlLoading(a2uiView, null as WebResourceRequest?)).isFalse() + assertThat(client.shouldOverrideUrlLoading(a2uiView, requestWithNullUrl)).isFalse() + } + + /** Verifies that shouldOverrideUrlLoading preserves Maps package when Maps app is installed. */ + @Test + fun shouldOverrideUrlLoading_mapsUrlWithInstalledMapsApp_setsMapsPackage() { + val mapsIntent = + Intent(Intent.ACTION_VIEW, Uri.parse("https://maps.google.com/?q=coffee")).apply { + `package` = "com.google.android.apps.maps" + } + val resolveInfo = + android.content.pm.ResolveInfo().apply { + activityInfo = + android.content.pm.ActivityInfo().apply { + packageName = "com.google.android.apps.maps" + name = "com.google.android.maps.MapsActivity" + } + } + shadowOf(activity.packageManager).addResolveInfoForIntent(mapsIntent, resolveInfo) + + val request = createMockRequest("https://maps.google.com/?q=coffee") + + val client = shadowOf(a2uiView).webViewClient + val handled = client.shouldOverrideUrlLoading(a2uiView, request) + assertThat(handled).isTrue() + + val startedIntent = shadowOf(activity).nextStartedActivity + assertThat(startedIntent).isNotNull() + assertThat(startedIntent.`package`).isEqualTo("com.google.android.apps.maps") + } + + /** Verifies that onJsReadyInternal does not evaluate JS when a2uiJson is empty. */ + @Test + fun onJsReadyInternal_withEmptyPayload_doesNotTriggerUpdate() { + a2uiView.a2uiJson = "" + a2uiView.onJsReadyInternal() + ShadowLooper.idleMainLooper() + + val lastEvaluatedJs = shadowOf(a2uiView).lastEvaluatedJavascript + assertThat(lastEvaluatedJs).isNull() + } +} diff --git a/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/WebAppInterfaceTest.kt b/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/WebAppInterfaceTest.kt new file mode 100644 index 0000000..c24dce0 --- /dev/null +++ b/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/WebAppInterfaceTest.kt @@ -0,0 +1,174 @@ +// 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 android.app.Activity +import android.content.Context +import android.widget.FrameLayout +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.shadows.ShadowLooper + +/** + * Unit tests for [WebAppInterface]. + * + * Verifies Web-to-Native event bridging, including user action callbacks (directions requests), + * JavaScript ready signaling, and WebView dynamic height adjustment. + */ +@RunWith(RobolectricTestRunner::class) +class WebAppInterfaceTest { + + private lateinit var context: Context + private lateinit var a2uiView: A2UIView + private lateinit var webAppInterface: WebAppInterface + + @Before + fun setUp() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + context = activity + A2UIServices.provideAPIKey("test-api-key") + a2uiView = A2UIView(context) + activity.setContentView(a2uiView) + webAppInterface = WebAppInterface(a2uiView, a2uiView) + } + + @After + fun tearDown() { + A2UIServices.provideAPIKey("") + } + + /** Verifies that sendA2uiMessages posts JavaScript evaluation and resets the resized state. */ + @Test + fun sendA2uiMessages_validPayload_postsJsEvaluationAndResetsResizeState() { + webAppInterface.resized = true + val payload = """[{"createSurface": {"surfaceId": "msg-1"}}]""" + + webAppInterface.sendA2uiMessages(payload) + ShadowLooper.idleMainLooper() + + assertThat(webAppInterface.resized).isFalse() + val lastEvaluatedJs = shadowOf(a2uiView).lastEvaluatedJavascript + assertThat(lastEvaluatedJs).isNotNull() + assertThat(lastEvaluatedJs).contains("shell.processA2uiMessages") + assertThat(lastEvaluatedJs).contains("msg-1") + } + + /** + * Verifies that when the web layer triggers onGetDirections(), the user action callback on + * [A2UIView] is notified with the exact action JSON payload. + */ + @Test + fun onGetDirections_actionPayload_surfacesActionToCallback() { + var receivedAction: String? = null + a2uiView.onUserAction = { actionJson -> receivedAction = actionJson } + + val actionPayload = """{"placeId": "ChIJN1t_tDeuEmsRUsoyG83frY4", "action": "get_directions"}""" + webAppInterface.onGetDirections(actionPayload) + + assertThat(receivedAction).isEqualTo(actionPayload) + } + + /** Verifies that onGetDirections() does not throw when no user action callback is registered. */ + @Test + fun onGetDirections_withNullActionCallback_doesNotCrash() { + a2uiView.onUserAction = null + webAppInterface.onGetDirections("""{"action": "test"}""") + } + + /** Verifies that onJsReady() triggers the JS ready lifecycle on A2UIView. */ + @Test + fun onJsReady_withQueuedPayload_triggersJsReadyOnA2uiView() { + val payload = """[{"createSurface": {"surfaceId": "test"}}]""" + a2uiView.a2uiJson = payload + + webAppInterface.onJsReady() + ShadowLooper.idleMainLooper() + + assertThat(a2uiView.a2uiJson).isEqualTo(payload) + val lastEvaluatedJs = shadowOf(a2uiView).lastEvaluatedJavascript + assertThat(lastEvaluatedJs).isNotNull() + assertThat(lastEvaluatedJs).contains("test") + } + + /** + * Verifies that onWebpageResized() updates the WebView layout parameters to match the content + * height and fires the onRenderComplete callback. + */ + @Test + fun onWebpageResized_newHeight_updatesLayoutAndTriggersRenderComplete() { + var renderCompleteInvoked = false + var completedStatus: String? = null + a2uiView.onRenderComplete = { _, status -> + renderCompleteInvoked = true + completedStatus = status + } + + a2uiView.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, 100) + + a2uiView.render("""[{"createSurface": {"surfaceId": "test"}}]""") + webAppInterface.onWebpageResized(350) + ShadowLooper.idleMainLooper() + + val expectedHeight = (350 * a2uiView.resources.displayMetrics.density).toInt() + assertThat(a2uiView.layoutParams.height).isEqualTo(expectedHeight) + assertThat(renderCompleteInvoked).isTrue() + assertThat(completedStatus).isEqualTo("A2UI Render Complete") + assertThat(webAppInterface.resized).isTrue() + } + + /** Verifies that subsequent onWebpageResized calls are ignored once resized is true. */ + @Test + fun onWebpageResized_whenAlreadyResized_isIdempotent() { + var renderCount = 0 + a2uiView.onRenderComplete = { _, _ -> renderCount++ } + + a2uiView.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, 100) + + a2uiView.render("""[{"createSurface": {"surfaceId": "test"}}]""") + webAppInterface.onWebpageResized(300) + ShadowLooper.idleMainLooper() + + assertThat(renderCount).isEqualTo(1) + + // Second resize call while resized == true should be ignored + webAppInterface.onWebpageResized(500) + ShadowLooper.idleMainLooper() + + assertThat(renderCount).isEqualTo(1) + } + + /** + * Verifies that onWebpageResized handles null layoutParams on the WebView gracefully without + * crashing. + */ + @Test + fun onWebpageResized_withNullLayoutParams_doesNotCrash() { + val unattachedView = A2UIView(context) + unattachedView.layoutParams = null + val customInterface = WebAppInterface(unattachedView, unattachedView) + customInterface.resized = false + + customInterface.onWebpageResized(300) + ShadowLooper.idleMainLooper() + + assertThat(customInterface.resized).isFalse() + } +} diff --git a/client/android/web_build/src/core-shell.ts b/client/android/web_build/src/core-shell.ts index ea3d5a2..37602c6 100644 --- a/client/android/web_build/src/core-shell.ts +++ b/client/android/web_build/src/core-shell.ts @@ -237,7 +237,7 @@ export abstract class A2UICoreShell extends LitElement { if (m.updateDataModel) surfaceId = m.updateDataModel.surfaceId; if (surfaceId) break; } - if (surfaceId) { + if (surfaceId && !this.rendererRef.getSurface(surfaceId)) { messages.unshift({ createSurface: { surfaceId, diff --git a/client/ios/GoogleMapsA2UI/Package.swift b/client/ios/GoogleMapsA2UI/Package.swift index 1cd990d..3693f4f 100644 --- a/client/ios/GoogleMapsA2UI/Package.swift +++ b/client/ios/GoogleMapsA2UI/Package.swift @@ -39,7 +39,9 @@ let package = Package( ), .testTarget( name: "GoogleMapsA2UITests", - dependencies: ["GoogleMapsA2UI"] + dependencies: ["GoogleMapsA2UI"], + // Test sources live directly under Tests/ rather than Tests/GoogleMapsA2UITests/. + path: "Tests" ), ] ) diff --git a/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/A2AResponseParser.swift b/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/A2AResponseParser.swift index f8650cc..55f99fc 100644 --- a/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/A2AResponseParser.swift +++ b/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/A2AResponseParser.swift @@ -113,8 +113,7 @@ public enum A2AResponseParser { } private static let a2uiKeys: Set = [ - "createSurface", "updateComponents", "updateDataModel", - "beginRendering", "surfaceUpdate", "dataModelUpdate", + "createSurface", "updateComponents", "updateDataModel", "deleteSurface", ] /// Checks if a given dictionary represents an A2UI payload. @@ -143,29 +142,32 @@ public enum A2AResponseParser { if textPart.contains(a2uiJsonTagOpen) { var remainingText = textPart while let startRange = remainingText.range(of: a2uiJsonTagOpen) { + let rest = remainingText[startRange.upperBound...] + guard let endRange = rest.range(of: a2uiJsonTagClose) else { + break + } + let intro = String(remainingText[.. WKWebView { + configureWebView(coordinator: context.coordinator) + } + + /// Configures the WKWebView instance with user scripts, bridge message handlers, and local HTML assets. + /// - Parameter coordinator: The coordinator handling navigation and message callbacks. + /// - Returns: A configured WKWebView instance. + func configureWebView(coordinator: Coordinator) -> WKWebView { let config = WKWebViewConfiguration() let contentController = WKUserContentController() // 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") + contentController.add(coordinator, name: "iOS") // Allows the JS ResizeObserver to notify Swift when the content height changes - contentController.add(context.coordinator, name: "heightObserver") + contentController.add(coordinator, name: "heightObserver") // Inject a script to intercept console.log and console.error output from the WKWebView. // This forwards JS logs to the native bridge, making it much easier to debug the web component in Xcode. @@ -144,8 +151,8 @@ struct A2UIMessageRepresentableView: UIViewRepresentable { webView.isInspectable = true } - webView.navigationDelegate = context.coordinator - webView.uiDelegate = context.coordinator + webView.navigationDelegate = coordinator + webView.uiDelegate = coordinator webView.scrollView.isScrollEnabled = false // Prevent double scrolling inside the chat list // Fix for the gray background sometimes seen at the boundaries of WKWebViews. @@ -173,9 +180,16 @@ struct A2UIMessageRepresentableView: UIViewRepresentable { /// - uiView: The WKWebView instance to update. /// - context: The SwiftUI context. func updateUIView(_ uiView: WKWebView, context: Context) { - // If the view updates and JS is ready, push the JSON - if context.coordinator.isJSReady { - context.coordinator.injectJSON(uiView, payload: payload) + updateWebView(uiView, coordinator: context.coordinator) + } + + /// Pushes the latest JSON payload to the WebView if the JavaScript bridge is ready. + /// - Parameters: + /// - uiView: The WKWebView instance to update. + /// - coordinator: The coordinator tracking the JavaScript ready state. + func updateWebView(_ uiView: WKWebView, coordinator: Coordinator) { + if coordinator.isJSReady { + coordinator.injectJSON(uiView, payload: payload) } } @@ -189,6 +203,7 @@ struct A2UIMessageRepresentableView: UIViewRepresentable { var parent: A2UIMessageRepresentableView var isJSReady = false var lastInjectedPayload: String? + var startTime: CFAbsoluteTime? = CFAbsoluteTimeGetCurrent() /// Initializes the coordinator with a reference to its parent view. /// - Parameter parent: The parent A2UIMessageRepresentableView. @@ -242,7 +257,8 @@ struct A2UIMessageRepresentableView: UIViewRepresentable { func injectJSON(_ webView: WKWebView, payload: Any) { // 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: []), + if JSONSerialization.isValidJSONObject(payload), + let jsonData = try? JSONSerialization.data(withJSONObject: payload, options: []), let str = String(data: jsonData, encoding: .utf8) { jsonString = str @@ -295,7 +311,9 @@ struct A2UIMessageRepresentableView: UIViewRepresentable { // Only update if difference > 5 to prevent infinite SwiftUI layout loops if abs(parent.dynamicHeight - targetHeight) > 5 { parent.dynamicHeight = targetHeight - parent.onRenderComplete?(parent.webViewID, 0.0, "success") + let latency = startTime != nil ? (CFAbsoluteTimeGetCurrent() - startTime!) : 0.0 + parent.onRenderComplete?(parent.webViewID, latency, "success") + startTime = nil } } } else if message.name == "iOS", diff --git a/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/Resources/index.html b/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/Resources/index.html index a5f7988..6389683 100644 --- a/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/Resources/index.html +++ b/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/Resources/index.html @@ -31,1202 +31,7421 @@ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; } - + diff --git a/client/ios/GoogleMapsA2UI/Tests/A2AResponseParserTests.swift b/client/ios/GoogleMapsA2UI/Tests/A2AResponseParserTests.swift index 4bc57f8..ac3f0d9 100644 --- a/client/ios/GoogleMapsA2UI/Tests/A2AResponseParserTests.swift +++ b/client/ios/GoogleMapsA2UI/Tests/A2AResponseParserTests.swift @@ -19,9 +19,8 @@ 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() { + func testParse_invalidJSONFormat() { let invalidPayload: [String: Any] = ["key": Date()] // Date is not valid JSON XCTAssertThrowsError(try A2AResponseParser.parse(invalidPayload)) { error in XCTAssertEqual(error as? A2AParserError, .invalidJSONFormat) @@ -29,15 +28,23 @@ final class A2AResponseParserTests: XCTestCase { } /// Tests that an error is thrown when the JSON payload lacks a recognizable `parts` structure. - func testParse_InvalidPayloadStructure() { + func testParse_invalidPayloadStructure() { let payloadWithNoParts: [String: Any] = ["status": "ok"] XCTAssertThrowsError(try A2AResponseParser.parse(payloadWithNoParts)) { error in XCTAssertEqual(error as? A2AParserError, .invalidPayloadStructure) } } + /// Tests that an error is thrown when `parts` is not an array of dictionaries. + func testParse_invalidPartsType_throwsError() { + let payloadWithInvalidParts: [String: Any] = ["parts": "not_an_array"] + XCTAssertThrowsError(try A2AResponseParser.parse(payloadWithInvalidParts)) { error in + XCTAssertEqual(error as? A2AParserError, .invalidPayloadStructure) + } + } + /// Tests that a single text part is parsed correctly into a text event. - func testParse_SimpleTextPart() throws { + func testParse_simpleTextPart() throws { let payload: [String: Any] = [ "parts": [ ["kind": "text", "text": "Show me some good sushi in Seattle"] @@ -46,16 +53,23 @@ final class A2AResponseParserTests: XCTestCase { let events = try A2AResponseParser.parse(payload) XCTAssertEqual(events.count, 1) + XCTAssertEqual(events.first?.textValue, "Show me some good sushi in Seattle") + } - guard case .text(let text) = events[0] else { - XCTFail("Expected text event") - return - } - XCTAssertEqual(text, "Show me some good sushi in Seattle") + /// Tests that parts containing empty text strings produce an empty event list. + func testParse_emptyTextPart_returnsEmptyList() throws { + let payload: [String: Any] = [ + "parts": [ + ["kind": "text", "text": ""] + ] + ] + + let events = try A2AResponseParser.parse(payload) + XCTAssertTrue(events.isEmpty) } /// Tests that multiple text parts within the `content.parts` path are parsed into separate text events. - func testParse_MultipleTextParts() throws { + func testParse_multipleTextParts() throws { let payload: [String: Any] = [ "content": [ "parts": [ @@ -66,23 +80,16 @@ final class A2AResponseParserTests: XCTestCase { ] let events = try A2AResponseParser.parse(payload) - XCTAssertEqual(events.count, 2) - - 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 .text(let text2) = events[1] { - XCTAssertEqual(text2, "What are their ratings?") - } else { - XCTFail("Expected second event to be text") - } + XCTAssertEqual( + events.map(\.textValue), + [ + "Show me some good sushi in Seattle", + "What are their ratings?", + ]) } /// Tests that an A2UI JSON payload embedded inside a text part using `` tags is extracted. - func testParse_EmbeddedA2UIJSON() throws { + func testParse_embeddedA2UIJSON() throws { let textWithJSON = "Here is the Seattle map {\"createSurface\": {\"surfaceId\": \"sushi-seattle\"}} Hope you like it!" let payload: [String: Any] = [ @@ -93,28 +100,69 @@ final class A2AResponseParserTests: XCTestCase { let events = try A2AResponseParser.parse(payload) XCTAssertEqual(events.count, 3) + XCTAssertEqual(events[0].textValue, "Here is the Seattle map") + XCTAssertEqual(events[1].metadataMimeType, "application/json+a2ui") + let dict = try XCTUnwrap(events[1].dataDictionaries?.first) + XCTAssertNotNil(dict["createSurface"]) + XCTAssertEqual(events[2].textValue, "Hope you like it!") + } - guard case .text(let prefix) = events[0] else { - return XCTFail("Expected text event") - } - XCTAssertEqual(prefix, "Here is the Seattle map") + /// Tests that an A2UI JSON array embedded inside a text part is parsed into a flat array of components. + func testParse_embeddedA2UIJSONArray_flattensToSingleArray() throws { + let textWithJSONArray = + "Here is the map: [{\"createSurface\": {\"surfaceId\": \"sushi-seattle\"}}, {\"updateComponents\": {\"surfaceId\": \"sushi-seattle\"}}] Done." + let payload: [String: Any] = [ + "parts": [ + ["kind": "text", "text": textWithJSONArray] + ] + ] - 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"]) + let events = try A2AResponseParser.parse(payload) + XCTAssertEqual(events.count, 3) + XCTAssertEqual(events[0].textValue, "Here is the map:") + XCTAssertEqual(events[1].metadataMimeType, "application/json+a2ui") + let array = try XCTUnwrap(events[1].dataDictionaries) + XCTAssertEqual(array.count, 2) + XCTAssertNotNil(array[0]["createSurface"]) + XCTAssertNotNil(array[1]["updateComponents"]) + XCTAssertEqual(events[2].textValue, "Done.") + } - guard case .text(let suffix) = events[2] else { - return XCTFail("Expected text event") - } - XCTAssertEqual(suffix, "Hope you like it!") + /// Tests that malformed JSON inside an embedded '' tag falls back gracefully to plain text. + func testParse_malformedEmbeddedA2UITag_fallsBackToPlainText() throws { + let textWithMalformedJSON = "Intro text {not_valid_json outro text" + let payload: [String: Any] = [ + "parts": [ + ["kind": "text", "text": textWithMalformedJSON] + ] + ] + + let events = try A2AResponseParser.parse(payload) + XCTAssertEqual( + events.map(\.textValue), + [ + "Intro text", + "{not_valid_json", + "outro text", + ]) + } + + /// Tests that an unclosed '' tag remains as plain text. + func testParse_unclosedA2UITag_fallsBackToPlainText() throws { + let unclosedTagText = "Before {\"createSurface\": {}} without closing tag" + let payload: [String: Any] = [ + "parts": [ + ["kind": "text", "text": unclosedTagText] + ] + ] + + let events = try A2AResponseParser.parse(payload) + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events.first?.textValue, unclosedTagText) } /// Tests that a data part with an explicit A2UI mime type is parsed and batched into an array. - func testParse_DataPartWithA2UIMimeType() throws { + func testParse_dataPartWithA2UIMimeType() throws { let payload: [String: Any] = [ "parts": [ [ @@ -133,19 +181,14 @@ final class A2AResponseParserTests: XCTestCase { let events = try A2AResponseParser.parse(payload) XCTAssertEqual(events.count, 1) + XCTAssertEqual(events.first?.metadataMimeType, "application/json+a2ui") - 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) + let a2uiArray = try XCTUnwrap(events.first?.dataArray) + XCTAssertEqual(a2uiArray.count, 1) } /// Tests that a data part is inferred as A2UI if it contains recognized keys, even without a mime type. - func testParse_DataPartWithImplicitA2UIKey() throws { + func testParse_dataPartWithImplicitA2UIKey() throws { let payload: [String: Any] = [ "parts": [ [ @@ -162,18 +205,13 @@ final class A2AResponseParserTests: XCTestCase { let events = try A2AResponseParser.parse(payload) XCTAssertEqual(events.count, 1) - - 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) - XCTAssertEqual(a2uiArray?.count, 1) + XCTAssertEqual(events.first?.metadataMimeType, "application/json+a2ui") + let a2uiArray = try XCTUnwrap(events.first?.dataArray) + XCTAssertEqual(a2uiArray.count, 1) } /// Tests that the parser can successfully locate and extract parts from the `status.message.parts` JSON path. - func testParse_StatusMessagePartsPath() throws { + func testParse_statusMessagePartsPath() throws { let payload: [String: Any] = [ "status": [ "message": [ @@ -186,16 +224,11 @@ final class A2AResponseParserTests: XCTestCase { let events = try A2AResponseParser.parse(payload) XCTAssertEqual(events.count, 1) - - guard case .text(let text) = events[0] else { - XCTFail("Expected text event") - return - } - XCTAssertEqual(text, "Seattle is home to a world-class sushi scene") + XCTAssertEqual(events.first?.textValue, "Seattle is home to a world-class sushi scene") } /// Tests that consecutive data parts identified as A2UI payloads are batched together into a single data event. - func testParse_ConsecutiveA2UIPayloadsAreBatched() throws { + func testParse_consecutiveA2UIPayloadsAreBatched() throws { let payload: [String: Any] = [ "parts": [ [ @@ -223,31 +256,16 @@ final class A2AResponseParserTests: XCTestCase { let events = try A2AResponseParser.parse(payload) XCTAssertEqual(events.count, 1) + XCTAssertEqual(events.first?.metadataMimeType, "application/json+a2ui") - guard case .data(let data, let metadata) = events[0] else { - XCTFail("Expected data event") - return - } - XCTAssertEqual(metadata?.mimeType, "application/json+a2ui") - - guard let a2uiArray = data as? [Any] else { - XCTFail("Expected data payload to be an array of batched items") - return - } + let a2uiArray = try XCTUnwrap(events.first?.dataDictionaries) XCTAssertEqual(a2uiArray.count, 2) - - guard let dict1 = a2uiArray[0] as? [String: Any], - let dict2 = a2uiArray[1] as? [String: Any] - else { - XCTFail("Expected array elements to be dictionaries") - return - } - XCTAssertNotNil(dict1["createSurface"]) - XCTAssertNotNil(dict2["updateComponents"]) + XCTAssertNotNil(a2uiArray[0]["createSurface"]) + XCTAssertNotNil(a2uiArray[1]["updateComponents"]) } /// Tests that an A2UI batch is finalized and a new one starts if interrupted by a text part. - func testParse_A2UIBatchInterruptedByTextPart() throws { + func testParse_a2uiBatchInterruptedByTextPart() throws { let payload: [String: Any] = [ "parts": [ [ @@ -267,31 +285,17 @@ final class A2AResponseParserTests: XCTestCase { let events = try A2AResponseParser.parse(payload) XCTAssertEqual(events.count, 3) - guard case .data(let data1, let metadata1) = events[0] else { - XCTFail("Expected first event to be data") - return - } - XCTAssertEqual(metadata1?.mimeType, "application/json+a2ui") - let batch1 = data1 as? [Any] - XCTAssertEqual(batch1?.count, 1) + XCTAssertEqual(events[0].metadataMimeType, "application/json+a2ui") + XCTAssertEqual(events[0].dataArray?.count, 1) - guard case .text(let text) = events[1] else { - XCTFail("Expected second event to be text") - return - } - XCTAssertEqual(text, "Middle Text explaining the surface") + XCTAssertEqual(events[1].textValue, "Middle Text explaining the surface") - guard case .data(let data2, let metadata2) = events[2] else { - XCTFail("Expected third event to be data") - return - } - XCTAssertEqual(metadata2?.mimeType, "application/json+a2ui") - let batch2 = data2 as? [Any] - XCTAssertEqual(batch2?.count, 1) + XCTAssertEqual(events[2].metadataMimeType, "application/json+a2ui") + XCTAssertEqual(events[2].dataArray?.count, 1) } /// Tests that multiple `` tags within a single text part are all extracted sequentially. - func testParse_MultipleEmbeddedA2UITags() throws { + func testParse_multipleEmbeddedA2UITags() throws { let textWithMultipleTags = "First map: {\"createSurface\": {\"surfaceId\": \"sushi\"}} Then: {\"updateComponents\": {\"surfaceId\": \"sushi\"}} Done." let payload: [String: Any] = [ @@ -303,24 +307,54 @@ final class A2AResponseParserTests: XCTestCase { let events = try A2AResponseParser.parse(payload) XCTAssertEqual(events.count, 5) - 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 - } + XCTAssertEqual(events[0].textValue, "First map:") + XCTAssertEqual(events[1].metadataMimeType, "application/json+a2ui") + let dict1 = try XCTUnwrap(events[1].dataDictionaries?.first) + XCTAssertNotNil(dict1["createSurface"]) + + XCTAssertEqual(events[2].textValue, "Then:") + XCTAssertEqual(events[3].metadataMimeType, "application/json+a2ui") + let dict2 = try XCTUnwrap(events[3].dataDictionaries?.first) + XCTAssertNotNil(dict2["updateComponents"]) + + XCTAssertEqual(events[4].textValue, "Done.") + } + + /// Tests that when the parts array is present but empty or contains unknown types, + /// the parser returns an empty list safely without throwing an exception. + func testParse_emptyOrUnknownParts_returnsEmptyList() throws { + let emptyPartsPayload: [String: Any] = ["parts": []] + let emptyEvents = try A2AResponseParser.parse(emptyPartsPayload) + XCTAssertEqual(emptyEvents.count, 0) - XCTAssertEqual(t1, "First map:") - XCTAssertEqual(m1?.mimeType, "application/json+a2ui") - XCTAssertNotNil((d1 as? [Any])?.first as? [String: Any]) + let unknownPartsPayload: [String: Any] = [ + "parts": [["kind": "unsupported_media"]] + ] + let unknownEvents = try A2AResponseParser.parse(unknownPartsPayload) + XCTAssertEqual(unknownEvents.count, 0) + } +} - XCTAssertEqual(t2, "Then:") - XCTAssertEqual(m2?.mimeType, "application/json+a2ui") - XCTAssertNotNil((d2 as? [Any])?.first as? [String: Any]) +// MARK: - Test Helpers + +extension ParsedA2AEvent { + fileprivate var textValue: String? { + guard case .text(let text) = self else { return nil } + return text + } + + fileprivate var dataArray: [Any]? { + guard case .data(let data, _) = self else { return nil } + return data as? [Any] + } + + fileprivate var dataDictionaries: [[String: Any]]? { + guard case .data(let data, _) = self else { return nil } + return data as? [[String: Any]] + } - XCTAssertEqual(t3, "Done.") + fileprivate var metadataMimeType: String? { + guard case .data(_, let metadata) = self else { return nil } + return metadata?.mimeType } } diff --git a/client/ios/GoogleMapsA2UI/Tests/A2UIServicesTests.swift b/client/ios/GoogleMapsA2UI/Tests/A2UIServicesTests.swift new file mode 100644 index 0000000..18b1791 --- /dev/null +++ b/client/ios/GoogleMapsA2UI/Tests/A2UIServicesTests.swift @@ -0,0 +1,74 @@ +// +// 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 + +/// Unit tests for `A2UIServices`. +/// +/// Verifies Google Maps API key configuration and HTML template resolution and caching. +@MainActor +final class A2UIServicesTests: XCTestCase { + + override func tearDown() { + super.tearDown() + A2UIServices.provideApiKey("") + } + + /// Tests that providing an API key injects the key into the resolved HTML content template. + func testProvideApiKey_SetsKeyAndInjectsIntoHtml() throws { + A2UIServices.provideApiKey("AIzaSyTestKey123") + let content = try XCTUnwrap(A2UIServices.getLocalHTMLContent()) + XCTAssertTrue(content.html.contains("AIzaSyTestKey123")) + XCTAssertFalse(content.html.contains("$GOOGLE_MAPS_API_KEY")) + } + + /// Tests that providing an empty API key clears the placeholder without crashing. + func testProvideApiKey_SetsEmptyKey_RemovesPlaceholder() throws { + A2UIServices.provideApiKey("") + let content = try XCTUnwrap(A2UIServices.getLocalHTMLContent()) + XCTAssertFalse(content.html.contains("$GOOGLE_MAPS_API_KEY")) + } + + /// Tests that getLocalHTMLContent returns the module bundle's resourceURL as baseURL. + func testGetLocalHTMLContent_ReturnsModuleBaseURL() throws { + A2UIServices.provideApiKey("TestKey") + let content = try XCTUnwrap(A2UIServices.getLocalHTMLContent()) + XCTAssertEqual(content.baseURL, Bundle.module.resourceURL) + } + + /// Tests that the resolved HTML template is cached when the API key does not change, + /// and invalidated/recalculated when a new key is provided. + func testGetLocalHTMLContent_CachesResultWhenKeyUnchanged() throws { + A2UIServices.provideApiKey("KeyAlpha") + let first = try XCTUnwrap(A2UIServices.getLocalHTMLContent()) + let second = try XCTUnwrap(A2UIServices.getLocalHTMLContent()) + XCTAssertEqual(first.html, second.html) + + A2UIServices.provideApiKey("KeyBeta") + let third = try XCTUnwrap(A2UIServices.getLocalHTMLContent()) + XCTAssertTrue(third.html.contains("KeyBeta")) + XCTAssertFalse(third.html.contains("KeyAlpha")) + } + + /// Tests that an API key containing special characters is injected verbatim without breaking HTML template resolution. + func testProvideApiKey_SpecialCharacters_InjectsVerbatim() throws { + A2UIServices.provideApiKey("AIzaSyTest-Key_123$!@#") + let content = try XCTUnwrap(A2UIServices.getLocalHTMLContent()) + XCTAssertTrue(content.html.contains("AIzaSyTest-Key_123$!@#")) + } +} diff --git a/client/ios/GoogleMapsA2UI/Tests/A2UIViewTests.swift b/client/ios/GoogleMapsA2UI/Tests/A2UIViewTests.swift new file mode 100644 index 0000000..ab70e0d --- /dev/null +++ b/client/ios/GoogleMapsA2UI/Tests/A2UIViewTests.swift @@ -0,0 +1,522 @@ +// +// 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 SwiftUI +import WebKit +import XCTest + +@testable import GoogleMapsA2UI + +/// Unit tests for `A2UIView` and its internal coordinator. +/// +/// Verifies JSON payload serialization, JavaScript injection deduplication, +/// and SwiftUI view hierarchy configuration for data vs text events. +@MainActor +final class A2UIViewTests: XCTestCase { + + /// Tests that the coordinator's injectJSON method correctly serializes complex native payloads + /// containing special characters, quotes, and newlines into the lastInjectedPayload cache. + func testCoordinator_InjectJSON_SerializesComplexPayload() throws { + var height: CGFloat = 100 + let parent = A2UIMessageRepresentableView( + webViewID: "test-view", + payload: [], + dynamicHeight: Binding(get: { height }, set: { height = $0 }), + onUserAction: { _ in }, + onRenderComplete: nil + ) + let coordinator = A2UIMessageRepresentableView.Coordinator(parent) + let webView = WKWebView() + + let payload: [String: Any] = [ + "createSurface": [ + "surfaceId": "sushi-1", + "description": "Text with \"double quotes\" and 'single' and \n newline.", + ] + ] + + coordinator.injectJSON(webView, payload: payload) + + let injected = try XCTUnwrap(coordinator.lastInjectedPayload) + XCTAssertTrue(injected.contains("sushi-1")) + XCTAssertTrue(injected.contains("double quotes")) + } + + /// Tests that the coordinator deduplicates subsequent identical JSON payload injections. + func testCoordinator_InjectJSON_DeduplicatesIdenticalPayload() throws { + var height: CGFloat = 100 + let parent = A2UIMessageRepresentableView( + webViewID: "test-view", + payload: [], + dynamicHeight: Binding(get: { height }, set: { height = $0 }), + onUserAction: { _ in }, + onRenderComplete: nil + ) + let coordinator = A2UIMessageRepresentableView.Coordinator(parent) + let webView = WKWebView() + + let payload = ["surfaceId": "test-dedupe"] + coordinator.injectJSON(webView, payload: payload) + let firstInjected = try XCTUnwrap(coordinator.lastInjectedPayload) + + // Second call with same payload + coordinator.injectJSON(webView, payload: payload) + XCTAssertEqual(coordinator.lastInjectedPayload, firstInjected) + } + + /// Tests that initializing `A2UIView` with an A2UI Data event produces a non-empty SwiftUI View body. + func testA2UIView_BodyStructure_WithDataEvent() { + let event = ParsedA2AEvent.data( + [["createSurface": ["surfaceId": "test"]]], + metadata: ParsedA2AEventMetadata(mimeType: "application/json+a2ui") + ) + + let view = A2UIView( + part: event, + id: "test-id", + onUserAction: { _ in } + ) + + let mirror = Mirror(reflecting: view.body) + let bodyDesc = String(describing: mirror.children.first?.value ?? "") + XCTAssertTrue(bodyDesc.contains("A2UIMessageInnerWrapper")) + } + + /// Tests that initializing `A2UIView` with a Text event safely defaults to an EmptyView. + func testA2UIView_BodyStructure_WithTextEvent() { + let event = ParsedA2AEvent.text("Just text") + let view = A2UIView( + part: event, + id: "text-id", + onUserAction: { _ in } + ) + + let mirror = Mirror(reflecting: view.body) + let bodyDesc = String(describing: mirror.children.first?.value ?? "") + XCTAssertTrue(bodyDesc.contains("EmptyView")) + } + + /// Tests that receiving an onGetDirections action via the iOS bridge triggers onUserAction callback. + func testCoordinator_UserContentController_OnGetDirections() { + var receivedAction: String? + var height: CGFloat = 100 + let parent = A2UIMessageRepresentableView( + webViewID: "test-view", + payload: [], + dynamicHeight: Binding(get: { height }, set: { height = $0 }), + onUserAction: { action in receivedAction = action }, + onRenderComplete: nil + ) + let coordinator = A2UIMessageRepresentableView.Coordinator(parent) + let message = MockScriptMessage( + name: "iOS", + body: ["action": "onGetDirections", "data": "{\"placeId\": \"123\"}"] + ) + + coordinator.userContentController(WKUserContentController(), didReceive: message) + + XCTAssertEqual(receivedAction, "{\"placeId\": \"123\"}") + } + + /// Tests that heightObserver updates the dynamic height and fires onRenderComplete when delta > 5. + func testCoordinator_UserContentController_HeightObserver_UpdatesHeight() { + var height: CGFloat = 100 + var renderCompletedId: String? + var renderStatus: String? + var renderLatency: Double? + let parent = A2UIMessageRepresentableView( + webViewID: "test-height-view", + payload: [], + dynamicHeight: Binding(get: { height }, set: { height = $0 }), + onUserAction: { _ in }, + onRenderComplete: { id, latency, status in + renderCompletedId = id + renderLatency = latency + renderStatus = status + } + ) + let coordinator = A2UIMessageRepresentableView.Coordinator(parent) + let message = MockScriptMessage(name: "heightObserver", body: CGFloat(250)) + + coordinator.userContentController(WKUserContentController(), didReceive: message) + + XCTAssertEqual(height, 250) + XCTAssertEqual(renderCompletedId, "test-height-view") + XCTAssertEqual(renderStatus, "success") + XCTAssertNotNil(renderLatency) + } + + /// Tests that heightObserver ignores small height changes (<= 5) to prevent layout thrashing. + func testCoordinator_UserContentController_HeightObserver_SuppressesSmallChanges() { + var height: CGFloat = 100 + var renderCalled = false + let parent = A2UIMessageRepresentableView( + webViewID: "test-suppress-view", + payload: [], + dynamicHeight: Binding(get: { height }, set: { height = $0 }), + onUserAction: { _ in }, + onRenderComplete: { _, _, _ in renderCalled = true } + ) + let coordinator = A2UIMessageRepresentableView.Coordinator(parent) + let message = MockScriptMessage(name: "heightObserver", body: CGFloat(103)) + + coordinator.userContentController(WKUserContentController(), didReceive: message) + + XCTAssertEqual(height, 100) + XCTAssertFalse(renderCalled) + } + + /// Tests that heightObserver ignores invalid/small heights (<= 50). + func testCoordinator_UserContentController_HeightObserver_IgnoresSmallHeights() { + var height: CGFloat = 100 + let parent = A2UIMessageRepresentableView( + webViewID: "test-view", + payload: [], + dynamicHeight: Binding(get: { height }, set: { height = $0 }), + onUserAction: { _ in }, + onRenderComplete: nil + ) + let coordinator = A2UIMessageRepresentableView.Coordinator(parent) + let message = MockScriptMessage(name: "heightObserver", body: CGFloat(30)) + + coordinator.userContentController(WKUserContentController(), didReceive: message) + + XCTAssertEqual(height, 100) + } + + /// Tests that onJsReady sets isJSReady flag and injects payload. + func testCoordinator_UserContentController_OnJsReady_SetsReadyAndInjects() { + var height: CGFloat = 100 + let webView = WKWebView() + let parent = A2UIMessageRepresentableView( + webViewID: "test-view", + payload: [["createSurface": ["surfaceId": "ready-test"]]], + dynamicHeight: Binding(get: { height }, set: { height = $0 }), + onUserAction: { _ in }, + onRenderComplete: nil + ) + let coordinator = A2UIMessageRepresentableView.Coordinator(parent) + let message = MockScriptMessage( + name: "iOS", + body: ["action": "onJsReady", "data": ""], + webView: webView + ) + + XCTAssertFalse(coordinator.isJSReady) + coordinator.userContentController(WKUserContentController(), didReceive: message) + + XCTAssertTrue(coordinator.isJSReady) + XCTAssertNotNil(coordinator.lastInjectedPayload) + } + + /// Tests that injectJSON falls back safely to "[]" when given an un-serializable payload. + func testCoordinator_InjectJSON_NonSerializablePayload_FallsBackSafely() { + var height: CGFloat = 100 + let parent = A2UIMessageRepresentableView( + webViewID: "test-view", + payload: [], + dynamicHeight: Binding(get: { height }, set: { height = $0 }), + onUserAction: { _ in }, + onRenderComplete: nil + ) + let coordinator = A2UIMessageRepresentableView.Coordinator(parent) + let webView = WKWebView() + + // Pass a non-serializable object + let invalidPayload: [String: Any] = ["invalid": NSObject()] + coordinator.injectJSON(webView, payload: invalidPayload) + + XCTAssertEqual(coordinator.lastInjectedPayload, "[]") + } + + /// Tests that the coordinator handles JavaScript logging and error messages without crashing. + func testCoordinator_UserContentController_LogAndErrorMessages() { + var height: CGFloat = 100 + let parent = A2UIMessageRepresentableView( + webViewID: "test-view", + payload: [], + dynamicHeight: Binding(get: { height }, set: { height = $0 }), + onUserAction: { _ in }, + onRenderComplete: nil + ) + let coordinator = A2UIMessageRepresentableView.Coordinator(parent) + + let logMessage = MockScriptMessage( + name: "iOS", + body: ["action": "log", "data": "Test log output"] + ) + coordinator.userContentController(WKUserContentController(), didReceive: logMessage) + + let errorMessage = MockScriptMessage( + name: "iOS", + body: ["action": "error", "data": "Test JS error"] + ) + coordinator.userContentController(WKUserContentController(), didReceive: errorMessage) + } + + /// Tests that malformed or unrecognized script messages are handled safely without throwing. + func testCoordinator_UserContentController_MalformedMessages_HandledGracefully() { + var height: CGFloat = 100 + let parent = A2UIMessageRepresentableView( + webViewID: "test-view", + payload: [], + dynamicHeight: Binding(get: { height }, set: { height = $0 }), + onUserAction: { _ in }, + onRenderComplete: nil + ) + let coordinator = A2UIMessageRepresentableView.Coordinator(parent) + + // 1. Missing action/data keys + let missingKeysMessage = MockScriptMessage( + name: "iOS", + body: ["invalid": "value"] + ) + coordinator.userContentController(WKUserContentController(), didReceive: missingKeysMessage) + + // 2. Unknown action string + let unknownActionMessage = MockScriptMessage( + name: "iOS", + body: ["action": "unknownCustomAction", "data": "{}"] + ) + coordinator.userContentController(WKUserContentController(), didReceive: unknownActionMessage) + + // 3. Height observer with non-numeric body + let badHeightMessage = MockScriptMessage( + name: "heightObserver", + body: "not-a-number" + ) + coordinator.userContentController(WKUserContentController(), didReceive: badHeightMessage) + + XCTAssertEqual(height, 100) + } + + /// Tests that external HTTP/HTTPS link clicks trigger navigation cancellation in decidePolicyFor. + func testCoordinator_DecidePolicyForNavigationAction_ExternalLink_CancelsPolicy() { + var height: CGFloat = 100 + let parent = A2UIMessageRepresentableView( + webViewID: "test-view", + payload: [], + dynamicHeight: Binding(get: { height }, set: { height = $0 }), + onUserAction: { _ in }, + onRenderComplete: nil + ) + let coordinator = A2UIMessageRepresentableView.Coordinator(parent) + let webView = WKWebView() + + let url = URL(string: "https://www.google.com")! + let action = MockNavigationAction( + navigationType: .linkActivated, + request: URLRequest(url: url) + ) + + var decidedPolicy: WKNavigationActionPolicy? + coordinator.webView(webView, decidePolicyFor: action) { policy in + decidedPolicy = policy + } + + XCTAssertEqual(decidedPolicy, .cancel) + } + + /// Tests that non-link navigation actions (e.g. other/reload) are allowed in decidePolicyFor. + func testCoordinator_DecidePolicyForNavigationAction_OtherNavigation_AllowsPolicy() { + var height: CGFloat = 100 + let parent = A2UIMessageRepresentableView( + webViewID: "test-view", + payload: [], + dynamicHeight: Binding(get: { height }, set: { height = $0 }), + onUserAction: { _ in }, + onRenderComplete: nil + ) + let coordinator = A2UIMessageRepresentableView.Coordinator(parent) + let webView = WKWebView() + + let url = URL(string: "https://www.google.com")! + let action = MockNavigationAction( + navigationType: .other, + request: URLRequest(url: url) + ) + + var decidedPolicy: WKNavigationActionPolicy? + coordinator.webView(webView, decidePolicyFor: action) { policy in + decidedPolicy = policy + } + + XCTAssertEqual(decidedPolicy, .allow) + } + + /// Tests that createWebViewWith returns nil when intercepting window.open popup requests. + func testCoordinator_CreateWebViewWith_ReturnsNil() { + var height: CGFloat = 100 + let parent = A2UIMessageRepresentableView( + webViewID: "test-view", + payload: [], + dynamicHeight: Binding(get: { height }, set: { height = $0 }), + onUserAction: { _ in }, + onRenderComplete: nil + ) + let coordinator = A2UIMessageRepresentableView.Coordinator(parent) + let webView = WKWebView() + let config = WKWebViewConfiguration() + + let url = URL(string: "https://maps.google.com")! + let action = MockNavigationAction( + navigationType: .linkActivated, + request: URLRequest(url: url) + ) + + let createdView = coordinator.webView( + webView, + createWebViewWith: config, + for: action, + windowFeatures: WKWindowFeatures() + ) + + XCTAssertNil(createdView) + } + + /// Tests that mounting A2UIView in a UIHostingController triggers makeCoordinator and makeUIView without crashing. + func testA2UIView_UIHostingController_MountsViewHierarchyAndInitializesWebView() { + let event = ParsedA2AEvent.data( + [["createSurface": ["surfaceId": "mount-test"]]], + metadata: ParsedA2AEventMetadata(mimeType: "application/json+a2ui") + ) + let view = A2UIView( + part: event, + id: "mount-test-id", + onUserAction: { _ in }, + onRenderComplete: { _, _, _ in } + ) + + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 375, height: 667)) + let hostingController = UIHostingController(rootView: view) + window.rootViewController = hostingController + window.makeKeyAndVisible() + hostingController.loadViewIfNeeded() + hostingController.view.layoutIfNeeded() + + XCTAssertNotNil(hostingController.view) + } + + /// Tests that mounting A2UIView with a Text event renders an EmptyView inside a UIHostingController. + func testA2UIView_UIHostingController_WithTextEvent_MountsEmptyView() { + let event = ParsedA2AEvent.text("mount text test") + let view = A2UIView( + part: event, + id: "mount-text-id", + onUserAction: { _ in } + ) + + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 375, height: 667)) + let hostingController = UIHostingController(rootView: view) + window.rootViewController = hostingController + window.makeKeyAndVisible() + hostingController.loadViewIfNeeded() + hostingController.view.layoutIfNeeded() + + XCTAssertNotNil(hostingController.view) + } + + /// Tests that A2UIMessageRepresentableView creates a configured coordinator instance. + func testA2UIMessageRepresentableView_MakeCoordinator_InitializesCorrectly() { + var height: CGFloat = 100 + let view = A2UIMessageRepresentableView( + webViewID: "coordinator-test-id", + payload: ["surfaceId": "test"], + dynamicHeight: Binding(get: { height }, set: { height = $0 }), + onUserAction: { _ in }, + onRenderComplete: nil + ) + + let coordinator = view.makeCoordinator() + XCTAssertEqual(coordinator.parent.webViewID, "coordinator-test-id") + XCTAssertFalse(coordinator.isJSReady) + } + + /// Tests that configureWebView properly sets up WKWebView properties, delegates, and configuration. + func testA2UIMessageRepresentableView_ConfigureWebView_InitializesConfigurationAndDelegates() { + var height: CGFloat = 100 + let view = A2UIMessageRepresentableView( + webViewID: "configure-test-id", + payload: ["surfaceId": "test"], + dynamicHeight: Binding(get: { height }, set: { height = $0 }), + onUserAction: { _ in }, + onRenderComplete: nil + ) + let coordinator = view.makeCoordinator() + let webView = view.configureWebView(coordinator: coordinator) + + XCTAssertFalse(webView.isOpaque) + XCTAssertEqual(webView.backgroundColor, .clear) + XCTAssertFalse(webView.scrollView.isScrollEnabled) + XCTAssertTrue(webView.navigationDelegate === coordinator) + XCTAssertTrue(webView.uiDelegate === coordinator) + } + + /// Tests that updateWebView injects JSON when JS is ready, and skips when not ready. + func testA2UIMessageRepresentableView_UpdateWebView_InjectsWhenJSReady() { + var height: CGFloat = 100 + let view = A2UIMessageRepresentableView( + webViewID: "update-test-id", + payload: ["surfaceId": "update-test"], + dynamicHeight: Binding(get: { height }, set: { height = $0 }), + onUserAction: { _ in }, + onRenderComplete: nil + ) + let coordinator = view.makeCoordinator() + let webView = WKWebView() + + // 1. When not ready, updateWebView does nothing + coordinator.isJSReady = false + view.updateWebView(webView, coordinator: coordinator) + XCTAssertNil(coordinator.lastInjectedPayload) + + // 2. When ready, updateWebView injects JSON + coordinator.isJSReady = true + view.updateWebView(webView, coordinator: coordinator) + XCTAssertNotNil(coordinator.lastInjectedPayload) + } +} + +private class MockNavigationAction: WKNavigationAction { + private let _navigationType: WKNavigationType + private let _request: URLRequest + + init(navigationType: WKNavigationType, request: URLRequest) { + self._navigationType = navigationType + self._request = request + super.init() + } + + override var navigationType: WKNavigationType { _navigationType } + override var request: URLRequest { _request } +} + +private class MockScriptMessage: WKScriptMessage { + private let _name: String + private let _body: Any + private weak var _webView: WKWebView? + + init(name: String, body: Any, webView: WKWebView? = nil) { + self._name = name + self._body = body + self._webView = webView + super.init() + } + + override var name: String { _name } + override var body: Any { _body } + override var webView: WKWebView? { _webView } +} diff --git a/client/ios/web_build/src/core-shell.ts b/client/ios/web_build/src/core-shell.ts index ea3d5a2..37602c6 100644 --- a/client/ios/web_build/src/core-shell.ts +++ b/client/ios/web_build/src/core-shell.ts @@ -237,7 +237,7 @@ export abstract class A2UICoreShell extends LitElement { if (m.updateDataModel) surfaceId = m.updateDataModel.surfaceId; if (surfaceId) break; } - if (surfaceId) { + if (surfaceId && !this.rendererRef.getSurface(surfaceId)) { messages.unshift({ createSurface: { surfaceId, diff --git a/client/web/src/lit/a2ui_client.ts b/client/web/src/lit/a2ui_client.ts index 64d4eda..a0af77c 100644 --- a/client/web/src/lit/a2ui_client.ts +++ b/client/web/src/lit/a2ui_client.ts @@ -55,36 +55,37 @@ export class A2UIClient { return this.client; } - async send( - message: any | string - ): Promise> { - const client = await this.getClient(); - let parts: Part[] = []; - - if (typeof message === 'string') { - // Try to parse as JSON first, just in case - try { - const parsed = JSON.parse(message); - if (typeof parsed === 'object' && parsed !== null) { - parts = [{ - kind: "data", - data: parsed as unknown as Record, - mimeType: A2UI_MIME_TYPE, - } as Part]; - } else { - parts = [{ kind: "text", text: message }]; - } - } catch { - parts = [{ kind: "text", text: message }]; - } - } else { - parts = [{ + private _buildMessageParts(message: any | string): Part[] { + if (typeof message !== 'string') { + return [{ kind: "data", data: message as unknown as Record, mimeType: A2UI_MIME_TYPE, } as Part]; } + try { + const parsed = JSON.parse(message); + if (typeof parsed === 'object' && parsed !== null) { + return [{ + kind: "data", + data: parsed as unknown as Record, + mimeType: A2UI_MIME_TYPE, + } as Part]; + } + } catch { + // Ignore JSON parse error, fall through to text + } + + return [{ kind: "text", text: message }]; + } + + async send( + message: any | string + ): Promise> { + const client = await this.getClient(); + const parts = this._buildMessageParts(message); + const response = await client.sendMessage({ message: { messageId: crypto.randomUUID(), @@ -113,4 +114,45 @@ export class A2UIClient { return []; } + + async *sendStream( + message: any | string + ): AsyncGenerator<{ type: "text"; text: string } | { type: "a2ui"; message: any }> { + const client = await this.getClient(); + const parts = this._buildMessageParts(message); + + const stream = client.sendMessageStream({ + message: { + messageId: crypto.randomUUID(), + role: "user", + parts: parts, + kind: "message", + }, + }); + + const yieldedDataPayloads = new Set(); + let yieldedText = ""; + + for await (const event of stream) { + if (event.kind !== 'status-update' || !event.status) continue; + if (!event.status.message?.parts) continue; + + for (const part of event.status.message.parts) { + if (part.kind === 'text' && (part as any).text) { + const newText = (part as any).text; + const deltaText = newText.startsWith(yieldedText) ? newText.substring(yieldedText.length) : newText; + if (deltaText) { + yield { type: "text", text: deltaText }; + yieldedText += deltaText; + } + } else if (part.kind === 'data' && part.data) { + const payloadStr = JSON.stringify(part.data); + if (!yieldedDataPayloads.has(payloadStr)) { + yield { type: "a2ui", message: part.data }; + yieldedDataPayloads.add(payloadStr); + } + } + } + } + } } diff --git a/client/web/src/lit/a2ui_client_test.ts b/client/web/src/lit/a2ui_client_test.ts new file mode 100644 index 0000000..86034bd --- /dev/null +++ b/client/web/src/lit/a2ui_client_test.ts @@ -0,0 +1,92 @@ +/* + * 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 { A2AClient } from "@a2a-js/sdk/client"; +import { A2UIClient } from "./a2ui_client"; + +describe("A2UIClient", () => { + let client: A2UIClient; + let mockA2AClient: any; + + beforeEach(() => { + if (!globalThis.crypto) { + (globalThis as any).crypto = {}; + } + globalThis.crypto.randomUUID = () => "11111111-2222-3333-4444-555555555555"; + + client = new A2UIClient("http://fake-server.com"); + + mockA2AClient = { + sendMessage: jasmine.createSpy("sendMessage"), + sendMessageStream: jasmine.createSpy("sendMessageStream"), + }; + + spyOn(A2AClient, "fromCardUrl").and.resolveTo(mockA2AClient as unknown as A2AClient); + }); + + it("should process traditional blocking response correctly", async () => { + mockA2AClient.sendMessage.and.resolveTo({ + result: { + kind: "task", + status: { + message: { + parts: [ + { kind: "text", text: "Here is your map." }, + { kind: "data", data: { createSurface: { surfaceId: "map_1" } } } + ] + } + } + } + }); + + const result = await client.send("Show me a map"); + + expect(result.length).toBe(2); + expect(result[0]).toEqual({ type: "text", text: "Here is your map." }); + expect(result[1]).toEqual({ type: "a2ui", message: { createSurface: { surfaceId: "map_1" } } }); + }); + + it("should parse and yield chunked stream correctly, handling text deltas and dropping status updates", async () => { + mockA2AClient.sendMessageStream.and.returnValue((async function* () { + yield { + kind: "status-update", + status: { message: { parts: [{ kind: "text", text: "Hello " }] } } + }; + + yield { + kind: "status-update", + status: { message: { parts: [{ kind: "text", text: "Hello World!" }] } } + }; + + yield { + kind: "status-update", + status: { message: { parts: [{ kind: "data", data: { updateDataModel: { foo: "bar" } } }] } } + }; + })()); + + const generator = client.sendStream("Say hello and show card"); + const yieldedItems = []; + + for await (const item of generator) { + yieldedItems.push(item); + } + + expect(yieldedItems.length).toBe(3); + expect(yieldedItems[0]).toEqual({ type: "text", text: "Hello " }); + expect(yieldedItems[1]).toEqual({ type: "text", text: "World!" }); + expect(yieldedItems[2]).toEqual({ type: "a2ui", message: { updateDataModel: { foo: "bar" } } }); + }); +}); diff --git a/client/web/src/lit/a2ui_renderer.ts b/client/web/src/lit/a2ui_renderer.ts index a1df0b8..008f6a7 100644 --- a/client/web/src/lit/a2ui_renderer.ts +++ b/client/web/src/lit/a2ui_renderer.ts @@ -14,13 +14,15 @@ limitations under the License. */ -import * as v0_9 from "@a2ui/web_core/v0_9"; -import { basicCatalog, Context } from "@a2ui/lit/v0_9"; -import { LitElement, html } from "lit"; -import { ContextProvider } from "@lit/context"; -import { renderMarkdown } from "@a2ui/markdown-it"; -import * as Types from "@a2ui/web_core/types/types"; -import { mapsAgenticUICatalog } from "./catalog"; +import {basicCatalog, Context} from '@a2ui/lit/v0_9'; +import {renderMarkdown} from '@a2ui/markdown-it'; +import * as Types from '@a2ui/web_core/types/types'; +import * as v0_9 from '@a2ui/web_core/v0_9'; +import {ContextProvider} from '@lit/context'; +import {html, LitElement} from 'lit'; + +import {mapsAgenticUICatalog} from './catalog'; +import {type GroundingSource} from './custom-components/grounding_sources'; export class MAUIProviders extends LitElement { private markdownProvider = new ContextProvider(this, { @@ -36,24 +38,46 @@ export class MAUIProviders extends LitElement { } } -if (!customElements.get("maui-providers")) { - customElements.define("maui-providers", MAUIProviders); +if (!customElements.get('maui-providers')) { + customElements.define('maui-providers', MAUIProviders); } -export type TimelineItem = - | { type: "text"; text: string } - | { type: "user"; text: string } - | { type: "action"; text: string; action: string } - | { type: "surface"; surfaceId: string }; +export type TimelineItem =|{ + type: 'text'; + text: string; + sources?: GroundingSource[] +} +|{ + type: 'user'; + text: string +} +|{ + type: 'action'; + text: string; + action: string +} +|{ + type: 'surface'; + surfaceId: string; + sources?: GroundingSource[] +} +|{ + type: 'sources'; + sources: GroundingSource[] +}; -const A2UI_TOP_LEVEL_KEYS = ['createSurface', 'updateComponents', 'updateDataModel', 'deleteSurface', 'beginRendering', 'surfaceUpdate', 'dataModelUpdate']; +const A2UI_TOP_LEVEL_KEYS = [ + 'createSurface', 'updateComponents', 'updateDataModel', 'deleteSurface', + 'beginRendering', 'surfaceUpdate', 'dataModelUpdate' +]; export class A2UIRenderer { private readonly messageProcessor = new v0_9.MessageProcessor( - [mapsAgenticUICatalog], - async (action: v0_9.A2uiClientAction): Promise => { - console.warn("Action handling is unimplemented", action); - }, + [mapsAgenticUICatalog], + async(action: v0_9.A2uiClientAction): + Promise => { + console.warn('Action handling is unimplemented', action); + }, ); private timelineItems: TimelineItem[] = []; @@ -90,21 +114,84 @@ export class A2UIRenderer { /** * Processes a response from the A2UI client and updates the timeline. */ - processResponse(orderedParts: Array<{ type: "text", text: string } | { type: "a2ui", message: any }>) { + processResponse( + orderedParts: + Array<{type: 'text', text: string}|{type: 'a2ui', message: any}>) { const uiMessages: any[] = []; const newItems: TimelineItem[] = []; for (const part of orderedParts) { - if (part.type === "text") { - newItems.push({ type: "text", text: part.text }); - } else if (part.type === "a2ui") { + if (part.type === 'text') { + const lastNewItem = + newItems.length > 0 ? newItems[newItems.length - 1] : null; + + let lastTimelineTextIndex = -1; + for (let j = this.timelineItems.length - 1; j >= 0; j--) { + if (this.timelineItems[j].type === 'text') { + lastTimelineTextIndex = j; + break; + } + } + + if (lastNewItem && lastNewItem.type === 'text') { + lastNewItem.text += part.text; + } else if (lastTimelineTextIndex !== -1) { + const lastTimelineTextItem = + this.timelineItems[lastTimelineTextIndex] as + {type: 'text', text: string}; + const updatedItem = { + ...lastTimelineTextItem, + text: lastTimelineTextItem.text + part.text + }; + this.timelineItems = [ + ...this.timelineItems.slice(0, lastTimelineTextIndex), updatedItem, + ...this.timelineItems.slice(lastTimelineTextIndex + 1) + ]; + } else { + newItems.push({type: 'text', text: part.text}); + } + } else if (part.type === 'a2ui') { + if (part.message && part.message.groundingSources) { + const sources = part.message.groundingSources as GroundingSource[]; + let attached = false; + for (let i = newItems.length - 1; i >= 0; i--) { + const item = newItems[i]; + if (item.type === 'surface' || item.type === 'text') { + item.sources = sources; + attached = true; + break; + } + } + if (!attached) { + for (let i = this.timelineItems.length - 1; i >= 0; i--) { + const item = this.timelineItems[i]; + if (item.type === 'surface' || item.type === 'text') { + const updatedItem = {...item, sources}; + this.timelineItems = [ + ...this.timelineItems.slice(0, i), + updatedItem, + ...this.timelineItems.slice(i + 1), + ]; + attached = true; + break; + } + } + } + if (!attached) { + newItems.push({type: 'sources', sources}); + } + continue; + } + uiMessages.push(part.message); const surfaceId = this.getSurfaceId(part.message); // Record the surface in the timeline if it's new - if (!this.timelineItems.find(t => t.type === "surface" && t.surfaceId === surfaceId) && - !newItems.find(t => t.type === "surface" && t.surfaceId === surfaceId)) { - newItems.push({ type: "surface", surfaceId }); + if (!this.timelineItems.find( + t => t.type === 'surface' && t.surfaceId === surfaceId) && + !newItems.find( + t => t.type === 'surface' && t.surfaceId === surfaceId)) { + newItems.push({type: 'surface', surfaceId}); } } } @@ -119,6 +206,6 @@ export class A2UIRenderer { * Adds a user message to the timeline. */ addUserMessage(text: string) { - this.timelineItems = [...this.timelineItems, { type: "user", text }]; + this.timelineItems = [...this.timelineItems, {type: 'user', text}]; } } diff --git a/client/web/src/lit/custom-components/3d_marker.ts b/client/web/src/lit/custom-components/3d_marker.ts new file mode 100644 index 0000000..42960cd --- /dev/null +++ b/client/web/src/lit/custom-components/3d_marker.ts @@ -0,0 +1,104 @@ +/* + Copyright 2026 Google LLC + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + 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. + */ + +let nextMarkerId = 0; + +/** + * Helper to dynamically load the maps3d library from google.maps at runtime if + * needed. + */ +export async function loadMaps3DLibrary(): + Promise { + if (typeof google !== 'undefined' && google.maps && + google.maps.importLibrary) { + return (await google.maps.importLibrary('maps3d')) as + google.maps.Maps3DLibrary; + } + return null; +} + +/** + * Marker3DElementOptions extending official + * google.maps.maps3d.Marker3DElementOptions. + */ +export interface Marker3DElementOptions extends google.maps.maps3d + .Marker3DElementOptions { + id?: string; + label?: string|null; + labelCollisionBehavior?: google.maps.CollisionBehavior; +} + +/** + * ThreeDMarker wraps the creation of web component elements + * based on Marker3DElementOptions, and optionally creates an associated + * element if a label is supplied. + */ +export class ThreeDMarker { + protected readonly element: HTMLElement; + protected readonly labelElement: HTMLElement|null; + + constructor(options: Marker3DElementOptions = {}) { + const markerId = options.id ?? `marker-${nextMarkerId++}`; + const marker = document.createElement('gmp-marker-3d') as + google.maps.maps3d.Marker3DElement; + + marker.id = markerId; + if (options.autofitsCamera != null) { + marker.autofitsCamera = options.autofitsCamera; + } + if (options.position) marker.position = options.position; + if (options.altitudeMode) marker.altitudeMode = options.altitudeMode; + if (options.collisionBehavior) { + marker.collisionBehavior = options.collisionBehavior; + } + if (options.collisionPriority != null) { + marker.collisionPriority = options.collisionPriority; + } + if (options.drawsWhenOccluded != null) { + marker.drawsWhenOccluded = options.drawsWhenOccluded; + } + if (options.extruded != null) marker.extruded = options.extruded; + if (options.sizePreserved != null) { + marker.sizePreserved = options.sizePreserved; + } + if (options.zIndex != null) marker.zIndex = options.zIndex; + + this.element = marker; + + if (options.label) { + const labelEl = document.createElement('gmp-label-3d') as any; + labelEl.id = `${markerId}-label`; + labelEl.setAttribute('for', markerId); + labelEl.collisionBehavior = options.labelCollisionBehavior ?? + (typeof google !== 'undefined' && google.maps && + google.maps.CollisionBehavior ? + google.maps.CollisionBehavior.OPTIONAL_AND_HIDES_LOWER_PRIORITY : + 'OPTIONAL_AND_HIDES_LOWER_PRIORITY'); + labelEl.textContent = options.label; + this.labelElement = labelEl; + } else { + this.labelElement = null; + } + } + + getElement(): HTMLElement { + return this.element; + } + + getLabel(): HTMLElement|null { + return this.labelElement; + } +} diff --git a/client/web/src/lit/custom-components/3d_marker_test.ts b/client/web/src/lit/custom-components/3d_marker_test.ts new file mode 100644 index 0000000..8a5c68d --- /dev/null +++ b/client/web/src/lit/custom-components/3d_marker_test.ts @@ -0,0 +1,54 @@ +// 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 {ThreeDMarker} from './3d_marker'; + +describe('ThreeDMarker Class', () => { + it('creates gmp-marker-3d element with correct properties', () => { + const marker = new ThreeDMarker({ + id: 'test-marker', + position: {lat: 37.7749, lng: -122.4194}, + autofitsCamera: true, + zIndex: 10, + }); + const el = marker.getElement() as any; + expect(el.tagName.toLowerCase()).toBe('gmp-marker-3d'); + expect(el.id).toBe('test-marker'); + expect(el.position).toEqual({lat: 37.7749, lng: -122.4194}); + expect(el.autofitsCamera).toBeTrue(); + expect(el.zIndex).toBe(10); + }); + + it('creates associated gmp-label-3d element when label prop is provided', + () => { + const marker = new ThreeDMarker({ + id: 'store-marker', + label: 'SF Flagship Store', + }); + const labelEl = marker.getLabel() as any; + expect(labelEl).not.toBeNull(); + expect(labelEl.tagName.toLowerCase()).toBe('gmp-label-3d'); + expect(labelEl.id).toBe('store-marker-label'); + expect(labelEl.getAttribute('for')).toBe('store-marker'); + expect(labelEl.textContent).toBe('SF Flagship Store'); + }); + + it('returns null for getLabel() when no label prop is provided', () => { + const marker = new ThreeDMarker({ + id: 'unlabeled-marker', + position: {lat: 37.7749, lng: -122.4194}, + }); + expect(marker.getLabel()).toBeNull(); + }); +}); diff --git a/client/web/src/lit/custom-components/anchor_marker.ts b/client/web/src/lit/custom-components/anchor_marker.ts new file mode 100644 index 0000000..dbb7208 --- /dev/null +++ b/client/web/src/lit/custom-components/anchor_marker.ts @@ -0,0 +1,84 @@ +/* + 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 {ANCHOR_PIN_SVG} from './anchor_marker_constants'; +import {calculateLatitudeZIndex, MarkerElementOptions} from './place_pin_marker'; + +export {ANCHOR_PIN_SVG} from './anchor_marker_constants'; + +/** Options for creating an AnchorMarker. */ +export interface AnchorMarkerOptions extends MarkerElementOptions { + svgContent?: string; +} + +/** + * Helper to generate custom anchor marker template element containing the SVG. + */ +export function createAnchorMarkerTemplate(svgContent = ANCHOR_PIN_SVG): + HTMLTemplateElement { + const template = document.createElement('template'); + template.style.display = 'block'; + + const parser = new DOMParser(); + const doc = (parser as any).parseFromString(svgContent, 'image/svg+xml'); + const svgElement = doc.documentElement; + + template.append(svgElement); + return template; +} + +/** + * AnchorMarker wraps the creation of web component elements with + * custom SVG anchor pin content directly using + * google.maps.maps3d.MarkerElement. + */ +export class AnchorMarker { + protected readonly element: HTMLElement; + + constructor(options: AnchorMarkerOptions = {}) { + const effectiveZIndex = options.zIndex ?? + (options.position ? calculateLatitudeZIndex(options.position.lat) : + null); + + const marker = document.createElement('gmp-marker') as + google.maps.maps3d.MarkerElement & + { + zIndex?: number|null; + }; + + marker.autofitsCamera = options.autofitsCamera ?? true; + + if (options.position) marker.position = options.position; + if (options.title) marker.title = options.title; + if (options.collisionBehavior) { + marker.collisionBehavior = + options.collisionBehavior as google.maps.CollisionBehaviorString; + } + if (options.collisionPriority != null) + marker.collisionPriority = options.collisionPriority; + if (effectiveZIndex != null) marker.zIndex = effectiveZIndex; + + const template = + options.htmlContent ?? createAnchorMarkerTemplate(options.svgContent); + marker.append(template); + + this.element = marker; + } + + getElement(): HTMLElement { + return this.element; + } +} diff --git a/client/web/src/lit/custom-components/anchor_marker_constants.ts b/client/web/src/lit/custom-components/anchor_marker_constants.ts new file mode 100644 index 0000000..a2c6439 --- /dev/null +++ b/client/web/src/lit/custom-components/anchor_marker_constants.ts @@ -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 + + 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. + */ + +export const ANCHOR_PIN_SVG = + ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/client/web/src/lit/custom-components/anchor_marker_test.ts b/client/web/src/lit/custom-components/anchor_marker_test.ts new file mode 100644 index 0000000..4e696fc --- /dev/null +++ b/client/web/src/lit/custom-components/anchor_marker_test.ts @@ -0,0 +1,45 @@ +// 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 {ANCHOR_PIN_SVG, AnchorMarker, createAnchorMarkerTemplate} from './anchor_marker'; + +describe('AnchorMarker Module', () => { + it('creates gmp-marker element with SVG content', () => { + const marker = new AnchorMarker({ + position: {lat: 37.7749, lng: -122.4194}, + label: 'Anchor Label', + zIndex: 1, + }); + const el = marker.getElement() as any; + expect(el.tagName.toLowerCase()).toBe('gmp-marker'); + expect(el.position).toEqual({lat: 37.7749, lng: -122.4194}); + + const template = el.querySelector('template') as HTMLTemplateElement; + expect(template).not.toBeNull(); + const svg = (template.content?.querySelector('svg') || + template.querySelector('svg')) as SVGElement | + null; + expect(svg).not.toBeNull(); + }); + + it('creates custom template with provided SVG content', () => { + const template = createAnchorMarkerTemplate(ANCHOR_PIN_SVG); + expect(template).toBeDefined(); + const svg = (template.content?.querySelector('svg') || + template.querySelector('svg')) as SVGElement | + null; + expect(svg).not.toBeNull(); + expect(svg?.getAttribute('viewBox')).toBe('0 0 64 40'); + }); +}); diff --git a/client/web/src/lit/custom-components/google_map.ts b/client/web/src/lit/custom-components/google_map.ts index 5190976..1fdd3ca 100644 --- a/client/web/src/lit/custom-components/google_map.ts +++ b/client/web/src/lit/custom-components/google_map.ts @@ -1,4 +1,3 @@ -/// /* Copyright 2026 Google LLC @@ -23,31 +22,47 @@ import {customElement} from 'lit/decorators.js'; import {styleMap} from 'lit/directives/style-map.js'; import {z} from 'zod'; +import {Marker3DElementOptions, ThreeDMarker} from './3d_marker'; +import {AnchorMarker} from './anchor_marker'; +import {MarkerElementOptions, PLACE_PIN_MARKER_STYLES, PlacePinMarker} from './place_pin_marker'; + const sheet = new CSSStyleSheet(); sheet.replaceSync(structuralStyles); +enum UIStrings { + MSG_MAP_VIEW = 'Map View', +} + let nextMarkerId = 0; const LatLngSchema = z.object({ - lat: DynamicNumberSchema, - lng: DynamicNumberSchema, -}).strict(); + lat: DynamicNumberSchema, + lng: DynamicNumberSchema, + }).strict(); const DynamicLatLngSchema = z.union([ LatLngSchema, - z.object({ path: z.string() }).strict(), + z.object({path: z.string()}).strict(), ]); const MapPinSchema = z.object({ - lat: DynamicNumberSchema, - lng: DynamicNumberSchema, - label: DynamicStringSchema, - placeId: DynamicStringSchema.optional(), -}).strict(); + lat: DynamicNumberSchema, + lng: DynamicNumberSchema, + label: DynamicStringSchema, + placeId: DynamicStringSchema.optional(), + placePrimaryType: DynamicStringSchema.optional(), + address: DynamicStringSchema.optional(), + }).strict(); + +const AnchorMarkerSchema = z.union([ + MapPinSchema.extend({ + label: DynamicStringSchema.optional(), + }), + z.object({path: z.string()}).strict(), +]); interface MarkerInput { position?: google.maps.LatLngLiteral; - placeId?: string|null; label?: string|null; zIndex?: number|null; collisionBehavior?: google.maps.CollisionBehavior; @@ -56,51 +71,58 @@ interface MarkerInput { /** A2UI GoogleMap interface. */ export const GoogleMapApi = { name: 'GoogleMap', - schema: z - .object({ - center: DynamicLatLngSchema.describe('The center point of the map.'), - zoom: DynamicNumberSchema.describe('The zoom level.'), - tilt: DynamicNumberSchema.describe('The tilt angle.').optional(), - heading: DynamicNumberSchema.describe('The heading angle.').optional(), - mode: z.enum(['roadmap', 'satellite']).default('roadmap').describe('The map mode.').optional(), - anchorMarker: MapPinSchema.describe('The anchor marker location.').optional(), - markers: z.array(MapPinSchema).describe('List of markers.').optional(), - origin: DynamicLatLngSchema.describe('Origin for routes.').optional(), - destination: DynamicLatLngSchema.describe('Destination for routes.').optional(), - travelMode: z.enum(['driving', 'walking', 'bicycling', 'transit']).describe('Travel mode for routes.').optional(), - routes: z.array(z.object({ - origin: MapPinSchema, - destination: MapPinSchema, - })).describe('Array of routes.').optional(), - }) - .strict(), + schema: + z.object({ + center: DynamicLatLngSchema.describe('The center point of the map.'), + zoom: DynamicNumberSchema.describe('The zoom level.'), + tilt: DynamicNumberSchema.describe('The tilt angle.').optional(), + heading: DynamicNumberSchema.describe('The heading angle.').optional(), + mode: z.enum(['roadmap', 'satellite']) + .default('roadmap') + .describe('The map mode.') + .optional(), + anchorMarker: + AnchorMarkerSchema.describe('The anchor marker location.') + .optional(), + markers: z.array(MapPinSchema).describe('List of markers.').optional(), + origin: DynamicLatLngSchema.describe('Origin for routes.').optional(), + destination: + DynamicLatLngSchema.describe('Destination for routes.').optional(), + travelMode: z.enum(['driving', 'walking', 'bicycling', 'transit']) + .describe('Travel mode for routes.') + .optional(), + routes: z.array(z.object({ + origin: MapPinSchema, + destination: MapPinSchema, + })) + .describe('Array of routes.') + .optional(), + }).strict(), } satisfies ComponentApi; declare global { - interface Map3DElement { - center: { lat: number, lng: number, altitude?: number }; + center: {lat: number, lng: number, altitude?: number}; range: number; tilt: number; heading: number; maxTilt: number; flyCameraTo(options: { endCamera: { - center: { lat: number; lng: number; altitude: number }; + center: {lat: number; lng: number; altitude: number}; tilt?: number; - heading?: number; - altitudeMode: string; + heading?: number; altitudeMode: string; }; }): void; } interface HTMLElementTagNameMap { - "gmp-map-3d": HTMLElement & Map3DElement; - "gmp-advanced-marker": HTMLElement & { - position: google.maps.LatLng | google.maps.LatLngLiteral; + 'gmp-map-3d': HTMLElement&Map3DElement; + 'gmp-advanced-marker': HTMLElement&{ + position: google.maps.LatLng|google.maps.LatLngLiteral; }; - "gmp-marker-3d": HTMLElement & { - position: { lat: number, lng: number, altitude?: number }; + 'gmp-marker-3d': HTMLElement&{ + position: {lat: number, lng: number, altitude?: number}; }; } } @@ -109,12 +131,12 @@ interface ResolvedMarker { lat: number; lng: number; label: string; - placeId?: string; + placePrimaryType?: string; collisionBehavior?: google.maps.CollisionBehavior; } /** A2UI Custom Component for GoogleMap */ -@customElement("a2ui-googlemap") +@customElement('a2ui-googlemap') export class GoogleMap extends A2uiLitElement { static override shadowRootOptions: ShadowRootInit = { ...LitElement.shadowRootOptions, @@ -126,21 +148,18 @@ export class GoogleMap extends A2uiLitElement { Map3DElement; } - get routeElements(): NodeListOf { - return this.renderRoot.querySelectorAll('gmp-route-3d'); - } - protected override createController() { return new A2uiController(this, GoogleMapApi); } private markers: HTMLElement[] = []; - private prevCenter: { lat: number; lng: number } | null = null; + private prevCenter: {lat: number; lng: number}|null = null; private prevMarkers: unknown = null; private prevRoutes: unknown = null; static override styles = [ sheet, + PLACE_PIN_MARKER_STYLES, css` :host { display: block; @@ -156,13 +175,13 @@ export class GoogleMap extends A2uiLitElement { getCenter() { const props = this.controller.props; - if (!props) return { lat: 0, lng: 0 }; + if (!props) return {lat: 0, lng: 0}; const center = props.center; const lat = center.lat ?? (center as any).latitude ?? 0; const lng = center?.lng ?? (center as any).longitude ?? 0; - return { lat: lat as number, lng: lng as number }; + return {lat: lat as number, lng: lng as number}; } private resolveMarkers(): ResolvedMarker[] { @@ -172,44 +191,28 @@ export class GoogleMap extends A2uiLitElement { const markers = props.markers; function filterMarkerFn(marker: any): boolean { - return !!marker.lat || !!marker.lng || !!marker.placeId || !!marker.label; + return !!marker.lat || !!marker.lng || !!marker.label; } if (Array.isArray(markers)) { - return markers.map((marker: any) => ({ - lat: marker.lat ?? 0 as number, - lng: marker.lng ?? 0 as number, - label: marker.label as string, - placeId: marker.placeId as string, - collisionBehavior: marker.collisionBehavior as google.maps.CollisionBehavior | undefined, - })).filter(filterMarkerFn); + return markers + .map( + (marker: any) => ({ + lat: marker.lat ?? 0 as number, + lng: marker.lng ?? 0 as number, + label: (marker.label ?? '') as string, + placePrimaryType: marker.placePrimaryType as string | undefined, + collisionBehavior: marker.collisionBehavior as + google.maps.CollisionBehavior | + undefined, + })) + .filter(filterMarkerFn) + .sort((a, b) => b.lat - a.lat); } return []; } - 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 { super.updated(changedProperties); const props = this.controller.props; @@ -219,17 +222,20 @@ export class GoogleMap extends A2uiLitElement { const markers = props.markers; const routes = props.routes; - if (center && (!this.prevCenter || this.prevCenter.lat !== center.lat || this.prevCenter.lng !== center.lng)) { + if (center && + (!this.prevCenter || this.prevCenter.lat !== center.lat || + this.prevCenter.lng !== center.lng)) { console.log('updating camera'); this.map3dElement.flyCameraTo({ endCamera: { - center: { lat: center.lat, lng: center.lng, altitude: 2400 }, + center: {lat: center.lat, lng: center.lng, altitude: 2400}, tilt: this.map3dElement.tilt, heading: this.map3dElement.heading, - altitudeMode: (google as any).maps.maps3d.AltitudeMode.RELATIVE_TO_GROUND - } + altitudeMode: + (google as any).maps.maps3d.AltitudeMode.RELATIVE_TO_GROUND, + }, }); - this.prevCenter = { lat: center.lat, lng: center.lng }; + this.prevCenter = {lat: center.lat, lng: center.lng}; } if (markers !== this.prevMarkers || routes !== this.prevRoutes) { @@ -244,7 +250,7 @@ export class GoogleMap extends A2uiLitElement { if (!props || !this.map3dElement) return; // Clear existing markers - this.markers.forEach(marker => marker.remove()); + this.markers.forEach((marker) => marker.remove()); this.markers = []; const markers = this.resolveMarkers(); @@ -253,82 +259,28 @@ export class GoogleMap extends A2uiLitElement { const routes = props.routes || []; // Add markers from props.markers - for (const { lat, lng, label, placeId } of markers) { - const {markerEl, labelEl} = this.createMarkerAndLabel({ - position: {lat, lng}, - placeId, - label, - }); - this.map3dElement.appendChild(markerEl); - this.map3dElement.appendChild(labelEl); - this.markers.push(markerEl); - } - - // Add destination marker if available - if (destination) { - const {markerEl, labelEl} = this.createMarkerAndLabel({ - position: - {lat: destination.lat as number, lng: destination.lng as number}, - label: 'Destination', - }); - this.map3dElement.appendChild(markerEl); - this.map3dElement.appendChild(labelEl); - this.markers.push(markerEl); + for (const {lat, lng, label, placePrimaryType} of markers) { + const marker = new PlacePinMarker({ + position: {lat, lng}, + label, + placePrimaryType, + }).getElement(); + this.map3dElement.appendChild(marker); + this.markers.push(marker); } // Add anchor marker if available and no routes if (anchorMarker && !routes.length) { - 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, - }); - if (typeof google !== "undefined" && google.maps && google.maps.marker && google.maps.marker.PinElement) { - const pin = new google.maps.marker.PinElement({ - background: "#5b99f6ff", - borderColor: "#2f79e8ff", - glyphColor: "#ffffff" - }); - markerEl.append(pin as any); - } - 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 { - 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 {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); + const marker = new AnchorMarker({ + position: { + lat: anchorMarker.lat as number, + lng: anchorMarker.lng as number + }, + label: anchorMarker.label as string, + zIndex: 1, + }).getElement(); + this.map3dElement.appendChild(marker); + this.markers.push(marker); } } @@ -345,7 +297,8 @@ export class GoogleMap extends A2uiLitElement { zoom = 16; } const heading = props.heading ?? 0; - const mode = (props.mode ?? 'roadmap').toUpperCase() as google.maps.maps3d.MapModeString; + const mode = (props.mode ?? 'roadmap').toUpperCase() as + google.maps.maps3d.MapModeString; let tilt = props.tilt ?? 0; if (mode !== 'SATELLITE') { @@ -355,16 +308,17 @@ export class GoogleMap extends A2uiLitElement { const routes = props.routes || []; const style = { - "width": "100%", - "aspect-ratio": "8 / 5", - "margin-bottom": "16px", - "border-radius": "16px", - "overflow": "hidden", - "border": "1px solid var(--gmp-mat-color-outline-decorative, light-dark(#ccc, #333))" + 'width': '100%', + 'aspect-ratio': '8 / 5', + 'margin-bottom': '16px', + 'border-radius': '16px', + 'overflow': 'hidden', + 'border': + '1px solid var(--gmp-mat-color-outline-decorative, light-dark(#ccc, #333))', }; return html` -
+
{ /** A2UI Definition for GoogleMap component */ export const A2uiGoogleMap = { ...GoogleMapApi, - tagName: "a2ui-googlemap", + tagName: 'a2ui-googlemap', }; diff --git a/client/web/src/lit/custom-components/google_map_test.ts b/client/web/src/lit/custom-components/google_map_test.ts index d48684c..ee4fa00 100644 --- a/client/web/src/lit/custom-components/google_map_test.ts +++ b/client/web/src/lit/custom-components/google_map_test.ts @@ -13,21 +13,23 @@ // limitations under the License. import './google_map'; -import type {GoogleMap} from './google_map'; + +import {type GoogleMap, GoogleMapApi} from './google_map'; +import {PlacePinMarker} from './place_pin_marker'; interface GoogleMapInternals { controller: { props: { - center: { lat: number; lng: number }; + 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 }; + origin: {lat: number; lng: number; label: string}; + destination: {lat: number; lng: number; label: string}; }>; }; }; - prevCenter: { lat: number; lng: number } | null; + prevCenter: {lat: number; lng: number}|null; } describe('GoogleMap Component', () => { @@ -45,7 +47,7 @@ describe('GoogleMap Component', () => { CollisionBehavior: { OPTIONAL_AND_HIDES_LOWER_PRIORITY: 'OPTIONAL_AND_HIDES_LOWER_PRIORITY' }, - maps3d: { AltitudeMode: { RELATIVE_TO_GROUND: 1 } } + maps3d: {AltitudeMode: {RELATIVE_TO_GROUND: 1}} } }; }); @@ -56,30 +58,33 @@ describe('GoogleMap Component', () => { 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']; + 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); + // 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; + // 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'); + // 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.8_atoui'); + // 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.8_atoui'); - // Cleanup - document.body.removeChild(element); - }); + // Cleanup + document.body.removeChild(element); + }); it('propagates travelMode to gmp-route-3d', async () => { // 1. Render the component with travelMode and routes @@ -87,17 +92,15 @@ describe('GoogleMap Component', () => { const internals = element as unknown as GoogleMapInternals; internals.controller = { props: { - center: { lat: 0, lng: 0 }, + center: {lat: 0, lng: 0}, travelMode: 'driving', - routes: [ - { - origin: { lat: 1, lng: 1, label: 'Origin' }, - destination: { lat: 2, lng: 2, label: 'Destination' }, - } - ] + routes: [{ + origin: {lat: 1, lng: 1, label: 'Origin'}, + destination: {lat: 2, lng: 2, label: 'Destination'}, + }] } }; - internals.prevCenter = { lat: 0, lng: 0 }; + internals.prevCenter = {lat: 0, lng: 0}; document.body.appendChild(element); // Wait for lit to finish initial render @@ -121,16 +124,14 @@ describe('GoogleMap Component', () => { 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' }, - } - ] + 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 }; + internals.prevCenter = {lat: 0, lng: 0}; document.body.appendChild(element); // Wait for lit to finish initial render @@ -154,17 +155,15 @@ describe('GoogleMap Component', () => { const internals = element as unknown as GoogleMapInternals; internals.controller = { props: { - center: { lat: 0, lng: 0 }, + center: {lat: 0, lng: 0}, travelMode: null, - routes: [ - { - origin: { lat: 1, lng: 1, label: 'Origin' }, - destination: { lat: 2, lng: 2, label: 'Destination' }, - } - ] + routes: [{ + origin: {lat: 1, lng: 1, label: 'Origin'}, + destination: {lat: 2, lng: 2, label: 'Destination'}, + }] } }; - internals.prevCenter = { lat: 0, lng: 0 }; + internals.prevCenter = {lat: 0, lng: 0}; document.body.appendChild(element); // Wait for lit to finish initial render @@ -188,17 +187,15 @@ describe('GoogleMap Component', () => { const internals = element as unknown as GoogleMapInternals; internals.controller = { props: { - center: { lat: 0, lng: 0 }, + center: {lat: 0, lng: 0}, travelMode: '', - routes: [ - { - origin: { lat: 1, lng: 1, label: 'Origin' }, - destination: { lat: 2, lng: 2, label: 'Destination' }, - } - ] + routes: [{ + origin: {lat: 1, lng: 1, label: 'Origin'}, + destination: {lat: 2, lng: 2, label: 'Destination'}, + }] } }; - internals.prevCenter = { lat: 0, lng: 0 }; + internals.prevCenter = {lat: 0, lng: 0}; document.body.appendChild(element); // Wait for lit to finish initial render @@ -215,4 +212,154 @@ describe('GoogleMap Component', () => { // Cleanup document.body.removeChild(element); }); + + it('falls back to generic pin color when icon/iconColor is not provided', + () => { + const markerEl = new PlacePinMarker({label: 'Sample'}).getElement(); + const template = + markerEl.querySelector('template') as HTMLTemplateElement; + expect(template).not.toBeNull(); + const iconDiv = + (template.content?.querySelector('.custom-marker-content-icon') || + template.querySelector('.custom-marker-content-icon')) as + HTMLElement; + expect(iconDiv).not.toBeNull(); + expect(iconDiv.style.backgroundColor).toBe('rgb(120, 144, 156)'); + }); + + it('uses lookup color when icon matches a known POI category', () => { + const markerEl = new PlacePinMarker({ + label: 'Shop', + placePrimaryType: 'retail' + }).getElement(); + const template = markerEl.querySelector('template') as HTMLTemplateElement; + expect(template).not.toBeNull(); + const iconDiv = + (template.content?.querySelector('.custom-marker-content-icon') || + template.querySelector('.custom-marker-content-icon')) as HTMLElement; + expect(iconDiv).not.toBeNull(); + expect(iconDiv.style.backgroundColor).toBe('rgb(5, 151, 255)'); + }); + + it('correctly parses and renders SVG icon content without sanitization error', + () => { + const markerEl = new PlacePinMarker({ + label: 'Shop', + placePrimaryType: 'retail', + }).getElement(); + const template = + markerEl.querySelector('template') as HTMLTemplateElement; + expect(template).not.toBeNull(); + const iconDiv = + (template.content?.querySelector('.custom-marker-content-icon') || + template.querySelector('.custom-marker-content-icon')) as + HTMLElement; + expect(iconDiv).not.toBeNull(); + const svg = iconDiv.querySelector('svg'); + expect(svg).not.toBeNull(); + }); + + it('assigns higher z-index to southern markers than northern markers', () => { + const northMarker = + new PlacePinMarker({ + position: {lat: 47.6062, lng: -122.3321}, // Seattle (North) + label: 'North', + }).getElement(); + const southMarker = + new PlacePinMarker({ + position: {lat: 34.0522, lng: -118.2437}, // Los Angeles (South) + label: 'South', + }).getElement(); + const northZ = Number( + (northMarker as any).zIndex ?? northMarker.getAttribute('z-index') ?? + 0); + const southZ = Number( + (southMarker as any).zIndex ?? southMarker.getAttribute('z-index') ?? + 0); + expect(southZ).toBeGreaterThan(northZ); + }); + + it('sorts markers from North to South in resolveMarkers', () => { + const element = document.createElement('a2ui-googlemap') as GoogleMap; + (element as any).controller = { + props: { + markers: [ + {lat: 34.0522, lng: -118.2437, label: 'LA (South)'}, + {lat: 47.6062, lng: -122.3321, label: 'Seattle (North)'}, + {lat: 37.7749, lng: -122.4194, label: 'SF (Middle)'}, + ], + }, + }; + const resolved = (element as any).resolveMarkers(); + expect(resolved.map((m: any) => m.label)).toEqual([ + 'Seattle (North)', + 'SF (Middle)', + 'LA (South)', + ]); + }); + + it('validates anchorMarker with only lat and lng (no label) in GoogleMapApi.schema', + () => { + const validPayload = { + center: {lat: 6.4485, lng: 3.449}, + zoom: 16, + mode: 'satellite', + anchorMarker: {lat: 6.4485, lng: 3.449}, + }; + const result = GoogleMapApi.schema.safeParse(validPayload); + expect(result.success).toBeTrue(); + }); + + it('validates anchorMarker with DataBinding path in GoogleMapApi.schema', + () => { + const validPayload = { + center: {lat: 6.4485, lng: 3.449}, + zoom: 16, + anchorMarker: {path: '/data/anchor'}, + }; + const result = GoogleMapApi.schema.safeParse(validPayload); + expect(result.success).toBeTrue(); + }); + + it('requires label for markers (MapPinSchema) in GoogleMapApi.schema', () => { + const invalidPayload = { + center: {lat: 6.4485, lng: 3.449}, + zoom: 16, + markers: [{lat: 6.4485, lng: 3.449}], + }; + const result = GoogleMapApi.schema.safeParse(invalidPayload); + expect(result.success).toBeFalse(); + }); + + it('renders AnchorMarker element when anchorMarker has only lat/lng and no label', + async () => { + const element = document.createElement('a2ui-googlemap') as GoogleMap; + const internals = element as unknown as { + controller: {props: Record}; + prevCenter: {lat: number; lng: number}; + updateMarkers: () => Promise; + }; + internals.controller = { + props: { + center: {lat: 6.4485, lng: 3.449}, + zoom: 16, + anchorMarker: {lat: 6.4485, lng: 3.449}, + }, + }; + internals.prevCenter = {lat: 6.4485, lng: 3.449}; + document.body.appendChild(element); + + await element.updateComplete; + await internals.updateMarkers(); + + const gmpMarker = + element.map3dElement.querySelector('gmp-marker') as unknown as { + position?: {lat: number; lng: number}; + } + |null; + expect(gmpMarker).not.toBeNull(); + expect(gmpMarker?.position).toEqual({lat: 6.4485, lng: 3.449}); + + document.body.removeChild(element); + }); }); diff --git a/client/web/src/lit/custom-components/grounding_sources.ts b/client/web/src/lit/custom-components/grounding_sources.ts new file mode 100644 index 0000000..ff89e63 --- /dev/null +++ b/client/web/src/lit/custom-components/grounding_sources.ts @@ -0,0 +1,457 @@ +/* + * 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 {css, html, LitElement, nothing} from 'lit'; +import {customElement, property, state} from 'lit/decorators.js'; + +export interface GroundingSource { + title: string; + url: string; + type?: string; + placeId?: string; +} + +const GOOGLE_MAPS_PIN_DATA_URL = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAYAAADimHc4AAAQAElEQV' + + 'R4AeycC7SlRXXn/7u+c+/tpmmbCWIENVEj4vQI0TARGmU1DwkPhV4B45gg2CiIMBkHRh1tF2Kz1A' + + 'jiKDO+ItJNAybLBaMi0agslIekQZyYdMaRScLDGVqkedrdQt97zle157frnMtykiBw7rmPXouva3' + + '9VtWtX1a7/3rWrzjkXkp555hWBZwwwr/BLzxjgGQPMMwLzPP2C3wHnnLNtn/e+/xdvPvsDOz5y5g' + + 'emrnrbB7ubTvpge9eb1vZ+esJ5uaw6ry2v+1D+6ZEfau86/MN502Ef6V218sO9jxx0fvvmV1/g+8' + + 'wzvk86/YIzwJVn37P44nfde+J/fc8DV5y/5uHNY9luT2aXm2sNyp7QmO1r5i802Z5msb542Z6y9E' + + 'LJ9i2eTnCzNZbt8pLL7Qd+pN18wEfbK373gvbEFZ/wxVpgT1oo+lzzzh+95r+f9Q+XdlO+P5lf0a' + + 'RyYlO0V5LXgyoUNcoqkk3/c4miFHzekUebag/rc5Lt5a4TEbmi1833739+e+n+5/deUxsXwCvWNa' + + '9q3HLGzUd/949/cHNH+aZG5S2plCXJi5J7hbEagHqAH5Ba5UebV+yTmwwSVPNfXo2ryshMpSjssw' + + 'Sxt3jSTa+8sHvzKz8+eTTceU3zZoDbT/3q/n972rduNW+/kTwflAC5khzgixpgSbh7ol7LAbyLNp' + + 'fRFmhG5vGCQm6aR7WfAP7x9ijT0YJRFGMcZLJvvOLCHbfu+/FH99c8PXNugDvf/vlld66+4jPu+V' + + 'Ypv8oqyAXAve/1GKIBdMNlG9qaAJ56NQK5AVQqAkDry0uUxWPkCaLo6ucaPNSjZLwGRWzljA4j6V' + + 'Wp0a0vv2j7Z/Y//+FlcOY0pbmc7b6TLjxgYkfZZJ7PwNubBATJs1KBIldWY0UJ0BvAblzV46OeqI' + + 'eykRtKR9nMKtBGXDFka4U22LxJGErTTPGEDJkM/CEOc0Z1SNjXz5hanDYtv2jrASEyVxTrmJO5Hn' + + 'rDue+yttwkz79hAJ3UAnSGCiAX4ngBBac+TaXPq8YQZactZIR8n0yq8AaQUTBkxeNQoNrnRUXCRp' + + 'Wic4Ucdsg5lqt1BkPmN6xpb1r+mQfeRfOcpFk3gK9dm35+/LsvkZUL8fwxc4DH4w2PF5QIOUEN4A' + + 'Uvyil2hrwaJEKUIdMBjlDW4CdkgRSDCDw9cBY4IiFyq3WX9PhOMIX4gKJFzCAF+IqxaudS67zHiu' + + 'vCf/3Z+y/RWo5rus5mijXN2vh+yOpFj/3tz76SvH2rlRZwsgToNqAmcnaDAbCRN8DSDPIEL6jRwB' + + 'AAFcrW+A+gqZjCGKG88YqywakJjA0eXeMtutYca9UcT0cs/B5i0DAEJQnkUZJuRdnKW1/66z/7yg' + + 'svvXtR7TRLL6afnZF9/7ePTS7pXa2Sj7UIN4BrAJ4gQQGuDUBOymooGwAk0AqKegKKhsM46lFOyP' + + 'RzB0sHRA12AbkJ7IIXpP/vsSoZLIuXmEJhBLkoO7hTAImSXBkRjxOBxmLluNTtfG3/z/+PsdpxFl' + + '5MO/pRWY51n/PQBs/5CAG2e2GheD+GMOqmDFgQebSL9uA100YiT0HwE0AE9Q0igA9yIP0lctYAGR' + + 'niolGPhx/x0FDrIB/Ax5mBuBzAo+50KsyllKOkYnlA6J3KEQ+m3S+ThyRjjTjNigGmjn79he7+pn' + + '5YKHgpBPiJ2C9ATxjhnwPv1SgBtv2y1wNJAzjBrzvA8X4X5wNUJIt/8EySmYmvLRiHGm3ioSRRRo' + + 'RapIDbkXUBKslloFC9n7kE+M58Vcqc3ZHl5m96wfo7Ph69R01MPdoh2yOPPMGyn50A2ViIvIBBS4' + + 'Y3BY+zQBjCKIdMUJQryWsoSiCWpssg11AORYkMFfhoM/gBXPCZQJELHqIsCFBhkiiT8HSsQpP3iQ' + + 'ZnDjQSE8q5+kbd5XChAJ5SwVmqYTBKKeXsPdfdfgKjjTRVvUc14uThh79Yni8OTzUWIRRXzjhUYf' + + '0RcshZWOwCOWWM0M+j3Kdoa2hLyE2TAWyaJkBKlaTEmWGUzVhBtJPVOrkGPI/ygCwMUctOLwq1Xv' + + 'Dyfp2YD7/IU1EJMkcLl0fOF1PwLnnO+r97MT1HlkZqgMbyBm9tN/a2CuAoQDFiJ0Ab5N7ShCEoJ7' + + 'xLAG0sMSiRBy/AVpRpmwa9kePhBXIMGWX1y8BQ2zzqVMitIj8oYxkSlX5CHTFAJbeimCfeouzMac' + + 'Ybo/AWXqOCAwVlwmeOcsrLMNLldBxZGpkBuocf8jYv6dWG56C9EgCyIHMWl8yBpSjAteCryCMMYY' + + 'QA3zBIP/cKbAIpmwa9ysN334Ky65KVk/lK+tDi/ltLto8teXRrZ0mnSb+VLB1K/D9ZVtY1si3RP1' + + 'CqOfM743nNFSUo8qAiQJXzsTtXbr9ewuPR3QE+qBogyk0+aNnl339bjD0KYk0zH8aPXPFrycufmE' + + 'vh+CqFpRTJIcoGSQWjZHjQAHx5v1xzZG2a1Jc199xI65L84P/4sefs+d7z/9Vpa/9k2Rc/+qHFN3' + + '76w4vv/uQnbcdV0BUfsLuvOtduvOZc++K3zmlOu/Yc2zOldDD91wF8Zjgl66+TutiTEsqWCjC6Yv' + + 'CMMzj1jAPxGUAltXAyeVAhhwypRN3yR5915cZf0wiekRigbcffp2zPFuizLtSKZbL5Y9GA6oEAYI' + + 'NnNYBRn6YE3+TsEIyhXI2Uot31F41svxM/8cLTTr3weX/FoE8rXf9++6vvYQxPzX4A/he4grwq56' + + 'r/KEf+uBHgVuABuaAHcMtjF6BLgSeoWKtC7laenXvtmqel0BMIz9gA4f3W+unxod2ZhA8x5sbDgk' + + 'yx7KIKPG0WBoEfdXYMTpj7RIw1DNWXz9tgHvv6T+236viLXno73WaUbn2v3f6DNZ1V8nysVGJsxi' + + 'toBpnLoQy5FTnA+6BcDRNSATr61RCEsxQMELujWO/0Z135rRnvghkboLTjZ3E3XGpySx1xBDjb3a' + + 'tHx0EmRRlCeXmLh7cAkGGTV14G72lq72SUFUd86oBvIDTS9NfvW/SNVJoVRYU5eAOuAL2gn0dYiT' + + 'rhp2CEQp1Aw4aON4ScD/ilZHnUU9m16zpLM3xmZAA/5JAO7v0OOVowUqmLkAqe7jDNDUPQWL07QC' + + '6KrdywmFh8IjdIGMIt/0230atWfnbljL1eT/D88L0Tt1svvwrd/saMN0CKWB+AFjw7Uw8qoVMYg7' + + 'YwhscuoN3FCoNXy11WmN+h66/vPMF0T4kNbE9J7l8UQtlj3MuzNRgl4MZtZBxqQc5CLMBHYQwlsc' + + 'A+4KXuBGNrG23k9zXqrTr4c69/RLP8/M81uz3S65RVReU+CA0dIIs8rp/o4gDv3IgKuzVTz5VfVN' + + 'gVBfBjt0iAb0Vu+dkTD2w7ZiYqD6AbcojkJ1fwAVyKhbhAlpJUzPF+tiuLMaPOAhSAq8i8J7FAZ/' + + 'nmeQr+8b99yZs3D6nF0+52xzt339yzcnxJZaoAZAvQDrgF3Qo6Ojsy+AXdpZ4q8KEzbXXHNEVuRb' + + 'Wcpk5+2gr8UoehDeArVsSfeLyujoVCkcdXA6gmY0Fx7SMCVaDFwgBaFoBDTrtYUGJx8u55yzecyc' + + '+TMcLc0U/e+dxb1eTzinGzaTIbF83DCOyAFv0KDuMAXtA3SJ0M6BD10N/Q3Q1HsvZ1uvLKwGIo5Y' + + 'c2QLtL82qlMhG3H7nkA48XuTomZyEKw1iRsSAjF3lh+zYonli0WXdzGpu8aCjNR9FpvFwEmJsdB3' + + 'H0ab1Q4nIAyAVdw8MVhmAtEZJCpq4L/R3DhRGkdmJR+sWrh1VnaAMo+WHh8WagzyiJWCnK/MDdB1' + + 'xODtFmqStrWiU8rGFBFnV2gNnUB1+0Ye3ksMrPtN9PTnnRZJvaDxb0yuHt6OgA7wNwPcrw3dA/8h' + + 'Ie38XbMBIyCkqEqKY9bFhdgGe4rpZ8ZcR/A3RjFMfbbRzd8BYPbyd+WgUbpcfYBZQTC02dHsboKX' + + 'Wm7tnrJcsuG2720fV64KfLLyuW74kDF29WQfcC2AVjFHYCYYrJWsUZIXhxJgjgrenCDwpj9FZS+R' + + 'fTkzGB7slEnqC98eWOsgL4mscOUJHgWQfPJ2YapE6RxW6owKMssTOxgGS9a2zt2qL5ftZacWuvUY' + + 'Mnh754uwfQ6Gjh3egrnEfsBhF6BM8ix0iVT12pXT7sMoYygB/zu88182UQnuESnm8NKuD9FrfiUB' + + 'bgC8YJAzjgp7FW1mnVjLdSZ0qpmbqaHgsiFfWuzuxO53wqfUBVMEChHoZR5GlKYQDDQH1iJ9dyj2' + + '0/NfTfEyUN80x09hEAO2RjRkjBkfF6jXkFue6AAHzc1YxlGXzRnsa6sk5Xaby3ddmL7rtxmKlno8' + + '/W35y6kTi/1atX9+Th4QNDBPgWdYwQO6KWox6ETNSNA3xYvYYyQFZvT+Hd1etju7J1LYwBCcC9ej' + + 'nAh1fRbmM9CfLxDPgtNHWHrb2hHVbpkfc79NAWz7/DCUNCXwdYPX5RQE3KYkcYHi8uD/1d0SO00h' + + 'aGoW1YndJQHcd91/BoBdBxwEIV+EEuDCLKaVGWJoJ6CgMoDq7YBePtz4aad1Y79X7mNqUiQk0AHs' + + 'Cir+HpsSMseABtzZSCZ4RTw8EMp7MIuUPqlobpZ+O+1CaKjNASCojwIrzbAD3OgESb4g85CEFaFH' + + 'IQxrJFGGJRV2lsx73DzDubfdx23BvhUXi/YQjDAA7o3kwqwI9bj/kUKvRk3iWHyI2raRgExlBpKA' + + 'NorF2s8aIAXQEy4UV4ggJ4DKHa1soBPXEWaIKtuqiVLW7pwwIW5YeG0nYWO6HnQ7EDAmwHfHFWBe' + + 'BW2r7HcxsywhM3Hi4eGSOwDm5HRtiyODuG1G0oA6QJ3ypifQUdkBVGINQ4hvDxnhQ7I/gRiqpBUJ' + + 'i6UXf6IbP7kPrOWreiqd0VMT71PV411ndl3ODEjvAwCjwLQ7BLkmEYQpD4rDDnBtBY+1D1dsKLh9' + + 'cDftSNcv0wVj2+yPD6wm6wQbuzA9IurZrFvb1mDckhB7Y0uZcC/DgDIOtMqhokyni4RZyPXUGeME' + + 'TshGokyvX2pOGeNFS3MT1kEVrw7hTgkodnizNAGCFhAAsebYbnF/gOL9qMHVLG2z2HmncWO3k1AD' + + 'Eeb09cFMTha3i58PbIjfPAlBWeLwwSBrD40MkHUONGOKxqwxlgoveQFrnELccBug9seHyB10oBOK' + + 'HGCE22uLATsizC0gR3bOpa3Nvb1/JjzrBaP1m/p9t+/dpOUvclKUDGyxU5nm6EJANswxgBehii3o' + + 'g4CwxjCAOZZ2ZjzbyHSWmYTtrFH1D16NwHF0PYLlkeOVR28VpWAE4IEqAX2gu7o2AYH/dnTe39fw' + + '8Zau5Z6LTk0eYQrpfPCq+P3ykitFi98WSFp4vDNoVBqjGKFGW8X5BXY5iGfYYygJ1z5z2+qHAOxE' + + '0HhQg1DrBGLkB2wpNhCI+vKILHLjHIJ/q7JpPnibxqWKVH3c/VXRU3IBHvRQgSh2t8CKQV1QAAD+' + + 'tJREFUfWKEnxSHLlRB51NC5CKPuG9hCJakQkQYUqmhDFDnWtRuFIewLc4qABrAq4JdZBhB43gFnu' + + '+EI8EvYYAgZAv9ehO+ytdq+PmrEiN4rV2b1EytqmBGSAlQyVN4O4YwqILuOFu0Ab6ZczWNuZ0Xu6' + + 'QpGykMlYYGgJi+kV3AXb8oAWjE+jBCiZCDATx2ALui0JbDCJwHDj/KsTPKuD9/y4rfXD2U1iPstM' + + 'uKR1dbk58vvH06zgf4qrG9yEvmg1gL4ViEImEEySXCj2gTBqE69wbQkt5G4dEBfHh3H3wUA2Th5S' + + 'I3cgd4RQ4VDuIAP/N5IPNJuUzovHs2Pn/on/M002fj2YutTJ1npSt5D6/OqoYAZKs7YMBz1sVtp4' + + 'IegFfw+zxjNyjleTBAu+QHWuSTAarw8oKXe/V+QlIA3XEV6sFz6qWSlDsQZ0PbYYljet6kxmf8tz' + + 'XD2mHp1nxWSv4842v01Dhe3oJxYbieKEi4thrqGEQBfNQdNp5vGMSom2lye6MfwB0qDR+CTvnJpM' + + 'b9q8LTHfA1oRr7HU8Pg1T+uISMPMBnJxTqmXIG/MIOaBtT29G5t932soOQnNO09DtnHqSmPTdCTY' + + 'QZ8b2O1JNSqwp+YjcQ91X/rhXUya143SWWXMQmxZO9/aoO3cCntqg9fRraADEVh++68Oz+vb8oA7' + + 'Lj4Qa4AXr8XlD4jiiAd8AuUISejEyvkboGpTSRk335xtte/oIYcy5o8bfPfIG3+Svu7QRGkDWAjS' + + '4KAts+uFTMRMIeReY0RLhBQcfznQY4UmrWwRo6zcgAY8c89N0yVu7yuPGwE4wfZxwjtIDutWwKT3' + + 'cMEkYI4DNGaFNSjwV0m0ZTltRL6dd3NPa1KzeumPHfWj4ZEkuv+/e7N03+GtM+BxUQN1XAvZC7wr' + + 'tpE5pXUoSeEMHr+8DTpaZCu+76xeGXfrdWh3zNyAAxZ5rQ+gC5x0hOKApPNwyS4wzAy8PrHY8vtG' + + 'fquRpAagG/TaY2Neqqo651XpEX5dv+9IcHD/37aujzq2jX605fbrn3fRz5FRAAgmwFuJGZKYCv/d' + + 'kQwuOdisOMXCbFgRv9kqzen6mv1wyfNMP+mlo0tq6M+aM+biqNVNgBJcDH68FV8btABvTalmg3kz' + + 'dJmV2QWVwP6sKb4ue1SXVe3JVu+fBthx07U73+af+l155xHGHkFje9mDyCiNwKYkZ4KSrsAMK8HI' + + 'M4evq0x8OxkAorYBQBfr/oj7bqzCj8iIepeM8g7Xrg/VtKxz4WuyBH2OGAzdwoClq3kePlBQqwyw' + + 'D4ADwDelcpwg/hKCnKQZPeWcqu+Nq7vn/M10+/ddWMd8Ouf3n68iXXvv3rsvZqL77UDYjRzQHWAd' + + 'QBvFAWwKKS4qFZxgIS1krICP1Fv75YQQTYmuZjj772ki1UZpQYaUb9a+ddyi4fx6vvKR08O3YB4a' + + 'UQhgqe31LPrKgklspCMqvMCsCNXdDEbVs9bzDCgNSoWyBPx0zKNr3hluPXv+7mNxzM4hmlTvfkL5' + + 'ctve7Ug5de+/b1aaxsYtpjhFsb+tSbDLeZhB5B5hbQi6qwBbnV8Z0dSlN/2jBCASpjMaLdyz3bFm' + + '0fyX+2yqh1vhm97KDNO8BsTQ/dSjKAlQorinKJhQw8P9PWEnIiJLUYoWWFPQAPCn4lT4/vip5zR/' + + 'FmdeudG19z8x/d9zs3nLRu+U0nrX7Jd09a+fxrV++9x/V/sOse15+56x6Ud7v2lJW7XfvW1cuue9' + + 'u6Z1136n1WdKPJV/OBNlBjfVjFyZjfQyfRCkVSAMu8qCTP8NEL5BEGHtbktDm699kwlNbooKt2ID' + + 'DjxAwzHqMOsOyVD/45wG5s8bIC4KFwAC4WG3nsjpbVFtoD/DgDurT1HMDN1PVG3dyoBYwoF0tyfm' + + 'DOoNLSr7W0R5FOUdF6Wbqer0L+vlN23aayYxvh7e8tpevdbL27nQJEe8h4B8UKAR6+KsmEcarOFm' + + '/mF/FfjhBzCqCFTJ8klSQzk0Wzk5s2bjti3Z/TMpKURjLKYJCsdFI22xbgtijtLKY1KUPh7S0LzD' + + 'LWlOCRazpv1FKu7SwyQ62nymPMKhueCRYq0Z/xsI4qKsVlGFWAaKIMwZIA1CkzJWVDpqg+kRksKs' + + '48YjyxC4UzeDEaIPQW86uweSxGhe1Q0TZr00ka4TNSAzz3t7fcXRp7R8sCwuszi2pZQIAY4Bb4Ue' + + '+x0GxJBQqZDAgtBFst8q6kHpRZdGGxGTCcA92QcfrAlpDjJTGmcpFpsBR3ylJ9ueRlmt/ITeKrB5' + + 'iCABYG4lRsQMjGXLVP8BggR56wdZIrvWPrkZfcjfDIUhrZSIOBnrt8y5eymkuzJWVCUeaen8MQIJ' + + 'IBKVujyFsW32WxNQQBYqEct6OCTAvlAJgxCnJOu3viqpjk1FWJCR3q4yMQkqUiMQ/o1reJukORly' + + 'KDCn2d8cQTXYPqbQjQrcCkXYPejh4eCMEjrf/FkV/4kkb8xPAjHlJqdiv/oTX9uMuiWjO8GSgih6' + + 'Iehumx7cMQvZxUAUeLACez4kLZC31YtQOWkRfAgE0qMjNFtWa0yWEbsHsSNqOd4OMuWFLq52bI4B' + + 'BIBau2DVpooBF5CorhzEOKVwyGnq704+1T9s5oHzWlUQ8Y4+21172PTdrYsW1KD2aW2uL1QZnF9E' + + 'BuilW2LC4ze46ctfYwVqEtg0UYwkEscsE3N1GVLBK7QDwYSE7Oq4lGypHh5vIwGhWnA10UvIBUGW' + + '0qX/WhWHOGkKg4JKdH5OglHmoPNt4cq2MvfozqyBMQjHzMOuDLXvZ/7gbsE1pL3RZgWxbVY3EtVM' + + 'IQANjj5lNqW1INU1gkjFQIWe7wZOBhYQMJeTEGSYCiZHg5hajHp1iTy8KLXXg4bdRhVNl4xQcwOq' + + 'Fbvy3kqQh1FGNIdIQMZh2mfwB1VXTC1iM//YRxH/EZpVkzQGj1b/a5+3vZ7IyejE+6jXp4VctuiD' + + 'xHWUkYSS2GcPgFJCoMGKCAhOHJPkCIZoGPzEywJRn/AJMOZhZViU4W8pLMHIKlovoEP4hBDEI03q' + + 'r7qfYPLhRlmRJnF+8zth/1ue9pFp9ZNUDovd9L77q0bez8AL1lUZHXnWBJLYsN8sgRLiy8uCk7wF' + + 'IGQiEmwZOZ4l/gWXN59fQwBk20ucRNqQyAp1leBMDGyCT44vjv1zw2B2Qc7AzPfLwZAzmHImVdsP' + + '2oT18axdmkWTdAKH/g3v/w/q6nS7vAQa5qhAhDABvhKcMvEXJAu5ZpoykwVAHdigkvA2BExHc6Sv' + + 'CNbRGenhA2GVOVyi/ukhVFmwDeFP+oC5ijbhRUeAUJERfiqixeiFy2/ehPr9EcPHNigFjHzfv86D' + + 'TAvSZCUAbg8PwMgBkgewDYklewkynwcxEcQNsLaNEWY3BQSMimJnhSBVg8ICYv7AgTvSsp3qyuXj' + + 'GjnTEcinPCVYeR4MNS/eYzuWI8S/aX27fdf6rm6EHFuZlpramktP3f9WQ39eN+UuRBGbDrTvCkDO' + + 'AkQoMJSABFcjdeUt0BFOO6H4pTlPHPJQGcRFl4sHgsqPAKQUYSnwFixBjK6BDtVRzDGZXgYZBbt+' + + '868Qd641WZnnOSqnpzMhOTHLP3HVO5seO6yTb1YhcE8FwzwwABfOyGWLlzKGdACoDiNEBMgAOMph' + + 'RohdaOAGUSPPWfyrda90AUIjFMX7aOJSnarMAj0ShR9lT+l3nv9TrokyP5ko1pnlIKlZ+S4KiE3r' + + 'z397c91lt0VCu7qzU8HpoOPzncMzXKeKTiJkTdDRUBSOTBDswEambGmxpeHiAHT+HNEgBLFWDxwB' + + 'NiCIsuwaiE3aXoGJT0k+LtkduOWvew5vhhdXM8I9Odte93tkxm+73W030ZVOpnAwDOMmXACrwRUz' + + 'i5igm2AkQDbNXHZWZ9/GpdSgiYGTWXYgDGi5iOIC2ObHQmD8BVZHAFsSu2YOsjHvu99fPyX+3Miw' + + 'FASWv2+85dPXWObr3Z2paGzwKmFrcMIzieT8gWmUBKFkYAYjCVHFYC6MgBPHgAqCobL4hWugGt4n' + + 'EhJqcY/FoIGQ+Ob+3kctTWwy++k+Z5SfNmgFjth17+9U1dS7/fJpsiJAmM2QH4JoiWipqFmIIftx' + + 'fJFID3PbwCiEGQpxiObYYEV1XE4LuCVwEXVdqcPJgxBufAVNOm3//5Ues3BXu+aF4NEIu+aN+rby' + + 'BivCVzCkI1OFBXgOggWhyAqwVAUAq2BN/gi1x9tqKoeIIv+gz4oiFYKV54vcmQKoWvpU/++dGX3E' + + 'BlXtO8GyBW/6ev+PKVLvtPEX4C8OLCyRNEGAGv8H4gVXgzVSA0KHr28/6bTtMCNCWADyEzWmkK/G' + + 'EjERWdvfXwDVdFfb5pQRggQLjsd77039zTJxzA+kckQFUEwR0ty3S1iCjiijASN50wTJAcoM1UH6' + + 'fPoODsqahVGdox5n/ZdsSGT9XmBfBiaQtAi4EKV+5/xXv4HuibDpgeCIcBaIu6GeAGPwW6wEk2/Q' + + 'MM8VxVNHjuSiFKHkyDJx4XBfdvbn3tZf+Z6oJJC8oAgOhtzn/IZeh/B35AhvOCZoBHhvdyNTUZRg' + + 'hgja8yKvgYpiJKp+gTFEagc/SUEE6y2zu93h8K22kBPWkB6VJV+eaBf7ZNKR1XpIfDa72CamBJjS' + + 'wUdlCMVL+Uo5CAmSa58Q6SM5YpikbJ3R7GUMc9fAxjU19IKdazkPSpunx7/3V3mNJqgPOKosAfNE' + + 'kVWrYBHFIwsBSlmhKtODvlgZtHG8Um6S1bD79s3u76KPSEaUEaILT9zgFf+LpMn8PvQZ/klDxaTB' + + 'bxhbpRtboCI8oYtRCAoogxKIkz+LOPHHbZyP9HsEw2klTVH8lIszBI2tG+mxD0YwwhM6ukADaQpY' + + '5JNH07Cr4UMrwHxpHZj3/esfdoAT8L2gA3HLphMuXOiRhhCtxVv54AzAA+AMcGeD4MowYJihAU9p' + + 'Fsyrz8kRhDC/hZ0AYI3G4++PObZPYxjEC1UFSlANmLJFbgxSgEYQhKkWBf8MhrL/+7KC9kQs+nqt' + + '78ybXjdgE3nJ9CsRE4gwE7LEBWtcLtSbQFw8QtdfOixY9eoJ3g2SkM8Nf/9uLHCOvvi3gTv/dysQ' + + 'HaeAdRHCRCjkLGk71v84j+elmz/OwUBggMfviaL/wZ9/5bpD7ocQ5EWIqNMM3r53bLg4dtGNlfL2' + + 'uWn53GAIFDLs17iornQJ9AFLw+cRgQfUoEoZLe3eftHO+dygA/WvmFjTL7tgN2Bl/s4P0HBodAkn' + + '37/teuZ5fQuJOktJPo+biaPfc/Lm6PuAhFcQ+lpZh7EV839PKZVHeqtNMZ4B8PXn9X8XYFh/KX2Q' + + 'UPB/Dy8uXsvQO3HHnFrP0N52xZdaczQABx18oN/3j3IevfeM/K9XtsPmT9HvceevkbHzj0i3dE28' + + '5GO6UBdjaQf5W+zxjgV6EzB23PGOBJQJ7t5v8HAAD//082YokAAAAGSURBVAMATkSzscm0Hx0AAA' + + 'AASUVORK5CYII='; + +@customElement('maui-grounding-sources') +export class MauiGroundingSources extends LitElement { + @property({type: Array}) sources: GroundingSource[] = []; + + @state() private isExpanded: boolean = false; + + static override styles = css` + :host { + display: block; + width: 100%; + box-sizing: border-box; + margin-top: 8px; + } + + .grounding-sources-section { + display: flex; + flex-direction: column; + align-items: flex-start; + width: 100%; + } + + .grounding-sources-toggle-btn { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 5px 12px; + background: light-dark(var(--n-95, #f1f3f4), var(--n-20, #2d2e30)); + border-radius: 9999px; + border: none; + font-family: inherit; + font-size: 0.75rem; + font-weight: 600; + color: light-dark(var(--n-10, #202124), var(--n-90, #e8eaed)); + cursor: pointer; + user-select: none; + transition: background-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + } + + .grounding-sources-toggle-btn:hover { + background: light-dark(var(--n-90, #e8eaed), var(--n-30, #3c4043)); + color: light-dark(var(--n-0, #000000), #ffffff); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08); + } + + .grounding-sources-toggle-btn:focus-visible { + outline: 2px solid light-dark(#1a73e8, #8ab4f8); + outline-offset: 1px; + } + + .sources-btn-label { + line-height: 1; + } + + .sources-btn-cluster { + display: inline-flex; + align-items: center; + gap: 4px; + } + + .google-maps-pin-logo { + object-fit: contain; + flex-shrink: 0; + display: inline-block; + vertical-align: middle; + width: 15px; + height: 15px; + } + + .sources-btn-count { + font-size: 0.75rem; + font-weight: 600; + line-height: 1; + color: var(--a2ui-primary-color, light-dark(#1a73e8, #8ab4f8)); + } + + .sources-chevron-icon { + font-size: 0.7rem; + line-height: 1; + transition: transform 0.24s cubic-bezier(0.16, 1, 0.3, 1); + display: inline-block; + } + + .sources-chevron-icon.rotated { + transform: rotate(180deg); + } + + .grounding-sources-drawer { + width: 100%; + margin-top: 8px; + overflow: hidden; + transition: max-height 0.3s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.22s ease; + } + + .grounding-sources-drawer.collapsed { + max-height: 0; + opacity: 0; + margin-top: 0; + pointer-events: none; + } + + .grounding-sources-drawer.expanded { + max-height: 2000px; + opacity: 1; + } + + .grounding-sources-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px; + width: 100%; + padding: 4px 2px 6px 2px; + box-sizing: border-box; + } + + .grounding-sources-grid.single-item { + grid-template-columns: minmax(0, 240px); + } + + @media (max-width: 400px) { + .grounding-sources-grid { + grid-template-columns: 1fr; + } + } + + .grounding-source-card { + background: light-dark(var(--n-95, #f1f3f4), var(--n-20, #2d2e30)); + border: none; + border-radius: 16px; + padding: 9px 12px 10px 12px; + text-decoration: none; + display: flex; + flex-direction: column; + justify-content: flex-start; + gap: 6px; + min-height: 64px; + box-sizing: border-box; + transition: background-color 0.15s ease; + cursor: pointer; + position: relative; + } + + .grounding-source-card:hover { + background: light-dark(var(--n-90, #e8eaed), var(--n-30, #3c4043)); + } + + .grounding-source-card:hover .grounding-source-title { + color: var(--a2ui-primary-color, light-dark(#1a73e8, #8ab4f8)); + } + + .grounding-source-card:active { + background: light-dark(var(--n-80, #dadce0), var(--n-40, #525252)); + } + + .grounding-source-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + width: 100%; + } + + .grounding-source-meta { + display: flex; + align-items: center; + gap: 5px; + min-width: 0; + } + + .GMP-attribution { + font-style: normal; + font-weight: 500; + font-size: 0.68rem; + letter-spacing: normal; + white-space: nowrap; + color: light-dark(var(--slate-550, #64748b), var(--n-70, #9e9e9e)); + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + } + + .grounding-source-title { + font-size: 0.75rem; + font-weight: 600; + line-height: 1.25; + color: light-dark(var(--slate-900, #0f172a), var(--n-100, #ffffff)); + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + word-break: break-word; + text-align: left; + flex: 1; + transition: color 0.15s ease; + } + `; + + private toggleExpanded() { + this.isExpanded = !this.isExpanded; + } + + override render() { + if (!this.sources || this.sources.length === 0) { + return nothing; + } + + return html` +
+ + +
+
+ ${ + this.sources.map( + (source) => html` + +
+
+ + Google Maps +
+
+
${ + source.title}
+
+ `)} +
+
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'maui-grounding-sources': MauiGroundingSources; + } +} diff --git a/client/web/src/lit/custom-components/grounding_sources_test.ts b/client/web/src/lit/custom-components/grounding_sources_test.ts new file mode 100644 index 0000000..5331bb3 --- /dev/null +++ b/client/web/src/lit/custom-components/grounding_sources_test.ts @@ -0,0 +1,76 @@ +// 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 './grounding_sources'; + +import type {MauiGroundingSources} from './grounding_sources'; + +describe('MauiGroundingSources Component', () => { + it('renders nothing when sources array is empty', async () => { + const element = document.createElement('maui-grounding-sources') as + MauiGroundingSources; + element.sources = []; + document.body.appendChild(element); + + await element.updateComplete; + + const section = + element.renderRoot.querySelector('.grounding-sources-section'); + expect(section).toBeNull(); + + document.body.removeChild(element); + }); + + it('renders sources pill button and drawer when sources are provided', + async () => { + const element = document.createElement('maui-grounding-sources') as + MauiGroundingSources; + element.sources = [ + { + title: 'Pike Place Market', + url: + 'https://www.google.com/maps/place/?q=place_id:ChIJp2t_tDeuEmsR', + type: 'place', + placeId: 'ChIJp2t_tDeuEmsR', + }, + { + title: 'Space Needle', + url: + 'https://www.google.com/maps/place/?q=place_id:ChIJx3t_tDeuEmsR', + type: 'place', + placeId: 'ChIJx3t_tDeuEmsR', + }, + ]; + document.body.appendChild(element); + + await element.updateComplete; + + const count = element.renderRoot.querySelector('.sources-btn-count'); + expect(count).not.toBeNull(); + expect(count!.textContent?.trim()).toBe('2'); + + const cards = + element.renderRoot.querySelectorAll('.grounding-source-card'); + expect(cards.length).toBe(2); + + const firstCardTitle = cards[0].querySelector('.grounding-source-title'); + expect(firstCardTitle?.textContent?.trim()).toBe('Pike Place Market'); + + const firstCardAttribution = cards[0].querySelector('.GMP-attribution'); + expect(firstCardAttribution?.textContent?.trim()).toBe('Google Maps'); + expect(firstCardAttribution?.getAttribute('translate')).toBe('no'); + + 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 c08806b..466dec8 100644 --- a/client/web/src/lit/custom-components/index.ts +++ b/client/web/src/lit/custom-components/index.ts @@ -14,5 +14,31 @@ limitations under the License. */ -export { A2uiGoogleMap, GoogleMap } from './google_map.js'; -export { A2uiPlaceDetailsCompact, PlaceDetailsCompact } from './place_details_compact.js'; +import {type Marker3DElementOptions, ThreeDMarker} from './3d_marker.js'; +import {AnchorMarker, type AnchorMarkerOptions} from './anchor_marker.js'; +import {A2uiGoogleMap, GoogleMap} from './google_map.js'; +import {GroundingSource, MauiGroundingSources} from './grounding_sources.js'; +import {A2uiPlaceDetailsCompact, PlaceDetailsCompact} from './place_details_compact.js'; +import {calculateLatitudeZIndex, type MarkerElementOptions, PlacePinMarker} from './place_pin_marker.js'; +import {getPinColor, getPinIcon, PLACE_PIN_COLOR_LOOKUP, PLACE_PIN_ICON_LOOKUP, PLACE_PIN_MARKER_STYLES} from './place_pin_marker_constants.js'; + +export {type Marker3DElementOptions, ThreeDMarker} from './3d_marker.js'; +export {AnchorMarker, type AnchorMarkerOptions} from './anchor_marker.js'; +export {A2uiGoogleMap, GoogleMap} from './google_map.js'; +export {type GroundingSource, MauiGroundingSources} from './grounding_sources.js'; +export {A2uiPlaceDetailsCompact, PlaceDetailsCompact} from './place_details_compact.js'; +export {calculateLatitudeZIndex, type MarkerElementOptions, PlacePinMarker} from './place_pin_marker.js'; +export {getPinColor, getPinIcon, PLACE_PIN_COLOR_LOOKUP, PLACE_PIN_ICON_LOOKUP, PLACE_PIN_MARKER_STYLES} from './place_pin_marker_constants.js'; + +export function registerA2UICustomElements(): void { + if (typeof customElements === 'undefined') return; + if (!customElements.get('a2ui-googlemap')) { + customElements.define('a2ui-googlemap', GoogleMap); + } + if (!customElements.get('a2ui-placedetailscompact')) { + customElements.define('a2ui-placedetailscompact', PlaceDetailsCompact); + } + if (!customElements.get('maui-grounding-sources')) { + customElements.define('maui-grounding-sources', MauiGroundingSources); + } +} \ No newline at end of file diff --git a/client/web/src/lit/custom-components/place_details_compact.ts b/client/web/src/lit/custom-components/place_details_compact.ts index c498744..1b2412a 100644 --- a/client/web/src/lit/custom-components/place_details_compact.ts +++ b/client/web/src/lit/custom-components/place_details_compact.ts @@ -15,35 +15,37 @@ */ import {A2uiController, A2uiLitElement} from '@a2ui/lit/v0_9'; -import {structuralStyles} from '@a2ui/web_core'; +import {structuralStyles} from '@a2ui/web_core/v0_8'; import {ComponentApi, DynamicStringSchema} from '@a2ui/web_core/v0_9'; import {css, html, LitElement, nothing} from 'lit'; import {customElement} from 'lit/decorators.js'; import {styleMap} from 'lit/directives/style-map.js'; -import {z} from 'zod' +import {z} from 'zod'; const sheet = new CSSStyleSheet(); sheet.replaceSync(structuralStyles); +enum UIStrings { + MSG_PLACE_DETAILS = 'Place Details', +} + 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(), + 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; declare global { interface HTMLElementTagNameMap { - "gmpx-place-details-compact": HTMLElement & { - place: string | object | null; - orientation: "horizontal" | "vertical"; + 'gmpx-place-details-compact': HTMLElement&{ + place: string|object|null; + orientation: 'horizontal'|'vertical'; }; } } @@ -81,13 +83,18 @@ export class PlaceDetailsCompact extends 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'; + // 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 orientation = (props.orientation ?? autoOrientation).toUpperCase() as + google.maps.places.PlaceDetailsOrientationString; const style = { 'width': '100%', @@ -98,7 +105,8 @@ export class PlaceDetailsCompact extends } return html` -
+
{ + if (typeof google !== 'undefined' && google.maps && + google.maps.importLibrary) { + return (await google.maps.importLibrary('maps3d')) as + google.maps.Maps3DLibrary; + } + return null; +} + +/** + * Calculates z-index based on latitude so southern-most markers have higher + * z-index. + */ +export function calculateLatitudeZIndex(lat: number): number { + return Math.round((90 - lat) * 10000); +} + +/** + * MarkerElementOptions extending official + * google.maps.maps3d.MarkerElementOptions. + */ +export interface MarkerElementOptions extends google.maps.maps3d + .MarkerElementOptions { + label?: string|null; + zIndex?: number|null; + placePrimaryType?: string|null; + htmlContent?: HTMLElement|HTMLTemplateElement|null; +} + +/** + * Interface extending google.maps.maps3d.MarkerElement with additional custom + * web component properties. + */ +export type PlacePinMarkerElement = google.maps.maps3d.MarkerElement&{ + label?: string|null; + zIndex?: number|null; +}; + +/** Helper to generate custom place pin marker template element. */ +export function createPlacePinMarkerTemplate(options: { + label?: string|null; + placePrimaryType?: string | null; + zIndex?: number | null; +}): HTMLTemplateElement { + const customMarker = document.createElement('template'); + customMarker.classList.add('custom-marker'); + customMarker.style.zIndex = options.zIndex?.toString() || '0'; + + const customMarkerContent = document.createElement('div'); + customMarkerContent.classList.add('custom-marker-content'); + + const iconContent = getPinIcon(options.placePrimaryType); + if (iconContent) { + const customMarkerContentIcon = document.createElement('div'); + customMarkerContentIcon.classList.add('custom-marker-content-icon'); + const safeTypeClass = + (options.placePrimaryType && + PLACE_PIN_ICON_LOOKUP.has(options.placePrimaryType)) ? + options.placePrimaryType : + 'generic'; + customMarkerContentIcon.classList.add( + `custom-marker-content-icon--${safeTypeClass}`); + + const backgroundColor = getPinColor(options.placePrimaryType); + customMarkerContentIcon.style.backgroundColor = backgroundColor; + + const parser = new DOMParser(); + const doc = (parser as any).parseFromString(iconContent, 'image/svg+xml'); + const svgElement = doc.documentElement; + customMarkerContentIcon.appendChild(svgElement); + customMarkerContent.append(customMarkerContentIcon); + } + + const customMarkerLabelContainer = document.createElement('div'); + customMarkerLabelContainer.classList.add('custom-marker-label-container'); + + const customMarkerLabel = document.createElement('div'); + customMarkerLabel.classList.add('custom-marker-label'); + customMarkerLabel.textContent = options.label || ''; + // Truncate label to 2 lines max, and add ellipsis to overflowing text. + // https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/line-clamp + customMarkerLabel.style.display = '-webkit-box'; + customMarkerLabel.style.webkitBoxOrient = 'vertical'; + customMarkerLabel.style.webkitLineClamp = '2'; + customMarkerLabel.style.overflow = 'hidden'; + + customMarkerLabelContainer.append(customMarkerLabel); + customMarkerContent.append(customMarkerLabelContainer); + + customMarker.appendChild(customMarkerContent); + customMarker.append(customMarkerLabelContainer); + + return customMarker; +} + +/** + * PlacePinMarker wraps the creation of web component elements + * based on MarkerElementOptions. + */ +export class PlacePinMarker { + protected readonly element: HTMLElement; + + constructor(options: MarkerElementOptions = {}) { + const effectiveZIndex = options.zIndex ?? + (options.position ? calculateLatitudeZIndex(options.position.lat) : + null); + + const marker = + document.createElement('gmp-marker') as PlacePinMarkerElement; + + // Ensure the marker is autofitted. + marker.autofitsCamera = options.autofitsCamera ?? true; + + // Pin SVG size is 28px, so subtract that from anchorTop to ensure the + // marker beak is positioned correctly on the anchor point. + marker.anchorTop = '-28px'; + + // Pass through options to the marker element. + if (options.position) marker.position = options.position; + if (options.label) marker.label = options.label; + if (options.title) marker.title = options.title; + if (options.collisionBehavior) { + marker.collisionBehavior = + options.collisionBehavior as google.maps.CollisionBehaviorString; + } + if (options.collisionPriority != null) + marker.collisionPriority = options.collisionPriority; + if (effectiveZIndex != null) marker.zIndex = effectiveZIndex; + + const template = options.htmlContent ?? createPlacePinMarkerTemplate({ + label: options.label, + placePrimaryType: options.placePrimaryType, + zIndex: effectiveZIndex, + }); + + marker.append(template); + + this.element = marker; + } + + getElement(): HTMLElement { + return this.element; + } +} diff --git a/client/web/src/lit/custom-components/place_pin_marker_constants.ts b/client/web/src/lit/custom-components/place_pin_marker_constants.ts new file mode 100644 index 0000000..52c923f --- /dev/null +++ b/client/web/src/lit/custom-components/place_pin_marker_constants.ts @@ -0,0 +1,240 @@ +/* + 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 {css, CSSResult} from 'lit'; + +const SVG_RETAIL = + ` + +`; +const SVG_FOOD_AND_DRINK = + ` + +`; +const SVG_OUTDOOR = + ` + + + + + +`; +const SVG_SERVICE = + ` + +`; +const SVG_LODGING = + ` + + +`; +const SVG_EMERGENCY = + ` + +`; +const SVG_ENTERTAINMENT = + ` + + +`; +const SVG_GENERIC = + ` + +`; +const SVG_AIRPORT = + ` + +`; +const SVG_PARKING = + ` + +`; +const SVG_EV = + ` + +`; +const SVG_CLOSED = + ` + +`; + +export const PLACE_PIN_ICON_LOOKUP = new Map([ + ['retail', SVG_RETAIL], + ['food_and_drink', SVG_FOOD_AND_DRINK], + ['outdoor', SVG_OUTDOOR], + ['entertainment', SVG_ENTERTAINMENT], + ['service', SVG_SERVICE], + ['lodging', SVG_LODGING], + ['emergency', SVG_EMERGENCY], + ['generic', SVG_GENERIC], + ['airport', SVG_AIRPORT], + ['parking', SVG_PARKING], + ['ev', SVG_EV], + ['closed', SVG_CLOSED], +]); +export const PLACE_PIN_COLOR_LOOKUP = new Map([ + ['retail', '#0597FF'], + ['food_and_drink', '#FF8126'], + ['outdoor', '#17A773'], + ['service', '#7986CB'], + ['lodging', '#F848C7'], + ['emergency', '#F74A55'], + ['entertainment', '#B56AFF'], + ['generic', '#78909C'], + ['airport', '#1A73E8'], + ['parking', '#B3C8FF'], + ['ev', '#C1E7CF'], + ['closed', '#AFB2B4'], +]); +export function getPinColor(placePrimaryType?: string|null): string { + if (placePrimaryType && PLACE_PIN_COLOR_LOOKUP.has(placePrimaryType)) { + return PLACE_PIN_COLOR_LOOKUP.get(placePrimaryType)!; + } + return PLACE_PIN_COLOR_LOOKUP.get('generic') || '#78909C'; +} +export function getPinIcon(placePrimaryType?: string|null): string|undefined { + if (placePrimaryType && PLACE_PIN_ICON_LOOKUP.has(placePrimaryType)) { + return PLACE_PIN_ICON_LOOKUP.get(placePrimaryType); + } + return PLACE_PIN_ICON_LOOKUP.get('generic'); +} + +/** Styles for place pin markers. */ +export const PLACE_PIN_MARKER_STYLES: CSSResult = css` + .custom-marker { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + position: relative; + } + .custom-marker-content { + position: relative; + width: 24px; + height: 24px; + background-color: #ffffff; + border-radius: 12px; + display: flex; + align-items: center; + padding: 2px; + box-sizing: border-box; + filter: drop-shadow(0 1px 2px rgba(60, 64, 67, 0.3)) + drop-shadow(0 1px 3px rgba(60, 64, 67, 0.15)); + } + .custom-marker-content::after { + content: ""; + position: absolute; + bottom: -3px; + left: 50%; + transform: translateX(-50%) rotate(45deg); + width: 8px; + height: 8px; + background-color: #ffffff; + border-bottom-right-radius: 2px; + } + .custom-marker-content-icon { + width: 20px; + height: 20px; + border-radius: 50%; + z-index: 1; + } + .custom-marker-content-icon { + svg { + position: relative; + } + + // Adjust icon position within the colored circle based on type. Each icon + // needs to be manually adjusted to ensure it's centered because they have + // different shapes. + &.custom-marker-content-icon--generic svg { + left: -4px; + top: -2px; + } + &.custom-marker-content-icon--food_and_drink svg { + left: 5px; + top: 0; + } + &.custom-marker-content-icon--retail svg { + left: 5px; + top: -2px; + } + &.custom-marker-content-icon--outdoor svg { + top: 1px; + left: 3px; + } + &.custom-marker-content-icon--entertainment svg { + left: 4px; + top: -1px; + } + &.custom-marker-content-icon--service svg { + left: -4px; + top: -2px; + } + &.custom-marker-content-icon--lodging svg { + left: -4px; + top: -3px; + } + &.custom-marker-content-icon--emergency svg { + left: -4px; + top: -2px; + } + &.custom-marker-content-icon--airport svg { + left: -4px; + top: -2.5px; + } + &.custom-marker-content-icon--parking svg { + left: 7.5px; + top: -1.5px; + } + &.custom-marker-content-icon--ev svg { + left: 7px; + top: -1px; + } + &.custom-marker-content-icon--closed svg { + left: -4px; + top: -3px; + } + } + .custom-marker-content-label { + color: #000; + font-family: "Google Sans"; + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: normal; + padding-left: 5px; + } + .custom-marker-label-container { + margin-top: 10px; + border-radius: 8px; + border: 0.5px solid #c7c7c7; + background: #fff; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.20), 0 1px 3px 1px rgba(0, 0, 0, 0.10); + text-align: center; + max-width: 110px; + } + .custom-marker-label { + color: #3d3833; + font-family: "Google Sans"; + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: normal; + margin: 4px 6px; + text-overflow: ellipsis; + text-shadow: 1px 1px 1px #fff, -1px -1px 1px #fff; + } +`; diff --git a/client/web/src/lit/custom-components/place_pin_marker_test.ts b/client/web/src/lit/custom-components/place_pin_marker_test.ts new file mode 100644 index 0000000..655b33d --- /dev/null +++ b/client/web/src/lit/custom-components/place_pin_marker_test.ts @@ -0,0 +1,64 @@ +// 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 {calculateLatitudeZIndex, createPlacePinMarkerTemplate, getPinColor, getPinIcon, PlacePinMarker} from './place_pin_marker'; + +describe('PlacePinMarker Module', () => { + it('creates gmp-marker element with correct properties', () => { + const marker = new PlacePinMarker({ + position: {lat: 37.7749, lng: -122.4194}, + label: 'SF Store', + zIndex: 10, + autofitsCamera: true, + }); + const el = marker.getElement() as any; + expect(el.tagName.toLowerCase()).toBe('gmp-marker'); + expect(el.position).toEqual({lat: 37.7749, lng: -122.4194}); + expect(el.label).toBe('SF Store'); + expect(el.zIndex).toBe(10); + }); + + it('correctly builds HTML custom marker template', () => { + const template = createPlacePinMarkerTemplate({ + label: 'Retail Store', + placePrimaryType: 'retail', + zIndex: 5, + }); + expect(template.style.zIndex).toBe('5'); + const labelEl = + (template.content?.querySelector('.custom-marker-label') || + template.querySelector('.custom-marker-label')) as HTMLElement; + expect(labelEl).not.toBeNull(); + expect(labelEl?.textContent).toBe('Retail Store'); + + const iconEl = + (template.content?.querySelector('.custom-marker-content-icon') || + template.querySelector('.custom-marker-content-icon')) as HTMLElement; + expect(iconEl).not.toBeNull(); + expect(iconEl.style.backgroundColor).toBe('rgb(5, 151, 255)'); + }); + + it('resolves POI color and icon lookup with fallbacks', () => { + expect(getPinColor('retail')).toBe('#0597FF'); + expect(getPinColor('unknown_category')).toBe('#78909C'); + expect(getPinIcon('retail')).toBeDefined(); + }); + + it('calculates latitude z-index so southern position gets higher z-index', + () => { + const northZ = calculateLatitudeZIndex(47.6062); // Seattle + const southZ = calculateLatitudeZIndex(34.0522); // LA + expect(southZ).toBeGreaterThan(northZ); + }); +}); diff --git a/client/web/src/lit/index.ts b/client/web/src/lit/index.ts index 62a8ef8..0f296f3 100644 --- a/client/web/src/lit/index.ts +++ b/client/web/src/lit/index.ts @@ -14,8 +14,8 @@ limitations under the License. */ +export {A2UIClient} from './a2ui_client'; export {A2UIRenderer, type TimelineItem} from './a2ui_renderer'; -export { A2UIClient } from './a2ui_client'; -export { mapsAgenticUICatalog } from "./catalog"; -export { themeStyleSheet } from "./theme"; - +export {mapsAgenticUICatalog} from './catalog'; +export {type GroundingSource, MauiGroundingSources, registerA2UICustomElements} from './custom-components'; +export {themeStyleSheet} from './theme';