"""Integration tests for session archive lifecycle and agent contents download. Covers: - ``PATCH /v1/sessions/{id}`` with ``archived=True/False`` - ``GET /v1/sessions`` with ``include_archived`` filtering - ``GET /v1/sessions/{id}/agent/contents`` returning a valid gzip tarball Uses the shared ``client`` fixture from ``tests/server/conftest.py`` (real stores + mock LLM) so the tests hit the real route-to-store pipeline without subprocesses. """ from __future__ import annotations import gzip import io import tarfile from unittest.mock import AsyncMock, patch import httpx import pytest from omnigent.server.routes import sessions as sessions_module from tests.server.helpers import create_test_session pytestmark = pytest.mark.asyncio # ── Archive / unarchive lifecycle ──────────────────────── async def test_session_not_archived_by_default( client: httpx.AsyncClient, ) -> None: """A freshly created session has ``archived=False``.""" session = await create_test_session(client, name="archive-default") assert session["archived"] is False async def test_archive_hides_session_from_default_listing( client: httpx.AsyncClient, ) -> None: """Archiving a session removes it from the default GET /v1/sessions listing.""" session = await create_test_session(client, name="archive-hide") session_id = session["id"] # Archive it. patch_resp = await client.patch( f"/v1/sessions/{session_id}", json={"archived": True}, ) assert patch_resp.status_code == 200 assert patch_resp.json()["archived"] is True # Default listing (include_archived=False) should not contain it. listing = await client.get("/v1/sessions") assert listing.status_code == 200 listed_ids = [s["id"] for s in listing.json()["data"]] assert session_id not in listed_ids async def test_archived_session_appears_with_include_archived( client: httpx.AsyncClient, ) -> None: """An archived session is returned when ``include_archived=True``.""" session = await create_test_session(client, name="archive-include") session_id = session["id"] await client.patch( f"/v1/sessions/{session_id}", json={"archived": True}, ) listing = await client.get("/v1/sessions", params={"include_archived": "true"}) assert listing.status_code == 200 listed_ids = [s["id"] for s in listing.json()["data"]] assert session_id in listed_ids async def test_unarchive_restores_session_to_default_listing( client: httpx.AsyncClient, ) -> None: """Unarchiving a session makes it visible in the default listing again.""" session = await create_test_session(client, name="archive-restore") session_id = session["id"] # Archive then unarchive. await client.patch(f"/v1/sessions/{session_id}", json={"archived": True}) patch_resp = await client.patch( f"/v1/sessions/{session_id}", json={"archived": False}, ) assert patch_resp.status_code == 200 assert patch_resp.json()["archived"] is False # Back in the default listing. listing = await client.get("/v1/sessions") assert listing.status_code == 200 listed_ids = [s["id"] for s in listing.json()["data"]] assert session_id in listed_ids # ── Best-effort stop before archive ─────────────────────── async def test_archive_running_session_attempts_stop( client: httpx.AsyncClient, ) -> None: """Archiving a running session calls ``_stop_session_via_runner``.""" session = await create_test_session(client, name="archive-running") session_id = session["id"] mock_stop = AsyncMock(return_value=True) sessions_module._session_status_cache[session_id] = "running" try: with patch.object(sessions_module, "_stop_session_via_runner", mock_stop): resp = await client.patch( f"/v1/sessions/{session_id}", json={"archived": True}, ) assert resp.status_code == 200 assert resp.json()["archived"] is True mock_stop.assert_awaited_once() finally: sessions_module._session_status_cache.pop(session_id, None) async def test_archive_proceeds_when_stop_fails( client: httpx.AsyncClient, ) -> None: """Archive succeeds even when the runner stop raises.""" session = await create_test_session(client, name="archive-stop-fail") session_id = session["id"] mock_stop = AsyncMock(side_effect=ConnectionError("runner gone")) sessions_module._session_status_cache[session_id] = "running" try: with patch.object(sessions_module, "_stop_session_via_runner", mock_stop): resp = await client.patch( f"/v1/sessions/{session_id}", json={"archived": True}, ) assert resp.status_code == 200 assert resp.json()["archived"] is True finally: sessions_module._session_status_cache.pop(session_id, None) async def test_archive_proceeds_when_child_lookup_fails( client: httpx.AsyncClient, ) -> None: """Archive succeeds even when the child-id DB lookup raises.""" session = await create_test_session(client, name="archive-db-fail") session_id = session["id"] sessions_module._session_status_cache[session_id] = "running" try: with patch.object( sessions_module, "_best_effort_stop", wraps=sessions_module._best_effort_stop, ): orig = sessions_module._best_effort_stop async def _patched_stop(sid, cs, rr): with patch.object( cs, "list_child_conversation_ids_by_parent", side_effect=RuntimeError("transient DB error"), ): await orig(sid, cs, rr) with patch.object(sessions_module, "_best_effort_stop", _patched_stop): resp = await client.patch( f"/v1/sessions/{session_id}", json={"archived": True}, ) assert resp.status_code == 200 assert resp.json()["archived"] is True finally: sessions_module._session_status_cache.pop(session_id, None) async def test_archive_idle_session( client: httpx.AsyncClient, ) -> None: """An idle session can be archived normally (no stop needed).""" session = await create_test_session(client, name="archive-idle") session_id = session["id"] mock_stop = AsyncMock() with patch.object(sessions_module, "_stop_session_via_runner", mock_stop): resp = await client.patch( f"/v1/sessions/{session_id}", json={"archived": True}, ) assert resp.status_code == 200 assert resp.json()["archived"] is True mock_stop.assert_not_awaited() async def test_unarchive_skips_stop( client: httpx.AsyncClient, ) -> None: """Unarchiving does not attempt a stop, even if the session is running.""" session = await create_test_session(client, name="unarchive-running") session_id = session["id"] await client.patch(f"/v1/sessions/{session_id}", json={"archived": True}) mock_stop = AsyncMock() sessions_module._session_status_cache[session_id] = "running" try: with patch.object(sessions_module, "_stop_session_via_runner", mock_stop): resp = await client.patch( f"/v1/sessions/{session_id}", json={"archived": False}, ) assert resp.status_code == 200 assert resp.json()["archived"] is False mock_stop.assert_not_awaited() finally: sessions_module._session_status_cache.pop(session_id, None) # ── Agent contents download ────────────────────────────── async def test_agent_contents_returns_valid_gzip_tarball( client: httpx.AsyncClient, ) -> None: """GET /v1/sessions/{id}/agent/contents returns a valid tar.gz bundle.""" session = await create_test_session(client, name="contents-download") session_id = session["id"] resp = await client.get(f"/v1/sessions/{session_id}/agent/contents") assert resp.status_code == 200 assert resp.headers["content-type"] == "application/gzip" # Verify the bytes are valid gzip. decompressed = gzip.decompress(resp.content) assert len(decompressed) > 0 # Verify the bytes are a valid tar archive containing config.yaml. with tarfile.open(fileobj=io.BytesIO(resp.content), mode="r:gz") as tf: names = tf.getnames() assert "config.yaml" in names async def test_agent_contents_404_for_nonexistent_session( client: httpx.AsyncClient, ) -> None: """GET /v1/sessions/{id}/agent/contents returns 404 for a missing session.""" resp = await client.get("/v1/sessions/conv_nonexistent/agent/contents") assert resp.status_code == 404