Skip to content

Commit 17952c7

Browse files
seanzhougooglecopybara-github
authored andcommitted
chore: Enhance a2a context id parsing and construction logic
PiperOrigin-RevId: 775718282
1 parent 407f020 commit 17952c7

File tree

3 files changed

+239
-8
lines changed

3 files changed

+239
-8
lines changed

src/google/adk/a2a/converters/utils.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
ADK_METADATA_KEY_PREFIX = "adk_"
1818
ADK_CONTEXT_ID_PREFIX = "ADK"
19+
ADK_CONTEXT_ID_SEPARATOR = "/"
1920

2021

2122
def _get_adk_metadata_key(key: str) -> str:
@@ -45,8 +46,17 @@ def _to_a2a_context_id(app_name: str, user_id: str, session_id: str) -> str:
4546
4647
Returns:
4748
The A2A context id.
49+
50+
Raises:
51+
ValueError: If any of the input parameters are empty or None.
4852
"""
49-
return [ADK_CONTEXT_ID_PREFIX, app_name, user_id, session_id].join("$")
53+
if not all([app_name, user_id, session_id]):
54+
raise ValueError(
55+
"All parameters (app_name, user_id, session_id) must be non-empty"
56+
)
57+
return ADK_CONTEXT_ID_SEPARATOR.join(
58+
[ADK_CONTEXT_ID_PREFIX, app_name, user_id, session_id]
59+
)
5060

5161

5262
def _from_a2a_context_id(context_id: str) -> tuple[str, str, str]:
@@ -64,8 +74,16 @@ def _from_a2a_context_id(context_id: str) -> tuple[str, str, str]:
6474
if not context_id:
6575
return None, None, None
6676

67-
prefix, app_name, user_id, session_id = context_id.split("$")
68-
if prefix == "ADK" and app_name and user_id and session_id:
69-
return app_name, user_id, session_id
77+
try:
78+
parts = context_id.split(ADK_CONTEXT_ID_SEPARATOR)
79+
if len(parts) != 4:
80+
return None, None, None
81+
82+
prefix, app_name, user_id, session_id = parts
83+
if prefix == ADK_CONTEXT_ID_PREFIX and app_name and user_id and session_id:
84+
return app_name, user_id, session_id
85+
except ValueError:
86+
# Handle any split errors gracefully
87+
pass
7088

7189
return None, None, None

tests/unittests/a2a/converters/test_request_converter.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,7 @@ def test_convert_a2a_request_basic(
244244

245245
request = Mock(spec=RequestContext)
246246
request.message = mock_message
247-
request.context_id = "ADK$app$user$session"
247+
request.context_id = "ADK/app/user/session"
248248

249249
mock_from_context_id.return_value = (
250250
"app_name",
@@ -271,7 +271,7 @@ def test_convert_a2a_request_basic(
271271
assert isinstance(result["run_config"], RunConfig)
272272

273273
# Verify calls
274-
mock_from_context_id.assert_called_once_with("ADK$app$user$session")
274+
mock_from_context_id.assert_called_once_with("ADK/app/user/session")
275275
mock_get_user_id.assert_called_once_with(request, "user_from_context")
276276
assert mock_convert_part.call_count == 2
277277
mock_convert_part.assert_any_call(mock_part1)
@@ -302,7 +302,7 @@ def test_convert_a2a_request_empty_parts(
302302

303303
request = Mock(spec=RequestContext)
304304
request.message = mock_message
305-
request.context_id = "ADK$app$user$session"
305+
request.context_id = "ADK/app/user/session"
306306

307307
mock_from_context_id.return_value = (
308308
"app_name",
@@ -431,7 +431,7 @@ def test_end_to_end_conversion_with_auth_user(self, mock_convert_part):
431431
request = Mock(spec=RequestContext)
432432
request.call_context = mock_call_context
433433
request.message = mock_message
434-
request.context_id = "ADK$myapp$context_user$mysession"
434+
request.context_id = "ADK/myapp/context_user/mysession"
435435
request.current_task = None
436436
request.task_id = "task123"
437437

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import sys
16+
17+
import pytest
18+
19+
# Skip all tests in this module if Python version is less than 3.10
20+
pytestmark = pytest.mark.skipif(
21+
sys.version_info < (3, 10), reason="A2A requires Python 3.10+"
22+
)
23+
24+
from google.adk.a2a.converters.utils import _from_a2a_context_id
25+
from google.adk.a2a.converters.utils import _get_adk_metadata_key
26+
from google.adk.a2a.converters.utils import _to_a2a_context_id
27+
from google.adk.a2a.converters.utils import ADK_CONTEXT_ID_PREFIX
28+
from google.adk.a2a.converters.utils import ADK_METADATA_KEY_PREFIX
29+
import pytest
30+
31+
32+
class TestUtilsFunctions:
33+
"""Test suite for utils module functions."""
34+
35+
def test_get_adk_metadata_key_success(self):
36+
"""Test successful metadata key generation."""
37+
key = "test_key"
38+
result = _get_adk_metadata_key(key)
39+
assert result == f"{ADK_METADATA_KEY_PREFIX}{key}"
40+
41+
def test_get_adk_metadata_key_empty_string(self):
42+
"""Test metadata key generation with empty string."""
43+
with pytest.raises(
44+
ValueError, match="Metadata key cannot be empty or None"
45+
):
46+
_get_adk_metadata_key("")
47+
48+
def test_get_adk_metadata_key_none(self):
49+
"""Test metadata key generation with None."""
50+
with pytest.raises(
51+
ValueError, match="Metadata key cannot be empty or None"
52+
):
53+
_get_adk_metadata_key(None)
54+
55+
def test_get_adk_metadata_key_whitespace(self):
56+
"""Test metadata key generation with whitespace string."""
57+
key = " "
58+
result = _get_adk_metadata_key(key)
59+
assert result == f"{ADK_METADATA_KEY_PREFIX}{key}"
60+
61+
def test_to_a2a_context_id_success(self):
62+
"""Test successful context ID generation."""
63+
app_name = "test-app"
64+
user_id = "test-user"
65+
session_id = "test-session"
66+
67+
result = _to_a2a_context_id(app_name, user_id, session_id)
68+
69+
expected = f"{ADK_CONTEXT_ID_PREFIX}/test-app/test-user/test-session"
70+
assert result == expected
71+
72+
def test_to_a2a_context_id_empty_app_name(self):
73+
"""Test context ID generation with empty app name."""
74+
with pytest.raises(
75+
ValueError,
76+
match=(
77+
"All parameters \\(app_name, user_id, session_id\\) must be"
78+
" non-empty"
79+
),
80+
):
81+
_to_a2a_context_id("", "user", "session")
82+
83+
def test_to_a2a_context_id_empty_user_id(self):
84+
"""Test context ID generation with empty user ID."""
85+
with pytest.raises(
86+
ValueError,
87+
match=(
88+
"All parameters \\(app_name, user_id, session_id\\) must be"
89+
" non-empty"
90+
),
91+
):
92+
_to_a2a_context_id("app", "", "session")
93+
94+
def test_to_a2a_context_id_empty_session_id(self):
95+
"""Test context ID generation with empty session ID."""
96+
with pytest.raises(
97+
ValueError,
98+
match=(
99+
"All parameters \\(app_name, user_id, session_id\\) must be"
100+
" non-empty"
101+
),
102+
):
103+
_to_a2a_context_id("app", "user", "")
104+
105+
def test_to_a2a_context_id_none_values(self):
106+
"""Test context ID generation with None values."""
107+
with pytest.raises(
108+
ValueError,
109+
match=(
110+
"All parameters \\(app_name, user_id, session_id\\) must be"
111+
" non-empty"
112+
),
113+
):
114+
_to_a2a_context_id(None, "user", "session")
115+
116+
def test_to_a2a_context_id_special_characters(self):
117+
"""Test context ID generation with special characters."""
118+
app_name = "test-app@2024"
119+
user_id = "user_123"
120+
session_id = "session-456"
121+
122+
result = _to_a2a_context_id(app_name, user_id, session_id)
123+
124+
expected = f"{ADK_CONTEXT_ID_PREFIX}/test-app@2024/user_123/session-456"
125+
assert result == expected
126+
127+
def test_from_a2a_context_id_success(self):
128+
"""Test successful context ID parsing."""
129+
context_id = f"{ADK_CONTEXT_ID_PREFIX}/test-app/test-user/test-session"
130+
131+
app_name, user_id, session_id = _from_a2a_context_id(context_id)
132+
133+
assert app_name == "test-app"
134+
assert user_id == "test-user"
135+
assert session_id == "test-session"
136+
137+
def test_from_a2a_context_id_none_input(self):
138+
"""Test context ID parsing with None input."""
139+
result = _from_a2a_context_id(None)
140+
assert result == (None, None, None)
141+
142+
def test_from_a2a_context_id_empty_string(self):
143+
"""Test context ID parsing with empty string."""
144+
result = _from_a2a_context_id("")
145+
assert result == (None, None, None)
146+
147+
def test_from_a2a_context_id_invalid_prefix(self):
148+
"""Test context ID parsing with invalid prefix."""
149+
context_id = "INVALID/test-app/test-user/test-session"
150+
151+
result = _from_a2a_context_id(context_id)
152+
153+
assert result == (None, None, None)
154+
155+
def test_from_a2a_context_id_too_few_parts(self):
156+
"""Test context ID parsing with too few parts."""
157+
context_id = f"{ADK_CONTEXT_ID_PREFIX}/test-app/test-user"
158+
159+
result = _from_a2a_context_id(context_id)
160+
161+
assert result == (None, None, None)
162+
163+
def test_from_a2a_context_id_too_many_parts(self):
164+
"""Test context ID parsing with too many parts."""
165+
context_id = (
166+
f"{ADK_CONTEXT_ID_PREFIX}/test-app/test-user/test-session/extra"
167+
)
168+
169+
result = _from_a2a_context_id(context_id)
170+
171+
assert result == (None, None, None)
172+
173+
def test_from_a2a_context_id_empty_components(self):
174+
"""Test context ID parsing with empty components."""
175+
context_id = f"{ADK_CONTEXT_ID_PREFIX}//test-user/test-session"
176+
177+
result = _from_a2a_context_id(context_id)
178+
179+
assert result == (None, None, None)
180+
181+
def test_from_a2a_context_id_no_dollar_separator(self):
182+
"""Test context ID parsing without dollar separators."""
183+
context_id = f"{ADK_CONTEXT_ID_PREFIX}-test-app-test-user-test-session"
184+
185+
result = _from_a2a_context_id(context_id)
186+
187+
assert result == (None, None, None)
188+
189+
def test_roundtrip_context_id(self):
190+
"""Test roundtrip conversion: to -> from."""
191+
app_name = "test-app"
192+
user_id = "test-user"
193+
session_id = "test-session"
194+
195+
# Convert to context ID
196+
context_id = _to_a2a_context_id(app_name, user_id, session_id)
197+
198+
# Convert back
199+
parsed_app, parsed_user, parsed_session = _from_a2a_context_id(context_id)
200+
201+
assert parsed_app == app_name
202+
assert parsed_user == user_id
203+
assert parsed_session == session_id
204+
205+
def test_from_a2a_context_id_special_characters(self):
206+
"""Test context ID parsing with special characters."""
207+
context_id = f"{ADK_CONTEXT_ID_PREFIX}/test-app@2024/user_123/session-456"
208+
209+
app_name, user_id, session_id = _from_a2a_context_id(context_id)
210+
211+
assert app_name == "test-app@2024"
212+
assert user_id == "user_123"
213+
assert session_id == "session-456"

0 commit comments

Comments
 (0)