"""Unit tests for ``omnigent session export``.""" from __future__ import annotations import json from pathlib import Path from typing import Any from unittest.mock import patch import httpx import respx from click.testing import CliRunner from omnigent.cli import cli _BASE = "http://localhost:6767" _SESSION_META = { "id": "conv_abc123", "title": "test session", "status": "idle", "created_at": 1700000000, "updated_at": 1700000001, "agent_id": None, "agent_name": None, "items": [], } _ITEMS_PAGE = { "data": [ { "id": "msg_1", "type": "message", "status": "completed", "response_id": "resp_1", "role": "user", "content": [{"type": "input_text", "text": "hello"}], }, { "id": "msg_2", "type": "message", "status": "completed", "response_id": "resp_1", "role": "assistant", "content": [{"type": "output_text", "text": "hi there"}], "model": "my-agent", }, ], "first_id": "msg_1", "last_id": "msg_2", "has_more": False, } def _patch_server(base_url: str = _BASE) -> Any: """Patch the CLI so it uses *base_url* without spawning a real server.""" return patch("omnigent.cli._resolve_attach_server", return_value=base_url) @respx.mock def test_session_export_writes_jsonl(tmp_path: Path) -> None: """Export writes one session_meta line then one item line per item.""" respx.get(f"{_BASE}/v1/sessions/conv_abc123").mock( return_value=httpx.Response(200, json=_SESSION_META) ) respx.get(f"{_BASE}/v1/sessions/conv_abc123/items").mock( return_value=httpx.Response(200, json=_ITEMS_PAGE) ) out_file = tmp_path / "out.jsonl" runner = CliRunner() with _patch_server(): result = runner.invoke( cli, ["session", "export", "--id", "conv_abc123", "--output", str(out_file)], ) assert result.exit_code == 0, result.output assert out_file.exists() lines = [json.loads(line) for line in out_file.read_text().splitlines() if line] assert len(lines) == 3 # 1 meta + 2 items meta = lines[0] assert meta["record_type"] == "session_meta" assert meta["id"] == "conv_abc123" assert meta["title"] == "test session" item_lines = lines[1:] assert all(r["record_type"] == "item" for r in item_lines) assert [r["role"] for r in item_lines] == ["user", "assistant"] assert item_lines[1]["content"] == [{"type": "output_text", "text": "hi there"}] @respx.mock def test_session_export_default_filename(tmp_path: Path) -> None: """Without --output, the file is named .jsonl in cwd.""" respx.get(f"{_BASE}/v1/sessions/conv_abc123").mock( return_value=httpx.Response(200, json=_SESSION_META) ) respx.get(f"{_BASE}/v1/sessions/conv_abc123/items").mock( return_value=httpx.Response(200, json={**_ITEMS_PAGE, "data": [], "has_more": False}) ) runner = CliRunner() with runner.isolated_filesystem(temp_dir=tmp_path), _patch_server(): result = runner.invoke(cli, ["session", "export", "--id", "conv_abc123"]) assert result.exit_code == 0, result.output default_path = Path("conv_abc123.jsonl") assert default_path.exists() lines = [json.loads(line) for line in default_path.read_text().splitlines() if line] assert len(lines) == 1 assert lines[0]["record_type"] == "session_meta" assert lines[0]["id"] == "conv_abc123" @respx.mock def test_session_export_missing_session_errors(tmp_path: Path) -> None: """Export of an unknown session id exits non-zero with a clear message.""" respx.get(f"{_BASE}/v1/sessions/conv_doesnotexist").mock( return_value=httpx.Response(404, json={"error": "not found"}) ) runner = CliRunner() with _patch_server(): result = runner.invoke( cli, [ "session", "export", "--id", "conv_doesnotexist", "--output", str(tmp_path / "out.jsonl"), ], ) assert result.exit_code != 0 assert "conv_doesnotexist" in result.output @respx.mock def test_session_export_items_ordered_ascending(tmp_path: Path) -> None: """Items in the JSONL appear in ascending position order (user then assistant).""" respx.get(f"{_BASE}/v1/sessions/conv_abc123").mock( return_value=httpx.Response(200, json=_SESSION_META) ) respx.get(f"{_BASE}/v1/sessions/conv_abc123/items").mock( return_value=httpx.Response(200, json=_ITEMS_PAGE) ) out_file = tmp_path / "ordered.jsonl" runner = CliRunner() with _patch_server(): result = runner.invoke( cli, ["session", "export", "--id", "conv_abc123", "--output", str(out_file)], ) assert result.exit_code == 0, result.output records = [json.loads(line) for line in out_file.read_text().splitlines() if line] item_records = [r for r in records if r["record_type"] == "item"] assert len(item_records) == 2 assert item_records[0]["role"] == "user" assert item_records[1]["role"] == "assistant" @respx.mock def test_session_export_pagination(tmp_path: Path) -> None: """Export follows has_more cursors to fetch all pages.""" page1 = { "data": [ { "id": "msg_1", "type": "message", "status": "completed", "response_id": "r1", "role": "user", "content": [], } ], "first_id": "msg_1", "last_id": "msg_1", "has_more": True, } page2 = { "data": [ { "id": "msg_2", "type": "message", "status": "completed", "response_id": "r1", "role": "assistant", "content": [], "model": "ag", } ], "first_id": "msg_2", "last_id": "msg_2", "has_more": False, } respx.get(f"{_BASE}/v1/sessions/conv_abc123").mock( return_value=httpx.Response(200, json=_SESSION_META) ) # First call (no after param) → page1; second call (after=msg_1) → page2. items_route = respx.get(f"{_BASE}/v1/sessions/conv_abc123/items") items_route.side_effect = [ httpx.Response(200, json=page1), httpx.Response(200, json=page2), ] out_file = tmp_path / "paged.jsonl" runner = CliRunner() with _patch_server(): result = runner.invoke( cli, ["session", "export", "--id", "conv_abc123", "--output", str(out_file)], ) assert result.exit_code == 0, result.output records = [json.loads(line) for line in out_file.read_text().splitlines() if line] item_records = [r for r in records if r["record_type"] == "item"] assert len(item_records) == 2 assert [r["id"] for r in item_records] == ["msg_1", "msg_2"]