diff --git a/src/google/adk/a2a/converters/utils.py b/src/google/adk/a2a/converters/utils.py index 00111f835d..b19aa2230f 100644 --- a/src/google/adk/a2a/converters/utils.py +++ b/src/google/adk/a2a/converters/utils.py @@ -14,6 +14,9 @@ from __future__ import annotations +from urllib.parse import quote +from urllib.parse import unquote + ADK_METADATA_KEY_PREFIX = "adk_" ADK_CONTEXT_ID_PREFIX = "ADK" ADK_CONTEXT_ID_SEPARATOR = "/" @@ -54,18 +57,23 @@ def _to_a2a_context_id(app_name: str, user_id: str, session_id: str) -> str: raise ValueError( "All parameters (app_name, user_id, session_id) must be non-empty" ) - return ADK_CONTEXT_ID_SEPARATOR.join( - [ADK_CONTEXT_ID_PREFIX, app_name, user_id, session_id] - ) + return ADK_CONTEXT_ID_SEPARATOR.join([ + ADK_CONTEXT_ID_PREFIX, + quote(app_name, safe=""), + quote(user_id, safe=""), + quote(session_id, safe=""), + ]) def _from_a2a_context_id( context_id: str | None, ) -> tuple[str, str, str] | tuple[None, None, None]: """Converts an A2A context id to app name, user id and session id. - if context_id is None, return None, None, None - if context_id is not None, but not in the format of - ADK$app_name$user_id$session_id, return None, None, None + + Reverses ``_to_a2a_context_id``. The context id is expected to be + ``ADK///`` with each field percent-encoded, so + that ids containing the separator still round-trip. Returns (None, None, None) + if context_id is None or does not match this format. Args: context_id: The A2A context id. @@ -76,16 +84,18 @@ def _from_a2a_context_id( if not context_id: return None, None, None - try: - parts = context_id.split(ADK_CONTEXT_ID_SEPARATOR) - if len(parts) != 4: - return None, None, None - - prefix, app_name, user_id, session_id = parts - if prefix == ADK_CONTEXT_ID_PREFIX and app_name and user_id and session_id: - return app_name, user_id, session_id - except ValueError: - # Handle any split errors gracefully - pass + parts = context_id.split(ADK_CONTEXT_ID_SEPARATOR) + if len(parts) != 4: + return None, None, None + + prefix, app_name, user_id, session_id = parts + if prefix != ADK_CONTEXT_ID_PREFIX: + return None, None, None + + app_name = unquote(app_name) + user_id = unquote(user_id) + session_id = unquote(session_id) + if app_name and user_id and session_id: + return app_name, user_id, session_id return None, None, None diff --git a/tests/unittests/a2a/converters/test_utils.py b/tests/unittests/a2a/converters/test_utils.py index bfbd25aae6..88c0d3f5c1 100644 --- a/tests/unittests/a2a/converters/test_utils.py +++ b/tests/unittests/a2a/converters/test_utils.py @@ -112,7 +112,7 @@ def test_to_a2a_context_id_special_characters(self): result = _to_a2a_context_id(app_name, user_id, session_id) - expected = f"{ADK_CONTEXT_ID_PREFIX}/test-app@2024/user_123/session-456" + expected = f"{ADK_CONTEXT_ID_PREFIX}/test-app%402024/user_123/session-456" assert result == expected def test_from_a2a_context_id_success(self): @@ -193,6 +193,32 @@ def test_roundtrip_context_id(self): assert parsed_user == user_id assert parsed_session == session_id + @pytest.mark.parametrize( + "app_name, user_id, session_id", + [ + ("app", "user", "projects/p/sessions/s"), + ("app", "projects/p/subscriptions/sub", "session"), + ("apps/a/versions/v", "user", "session"), + ("app", "user", "a/b/c"), + ], + ) + def test_roundtrip_context_id_with_separator_in_ids( + self, app_name, user_id, session_id + ): + """Test roundtrip conversion when ids contain the separator character. + + App names, user ids and session ids can legitimately contain the separator + (for example fully-qualified resource paths). The context id must still + round-trip so A2A session resolution does not silently fail. + """ + context_id = _to_a2a_context_id(app_name, user_id, session_id) + + parsed_app, parsed_user, parsed_session = _from_a2a_context_id(context_id) + + assert parsed_app == app_name + assert parsed_user == user_id + assert parsed_session == session_id + def test_from_a2a_context_id_special_characters(self): """Test context ID parsing with special characters.""" context_id = f"{ADK_CONTEXT_ID_PREFIX}/test-app@2024/user_123/session-456"