From d8de3bb70097e0376e31cf1dc1974c47107559aa Mon Sep 17 00:00:00 2001 From: Jack Date: Wed, 25 Feb 2026 15:27:09 +0000 Subject: [PATCH] Add pagination to list_sessions across SDK and MCP server Add offset/limit pagination support: - SDK: new SessionListResult with total/offset/limit fields - Generated API: offset/limit query params on list_sessions endpoint - MCP server: ListSessionsInput model, pagination display in output - Tests: updated and new pagination tests for SDK and MCP Co-Authored-By: Claude Opus 4.6 --- everyrow-mcp/manifest.json | 2 +- everyrow-mcp/src/everyrow_mcp/models.py | 98 ++++++ everyrow-mcp/src/everyrow_mcp/tool_helpers.py | 17 +- everyrow-mcp/src/everyrow_mcp/tools.py | 89 ++++- everyrow-mcp/tests/test_server.py | 310 +++++++++++++++++- src/everyrow/__init__.py | 10 +- .../list_sessions_endpoint_sessions_get.py | 93 +++++- .../generated/models/session_list_response.py | 24 ++ src/everyrow/session.py | 102 ++++-- tests/test_session.py | 196 +++++++++-- 10 files changed, 839 insertions(+), 102 deletions(-) diff --git a/everyrow-mcp/manifest.json b/everyrow-mcp/manifest.json index a601a8af..155c064d 100644 --- a/everyrow-mcp/manifest.json +++ b/everyrow-mcp/manifest.json @@ -67,7 +67,7 @@ }, { "name": "everyrow_list_sessions", - "description": "List all everyrow sessions owned by the authenticated user." + "description": "List everyrow sessions owned by the authenticated user (paginated)." }, { "name": "everyrow_cancel", diff --git a/everyrow-mcp/src/everyrow_mcp/models.py b/everyrow-mcp/src/everyrow_mcp/models.py index 4e33d742..50781e60 100644 --- a/everyrow-mcp/src/everyrow_mcp/models.py +++ b/everyrow-mcp/src/everyrow_mcp/models.py @@ -138,6 +138,28 @@ def _check_exactly_one( raise ValueError(f"{prefix}Provide exactly one of {fields}.") +def _validate_session_id(v: str | None) -> str | None: + """Validate session_id is a valid UUID string.""" + if v is not None: + try: + UUID(v) + except ValueError as exc: + raise ValueError(f"session_id must be a valid UUID: {v}") from exc + return v + + +def _check_session_exclusivity( + session_id: str | None, session_name: str | None +) -> None: + """Raise if both session_id and session_name are provided.""" + if session_id is not None and session_name is not None: + raise ValueError( + "session_id and session_name are mutually exclusive — " + "pass session_id to resume an existing session, " + "or session_name to create a new one." + ) + + class _SingleSourceInput(BaseModel): model_config = ConfigDict(str_strip_whitespace=True, extra="forbid") @@ -149,6 +171,14 @@ class _SingleSourceInput(BaseModel): default=None, description="Inline data as a list of row objects.", ) + session_id: str | None = Field( + default=None, + description="Session ID (UUID) to resume. Mutually exclusive with session_name.", + ) + session_name: str | None = Field( + default=None, + description="Human-readable name for a new session. Mutually exclusive with session_id.", + ) @field_validator("artifact_id") @classmethod @@ -160,6 +190,11 @@ def validate_artifact_id(cls, v: str | None) -> str | None: raise ValueError(f"artifact_id must be a valid UUID: {v}") from exc return v + @field_validator("session_id") + @classmethod + def validate_session_id(cls, v: str | None) -> str | None: + return _validate_session_id(v) + @field_validator("data") @classmethod def validate_data_size( @@ -181,6 +216,7 @@ def check_input_source(self): field_names=("artifact_id", "data"), label="Input", ) + _check_session_exclusivity(self.session_id, self.session_name) return self @property @@ -334,6 +370,15 @@ class MergeInput(BaseModel): description="Relationship type: many_to_one (default) or one_to_one.", ) + session_id: str | None = Field( + default=None, + description="Session ID (UUID) to resume. Mutually exclusive with session_name.", + ) + session_name: str | None = Field( + default=None, + description="Human-readable name for a new session. Mutually exclusive with session_id.", + ) + @field_validator("left_artifact_id", "right_artifact_id") @classmethod def validate_artifact_ids(cls, v: str | None) -> str | None: @@ -344,6 +389,11 @@ def validate_artifact_ids(cls, v: str | None) -> str | None: raise ValueError(f"artifact_id must be a valid UUID: {v}") from exc return v + @field_validator("session_id") + @classmethod + def validate_session_id(cls, v: str | None) -> str | None: + return _validate_session_id(v) + @field_validator("left_data", "right_data") @classmethod def validate_data_size( @@ -370,6 +420,7 @@ def check_sources(self) -> "MergeInput": field_names=("right_artifact_id", "right_data"), label="Right table", ) + _check_session_exclusivity(self.session_id, self.session_name) return self @property @@ -425,6 +476,14 @@ class UploadDataInput(BaseModel): "or absolute local file path (stdio mode only).", min_length=1, ) + session_id: str | None = Field( + default=None, + description="Session ID (UUID) to resume. Mutually exclusive with session_name.", + ) + session_name: str | None = Field( + default=None, + description="Human-readable name for a new session. Mutually exclusive with session_id.", + ) @field_validator("source") @classmethod @@ -443,6 +502,16 @@ def validate_source(cls, v: str) -> str: validate_csv_path(v) return v + @field_validator("session_id") + @classmethod + def validate_session_id(cls, v: str | None) -> str | None: + return _validate_session_id(v) + + @model_validator(mode="after") + def check_session_exclusivity(self) -> "UploadDataInput": + _check_session_exclusivity(self.session_id, self.session_name) + return self + class SingleAgentInput(BaseModel): """Input for a single agent operation (no CSV).""" @@ -462,6 +531,14 @@ class SingleAgentInput(BaseModel): default=None, description="Optional JSON schema for the agent response.", ) + session_id: str | None = Field( + default=None, + description="Session ID (UUID) to resume. Mutually exclusive with session_name.", + ) + session_name: str | None = Field( + default=None, + description="Human-readable name for a new session. Mutually exclusive with session_id.", + ) @field_validator("response_schema") @classmethod @@ -470,6 +547,16 @@ def validate_response_schema( ) -> dict[str, Any] | None: return _validate_response_schema(v) + @field_validator("session_id") + @classmethod + def validate_session_id(cls, v: str | None) -> str | None: + return _validate_session_id(v) + + @model_validator(mode="after") + def check_session_exclusivity(self) -> "SingleAgentInput": + _check_session_exclusivity(self.session_id, self.session_name) + return self + def _validate_task_id(v: str) -> str: """Validate task_id is a valid UUID.""" @@ -582,3 +669,14 @@ def validate_output(cls, v: str | None) -> str | None: if v is not None and not v.lower().endswith(".csv"): raise ValueError("output_path must end in .csv") return v + + +class ListSessionsInput(BaseModel): + """Input for listing sessions with pagination.""" + + model_config = ConfigDict(extra="forbid") + + offset: int = Field(0, ge=0, description="Number of sessions to skip") + limit: int = Field( + 25, ge=1, le=1000, description="Max sessions per page (default 25, max 1000)" + ) diff --git a/everyrow-mcp/src/everyrow_mcp/tool_helpers.py b/everyrow-mcp/src/everyrow_mcp/tool_helpers.py index 1f18515c..f6dbdbba 100644 --- a/everyrow-mcp/src/everyrow_mcp/tool_helpers.py +++ b/everyrow-mcp/src/everyrow_mcp/tool_helpers.py @@ -65,17 +65,21 @@ def _get_client(ctx: EveryRowContext) -> AuthenticatedClient: return ctx.request_context.lifespan_context.client_factory() -def _submission_text(label: str, session_url: str, task_id: str) -> str: +def _submission_text( + label: str, session_url: str, task_id: str, session_id: str = "" +) -> str: """Build human-readable text for submission tool results.""" if settings.is_stdio: + session_line = f"\nSession ID: {session_id}" if session_id else "" return dedent(f"""\ {label} - Session: {session_url} + Session: {session_url}{session_line} Task ID: {task_id} Share the session_url with the user, then immediately call everyrow_progress(task_id='{task_id}').""") + session_line = f"\nSession ID: {session_id}" if session_id else "" return dedent(f"""\ - {label} + {label}{session_line} Task ID: {task_id} Immediately call everyrow_progress(task_id='{task_id}').""") @@ -87,6 +91,7 @@ async def _submission_ui_json( total: int, token: str, mcp_server_url: str = "", + session_id: str = "", ) -> str: """Build JSON for the session MCP App widget, and store the token for polling.""" poll_token = secrets.token_urlsafe(32) @@ -115,6 +120,8 @@ async def _submission_ui_json( "total": total, "status": "submitted", } + if session_id: + data["session_id"] = session_id if mcp_server_url: data["progress_url"] = f"{mcp_server_url}/api/progress/{task_id}" data["poll_token"] = poll_token @@ -129,13 +136,14 @@ async def create_tool_response( token: str, total: int, mcp_server_url: str = "", + session_id: str = "", ) -> list[TextContent]: """Build the standard submission response for a tool. Returns human-readable text in all modes, plus a widget JSON prepended in HTTP mode. """ - text = _submission_text(label, session_url, task_id) + text = _submission_text(label, session_url, task_id, session_id=session_id) main_content = TextContent(type="text", text=text) if settings.is_http: ui_json = await _submission_ui_json( @@ -144,6 +152,7 @@ async def create_tool_response( total=total, token=token, mcp_server_url=mcp_server_url, + session_id=session_id, ) return [TextContent(type="text", text=ui_json), main_content] return [main_content] diff --git a/everyrow-mcp/src/everyrow_mcp/tools.py b/everyrow-mcp/src/everyrow_mcp/tools.py index 0363bd94..1d55e98c 100644 --- a/everyrow-mcp/src/everyrow_mcp/tools.py +++ b/everyrow-mcp/src/everyrow_mcp/tools.py @@ -38,6 +38,7 @@ DedupeInput, ForecastInput, HttpResultsInput, + ListSessionsInput, MergeInput, ProgressInput, RankInput, @@ -140,8 +141,11 @@ async def everyrow_agent(params: AgentInput, ctx: EveryRowContext) -> list[TextC if params.response_schema: response_model = _schema_to_model("AgentResult", params.response_schema) - async with create_session(client=client) as session: + async with create_session( + client=client, session_id=params.session_id, name=params.session_name + ) as session: session_url = session.get_url() + session_id_str = str(session.session_id) kwargs: dict[str, Any] = { "task": params.task, "session": session, @@ -169,6 +173,7 @@ async def everyrow_agent(params: AgentInput, ctx: EveryRowContext) -> list[TextC token=client.token, total=total, mcp_server_url=ctx.request_context.lifespan_context.mcp_server_url, + session_id=session_id_str, ) @@ -218,8 +223,11 @@ async def everyrow_single_agent( DynamicInput = create_model("DynamicInput", **fields) # pyright: ignore[reportArgumentType, reportCallIssue] input_model = DynamicInput() - async with create_session(client=client) as session: + async with create_session( + client=client, session_id=params.session_id, name=params.session_name + ) as session: session_url = session.get_url() + session_id_str = str(session.session_id) kwargs: dict[str, Any] = {"task": params.task, "session": session} if input_model is not None: kwargs["input"] = input_model @@ -242,6 +250,7 @@ async def everyrow_single_agent( token=client.token, total=1, mcp_server_url=ctx.request_context.lifespan_context.mcp_server_url, + session_id=session_id_str, ) @@ -294,8 +303,11 @@ async def everyrow_rank(params: RankInput, ctx: EveryRowContext) -> list[TextCon if params.response_schema: response_model = _schema_to_model("RankResult", params.response_schema) - async with create_session(client=client) as session: + async with create_session( + client=client, session_id=params.session_id, name=params.session_name + ) as session: session_url = session.get_url() + session_id_str = str(session.session_id) cohort_task = await rank_async( task=params.task, session=session, @@ -324,6 +336,7 @@ async def everyrow_rank(params: RankInput, ctx: EveryRowContext) -> list[TextCon token=client.token, total=total, mcp_server_url=ctx.request_context.lifespan_context.mcp_server_url, + session_id=session_id_str, ) @@ -383,8 +396,11 @@ async def everyrow_screen( if params.response_schema: response_model = _schema_to_model("ScreenResult", params.response_schema) - async with create_session(client=client) as session: + async with create_session( + client=client, session_id=params.session_id, name=params.session_name + ) as session: session_url = session.get_url() + session_id_str = str(session.session_id) cohort_task = await screen_async( task=params.task, session=session, @@ -410,6 +426,7 @@ async def everyrow_screen( token=client.token, total=total, mcp_server_url=ctx.request_context.lifespan_context.mcp_server_url, + session_id=session_id_str, ) @@ -461,8 +478,11 @@ async def everyrow_dedupe( input_data = params._aid_or_dataframe - async with create_session(client=client) as session: + async with create_session( + client=client, session_id=params.session_id, name=params.session_name + ) as session: session_url = session.get_url() + session_id_str = str(session.session_id) cohort_task = await dedupe_async( equivalence_relation=params.equivalence_relation, session=session, @@ -487,6 +507,7 @@ async def everyrow_dedupe( token=client.token, total=total, mcp_server_url=ctx.request_context.lifespan_context.mcp_server_url, + session_id=session_id_str, ) @@ -549,8 +570,11 @@ async def everyrow_merge(params: MergeInput, ctx: EveryRowContext) -> list[TextC left_input = params._left_aid_or_dataframe right_input = params._right_aid_or_dataframe - async with create_session(client=client) as session: + async with create_session( + client=client, session_id=params.session_id, name=params.session_name + ) as session: session_url = session.get_url() + session_id_str = str(session.session_id) cohort_task = await merge_async( task=params.task, session=session, @@ -580,6 +604,7 @@ async def everyrow_merge(params: MergeInput, ctx: EveryRowContext) -> list[TextC token=client.token, total=total, mcp_server_url=ctx.request_context.lifespan_context.mcp_server_url, + session_id=session_id_str, ) @@ -629,8 +654,11 @@ async def everyrow_forecast( _clear_task_state() input_data = params._aid_or_dataframe - async with create_session(client=client) as session: + async with create_session( + client=client, session_id=params.session_id, name=params.session_name + ) as session: session_url = session.get_url() + session_id_str = str(session.session_id) cohort_task = await forecast_async( task=params.context or "", session=session, @@ -655,6 +683,7 @@ async def everyrow_forecast( token=client.token, total=total, mcp_server_url=ctx.request_context.lifespan_context.mcp_server_url, + session_id=session_id_str, ) @@ -700,7 +729,10 @@ async def everyrow_upload_data( if df.empty: raise ValueError(f"CSV file is empty: {params.source}") - async with create_session(client=client) as session: + async with create_session( + client=client, session_id=params.session_id, name=params.session_name + ) as session: + session_id_str = str(session.session_id) artifact_id = await create_table_artifact(df, session) return [ @@ -709,6 +741,7 @@ async def everyrow_upload_data( text=json.dumps( { "artifact_id": str(artifact_id), + "session_id": session_id_str, "rows": len(df), "columns": list(df.columns), } @@ -923,24 +956,42 @@ async def everyrow_results_http( # noqa: PLR0911 openWorldHint=False, ), ) -async def everyrow_list_sessions(ctx: EveryRowContext) -> list[TextContent]: - """List all everyrow sessions owned by the authenticated user. +async def everyrow_list_sessions( + params: ListSessionsInput, ctx: EveryRowContext +) -> list[TextContent]: + """List everyrow sessions owned by the authenticated user (paginated). Returns session names, IDs, timestamps, and dashboard URLs. Use this to find past sessions or check what's been run. + Results are paginated — 25 sessions per page by default. """ client = _get_client(ctx) try: - sessions = await list_sessions(client=client) + result = await list_sessions( + client=client, offset=params.offset, limit=params.limit + ) except Exception as e: return [TextContent(type="text", text=f"Error listing sessions: {e!r}")] - if not sessions: + if not result.sessions: + if result.total > 0: + return [ + TextContent( + type="text", + text=f"No sessions on this page (offset={params.offset}). " + f"Total sessions: {result.total}.", + ) + ] return [TextContent(type="text", text="No sessions found.")] - lines = [f"Found {len(sessions)} session(s):\n"] - for s in sessions: + start = result.offset + 1 + end = result.offset + len(result.sessions) + total_pages = (result.total + result.limit - 1) // result.limit + current_page = (result.offset // result.limit) + 1 + + lines = [f"Found {result.total} session(s) (showing {start}-{end}):\n"] + for s in result.sessions: lines.append( f"- **{s.name}** (id: {s.session_id})\n" f" Created: {s.created_at:%Y-%m-%d %H:%M UTC} | " @@ -948,6 +999,16 @@ async def everyrow_list_sessions(ctx: EveryRowContext) -> list[TextContent]: f" URL: {s.get_url()}" ) + has_more = (result.offset + result.limit) < result.total + lines.append( + f"\nPage {current_page} of {total_pages}" + + ( + f" | Use offset={result.offset + result.limit} to see next page" + if has_more + else "" + ) + ) + return [TextContent(type="text", text="\n".join(lines))] diff --git a/everyrow-mcp/tests/test_server.py b/everyrow-mcp/tests/test_server.py index ad7e4971..ac6e6a7e 100644 --- a/everyrow-mcp/tests/test_server.py +++ b/everyrow-mcp/tests/test_server.py @@ -36,6 +36,7 @@ CancelInput, DedupeInput, HttpResultsInput, + ListSessionsInput, MergeInput, ProgressInput, RankInput, @@ -817,6 +818,17 @@ async def test_results_http_store_failure_falls_back_to_inline(self): class TestListSessions: """Tests for everyrow_list_sessions.""" + @staticmethod + def _make_session_list_result(sessions, *, total=None, offset=0, limit=25): + """Create a mock SessionListResult.""" + tc = total if total is not None else len(sessions) + result = MagicMock() + result.sessions = sessions + result.total = tc + result.offset = offset + result.limit = limit + return result + @pytest.mark.asyncio async def test_list_sessions_returns_sessions(self): """Test that list_sessions returns formatted session info.""" @@ -842,9 +854,9 @@ async def test_list_sessions_returns_sessions(self): with patch( "everyrow_mcp.tools.list_sessions", new_callable=AsyncMock, - return_value=mock_sessions, + return_value=self._make_session_list_result(mock_sessions), ): - result = await everyrow_list_sessions(ctx) + result = await everyrow_list_sessions(ListSessionsInput(), ctx) text = result[0].text assert "2 session(s)" in text @@ -860,9 +872,9 @@ async def test_list_sessions_empty(self): with patch( "everyrow_mcp.tools.list_sessions", new_callable=AsyncMock, - return_value=[], + return_value=self._make_session_list_result([]), ): - result = await everyrow_list_sessions(ctx) + result = await everyrow_list_sessions(ListSessionsInput(), ctx) assert "No sessions found" in result[0].text @@ -877,24 +889,24 @@ async def test_list_sessions_api_error(self): new_callable=AsyncMock, side_effect=RuntimeError("API error"), ): - result = await everyrow_list_sessions(ctx) + result = await everyrow_list_sessions(ListSessionsInput(), ctx) assert "Error listing sessions" in result[0].text @pytest.mark.asyncio - async def test_list_sessions_passes_client_from_context(self): - """Test that the tool passes the context client to list_sessions.""" + async def test_list_sessions_passes_client_and_pagination(self): + """Test that the tool passes the context client and pagination params to list_sessions.""" mock_client = _make_mock_client() ctx = make_test_context(mock_client) with patch( "everyrow_mcp.tools.list_sessions", new_callable=AsyncMock, - return_value=[], + return_value=self._make_session_list_result([], offset=5, limit=10), ) as mock_ls: - await everyrow_list_sessions(ctx) + await everyrow_list_sessions(ListSessionsInput(offset=5, limit=10), ctx) - mock_ls.assert_called_once_with(client=mock_client) + mock_ls.assert_called_once_with(client=mock_client, offset=5, limit=10) @pytest.mark.asyncio async def test_list_sessions_output_contains_urls_and_dates(self): @@ -915,9 +927,9 @@ async def test_list_sessions_output_contains_urls_and_dates(self): with patch( "everyrow_mcp.tools.list_sessions", new_callable=AsyncMock, - return_value=mock_sessions, + return_value=self._make_session_list_result(mock_sessions), ): - result = await everyrow_list_sessions(ctx) + result = await everyrow_list_sessions(ListSessionsInput(), ctx) text = result[0].text assert "Pipeline Run" in text @@ -925,6 +937,70 @@ async def test_list_sessions_output_contains_urls_and_dates(self): assert "2025-08-15 10:45 UTC" in text assert f"https://everyrow.io/sessions/{session_id}" in text + @pytest.mark.asyncio + async def test_list_sessions_pagination_params(self): + """Test that limit and offset are passed through to SDK.""" + mock_client = _make_mock_client() + ctx = make_test_context(mock_client) + + with patch( + "everyrow_mcp.tools.list_sessions", + new_callable=AsyncMock, + return_value=self._make_session_list_result( + [], total=0, offset=5, limit=10 + ), + ) as mock_ls: + await everyrow_list_sessions(ListSessionsInput(limit=10, offset=5), ctx) + + mock_ls.assert_called_once_with(client=mock_client, offset=5, limit=10) + + @pytest.mark.asyncio + async def test_list_sessions_shows_pagination_info(self): + """Test that output includes page info and next-page hint.""" + mock_client = _make_mock_client() + ctx = make_test_context(mock_client) + now = datetime.now(UTC) + mock_sessions = [ + MagicMock( + session_id=uuid4(), + name=f"Session {i}", + created_at=now, + updated_at=now, + get_url=lambda: "https://everyrow.io/sessions/x", + ) + for i in range(10) + ] + + with patch( + "everyrow_mcp.tools.list_sessions", + new_callable=AsyncMock, + return_value=self._make_session_list_result( + mock_sessions, total=30, offset=0, limit=10 + ), + ): + result = await everyrow_list_sessions(ListSessionsInput(limit=10), ctx) + + text = result[0].text + assert "showing 1-10" in text + assert "30 session(s)" in text + assert "Page 1 of 3" in text + assert "offset=10" in text + + @pytest.mark.asyncio + async def test_list_sessions_default_pagination(self): + """Test that default params are offset=0, limit=25.""" + mock_client = _make_mock_client() + ctx = make_test_context(mock_client) + + with patch( + "everyrow_mcp.tools.list_sessions", + new_callable=AsyncMock, + return_value=self._make_session_list_result([]), + ) as mock_ls: + await everyrow_list_sessions(ListSessionsInput(), ctx) + + mock_ls.assert_called_once_with(client=mock_client, offset=0, limit=25) + class TestCancel: """Tests for everyrow_cancel.""" @@ -1670,3 +1746,213 @@ async def test_http_widget_includes_session_url(self): assert len(result) == 2 widget_data = json.loads(result[0].text) assert widget_data["session_url"] == session_url + + +# ---------- Session resumption / naming ---------- + + +class TestSessionParams: + """Tests for session_id and session_name fields on input models.""" + + # ── Input validation ───────────────────────────────────── + + def test_single_source_accepts_session_id(self): + uid = str(uuid4()) + params = AgentInput(task="test", artifact_id=str(uuid4()), session_id=uid) + assert params.session_id == uid + + def test_single_source_accepts_session_name(self): + params = AgentInput( + task="test", artifact_id=str(uuid4()), session_name="My Session" + ) + assert params.session_name == "My Session" + + def test_single_source_rejects_both_session_params(self): + with pytest.raises(ValidationError, match="mutually exclusive"): + AgentInput( + task="test", + artifact_id=str(uuid4()), + session_id=str(uuid4()), + session_name="conflict", + ) + + def test_single_source_rejects_invalid_session_id(self): + with pytest.raises(ValidationError, match="session_id must be a valid UUID"): + AgentInput( + task="test", + artifact_id=str(uuid4()), + session_id="not-a-uuid", + ) + + def test_merge_accepts_session_id(self): + params = MergeInput( + task="match", + left_data=[{"a": 1}], + right_data=[{"b": 2}], + session_id=str(uuid4()), + ) + assert params.session_id is not None + + def test_merge_rejects_both_session_params(self): + with pytest.raises(ValidationError, match="mutually exclusive"): + MergeInput( + task="match", + left_data=[{"a": 1}], + right_data=[{"b": 2}], + session_id=str(uuid4()), + session_name="conflict", + ) + + def test_single_agent_accepts_session_id(self): + params = SingleAgentInput(task="test", session_id=str(uuid4())) + assert params.session_id is not None + + def test_single_agent_rejects_both_session_params(self): + with pytest.raises(ValidationError, match="mutually exclusive"): + SingleAgentInput( + task="test", + session_id=str(uuid4()), + session_name="conflict", + ) + + def test_upload_data_accepts_session_id(self): + params = UploadDataInput( + source="https://example.com/data.csv", session_id=str(uuid4()) + ) + assert params.session_id is not None + + def test_upload_data_rejects_both_session_params(self): + with pytest.raises(ValidationError, match="mutually exclusive"): + UploadDataInput( + source="https://example.com/data.csv", + session_id=str(uuid4()), + session_name="conflict", + ) + + # ── Tool invocations ───────────────────────────────────── + + @pytest.mark.asyncio + async def test_agent_passes_session_params(self): + """everyrow_agent forwards session_id and session_name to create_session.""" + mock_task = _make_mock_task() + mock_session = _make_mock_session() + mock_client = _make_mock_client() + ctx = make_test_context(mock_client) + sid = str(uuid4()) + + with ( + patch( + "everyrow_mcp.tools.agent_map_async", new_callable=AsyncMock + ) as mock_op, + patch("everyrow_mcp.tools.create_session") as mock_cs, + ): + mock_cs.return_value = _make_async_context_manager(mock_session) + mock_op.return_value = mock_task + + params = AgentInput( + task="Find HQ", + data=[{"name": "Acme"}], + session_id=sid, + ) + result = await everyrow_agent(params, ctx) + + mock_cs.assert_called_once_with( + client=mock_client, session_id=sid, name=None + ) + text = result[0].text + assert str(mock_session.session_id) in text + + @pytest.mark.asyncio + async def test_agent_passes_session_name(self): + """everyrow_agent forwards session_name to create_session.""" + mock_task = _make_mock_task() + mock_session = _make_mock_session() + mock_client = _make_mock_client() + ctx = make_test_context(mock_client) + + with ( + patch( + "everyrow_mcp.tools.agent_map_async", new_callable=AsyncMock + ) as mock_op, + patch("everyrow_mcp.tools.create_session") as mock_cs, + ): + mock_cs.return_value = _make_async_context_manager(mock_session) + mock_op.return_value = mock_task + + params = AgentInput( + task="Find HQ", + data=[{"name": "Acme"}], + session_name="My Pipeline", + ) + await everyrow_agent(params, ctx) + + mock_cs.assert_called_once_with( + client=mock_client, session_id=None, name="My Pipeline" + ) + + @pytest.mark.asyncio + async def test_upload_data_passes_session_id(self): + """everyrow_upload_data forwards session_id to create_session.""" + mock_session = _make_mock_session() + mock_client = _make_mock_client() + ctx = make_test_context(mock_client) + artifact_uuid = uuid4() + sid = str(uuid4()) + + mock_df = pd.DataFrame([{"a": 1}]) + + with ( + patch( + "everyrow_mcp.tools.fetch_csv_from_url", + new_callable=AsyncMock, + return_value=mock_df, + ), + patch("everyrow_mcp.tools.create_session") as mock_cs, + patch( + "everyrow_mcp.tools.create_table_artifact", + new_callable=AsyncMock, + return_value=artifact_uuid, + ), + ): + mock_cs.return_value = _make_async_context_manager(mock_session) + + params = UploadDataInput( + source="https://example.com/data.csv", session_id=sid + ) + result = await everyrow_upload_data(params, ctx) + + mock_cs.assert_called_once_with( + client=mock_client, session_id=sid, name=None + ) + data = json.loads(result[0].text) + assert data["session_id"] == str(mock_session.session_id) + + # ── Response includes session_id ───────────────────────── + + @pytest.mark.asyncio + async def test_response_includes_session_id(self): + """Submission response text includes the session ID.""" + mock_task = _make_mock_task() + mock_session = _make_mock_session() + mock_client = _make_mock_client() + ctx = make_test_context(mock_client) + + with ( + patch( + "everyrow_mcp.tools.agent_map_async", new_callable=AsyncMock + ) as mock_op, + patch( + "everyrow_mcp.tools.create_session", + return_value=_make_async_context_manager(mock_session), + ), + ): + mock_op.return_value = mock_task + + params = AgentInput( + task="Find HQ", + data=[{"name": "Acme"}], + ) + result = await everyrow_agent(params, ctx) + + text = result[0].text + assert f"Session ID: {mock_session.session_id}" in text diff --git a/src/everyrow/__init__.py b/src/everyrow/__init__.py index 91751bf3..983a4c70 100644 --- a/src/everyrow/__init__.py +++ b/src/everyrow/__init__.py @@ -2,14 +2,22 @@ from everyrow.api_utils import create_client from everyrow.billing import BillingResponse, get_billing_balance -from everyrow.session import SessionInfo, create_session, list_sessions +from everyrow.session import ( + Session, + SessionInfo, + SessionListResult, + create_session, + list_sessions, +) from everyrow.task import fetch_task_data, print_progress __version__ = version("everyrow") __all__ = [ "BillingResponse", + "Session", "SessionInfo", + "SessionListResult", "__version__", "create_client", "create_session", diff --git a/src/everyrow/generated/api/sessions/list_sessions_endpoint_sessions_get.py b/src/everyrow/generated/api/sessions/list_sessions_endpoint_sessions_get.py index 718676c4..fa5e38b2 100644 --- a/src/everyrow/generated/api/sessions/list_sessions_endpoint_sessions_get.py +++ b/src/everyrow/generated/api/sessions/list_sessions_endpoint_sessions_get.py @@ -5,32 +5,55 @@ from ... import errors from ...client import AuthenticatedClient, Client +from ...models.http_validation_error import HTTPValidationError from ...models.session_list_response import SessionListResponse -from ...types import Response +from ...types import UNSET, Response, Unset -def _get_kwargs() -> dict[str, Any]: +def _get_kwargs( + *, + offset: int | Unset = 0, + limit: int | Unset = 25, +) -> dict[str, Any]: + params: dict[str, Any] = {} + + params["offset"] = offset + + params["limit"] = limit + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + _kwargs: dict[str, Any] = { "method": "get", "url": "/sessions", + "params": params, } return _kwargs -def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> SessionListResponse | None: +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | SessionListResponse | None: if response.status_code == 200: response_200 = SessionListResponse.from_dict(response.json()) return response_200 + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: return None -def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[SessionListResponse]: +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | SessionListResponse]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -42,20 +65,29 @@ def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Res def sync_detailed( *, client: AuthenticatedClient, -) -> Response[SessionListResponse]: + offset: int | Unset = 0, + limit: int | Unset = 25, +) -> Response[HTTPValidationError | SessionListResponse]: """List sessions - List all sessions owned by the authenticated user. + List sessions owned by the authenticated user with pagination. + + Args: + offset (int | Unset): Number of sessions to skip Default: 0. + limit (int | Unset): Max sessions per page (default 25, max 1000) Default: 25. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[SessionListResponse] + Response[HTTPValidationError | SessionListResponse] """ - kwargs = _get_kwargs() + kwargs = _get_kwargs( + offset=offset, + limit=limit, + ) response = client.get_httpx_client().request( **kwargs, @@ -67,41 +99,58 @@ def sync_detailed( def sync( *, client: AuthenticatedClient, -) -> SessionListResponse | None: + offset: int | Unset = 0, + limit: int | Unset = 25, +) -> HTTPValidationError | SessionListResponse | None: """List sessions - List all sessions owned by the authenticated user. + List sessions owned by the authenticated user with pagination. + + Args: + offset (int | Unset): Number of sessions to skip Default: 0. + limit (int | Unset): Max sessions per page (default 25, max 1000) Default: 25. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - SessionListResponse + HTTPValidationError | SessionListResponse """ return sync_detailed( client=client, + offset=offset, + limit=limit, ).parsed async def asyncio_detailed( *, client: AuthenticatedClient, -) -> Response[SessionListResponse]: + offset: int | Unset = 0, + limit: int | Unset = 25, +) -> Response[HTTPValidationError | SessionListResponse]: """List sessions - List all sessions owned by the authenticated user. + List sessions owned by the authenticated user with pagination. + + Args: + offset (int | Unset): Number of sessions to skip Default: 0. + limit (int | Unset): Max sessions per page (default 25, max 1000) Default: 25. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[SessionListResponse] + Response[HTTPValidationError | SessionListResponse] """ - kwargs = _get_kwargs() + kwargs = _get_kwargs( + offset=offset, + limit=limit, + ) response = await client.get_async_httpx_client().request(**kwargs) @@ -111,21 +160,29 @@ async def asyncio_detailed( async def asyncio( *, client: AuthenticatedClient, -) -> SessionListResponse | None: + offset: int | Unset = 0, + limit: int | Unset = 25, +) -> HTTPValidationError | SessionListResponse | None: """List sessions - List all sessions owned by the authenticated user. + List sessions owned by the authenticated user with pagination. + + Args: + offset (int | Unset): Number of sessions to skip Default: 0. + limit (int | Unset): Max sessions per page (default 25, max 1000) Default: 25. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - SessionListResponse + HTTPValidationError | SessionListResponse """ return ( await asyncio_detailed( client=client, + offset=offset, + limit=limit, ) ).parsed diff --git a/src/everyrow/generated/models/session_list_response.py b/src/everyrow/generated/models/session_list_response.py index dc26a310..9bc79c09 100644 --- a/src/everyrow/generated/models/session_list_response.py +++ b/src/everyrow/generated/models/session_list_response.py @@ -18,9 +18,15 @@ class SessionListResponse: """ Attributes: sessions (list[SessionListItem]): List of sessions + total (int): Total number of sessions matching the query + offset (int): Current offset + limit (int): Current page size """ sessions: list[SessionListItem] + total: int + offset: int + limit: int additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: @@ -29,11 +35,20 @@ def to_dict(self) -> dict[str, Any]: sessions_item = sessions_item_data.to_dict() sessions.append(sessions_item) + total = self.total + + offset = self.offset + + limit = self.limit + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( { "sessions": sessions, + "total": total, + "offset": offset, + "limit": limit, } ) @@ -51,8 +66,17 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: sessions.append(sessions_item) + total = d.pop("total") + + offset = d.pop("offset") + + limit = d.pop("limit") + session_list_response = cls( sessions=sessions, + total=total, + offset=offset, + limit=limit, ) session_list_response.additional_properties = d diff --git a/src/everyrow/session.py b/src/everyrow/session.py index 7a7f72be..6071e527 100644 --- a/src/everyrow/session.py +++ b/src/everyrow/session.py @@ -34,6 +34,16 @@ def get_url(self) -> str: return get_session_url(self.session_id) +@dataclass +class SessionListResult: + """Paginated session listing result.""" + + sessions: list[SessionInfo] + total: int + offset: int + limit: int + + class Session: """Session object containing client and session_id.""" @@ -50,40 +60,61 @@ def get_url(self) -> str: async def create_session( client: AuthenticatedClient | None = None, name: str | None = None, + session_id: UUID | str | None = None, ) -> AsyncGenerator[Session, None]: - """Create a new session and yield it as an async context manager. + """Create a new session — or resume an existing one — and yield it. Args: client: Optional authenticated client. If not provided, one will be created automatically using the EVERYROW_API_KEY environment variable and managed within this context manager. - name: Name for the session. If not provided, defaults to - "everyrow-sdk-session-{timestamp}". + name: Name for a *new* session. If not provided, defaults to + "everyrow-sdk-session-{timestamp}". Mutually exclusive with + ``session_id``. + session_id: UUID (or string) of an existing session to resume. + When provided, no ``POST /sessions`` call is made — + the context manager yields a ``Session`` pointing at the + given ID directly. Mutually exclusive with ``name``. + + Raises: + ValueError: If both ``session_id`` and ``name`` are provided, or if + ``session_id`` is not a valid UUID. Example: - # With explicit client (client lifecycle managed externally) - async with create_client() as client: - async with create_session(client=client, name="My Session") as session: - ... + # Create a new session + async with create_session(client=client, name="My Session") as session: + ... - # Without client (client created and managed internally) - async with create_session(name="My Session") as session: + # Resume an existing session + async with create_session(client=client, session_id="...") as session: ... """ + if session_id is not None and name is not None: + raise ValueError( + "session_id and name are mutually exclusive — " + "pass session_id to resume an existing session, " + "or name to create a new one." + ) + owns_client = client is None if owns_client: client = create_client() await client.__aenter__() try: - response = await create_session_endpoint_sessions_post.asyncio( - client=client, - body=CreateSession( - name=name or f"everyrow-sdk-session-{datetime.now().isoformat()}" - ), - ) - response = handle_response(response) - session = Session(client=client, session_id=response.session_id) + if session_id is not None: + if not isinstance(session_id, UUID): + session_id = UUID(str(session_id)) + session = Session(client=client, session_id=session_id) + else: + response = await create_session_endpoint_sessions_post.asyncio( + client=client, + body=CreateSession( + name=name or f"everyrow-sdk-session-{datetime.now().isoformat()}" + ), + ) + response = handle_response(response) + session = Session(client=client, session_id=response.session_id) yield session finally: if owns_client: @@ -92,15 +123,19 @@ async def create_session( async def list_sessions( client: AuthenticatedClient | None = None, -) -> list[SessionInfo]: - """List all sessions owned by the authenticated user. + offset: int = 0, + limit: int = 25, +) -> SessionListResult: + """List sessions owned by the authenticated user with pagination. Args: client: Optional authenticated client. If not provided, one will be created automatically using the EVERYROW_API_KEY environment variable. + offset: Number of sessions to skip (default 0). + limit: Max sessions per page (default 25, max 1000). Returns: - A list of SessionInfo objects. + A SessionListResult with sessions and pagination metadata. """ owns_client = client is None if owns_client: @@ -108,17 +143,24 @@ async def list_sessions( await client.__aenter__() try: - response = await list_sessions_endpoint_sessions_get.asyncio(client=client) + response = await list_sessions_endpoint_sessions_get.asyncio( + client=client, offset=offset, limit=limit + ) response = handle_response(response) - return [ - SessionInfo( - session_id=item.session_id, - name=item.name, - created_at=item.created_at, - updated_at=item.updated_at, - ) - for item in response.sessions - ] + return SessionListResult( + sessions=[ + SessionInfo( + session_id=item.session_id, + name=item.name, + created_at=item.created_at, + updated_at=item.updated_at, + ) + for item in response.sessions + ], + total=response.total, + offset=response.offset, + limit=response.limit, + ) finally: if owns_client: await client.__aexit__() diff --git a/tests/test_session.py b/tests/test_session.py index b8df2d89..8b98b6e3 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -1,14 +1,22 @@ -"""Unit tests for everyrow.session — SessionInfo, list_sessions.""" +"""Unit tests for everyrow.session — SessionInfo, list_sessions, create_session.""" import uuid from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock +from uuid import UUID import pytest from everyrow.generated.models.session_list_item import SessionListItem from everyrow.generated.models.session_list_response import SessionListResponse -from everyrow.session import SessionInfo, get_session_url, list_sessions +from everyrow.session import ( + Session, + SessionInfo, + SessionListResult, + create_session, + get_session_url, + list_sessions, +) @pytest.fixture(autouse=True) @@ -75,28 +83,50 @@ def test_round_trip(self): created_at=created, updated_at=updated, ) - ] + ], + total=1, + offset=0, + limit=25, ) d = resp.to_dict() assert len(d["sessions"]) == 1 assert d["sessions"][0]["name"] == "Session A" + assert d["total"] == 1 + assert d["offset"] == 0 + assert d["limit"] == 25 restored = SessionListResponse.from_dict(d) assert len(restored.sessions) == 1 assert restored.sessions[0].session_id == sid + assert restored.total == 1 + assert restored.offset == 0 + assert restored.limit == 25 def test_empty_sessions(self): - resp = SessionListResponse(sessions=[]) + resp = SessionListResponse(sessions=[], total=0, offset=0, limit=25) d = resp.to_dict() assert d["sessions"] == [] + assert d["total"] == 0 restored = SessionListResponse.from_dict(d) assert restored.sessions == [] + assert restored.total == 0 # --- list_sessions --- +def _make_api_response(sessions, *, total=None, offset=0, limit=25): + """Create a SessionListResponse for mocking.""" + tc = total if total is not None else len(sessions) + return SessionListResponse( + sessions=sessions, + total=tc, + offset=offset, + limit=limit, + ) + + class TestListSessions: @pytest.mark.asyncio async def test_with_explicit_client(self, mocker): @@ -106,8 +136,8 @@ async def test_with_explicit_client(self, mocker): created = datetime(2025, 6, 1, 12, 0, tzinfo=UTC) updated = datetime(2025, 6, 1, 13, 0, tzinfo=UTC) - api_response = SessionListResponse( - sessions=[ + api_response = _make_api_response( + [ SessionListItem( session_id=sid, name="SDK Session", @@ -125,13 +155,17 @@ async def test_with_explicit_client(self, mocker): result = await list_sessions(client=mock_client) - mock_api.assert_called_once_with(client=mock_client) - assert len(result) == 1 - assert isinstance(result[0], SessionInfo) - assert result[0].session_id == sid - assert result[0].name == "SDK Session" - assert result[0].created_at == created - assert result[0].updated_at == updated + mock_api.assert_called_once_with(client=mock_client, offset=0, limit=25) + assert isinstance(result, SessionListResult) + assert len(result.sessions) == 1 + assert isinstance(result.sessions[0], SessionInfo) + assert result.sessions[0].session_id == sid + assert result.sessions[0].name == "SDK Session" + assert result.sessions[0].created_at == created + assert result.sessions[0].updated_at == updated + assert result.total == 1 + assert result.offset == 0 + assert result.limit == 25 @pytest.mark.asyncio async def test_auto_creates_client(self, mocker): @@ -140,8 +174,8 @@ async def test_auto_creates_client(self, mocker): created = datetime(2025, 6, 1, 12, 0, tzinfo=UTC) updated = datetime(2025, 6, 1, 13, 0, tzinfo=UTC) - api_response = SessionListResponse( - sessions=[ + api_response = _make_api_response( + [ SessionListItem( session_id=sid, name="Auto", @@ -166,8 +200,9 @@ async def test_auto_creates_client(self, mocker): mock_client.__aenter__.assert_called_once() mock_client.__aexit__.assert_called_once() - assert len(result) == 1 - assert result[0].name == "Auto" + assert isinstance(result, SessionListResult) + assert len(result.sessions) == 1 + assert result.sessions[0].name == "Auto" @pytest.mark.asyncio async def test_empty_list(self, mocker): @@ -175,11 +210,13 @@ async def test_empty_list(self, mocker): mocker.patch( "everyrow.session.list_sessions_endpoint_sessions_get.asyncio", new_callable=AsyncMock, - return_value=SessionListResponse(sessions=[]), + return_value=_make_api_response([]), ) result = await list_sessions(client=mock_client) - assert result == [] + assert isinstance(result, SessionListResult) + assert result.sessions == [] + assert result.total == 0 @pytest.mark.asyncio async def test_multiple_sessions(self, mocker): @@ -197,12 +234,13 @@ async def test_multiple_sessions(self, mocker): mocker.patch( "everyrow.session.list_sessions_endpoint_sessions_get.asyncio", new_callable=AsyncMock, - return_value=SessionListResponse(sessions=items), + return_value=_make_api_response(items, total=5), ) result = await list_sessions(client=mock_client) - assert len(result) == 5 - assert [s.name for s in result] == [f"Session {i}" for i in range(5)] + assert len(result.sessions) == 5 + assert [s.name for s in result.sessions] == [f"Session {i}" for i in range(5)] + assert result.total == 5 @pytest.mark.asyncio async def test_cleans_up_client_on_error(self, mocker): @@ -222,3 +260,117 @@ async def test_cleans_up_client_on_error(self, mocker): await list_sessions() mock_client.__aexit__.assert_called_once() + + @pytest.mark.asyncio + async def test_pagination_params_passed(self, mocker): + """list_sessions(limit=10, offset=5) passes params to generated API.""" + mock_client = MagicMock() + mock_api = mocker.patch( + "everyrow.session.list_sessions_endpoint_sessions_get.asyncio", + new_callable=AsyncMock, + return_value=_make_api_response([], total=20, offset=5, limit=10), + ) + + result = await list_sessions(client=mock_client, limit=10, offset=5) + + mock_api.assert_called_once_with(client=mock_client, offset=5, limit=10) + assert result.offset == 5 + assert result.limit == 10 + assert result.total == 20 + + @pytest.mark.asyncio + async def test_default_pagination(self, mocker): + """Default call uses offset=0, limit=25.""" + mock_client = MagicMock() + mock_api = mocker.patch( + "everyrow.session.list_sessions_endpoint_sessions_get.asyncio", + new_callable=AsyncMock, + return_value=_make_api_response([], total=0, offset=0, limit=25), + ) + + result = await list_sessions(client=mock_client) + + mock_api.assert_called_once_with(client=mock_client, offset=0, limit=25) + assert result.offset == 0 + assert result.limit == 25 + + +# --- create_session (resumption) --- + + +class TestCreateSessionResumption: + @pytest.mark.asyncio + async def test_resume_with_session_id_skips_api_call(self, mocker): + """When session_id is provided, no POST /sessions call is made.""" + mock_client = MagicMock() + sid = uuid.uuid4() + + mock_api = mocker.patch( + "everyrow.session.create_session_endpoint_sessions_post.asyncio", + new_callable=AsyncMock, + ) + + async with create_session(client=mock_client, session_id=sid) as session: + assert isinstance(session, Session) + assert session.session_id == sid + assert session.client is mock_client + + mock_api.assert_not_called() + + @pytest.mark.asyncio + async def test_resume_with_string_session_id(self, mocker): + """String session_id is coerced to UUID.""" + mock_client = MagicMock() + sid = uuid.uuid4() + + mocker.patch( + "everyrow.session.create_session_endpoint_sessions_post.asyncio", + new_callable=AsyncMock, + ) + + async with create_session(client=mock_client, session_id=str(sid)) as session: + assert session.session_id == sid + assert isinstance(session.session_id, UUID) + + @pytest.mark.asyncio + async def test_resume_rejects_both_session_id_and_name(self): + """Providing both session_id and name raises ValueError.""" + mock_client = MagicMock() + + with pytest.raises(ValueError, match="mutually exclusive"): + async with create_session( + client=mock_client, session_id=uuid.uuid4(), name="My Session" + ): + pass # pragma: no cover + + @pytest.mark.asyncio + async def test_resume_with_auto_created_client(self, mocker): + """Client lifecycle still works when resuming without explicit client.""" + sid = uuid.uuid4() + + mocker.patch( + "everyrow.session.create_session_endpoint_sessions_post.asyncio", + new_callable=AsyncMock, + ) + + mock_client = MagicMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mocker.patch("everyrow.session.create_client", return_value=mock_client) + + async with create_session(session_id=sid) as session: + assert session.session_id == sid + + mock_client.__aenter__.assert_called_once() + mock_client.__aexit__.assert_called_once() + + @pytest.mark.asyncio + async def test_resume_with_invalid_session_id(self): + """Invalid UUID string raises ValueError.""" + mock_client = MagicMock() + + with pytest.raises(ValueError): + async with create_session( + client=mock_client, session_id="not-a-valid-uuid" + ): + pass # pragma: no cover