From 4baf7e136a5dcb94c96e7c3f2136f360ad75f4eb Mon Sep 17 00:00:00 2001 From: wpn10 Date: Thu, 19 Feb 2026 02:07:35 +0530 Subject: [PATCH 1/2] fix(artifacts): Preserve .text on GcsArtifactService load (#3157) Store _adk_is_text metadata flag on GCS blobs for text artifacts and use it on load to reconstruct as Part(text=...) instead of Part.from_bytes(). Switch to get_blob() to fetch blob metadata. --- .../adk/artifacts/gcs_artifact_service.py | 14 +++++--- .../artifacts/test_artifact_service.py | 32 +++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/google/adk/artifacts/gcs_artifact_service.py b/src/google/adk/artifacts/gcs_artifact_service.py index 4108cfb06b..67dec74d87 100644 --- a/src/google/adk/artifacts/gcs_artifact_service.py +++ b/src/google/adk/artifacts/gcs_artifact_service.py @@ -221,7 +221,8 @@ def _save_artifact( data=artifact.inline_data.data, content_type=artifact.inline_data.mime_type, ) - elif artifact.text: + elif artifact.text is not None: + blob.metadata = {**(blob.metadata or {}), "_adk_is_text": "true"} blob.upload_from_string( data=artifact.text, content_type="text/plain", @@ -260,15 +261,20 @@ def _load_artifact( blob_name = self._get_blob_name( app_name, user_id, filename, version, session_id ) - blob = self.bucket.blob(blob_name) + blob = self.bucket.get_blob(blob_name) + if not blob: + return None artifact_bytes = blob.download_as_bytes() if not artifact_bytes: return None - artifact = types.Part.from_bytes( + + if blob.metadata and blob.metadata.get("_adk_is_text") == "true": + return types.Part(text=artifact_bytes.decode("utf-8")) + + return types.Part.from_bytes( data=artifact_bytes, mime_type=blob.content_type ) - return artifact def _list_artifact_keys( self, app_name: str, user_id: str, session_id: Optional[str] diff --git a/tests/unittests/artifacts/test_artifact_service.py b/tests/unittests/artifacts/test_artifact_service.py index ec74f8abe3..4d6359e747 100644 --- a/tests/unittests/artifacts/test_artifact_service.py +++ b/tests/unittests/artifacts/test_artifact_service.py @@ -766,3 +766,35 @@ async def test_file_save_artifact_rejects_absolute_path_within_scope(tmp_path): filename=str(absolute_in_scope), artifact=part, ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "service_type", + [ + ArtifactServiceType.IN_MEMORY, + ArtifactServiceType.GCS, + ArtifactServiceType.FILE, + ], +) +async def test_save_load_text_artifact(service_type, artifact_service_factory): + """Tests that text artifacts retain .text after round-trip save/load.""" + artifact_service = artifact_service_factory(service_type) + artifact = types.Part.from_text(text='{"key": "value"}') + + await artifact_service.save_artifact( + app_name="app0", + user_id="user0", + session_id="123", + filename="data.json", + artifact=artifact, + ) + loaded = await artifact_service.load_artifact( + app_name="app0", + user_id="user0", + session_id="123", + filename="data.json", + ) + assert loaded is not None + assert loaded.text == '{"key": "value"}' + assert loaded.inline_data is None From e3d729e92ba71dc192494a6fbe09095f7ba35b6e Mon Sep 17 00:00:00 2001 From: wpn10 Date: Thu, 19 Feb 2026 02:45:09 +0530 Subject: [PATCH 2/2] fix: address review feedback for empty text artifacts --- .../adk/artifacts/gcs_artifact_service.py | 2 - .../artifacts/test_artifact_service.py | 45 +++++++++++++++++-- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/google/adk/artifacts/gcs_artifact_service.py b/src/google/adk/artifacts/gcs_artifact_service.py index 67dec74d87..4a46c9e4c6 100644 --- a/src/google/adk/artifacts/gcs_artifact_service.py +++ b/src/google/adk/artifacts/gcs_artifact_service.py @@ -266,8 +266,6 @@ def _load_artifact( return None artifact_bytes = blob.download_as_bytes() - if not artifact_bytes: - return None if blob.metadata and blob.metadata.get("_adk_is_text") == "true": return types.Part(text=artifact_bytes.decode("utf-8")) diff --git a/tests/unittests/artifacts/test_artifact_service.py b/tests/unittests/artifacts/test_artifact_service.py index 4d6359e747..59e31d93bc 100644 --- a/tests/unittests/artifacts/test_artifact_service.py +++ b/tests/unittests/artifacts/test_artifact_service.py @@ -777,10 +777,16 @@ async def test_file_save_artifact_rejects_absolute_path_within_scope(tmp_path): ArtifactServiceType.FILE, ], ) -async def test_save_load_text_artifact(service_type, artifact_service_factory): +@pytest.mark.parametrize( + "text_content", + ['{"key": "value"}', "some other text"], +) +async def test_save_load_text_artifact( + service_type, artifact_service_factory, text_content +): """Tests that text artifacts retain .text after round-trip save/load.""" artifact_service = artifact_service_factory(service_type) - artifact = types.Part.from_text(text='{"key": "value"}') + artifact = types.Part.from_text(text=text_content) await artifact_service.save_artifact( app_name="app0", @@ -796,5 +802,38 @@ async def test_save_load_text_artifact(service_type, artifact_service_factory): filename="data.json", ) assert loaded is not None - assert loaded.text == '{"key": "value"}' + assert loaded.text == text_content + assert loaded.inline_data is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "service_type", + [ + ArtifactServiceType.GCS, + ArtifactServiceType.FILE, + ], +) +async def test_save_load_empty_text_artifact( + service_type, artifact_service_factory +): + """Tests that empty text artifacts survive round-trip save/load.""" + artifact_service = artifact_service_factory(service_type) + artifact = types.Part.from_text(text="") + + await artifact_service.save_artifact( + app_name="app0", + user_id="user0", + session_id="123", + filename="empty.txt", + artifact=artifact, + ) + loaded = await artifact_service.load_artifact( + app_name="app0", + user_id="user0", + session_id="123", + filename="empty.txt", + ) + assert loaded is not None + assert loaded.text == "" assert loaded.inline_data is None