Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions src/google/adk/cli/dev_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@
logger = logging.getLogger("google_adk." + __name__)

_EVAL_SET_FILE_EXTENSION = ".evalset.json"
_INITIAL_STATE_FILENAME = "initial_state.json"
_PROCESS_TERMINATION_GRACE_SECONDS = 0.5
_PROCESS_TERMINATOR_TIMEOUT_SECONDS = 1.0
_TEST_OUTPUT_CHUNK_BYTES = 64 * 1024
Expand Down Expand Up @@ -529,6 +530,55 @@ def _get_agent_dir(self, app_name: str) -> str:

return str(resolved_path)

def _load_initial_state(self, app_name: str) -> dict[str, Any]:
"""Reads the optional ``initial_state.json`` next to an agent.

The file is read on every call, so edits apply to the next session without
restarting the server. A missing, unreadable or malformed file yields an
empty state rather than failing session creation.
"""
try:
path = Path(self._get_agent_dir(app_name)) / _INITIAL_STATE_FILENAME
except HTTPException:
return {}
if not path.is_file():
return {}
try:
with open(path, "r", encoding="utf-8") as f:
initial_state = json.load(f)
except (OSError, ValueError) as e:
logger.warning("Ignoring %s: %s", path, e)
return {}
if not isinstance(initial_state, dict):
logger.warning("Ignoring %s: expected a JSON object", path)
return {}
return initial_state

async def _create_session(
self,
*,
app_name: str,
user_id: str,
session_id: Optional[str] = None,
state: Optional[dict[str, Any]] = None,
) -> Session:
"""Creates a session seeded from the agent's ``initial_state.json``.

State supplied by the caller takes precedence over the file's values, key by
key. Only explicit session creation is seeded; a session that the runner
creates itself (``--auto_create_session``) is not. ``app:`` and ``user:``
keys in the file follow the usual state scoping.
"""
initial_state = self._load_initial_state(app_name)
if initial_state:
state = {**initial_state, **(state or {})}
return await super()._create_session(
app_name=app_name,
user_id=user_id,
session_id=session_id,
state=state,
)

def _get_test_file_path(self, *, app_name: str, test_name: str) -> str:
"""Resolves a test file to a path inside the app's own tests directory.

Expand Down
83 changes: 83 additions & 0 deletions tests/unittests/cli/test_fast_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -7209,6 +7209,89 @@ def test_dev_endpoint_rejects_app_name_that_is_not_an_identifier(
assert "must be valid" in rejected.json()["detail"]


def _create_builder_session(client, payload=None):
response = client.post(
"/apps/test_app/users/test_user/sessions", json=payload or {}
)
assert response.status_code == 200, response.text
return response.json()


def test_dev_create_session_seeds_state_from_initial_state_file(
builder_test_client, tmp_path
):
(tmp_path / "test_app").mkdir()
(tmp_path / "test_app" / "initial_state.json").write_text(
json.dumps({"city": "New York", "time": "10:30"}), encoding="utf-8"
)

session = _create_builder_session(builder_test_client)

assert session["state"] == {"city": "New York", "time": "10:30"}


def test_dev_create_session_client_state_overrides_initial_state_file(
builder_test_client, tmp_path
):
(tmp_path / "test_app").mkdir()
(tmp_path / "test_app" / "initial_state.json").write_text(
json.dumps({"city": "New York", "time": "10:30"}), encoding="utf-8"
)

session = _create_builder_session(
builder_test_client, {"state": {"city": "Paris", "mood": "calm"}}
)

assert session["state"] == {
"city": "Paris",
"time": "10:30",
"mood": "calm",
}


def test_dev_create_session_without_initial_state_file_is_unchanged(
builder_test_client, tmp_path
):
(tmp_path / "test_app").mkdir()

session = _create_builder_session(builder_test_client, {"state": {"a": 1}})

assert session["state"] == {"a": 1}


@pytest.mark.parametrize("content", ["{not json", "[1, 2]", '"text"'])
def test_dev_create_session_ignores_invalid_initial_state_file(
builder_test_client, tmp_path, content
):
(tmp_path / "test_app").mkdir()
(tmp_path / "test_app" / "initial_state.json").write_text(
content, encoding="utf-8"
)

session = _create_builder_session(builder_test_client, {"state": {"a": 1}})

assert session["state"] == {"a": 1}


def test_api_server_create_session_ignores_initial_state_file(
test_app, test_session_info, tmp_path
):
"""Only the dev server reads initial_state.json."""
(tmp_path / test_session_info["app_name"]).mkdir()
(tmp_path / test_session_info["app_name"] / "initial_state.json").write_text(
json.dumps({"city": "New York"}), encoding="utf-8"
)

response = test_app.post(
f"/apps/{test_session_info['app_name']}/users/"
f"{test_session_info['user_id']}/sessions",
json={},
)

assert response.status_code == 200
assert "city" not in response.json().get("state", {})


#################################################
# Eval endpoint plumbing
#################################################
Expand Down
Loading