From 75986786b7ffbf46e458746616a68e6d2217f9f6 Mon Sep 17 00:00:00 2001 From: straeter Date: Tue, 24 Feb 2026 15:49:47 +0100 Subject: [PATCH 1/4] add list sessions in sdk --- docs/getting-started.md | 15 ++ src/everyrow/__init__.py | 4 +- .../generated/api/sessions/__init__.py | 2 + .../list_sessions_endpoint_sessions_get.py | 141 ++++++++++++++++++ src/everyrow/generated/models/__init__.py | 4 + .../generated/models/session_list_item.py | 81 ++++++++++ .../generated/models/session_list_response.py | 66 ++++++++ src/everyrow/session.py | 50 +++++++ 8 files changed, 362 insertions(+), 1 deletion(-) create mode 100644 src/everyrow/generated/api/sessions/list_sessions_endpoint_sessions_get.py create mode 100644 src/everyrow/generated/models/session_list_item.py create mode 100644 src/everyrow/generated/models/session_list_response.py diff --git a/docs/getting-started.md b/docs/getting-started.md index bf2309a7..5d1b0bce 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -90,6 +90,21 @@ async with create_session(name="Lead Qualification") as session: The session URL lets you monitor progress and inspect results in the web UI while your script runs. +### Listing Sessions + +Retrieve all your sessions programmatically with `list_sessions`: + +```python +from everyrow import list_sessions + +sessions = await list_sessions() +for s in sessions: + print(f"{s.name} ({s.session_id}) — created {s.created_at:%Y-%m-%d}") + print(f" View: {s.get_url()}") +``` + +Each item is a `SessionInfo` with `session_id`, `name`, `created_at`, and `updated_at` fields. + ## Async Operations For long-running jobs, use the `_async` variants to submit work and continue without blocking: diff --git a/src/everyrow/__init__.py b/src/everyrow/__init__.py index e5f073b1..91751bf3 100644 --- a/src/everyrow/__init__.py +++ b/src/everyrow/__init__.py @@ -2,17 +2,19 @@ from everyrow.api_utils import create_client from everyrow.billing import BillingResponse, get_billing_balance -from everyrow.session import create_session +from everyrow.session import SessionInfo, create_session, list_sessions from everyrow.task import fetch_task_data, print_progress __version__ = version("everyrow") __all__ = [ "BillingResponse", + "SessionInfo", "__version__", "create_client", "create_session", "fetch_task_data", "get_billing_balance", + "list_sessions", "print_progress", ] diff --git a/src/everyrow/generated/api/sessions/__init__.py b/src/everyrow/generated/api/sessions/__init__.py index 2d7c0b23..6ca121ae 100644 --- a/src/everyrow/generated/api/sessions/__init__.py +++ b/src/everyrow/generated/api/sessions/__init__.py @@ -1 +1,3 @@ """Contains endpoint functions for accessing the API""" + +from . import list_sessions_endpoint_sessions_get 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 new file mode 100644 index 00000000..df8ad8f9 --- /dev/null +++ b/src/everyrow/generated/api/sessions/list_sessions_endpoint_sessions_get.py @@ -0,0 +1,141 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +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 + + +def _get_kwargs() -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/sessions", + } + + return _kwargs + + +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[HTTPValidationError | SessionListResponse]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, +) -> Response[HTTPValidationError | SessionListResponse]: + """List sessions + + List all sessions owned by the authenticated user. + + 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[HTTPValidationError | SessionListResponse] + """ + + kwargs = _get_kwargs() + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, +) -> HTTPValidationError | SessionListResponse | None: + """List sessions + + List all sessions owned by the authenticated user. + + 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: + HTTPValidationError | SessionListResponse + """ + + return sync_detailed( + client=client, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, +) -> Response[HTTPValidationError | SessionListResponse]: + """List sessions + + List all sessions owned by the authenticated user. + + 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[HTTPValidationError | SessionListResponse] + """ + + kwargs = _get_kwargs() + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, +) -> HTTPValidationError | SessionListResponse | None: + """List sessions + + List all sessions owned by the authenticated user. + + 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: + HTTPValidationError | SessionListResponse + """ + + return ( + await asyncio_detailed( + client=client, + ) + ).parsed diff --git a/src/everyrow/generated/models/__init__.py b/src/everyrow/generated/models/__init__.py index 70dbc540..c7be89e3 100644 --- a/src/everyrow/generated/models/__init__.py +++ b/src/everyrow/generated/models/__init__.py @@ -42,6 +42,8 @@ from .screen_operation_input_type_1_item import ScreenOperationInputType1Item from .screen_operation_input_type_2 import ScreenOperationInputType2 from .screen_operation_response_schema_type_0 import ScreenOperationResponseSchemaType0 +from .session_list_item import SessionListItem +from .session_list_response import SessionListResponse from .session_response import SessionResponse from .single_agent_operation import SingleAgentOperation from .single_agent_operation_input_type_1_item import SingleAgentOperationInputType1Item @@ -99,6 +101,8 @@ "ScreenOperationInputType1Item", "ScreenOperationInputType2", "ScreenOperationResponseSchemaType0", + "SessionListItem", + "SessionListResponse", "SessionResponse", "SingleAgentOperation", "SingleAgentOperationInputType1Item", diff --git a/src/everyrow/generated/models/session_list_item.py b/src/everyrow/generated/models/session_list_item.py new file mode 100644 index 00000000..c60bc40a --- /dev/null +++ b/src/everyrow/generated/models/session_list_item.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import datetime +from collections.abc import Mapping +from typing import Any, TypeVar +from uuid import UUID + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="SessionListItem") + + +@_attrs_define +class SessionListItem: + """ + Attributes: + session_id (UUID): The ID of the session + name (str): The name of the session + created_at (datetime.datetime): When the session was created + updated_at (datetime.datetime): When the session was last updated + """ + + session_id: UUID + name: str + created_at: datetime.datetime + updated_at: datetime.datetime + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + session_id = str(self.session_id) + name = self.name + created_at = self.created_at.isoformat() + updated_at = self.updated_at.isoformat() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "session_id": session_id, + "name": name, + "created_at": created_at, + "updated_at": updated_at, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + session_id = UUID(d.pop("session_id")) + name = d.pop("name") + created_at = datetime.datetime.fromisoformat(d.pop("created_at")) + updated_at = datetime.datetime.fromisoformat(d.pop("updated_at")) + + session_list_item = cls( + session_id=session_id, + name=name, + created_at=created_at, + updated_at=updated_at, + ) + + session_list_item.additional_properties = d + return session_list_item + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/everyrow/generated/models/session_list_response.py b/src/everyrow/generated/models/session_list_response.py new file mode 100644 index 00000000..19cc6cdc --- /dev/null +++ b/src/everyrow/generated/models/session_list_response.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from .session_list_item import SessionListItem + +T = TypeVar("T", bound="SessionListResponse") + + +@_attrs_define +class SessionListResponse: + """ + Attributes: + sessions (list['SessionListItem']): The list of sessions + """ + + sessions: list[SessionListItem] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + sessions = [s.to_dict() for s in self.sessions] + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "sessions": sessions, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from .session_list_item import SessionListItem + + d = dict(src_dict) + sessions = [SessionListItem.from_dict(s) for s in d.pop("sessions")] + + session_list_response = cls( + sessions=sessions, + ) + + session_list_response.additional_properties = d + return session_list_response + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/everyrow/session.py b/src/everyrow/session.py index 874ca596..7a7f72be 100644 --- a/src/everyrow/session.py +++ b/src/everyrow/session.py @@ -1,6 +1,7 @@ import os from collections.abc import AsyncGenerator from contextlib import asynccontextmanager +from dataclasses import dataclass from datetime import datetime from uuid import UUID @@ -8,6 +9,7 @@ from everyrow.constants import DEFAULT_EVERYROW_APP_URL from everyrow.generated.api.sessions import ( create_session_endpoint_sessions_post, + list_sessions_endpoint_sessions_get, ) from everyrow.generated.client import AuthenticatedClient from everyrow.generated.models.create_session import CreateSession @@ -18,6 +20,20 @@ def get_session_url(session_id: UUID) -> str: return f"{base_url}/sessions/{session_id}" +@dataclass +class SessionInfo: + """Summary of an existing session.""" + + session_id: UUID + name: str + created_at: datetime + updated_at: datetime + + def get_url(self) -> str: + """Get the URL to view this session in the web interface.""" + return get_session_url(self.session_id) + + class Session: """Session object containing client and session_id.""" @@ -72,3 +88,37 @@ async def create_session( finally: if owns_client: await client.__aexit__() + + +async def list_sessions( + client: AuthenticatedClient | None = None, +) -> list[SessionInfo]: + """List all sessions owned by the authenticated user. + + Args: + client: Optional authenticated client. If not provided, one will be created + automatically using the EVERYROW_API_KEY environment variable. + + Returns: + A list of SessionInfo objects. + """ + owns_client = client is None + if owns_client: + client = create_client() + await client.__aenter__() + + try: + response = await list_sessions_endpoint_sessions_get.asyncio(client=client) + 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 + ] + finally: + if owns_client: + await client.__aexit__() From bd2e469bf2272e61b79d4e11e99372014911d8f2 Mon Sep 17 00:00:00 2001 From: straeter Date: Tue, 24 Feb 2026 15:59:10 +0100 Subject: [PATCH 2/4] add list_sessions to mcp --- docs/mcp-server.md | 6 ++ everyrow-mcp/src/everyrow_mcp/server.py | 1 + everyrow-mcp/src/everyrow_mcp/tools.py | 41 +++++++++++++- everyrow-mcp/tests/test_server.py | 75 +++++++++++++++++++++++++ 4 files changed, 122 insertions(+), 1 deletion(-) diff --git a/docs/mcp-server.md b/docs/mcp-server.md index c5c5b3bc..932aab75 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -122,6 +122,12 @@ Cancel a running task. Use when the user wants to stop a task that is currently Returns a confirmation message. If the task has already finished, returns an error with its current state. +### everyrow_list_sessions + +List all sessions owned by the authenticated user. Returns session names, IDs, timestamps, and dashboard URLs. No parameters required. + +Returns a formatted list of sessions with links to the web dashboard. + ## Workflow ``` diff --git a/everyrow-mcp/src/everyrow_mcp/server.py b/everyrow-mcp/src/everyrow_mcp/server.py index a57e5e23..e7f0ef27 100644 --- a/everyrow-mcp/src/everyrow_mcp/server.py +++ b/everyrow-mcp/src/everyrow_mcp/server.py @@ -30,6 +30,7 @@ everyrow_agent, everyrow_cancel, everyrow_dedupe, + everyrow_list_sessions, everyrow_merge, everyrow_progress, everyrow_rank, diff --git a/everyrow-mcp/src/everyrow_mcp/tools.py b/everyrow-mcp/src/everyrow_mcp/tools.py index 979c5448..1459c3ba 100644 --- a/everyrow-mcp/src/everyrow_mcp/tools.py +++ b/everyrow-mcp/src/everyrow_mcp/tools.py @@ -28,7 +28,7 @@ screen_async, single_agent_async, ) -from everyrow.session import create_session, get_session_url +from everyrow.session import create_session, get_session_url, list_sessions from everyrow.task import cancel_task from mcp.types import TextContent, ToolAnnotations from pydantic import BaseModel, create_model @@ -828,6 +828,45 @@ async def everyrow_results(params: ResultsInput) -> list[TextContent]: return [TextContent(type="text", text=f"Error retrieving results: {e!r}")] +@mcp.tool( + name="everyrow_list_sessions", + structured_output=False, + annotations=ToolAnnotations( + title="List Sessions", + readOnlyHint=True, + destructiveHint=False, + idempotentHint=True, + openWorldHint=False, + ), +) +async def everyrow_list_sessions() -> list[TextContent]: + """List all everyrow sessions owned by the authenticated user. + + Returns session names, IDs, timestamps, and dashboard URLs. + Use this to find past sessions or check what's been run. + """ + client = _get_client() + + try: + sessions = await list_sessions(client=client) + except Exception as e: + return [TextContent(type="text", text=f"Error listing sessions: {e!r}")] + + if not sessions: + return [TextContent(type="text", text="No sessions found.")] + + lines = [f"Found {len(sessions)} session(s):\n"] + for s in sessions: + lines.append( + f"- **{s.name}** (id: {s.session_id})\n" + f" Created: {s.created_at:%Y-%m-%d %H:%M UTC} | " + f"Updated: {s.updated_at:%Y-%m-%d %H:%M UTC}\n" + f" URL: {s.get_url()}" + ) + + return [TextContent(type="text", text="\n".join(lines))] + + @mcp.tool( name="everyrow_cancel", structured_output=False, diff --git a/everyrow-mcp/tests/test_server.py b/everyrow-mcp/tests/test_server.py index d9dd0f7f..193e8599 100644 --- a/everyrow-mcp/tests/test_server.py +++ b/everyrow-mcp/tests/test_server.py @@ -35,6 +35,7 @@ _schema_to_model, everyrow_agent, everyrow_cancel, + everyrow_list_sessions, everyrow_progress, everyrow_results, everyrow_single_agent, @@ -621,6 +622,80 @@ async def test_results_saves_csv(self, tmp_path: Path): assert list(output_df.columns) == ["name", "answer"] +class TestListSessions: + """Tests for everyrow_list_sessions.""" + + @pytest.mark.asyncio + async def test_list_sessions_returns_sessions(self): + """Test that list_sessions returns formatted session info.""" + mock_client = _make_mock_client() + mock_sessions = [ + MagicMock( + session_id=uuid4(), + name="My Session", + created_at=datetime(2025, 6, 1, 12, 0, tzinfo=UTC), + updated_at=datetime(2025, 6, 1, 13, 0, tzinfo=UTC), + get_url=lambda: "https://everyrow.io/sessions/abc", + ), + MagicMock( + session_id=uuid4(), + name="Another Session", + created_at=datetime(2025, 6, 2, 10, 0, tzinfo=UTC), + updated_at=datetime(2025, 6, 2, 11, 0, tzinfo=UTC), + get_url=lambda: "https://everyrow.io/sessions/def", + ), + ] + + with ( + patch("everyrow_mcp.app._client", mock_client), + patch( + "everyrow_mcp.tools.list_sessions", + new_callable=AsyncMock, + return_value=mock_sessions, + ), + ): + result = await everyrow_list_sessions() + + text = result[0].text + assert "2 session(s)" in text + assert "My Session" in text + assert "Another Session" in text + + @pytest.mark.asyncio + async def test_list_sessions_empty(self): + """Test that list_sessions handles no sessions.""" + mock_client = _make_mock_client() + + with ( + patch("everyrow_mcp.app._client", mock_client), + patch( + "everyrow_mcp.tools.list_sessions", + new_callable=AsyncMock, + return_value=[], + ), + ): + result = await everyrow_list_sessions() + + assert "No sessions found" in result[0].text + + @pytest.mark.asyncio + async def test_list_sessions_api_error(self): + """Test that list_sessions handles API errors gracefully.""" + mock_client = _make_mock_client() + + with ( + patch("everyrow_mcp.app._client", mock_client), + patch( + "everyrow_mcp.tools.list_sessions", + new_callable=AsyncMock, + side_effect=RuntimeError("API error"), + ), + ): + result = await everyrow_list_sessions() + + assert "Error listing sessions" in result[0].text + + class TestCancel: """Tests for everyrow_cancel.""" From e53ffab361af4dfd0182ec9bc7df18d89b541579 Mon Sep 17 00:00:00 2001 From: straeter Date: Tue, 24 Feb 2026 16:10:27 +0100 Subject: [PATCH 3/4] add tests --- everyrow-mcp/tests/test_server.py | 44 ++++++ tests/test_session.py | 224 ++++++++++++++++++++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 tests/test_session.py diff --git a/everyrow-mcp/tests/test_server.py b/everyrow-mcp/tests/test_server.py index 8617de13..1db29097 100644 --- a/everyrow-mcp/tests/test_server.py +++ b/everyrow-mcp/tests/test_server.py @@ -884,6 +884,50 @@ async def test_list_sessions_api_error(self): 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.""" + mock_client = _make_mock_client() + ctx = make_test_context(mock_client) + + with patch( + "everyrow_mcp.tools.list_sessions", + new_callable=AsyncMock, + return_value=[], + ) as mock_ls: + await everyrow_list_sessions(ctx) + + mock_ls.assert_called_once_with(client=mock_client) + + @pytest.mark.asyncio + async def test_list_sessions_output_contains_urls_and_dates(self): + """Test that the formatted output includes URLs and timestamps.""" + mock_client = _make_mock_client() + ctx = make_test_context(mock_client) + session_id = uuid4() + mock_sessions = [ + MagicMock( + session_id=session_id, + name="Pipeline Run", + created_at=datetime(2025, 8, 15, 9, 30, tzinfo=UTC), + updated_at=datetime(2025, 8, 15, 10, 45, tzinfo=UTC), + get_url=lambda: f"https://everyrow.io/sessions/{session_id}", + ), + ] + + with patch( + "everyrow_mcp.tools.list_sessions", + new_callable=AsyncMock, + return_value=mock_sessions, + ): + result = await everyrow_list_sessions(ctx) + + text = result[0].text + assert "Pipeline Run" in text + assert "2025-08-15 09:30 UTC" in text + assert "2025-08-15 10:45 UTC" in text + assert f"https://everyrow.io/sessions/{session_id}" in text + class TestCancel: """Tests for everyrow_cancel.""" diff --git a/tests/test_session.py b/tests/test_session.py new file mode 100644 index 00000000..b8df2d89 --- /dev/null +++ b/tests/test_session.py @@ -0,0 +1,224 @@ +"""Unit tests for everyrow.session — SessionInfo, list_sessions.""" + +import uuid +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock + +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 + + +@pytest.fixture(autouse=True) +def mock_env(monkeypatch): + monkeypatch.setenv("EVERYROW_API_KEY", "test-key") + monkeypatch.setenv("EVERYROW_APP_URL", "https://everyrow.io") + + +# --- SessionInfo --- + + +class TestSessionInfo: + def test_fields(self): + sid = uuid.uuid4() + now = datetime.now(UTC) + info = SessionInfo(session_id=sid, name="Test", created_at=now, updated_at=now) + assert info.session_id == sid + assert info.name == "Test" + assert info.created_at == now + assert info.updated_at == now + + def test_get_url(self): + sid = uuid.uuid4() + now = datetime.now(UTC) + info = SessionInfo(session_id=sid, name="Test", created_at=now, updated_at=now) + assert info.get_url() == get_session_url(sid) + assert str(sid) in info.get_url() + + +# --- Generated models --- + + +class TestSessionListItem: + def test_round_trip(self): + sid = uuid.uuid4() + created = datetime(2025, 6, 1, 12, 0, tzinfo=UTC) + updated = datetime(2025, 6, 1, 13, 0, tzinfo=UTC) + + item = SessionListItem( + session_id=sid, name="My Session", created_at=created, updated_at=updated + ) + d = item.to_dict() + assert d["session_id"] == str(sid) + assert d["name"] == "My Session" + + restored = SessionListItem.from_dict(d) + assert restored.session_id == sid + assert restored.name == "My Session" + assert restored.created_at == created + assert restored.updated_at == updated + + +class TestSessionListResponse: + def test_round_trip(self): + sid = uuid.uuid4() + created = datetime(2025, 6, 1, 12, 0, tzinfo=UTC) + updated = datetime(2025, 6, 1, 13, 0, tzinfo=UTC) + + resp = SessionListResponse( + sessions=[ + SessionListItem( + session_id=sid, + name="Session A", + created_at=created, + updated_at=updated, + ) + ] + ) + d = resp.to_dict() + assert len(d["sessions"]) == 1 + assert d["sessions"][0]["name"] == "Session A" + + restored = SessionListResponse.from_dict(d) + assert len(restored.sessions) == 1 + assert restored.sessions[0].session_id == sid + + def test_empty_sessions(self): + resp = SessionListResponse(sessions=[]) + d = resp.to_dict() + assert d["sessions"] == [] + + restored = SessionListResponse.from_dict(d) + assert restored.sessions == [] + + +# --- list_sessions --- + + +class TestListSessions: + @pytest.mark.asyncio + async def test_with_explicit_client(self, mocker): + """list_sessions uses the provided client and does not create its own.""" + mock_client = MagicMock() + sid = uuid.uuid4() + created = datetime(2025, 6, 1, 12, 0, tzinfo=UTC) + updated = datetime(2025, 6, 1, 13, 0, tzinfo=UTC) + + api_response = SessionListResponse( + sessions=[ + SessionListItem( + session_id=sid, + name="SDK Session", + created_at=created, + updated_at=updated, + ) + ] + ) + + mock_api = mocker.patch( + "everyrow.session.list_sessions_endpoint_sessions_get.asyncio", + new_callable=AsyncMock, + return_value=api_response, + ) + + 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 + + @pytest.mark.asyncio + async def test_auto_creates_client(self, mocker): + """list_sessions creates and cleans up its own client when none is provided.""" + sid = uuid.uuid4() + created = datetime(2025, 6, 1, 12, 0, tzinfo=UTC) + updated = datetime(2025, 6, 1, 13, 0, tzinfo=UTC) + + api_response = SessionListResponse( + sessions=[ + SessionListItem( + session_id=sid, + name="Auto", + created_at=created, + updated_at=updated, + ) + ] + ) + + mocker.patch( + "everyrow.session.list_sessions_endpoint_sessions_get.asyncio", + new_callable=AsyncMock, + return_value=api_response, + ) + + 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) + + result = await list_sessions() + + mock_client.__aenter__.assert_called_once() + mock_client.__aexit__.assert_called_once() + assert len(result) == 1 + assert result[0].name == "Auto" + + @pytest.mark.asyncio + async def test_empty_list(self, mocker): + mock_client = MagicMock() + mocker.patch( + "everyrow.session.list_sessions_endpoint_sessions_get.asyncio", + new_callable=AsyncMock, + return_value=SessionListResponse(sessions=[]), + ) + + result = await list_sessions(client=mock_client) + assert result == [] + + @pytest.mark.asyncio + async def test_multiple_sessions(self, mocker): + mock_client = MagicMock() + now = datetime.now(UTC) + items = [ + SessionListItem( + session_id=uuid.uuid4(), + name=f"Session {i}", + created_at=now, + updated_at=now, + ) + for i in range(5) + ] + mocker.patch( + "everyrow.session.list_sessions_endpoint_sessions_get.asyncio", + new_callable=AsyncMock, + return_value=SessionListResponse(sessions=items), + ) + + 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)] + + @pytest.mark.asyncio + async def test_cleans_up_client_on_error(self, mocker): + """Auto-created client is cleaned up even when the API call fails.""" + mocker.patch( + "everyrow.session.list_sessions_endpoint_sessions_get.asyncio", + new_callable=AsyncMock, + side_effect=RuntimeError("API down"), + ) + + 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) + + with pytest.raises(RuntimeError, match="API down"): + await list_sessions() + + mock_client.__aexit__.assert_called_once() From 93b60238c6af8bbf53cbe721967477dc3ee11f91 Mon Sep 17 00:00:00 2001 From: straeter Date: Tue, 24 Feb 2026 16:17:00 +0100 Subject: [PATCH 4/4] fix tests --- everyrow-mcp/manifest.json | 4 ++++ everyrow-mcp/tests/test_mcp_e2e.py | 1 + 2 files changed, 5 insertions(+) diff --git a/everyrow-mcp/manifest.json b/everyrow-mcp/manifest.json index e73653b5..5db2513d 100644 --- a/everyrow-mcp/manifest.json +++ b/everyrow-mcp/manifest.json @@ -65,6 +65,10 @@ "name": "everyrow_results", "description": "Retrieve results from a completed everyrow task and save them to a CSV." }, + { + "name": "everyrow_list_sessions", + "description": "List all everyrow sessions owned by the authenticated user." + }, { "name": "everyrow_cancel", "description": "Cancel a running everyrow task. Use when the user wants to stop a task that is currently processing." diff --git a/everyrow-mcp/tests/test_mcp_e2e.py b/everyrow-mcp/tests/test_mcp_e2e.py index 93ebbede..795e0028 100644 --- a/everyrow-mcp/tests/test_mcp_e2e.py +++ b/everyrow-mcp/tests/test_mcp_e2e.py @@ -138,6 +138,7 @@ async def test_list_tools(self, _http_state): "everyrow_cancel", "everyrow_dedupe", "everyrow_forecast", + "everyrow_list_sessions", "everyrow_merge", "everyrow_progress", "everyrow_rank",