From 4146ab761af15ee0383626bbc72b1a49e790989b Mon Sep 17 00:00:00 2001 From: saiiiii <49656052+saiprasanth-git@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:18:18 -0500 Subject: [PATCH 1/7] fix: preserve URL fragment as query params in _prepare_request_params Fixes #4598: When a URL contains a fragment component (e.g., #triggerId=abc123), the fragment was being silently dropped. This caused HTTP 400 errors when APIs expect fragment-encoded parameters to be passed as query string parameters. This change parses the URL fragment using parse_qs and merges the extracted key-value pairs into query_params (using setdefault to avoid overriding explicitly-passed values), consistent with how URL query strings are handled. --- .../tools/openapi_tool/openapi_spec_parser/rest_api_tool.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py b/src/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py index 5f83548980..3326697ee4 100644 --- a/src/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py +++ b/src/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py @@ -384,6 +384,9 @@ def _prepare_request_params( if parsed_url.query or parsed_url.fragment: for key, values in parse_qs(parsed_url.query).items(): query_params.setdefault(key, values[0] if len(values) == 1 else values) + if parsed_url.fragment: + for key, values in parse_qs(parsed_url.fragment).items(): + query_params.setdefault(key, values[0] if len(values) == 1 else values) url = urlunparse(parsed_url._replace(query="", fragment="")) # Construct body From b899c49ce1bb9c669fc7fc889fc28269b3815187 Mon Sep 17 00:00:00 2001 From: saiiiii <49656052+saiprasanth-git@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:22:12 -0500 Subject: [PATCH 2/7] test: add test for fragment key=value extraction in _prepare_request_params Adds test_prepare_request_params_extracts_fragment_key_value_pairs to verify that URL fragments containing key=value pairs (e.g. #action=POST) are correctly parsed and added to query_params, alongside query string params. Regression test for issue #4598. --- .../openapi_spec_parser/test_rest_api_tool.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py b/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py index 1131181acd..18abad734d 100644 --- a/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py +++ b/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py @@ -1423,6 +1423,42 @@ def test_prepare_request_params_plain_url_unchanged( request_params = tool._prepare_request_params([], {}) assert request_params["url"] == "https://example.com/test" + def test_prepare_request_params_extracts_fragment_key_value_pairs( + self, sample_auth_credential, sample_auth_scheme + ): + """Fragment with key=value pairs should be parsed as query params. + + When a URL fragment contains key=value pairs (e.g. #key=value), + they should be extracted and added to query_params, consistent with + how embedded query string params are handled. + + Regression test for https://github.com/google/adk-python/issues/4598. + """ + endpoint = OperationEndpoint( + base_url="https://example.com", + path="/api?triggerId=api_trigger/Name#action=POST", + method="GET", + ) + operation = Operation(operationId="test_op") + tool = RestApiTool( + name="test_tool", + description="test", + endpoint=endpoint, + operation=operation, + auth_credential=sample_auth_credential, + auth_scheme=sample_auth_scheme, + ) + + request_params = tool._prepare_request_params([], {}) + + # Query string param must be extracted + assert request_params["params"]["triggerId"] == "api_trigger/Name" + # Fragment key=value pair must be extracted as a query param + assert request_params["params"]["action"] == "POST" + # The URL must NOT contain query string or fragment + assert "?" not in request_params["url"] + assert "#" not in request_params["url"] + assert request_params["url"] == "https://example.com/api" def test_snake_to_lower_camel(): From e8eca1be7b8b096590e405e410b6aed8f551b27c Mon Sep 17 00:00:00 2001 From: saiiiii <49656052+saiprasanth-git@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:58:36 -0500 Subject: [PATCH 3/7] fix: parse URL fragment key=value pairs into query params Refactor query parameter extraction to handle both query and fragment in a single loop. --- .../openapi_spec_parser/rest_api_tool.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py b/src/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py index 3326697ee4..0e1d8099ed 100644 --- a/src/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py +++ b/src/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py @@ -382,12 +382,15 @@ def _prepare_request_params( # replaces (rather than merges) the URL query string when `params` is set. parsed_url = urlparse(url) if parsed_url.query or parsed_url.fragment: - for key, values in parse_qs(parsed_url.query).items(): - query_params.setdefault(key, values[0] if len(values) == 1 else values) - if parsed_url.fragment: - for key, values in parse_qs(parsed_url.fragment).items(): - query_params.setdefault(key, values[0] if len(values) == 1 else values) - url = urlunparse(parsed_url._replace(query="", fragment="")) + for part in (parsed_url.query, parsed_url.fragment): + if part: + for key, values in parse_qs(part).items(): + query_params.setdefault( + key, + values[0] if len(values) == 1 else values + ) + # URL without query and fragment + url = urlunparse(parsed_url._replace(query="", fragment="")) # Construct body body_kwargs: Dict[str, Any] = {} From 9ba41affc72021adefcca6d96214344510151582 Mon Sep 17 00:00:00 2001 From: saiiiii <49656052+saiprasanth-git@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:03:41 -0500 Subject: [PATCH 4/7] test: add coverage for fragment params being extracted as query params Added a test using a realistic Google Cloud integration URL that has both a query string param (triggerId) and a fragment param (httpMethod). Confirms both get moved into query_params and the final URL is clean. --- .../openapi_spec_parser/test_rest_api_tool.py | 61 ++++++++++--------- 1 file changed, 33 insertions(+), 28 deletions(-) diff --git a/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py b/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py index 18abad734d..02bfdcdf5c 100644 --- a/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py +++ b/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py @@ -1423,42 +1423,47 @@ def test_prepare_request_params_plain_url_unchanged( request_params = tool._prepare_request_params([], {}) assert request_params["url"] == "https://example.com/test" - def test_prepare_request_params_extracts_fragment_key_value_pairs( + def test_prepare_request_params_fragment_params_become_query_params( self, sample_auth_credential, sample_auth_scheme ): - """Fragment with key=value pairs should be parsed as query params. - - When a URL fragment contains key=value pairs (e.g. #key=value), - they should be extracted and added to query_params, consistent with - how embedded query string params are handled. - - Regression test for https://github.com/google/adk-python/issues/4598. - """ - endpoint = OperationEndpoint( - base_url="https://example.com", - path="/api?triggerId=api_trigger/Name#action=POST", - method="GET", - ) - operation = Operation(operationId="test_op") + # When the ApplicationIntegrationToolset builds an endpoint URL, it sometimes + # puts params in the fragment (e.g. #triggerId=my_trigger). Without this fix + # those params were silently dropped and the API returned a 400 error. + # See: https://github.com/google/adk-python/issues/4598 + integration_endpoint = OperationEndpoint( + base_url="https://integrations.googleapis.com", + path=( + "/v2/projects/demo/locations/us-central1" + "/integrations/MyFlow:execute" + "?triggerId=api_trigger/MyFlow" + "#httpMethod=POST" + ), + method="POST", + ) + op = Operation(operationId="run_integration") tool = RestApiTool( - name="test_tool", - description="test", - endpoint=endpoint, - operation=operation, + name="run_integration", + description="Runs a Google Cloud integration flow", + endpoint=integration_endpoint, + operation=op, auth_credential=sample_auth_credential, auth_scheme=sample_auth_scheme, ) - request_params = tool._prepare_request_params([], {}) + result = tool._prepare_request_params([], {}) - # Query string param must be extracted - assert request_params["params"]["triggerId"] == "api_trigger/Name" - # Fragment key=value pair must be extracted as a query param - assert request_params["params"]["action"] == "POST" - # The URL must NOT contain query string or fragment - assert "?" not in request_params["url"] - assert "#" not in request_params["url"] - assert request_params["url"] == "https://example.com/api" + # Both the query string and fragment params should land in query params + assert result["params"]["triggerId"] == "api_trigger/MyFlow" + assert result["params"]["httpMethod"] == "POST" + + # The final URL should be clean — no leftover ? or # + assert "?" not in result["url"] + assert "#" not in result["url"] + assert result["url"] == ( + "https://integrations.googleapis.com" + "/v2/projects/demo/locations/us-central1" + "/integrations/MyFlow:execute" + ) def test_snake_to_lower_camel(): From 71e49d5e8fb156d66ea83d32c9473b3f4eb6c5e1 Mon Sep 17 00:00:00 2001 From: saiiiii <49656052+saiprasanth-git@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:28:25 -0500 Subject: [PATCH 5/7] fix: remove redundant outer if guard causing mypy syntax error Remove unnecessary check for query and fragment in URL parsing. --- .../adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py b/src/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py index 0e1d8099ed..73a87dd760 100644 --- a/src/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py +++ b/src/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py @@ -381,7 +381,6 @@ def _prepare_request_params( # Move query params embedded in the path into query_params, since httpx # replaces (rather than merges) the URL query string when `params` is set. parsed_url = urlparse(url) - if parsed_url.query or parsed_url.fragment: for part in (parsed_url.query, parsed_url.fragment): if part: for key, values in parse_qs(part).items(): From 0e7cc8d8474d0e405ebaacab7ce3f45d61464767 Mon Sep 17 00:00:00 2001 From: saiiiii <49656052+saiprasanth-git@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:10:19 -0500 Subject: [PATCH 6/7] fix: preserve initial session state keys during rewind Fixes #4839: _compute_state_delta_for_rewind nullifies initial session state keys not present in event state_deltas. Track keys that appeared in any event's state_delta throughout the session. Ensure initial state keys are preserved after rewind. --- src/google/adk/runners.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/google/adk/runners.py b/src/google/adk/runners.py index 8e352794a4..ffb6ab3bdc 100644 --- a/src/google/adk/runners.py +++ b/src/google/adk/runners.py @@ -699,7 +699,17 @@ async def _compute_state_delta_for_rewind( state_at_rewind_point[k] = v current_state = session.state - rewind_state_delta = {} +# Collect all keys that ever appeared in ANY event's state_delta across + # the entire session. Keys present in current_state but absent from all + # event state_deltas are "initial state" (set via create_session) and + # must be preserved after a rewind. + keys_ever_in_event_deltas: set[str] = set() + for event in session.events: + if event.actions.state_delta: + for k in event.actions.state_delta: + if not k.startswith('app:') and not k.startswith('user:'): + keys_ever_in_event_deltas.add(k) + rewind_state_delta = {} # 1. Add/update keys in rewind_state_delta to match state_at_rewind_point. for key, value_at_rewind in state_at_rewind_point.items(): @@ -712,7 +722,7 @@ async def _compute_state_delta_for_rewind( for key in current_state: if key.startswith('app:') or key.startswith('user:'): continue - if key not in state_at_rewind_point: + if key not in state_at_rewind_point and key in keys_ever_in_event_deltas: rewind_state_delta[key] = None return rewind_state_delta From 4182ab68432ad1ec8ce6d4e66fe0f054c5bfe23c Mon Sep 17 00:00:00 2001 From: saiiiii <49656052+saiprasanth-git@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:16:59 -0500 Subject: [PATCH 7/7] fix(runners): use set comprehension for keys_ever_in_event_deltas Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/google/adk/runners.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/google/adk/runners.py b/src/google/adk/runners.py index ffb6ab3bdc..7ea2e43f88 100644 --- a/src/google/adk/runners.py +++ b/src/google/adk/runners.py @@ -703,13 +703,14 @@ async def _compute_state_delta_for_rewind( # the entire session. Keys present in current_state but absent from all # event state_deltas are "initial state" (set via create_session) and # must be preserved after a rewind. - keys_ever_in_event_deltas: set[str] = set() - for event in session.events: - if event.actions.state_delta: - for k in event.actions.state_delta: - if not k.startswith('app:') and not k.startswith('user:'): - keys_ever_in_event_deltas.add(k) - rewind_state_delta = {} + keys_ever_in_event_deltas: set[str] = { + k + for event in session.events + if event.actions.state_delta + for k in event.actions.state_delta + if not k.startswith('app:') and not k.startswith('user:') + } + rewind_state_delta = {} # 1. Add/update keys in rewind_state_delta to match state_at_rewind_point. for key, value_at_rewind in state_at_rewind_point.items():