diff --git a/CHANGELOG.md b/CHANGELOG.md index ab9302c6..901265ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Stream-final retries pin to the `AttemptPlan` that succeeded rather than re-running provider selection through the outer `current_attempt` ContextVar (which could roll streaming back to primary after the tool loop had already switched to fallback) - OpenAI structured-output calls continue to use `chat.completions.parse()` with strict schema enforcement, while tool-calling paths use `chat.completions.create()` without `strict:True` for broader proxy compatibility (OpenRouter, vLLM, Ollama) - Gemini `cached_content` reuse keys now include `system_instruction` and `tool_config` so cache hits don't cross configurations that differ only in those fields +- `reverse` query parameter is now honored on the v3 workspace list (`POST /v3/workspaces/list`), peer list (`POST /v3/workspaces/{workspace_id}/peers/list`), workspace-scoped session list (`POST /v3/workspaces/{workspace_id}/sessions/list`), and peer-scoped session list (`POST /v3/workspaces/{workspace_id}/peers/{peer_id}/sessions`). Honcho SDKs at 2.1.0+ were already sending `reverse=true` for these routes but the server silently ignored it. Ties on `created_at` now fall back to the internal nanoid `id` so ordering remains stable across pages. ### Removed diff --git a/docs/v3/openapi.json b/docs/v3/openapi.json index a2e46b2c..161cafc5 100644 --- a/docs/v3/openapi.json +++ b/docs/v3/openapi.json @@ -99,6 +99,25 @@ }, "description": "Page number" }, + { + "name": "reverse", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Whether to reverse the order of results", + "default": false, + "title": "Reverse" + }, + "description": "Whether to reverse the order of results" + }, { "name": "size", "in": "query", @@ -493,6 +512,25 @@ "title": "Workspace Id" } }, + { + "name": "reverse", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Whether to reverse the order of results", + "default": false, + "title": "Reverse" + }, + "description": "Whether to reverse the order of results" + }, { "name": "page", "in": "query", @@ -719,6 +757,25 @@ "title": "Peer Id" } }, + { + "name": "reverse", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Whether to reverse the order of results", + "default": false, + "title": "Reverse" + }, + "description": "Whether to reverse the order of results" + }, { "name": "page", "in": "query", @@ -1358,6 +1415,25 @@ "title": "Workspace Id" } }, + { + "name": "reverse", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Whether to reverse the order of results", + "default": false, + "title": "Reverse" + }, + "description": "Whether to reverse the order of results" + }, { "name": "page", "in": "query", diff --git a/sdks/python/CHANGELOG.md b/sdks/python/CHANGELOG.md index 36576f05..a7056000 100644 --- a/sdks/python/CHANGELOG.md +++ b/sdks/python/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [Unreleased] + +### Added + +- `page`, `size`, and `reverse` pagination parameters on `Honcho.workspaces()` and `HonchoAio.workspaces()`, closing the gap from 2.1.0 which added these to `peers()`, `sessions()`, `messages()`, and `conclusions.list()` but not to `workspaces()`. Honoring `reverse` on the workspace/peer/session list routes also requires a Honcho server with the matching API fix; older servers silently ignore the parameter. + ## [2.1.1] - 2026-04-01 ### Fixed diff --git a/sdks/python/src/honcho/aio.py b/sdks/python/src/honcho/aio.py index 04ca42fb..9ff27a12 100644 --- a/sdks/python/src/honcho/aio.py +++ b/sdks/python/src/honcho/aio.py @@ -92,24 +92,30 @@ class HonchoAio(AsyncMetadataConfigMixin): _honcho: "Honcho" def __init__(self, honcho: "Honcho") -> None: + """Create an async view backed by a sync Honcho client.""" self._honcho = honcho # AsyncMetadataConfigMixin implementation def _get_async_http_client(self): + """Return the async HTTP client used by metadata helpers.""" return self._honcho._async_http_client def _get_fetch_route(self) -> str: + """Return the workspace fetch route for metadata helpers.""" return routes.workspaces() def _get_update_route(self) -> str: + """Return the workspace update route for metadata helpers.""" return routes.workspace(self._honcho.workspace_id) def _get_fetch_body(self) -> dict[str, Any]: + """Return the request body used to fetch this workspace.""" return {"id": self._honcho.workspace_id} def _parse_response( self, data: dict[str, Any] ) -> tuple[dict[str, object], dict[str, object]]: + """Parse workspace metadata and configuration from an API response.""" workspace = WorkspaceResponse.model_validate(data) # Return configuration as dict for mixin compatibility return workspace.metadata or {}, workspace.configuration.model_dump( @@ -117,18 +123,22 @@ class HonchoAio(AsyncMetadataConfigMixin): ) def _set_metadata(self, metadata: dict[str, object]) -> None: + """Update cached workspace metadata on the parent client.""" self._honcho._metadata = metadata def _set_configuration(self, configuration: dict[str, object]) -> None: + """Update cached workspace configuration on the parent client.""" # Convert dict to typed configuration self._honcho._configuration = WorkspaceConfiguration.model_validate( configuration ) def _get_metadata(self) -> dict[str, object]: + """Return cached workspace metadata from the parent client.""" return self._honcho._metadata or {} def _get_configuration(self) -> dict[str, object]: + """Return cached workspace configuration from the parent client.""" if self._honcho._configuration is None: return {} return self._honcho._configuration.model_dump(exclude_none=True) @@ -216,6 +226,7 @@ class HonchoAio(AsyncMetadataConfigMixin): ) def transform(peer: PeerResponse) -> Peer: + """Convert a peer API response into a Peer SDK object.""" return Peer( peer.id, self._honcho, @@ -225,6 +236,7 @@ class HonchoAio(AsyncMetadataConfigMixin): ) async def fetch_next(next_page: int) -> AsyncPage[PeerResponse, Peer]: + """Fetch the next page while preserving filters and ordering.""" next_query: dict[str, Any] = {"page": next_page, "size": size} if reverse: next_query["reverse"] = "true" @@ -318,6 +330,7 @@ class HonchoAio(AsyncMetadataConfigMixin): ) def transform(session: SessionResponse) -> Session: + """Convert a session API response into a Session SDK object.""" return Session( session.id, self._honcho, @@ -328,6 +341,7 @@ class HonchoAio(AsyncMetadataConfigMixin): ) async def fetch_next(next_page: int) -> AsyncPage[SessionResponse, Session]: + """Fetch the next page while preserving filters and ordering.""" next_query: dict[str, Any] = {"page": next_page, "size": size} if reverse: next_query["reverse"] = "true" @@ -341,22 +355,37 @@ class HonchoAio(AsyncMetadataConfigMixin): return AsyncPage(data, SessionResponse, transform, fetch_next) async def workspaces( - self, filters: dict[str, object] | None = None + self, + filters: dict[str, object] | None = None, + *, + page: int = 1, + size: int = 50, + reverse: bool = False, ) -> AsyncPage[WorkspaceResponse, str]: """Get all workspace IDs asynchronously.""" + query: dict[str, Any] = {"page": page, "size": size} + if reverse: + query["reverse"] = "true" + data = await self._honcho._async_http_client.post( routes.workspaces_list(), body={"filters": filters} if filters else None, + query=query, ) def transform(workspace: WorkspaceResponse) -> str: + """Convert a workspace API response into its workspace ID.""" return workspace.id - async def fetch_next(page: int) -> AsyncPage[WorkspaceResponse, str]: + async def fetch_next(next_page: int) -> AsyncPage[WorkspaceResponse, str]: + """Fetch the next page while preserving filters and ordering.""" + next_query: dict[str, Any] = {"page": next_page, "size": size} + if reverse: + next_query["reverse"] = "true" next_data = await self._honcho._async_http_client.post( routes.workspaces_list(), body={"filters": filters} if filters else None, - query={"page": page}, + query=next_query, ) return AsyncPage(next_data, WorkspaceResponse, transform, fetch_next) diff --git a/sdks/python/src/honcho/client.py b/sdks/python/src/honcho/client.py index 1dbd3a8c..06a2887c 100644 --- a/sdks/python/src/honcho/client.py +++ b/sdks/python/src/honcho/client.py @@ -83,20 +83,25 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul # MetadataConfigMixin implementation def _get_http_client(self): + """Return the sync HTTP client used by metadata helpers.""" return self._http def _get_fetch_route(self) -> str: + """Return the workspace fetch route for metadata helpers.""" return routes.workspaces() def _get_update_route(self) -> str: + """Return the workspace update route for metadata helpers.""" return routes.workspace(self.workspace_id) def _get_fetch_body(self) -> dict[str, Any]: + """Return the request body used to fetch this workspace.""" return {"id": self.workspace_id} def _parse_response( self, data: dict[str, Any] ) -> tuple[dict[str, object], dict[str, object]]: + """Parse workspace metadata and configuration from an API response.""" workspace = WorkspaceResponse.model_validate(data) # Return configuration as dict for mixin compatibility return workspace.metadata or {}, workspace.configuration.model_dump( @@ -365,6 +370,7 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul ) def transform(peer: PeerResponse) -> Peer: + """Convert a peer API response into a Peer SDK object.""" return Peer( peer.id, self, @@ -374,6 +380,7 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul ) def fetch_next(next_page: int) -> SyncPage[PeerResponse, Peer]: + """Fetch the next page while preserving filters and ordering.""" next_query: dict[str, Any] = {"page": next_page, "size": size} if reverse: next_query["reverse"] = "true" @@ -483,6 +490,7 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul ) def transform(session: SessionResponse) -> Session: + """Convert a session API response into a Session SDK object.""" return Session( session.id, self, @@ -493,6 +501,7 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul ) def fetch_next(next_page: int) -> SyncPage[SessionResponse, Session]: + """Fetch the next page while preserving filters and ordering.""" next_query: dict[str, Any] = {"page": next_page, "size": size} if reverse: next_query["reverse"] = "true" @@ -506,7 +515,12 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul return SyncPage(data, SessionResponse, transform, fetch_next) def workspaces( - self, filters: dict[str, object] | None = None + self, + filters: dict[str, object] | None = None, + *, + page: int = 1, + size: int = 50, + reverse: bool = False, ) -> SyncPage[WorkspaceResponse, str]: """ Get all workspace IDs from the Honcho instance. @@ -514,22 +528,38 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul Makes an API call to retrieve all workspace IDs that the authenticated user has access to. + Args: + filters: Optional filter criteria. + page: Page number (1-indexed). Default: 1. + size: Number of items per page. Default: 50. + reverse: If True, reverses the default ordering. Default: False. + Returns: A paginated SyncPage of workspace ID strings """ + query: dict[str, Any] = {"page": page, "size": size} + if reverse: + query["reverse"] = "true" + data = self._http.post( routes.workspaces_list(), body={"filters": filters} if filters else None, + query=query, ) def transform(workspace: WorkspaceResponse) -> str: + """Convert a workspace API response into its workspace ID.""" return workspace.id - def fetch_next(page: int) -> SyncPage[WorkspaceResponse, str]: + def fetch_next(next_page: int) -> SyncPage[WorkspaceResponse, str]: + """Fetch the next page while preserving filters and ordering.""" + next_query: dict[str, Any] = {"page": next_page, "size": size} + if reverse: + next_query["reverse"] = "true" next_data = self._http.post( routes.workspaces_list(), body={"filters": filters} if filters else None, - query={"page": page}, + query=next_query, ) return SyncPage(next_data, WorkspaceResponse, transform, fetch_next) diff --git a/sdks/typescript/CHANGELOG.md b/sdks/typescript/CHANGELOG.md index 008d2f45..e5ab73ac 100644 --- a/sdks/typescript/CHANGELOG.md +++ b/sdks/typescript/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [Unreleased] + +### Fixed + +- `Honcho.workspaces()` now actually forwards the `reverse` option to the server. The 2.1.0 changelog listed `workspaces()` among the list methods that gained `reverse`, but `client.ts` was missing the field on the params type and request builder, so the option was silently dropped. Honoring `reverse` on the workspace/peer/session list routes also requires a Honcho server with the matching API fix; older servers silently ignore the parameter. + ## [2.1.1] - 2026-04-01 ### Fixed diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts index 4b510945..db8e237a 100644 --- a/sdks/typescript/src/client.ts +++ b/sdks/typescript/src/client.ts @@ -232,6 +232,7 @@ export class Honcho { filters?: Record page?: number size?: number + reverse?: boolean }): Promise> { return this._http.post>( `/${API_VERSION}/workspaces/list`, @@ -242,6 +243,7 @@ export class Honcho { query: { page: params?.page, size: params?.size, + reverse: params?.reverse ? 'true' : undefined, }, } ) @@ -707,7 +709,7 @@ export class Honcho { * user has access to. * * @param options - Either a legacy raw filter object or an options object with - * `filters`, `page`, and `size`. See + * `filters`, `page`, `size`, and `reverse`. See * [search filters documentation](https://honcho.dev/docs/v3/documentation/core-concepts/features/using-filters). * @returns Promise resolving to a Page of workspace ID strings. Returns an empty * page if no workspaces are accessible or none exist @@ -719,20 +721,24 @@ export class Honcho { filters?: Filters page?: number size?: number + reverse?: boolean } ): Promise> { const normalizedOptions = normalizeListOptions(options, [ 'filters', 'page', 'size', + 'reverse', ]) const validatedFilter = normalizedOptions.filters ? FilterSchema.parse(normalizedOptions.filters) : undefined + const reverse = normalizedOptions.reverse const workspacesPage = await this._listWorkspaces({ filters: validatedFilter, page: normalizedOptions.page, size: normalizedOptions.size, + reverse, }) const fetchNextPage = async ( @@ -743,6 +749,7 @@ export class Honcho { filters: validatedFilter, page, size, + reverse, }) } diff --git a/sdks/typescript/src/types/api.ts b/sdks/typescript/src/types/api.ts index 1622c99c..65c6d88c 100644 --- a/sdks/typescript/src/types/api.ts +++ b/sdks/typescript/src/types/api.ts @@ -35,6 +35,7 @@ export interface WorkspaceListParams { filters?: Record page?: number size?: number + reverse?: boolean } // ============================================================================= @@ -64,6 +65,7 @@ export interface PeerListParams { filters?: Record page?: number size?: number + reverse?: boolean } export interface PeerChatParams { @@ -141,6 +143,7 @@ export interface SessionListParams { filters?: Record page?: number size?: number + reverse?: boolean } export interface SessionCloneParams { @@ -226,6 +229,7 @@ export interface MessageListParams { filters?: Record page?: number size?: number + reverse?: boolean } export interface MessageSearchParams { @@ -262,6 +266,7 @@ export interface ConclusionListParams { filters?: Record page?: number size?: number + reverse?: boolean } export interface ConclusionQueryParams { diff --git a/src/crud/peer.py b/src/crud/peer.py index 4c144b9b..21792b0f 100644 --- a/src/crud/peer.py +++ b/src/crud/peer.py @@ -1,3 +1,5 @@ +"""CRUD helpers for peer records and peer-scoped session queries.""" + from logging import getLogger from typing import Any @@ -210,13 +212,17 @@ async def get_peer( async def get_peers( workspace_name: str, - filters: dict[str, str] | None = None, + filters: dict[str, Any] | None = None, + reverse: bool = False, ) -> Select[tuple[models.Peer]]: + """Build a filtered peer list query ordered by creation time.""" stmt = select(models.Peer).where(models.Peer.workspace_name == workspace_name) stmt = apply_filter(stmt, models.Peer, filters) - return stmt.order_by(models.Peer.created_at) + if reverse: + return stmt.order_by(models.Peer.created_at.desc(), models.Peer.id.desc()) + return stmt.order_by(models.Peer.created_at.asc(), models.Peer.id.asc()) async def update_peer( @@ -285,6 +291,7 @@ async def get_sessions_for_peer( workspace_name: str, peer_name: str, filters: dict[str, Any] | None = None, + reverse: bool = False, ) -> Select[tuple[models.Session]]: """ Get all sessions for a peer through the session_peers relationship. @@ -293,6 +300,7 @@ async def get_sessions_for_peer( workspace_name: Name of the workspace peer_name: Name of the peer filters: Filter sessions by metadata + reverse: Whether to reverse the default creation order Returns: SQLAlchemy Select statement @@ -310,6 +318,9 @@ async def get_sessions_for_peer( stmt = apply_filter(stmt, models.Session, filters) - stmt: Select[tuple[models.Session]] = stmt.order_by(models.Session.created_at) + if reverse: + stmt = stmt.order_by(models.Session.created_at.desc(), models.Session.id.desc()) + else: + stmt = stmt.order_by(models.Session.created_at.asc(), models.Session.id.asc()) return stmt diff --git a/src/crud/session.py b/src/crud/session.py index 9580c16d..712188bc 100644 --- a/src/crud/session.py +++ b/src/crud/session.py @@ -1,3 +1,5 @@ +"""CRUD helpers for sessions and session-related relationship data.""" + from dataclasses import dataclass from logging import getLogger from typing import Any @@ -114,9 +116,18 @@ def count_observers_in_config( async def get_sessions( workspace_name: str, filters: dict[str, Any] | None = None, + reverse: bool = False, ) -> Select[tuple[models.Session]]: """ Get all active sessions in a workspace. + + Args: + workspace_name: Name of the workspace + filters: Optional filters to apply to the query + reverse: If True, order by created_at descending; if False, ascending + + Returns: + Select statement for Session objects """ stmt = ( select(models.Session) @@ -126,7 +137,9 @@ async def get_sessions( stmt = apply_filter(stmt, models.Session, filters) - return stmt.order_by(models.Session.created_at) + if reverse: + return stmt.order_by(models.Session.created_at.desc(), models.Session.id.desc()) + return stmt.order_by(models.Session.created_at.asc(), models.Session.id.asc()) async def get_or_create_session( diff --git a/src/crud/workspace.py b/src/crud/workspace.py index 3df2bb46..52662c79 100644 --- a/src/crud/workspace.py +++ b/src/crud/workspace.py @@ -1,3 +1,5 @@ +"""CRUD helpers for workspace records and workspace deletion checks.""" + from dataclasses import dataclass from logging import getLogger from typing import Any @@ -154,17 +156,25 @@ async def get_or_create_workspace( async def get_all_workspaces( filters: dict[str, Any] | None = None, + reverse: bool = False, ) -> Select[tuple[models.Workspace]]: """ Get all workspaces. Args: - db: Database session filters: Filter the workspaces by a dictionary of metadata + reverse: Whether to reverse the default creation order """ stmt = select(models.Workspace) stmt = apply_filter(stmt, models.Workspace, filters) - stmt: Select[tuple[models.Workspace]] = stmt.order_by(models.Workspace.created_at) + if reverse: + stmt = stmt.order_by( + models.Workspace.created_at.desc(), models.Workspace.id.desc() + ) + else: + stmt = stmt.order_by( + models.Workspace.created_at.asc(), models.Workspace.id.asc() + ) return stmt diff --git a/src/routers/peers.py b/src/routers/peers.py index 99014455..efa19a78 100644 --- a/src/routers/peers.py +++ b/src/routers/peers.py @@ -1,3 +1,5 @@ +"""FastAPI routes for peer resources and peer-scoped operations.""" + import json import logging from collections.abc import AsyncIterator @@ -40,6 +42,7 @@ async def get_peers( options: schemas.PeerGet | None = Body( None, description="Filtering options for the peers list" ), + reverse: bool = Query(False, description="Whether to reverse the order of results"), db: AsyncSession = db, ): """Get all Peers for a Workspace, paginated with optional filters.""" @@ -51,7 +54,11 @@ async def get_peers( return await apaginate( db, - await crud.get_peers(workspace_name=workspace_id, filters=filter_param), + await crud.get_peers( + workspace_name=workspace_id, + filters=filter_param, + reverse=reverse, + ), ) @@ -126,6 +133,7 @@ async def get_sessions_for_peer( options: schemas.SessionGet | None = Body( None, description="Filtering options for the sessions list" ), + reverse: bool = Query(False, description="Whether to reverse the order of results"), db: AsyncSession = db, ): """Get all Sessions for a Peer, paginated with optional filters.""" @@ -142,6 +150,7 @@ async def get_sessions_for_peer( workspace_name=workspace_id, peer_name=peer_id, filters=filter_param, + reverse=reverse, ), ) diff --git a/src/routers/sessions.py b/src/routers/sessions.py index 98f669df..98a8714a 100644 --- a/src/routers/sessions.py +++ b/src/routers/sessions.py @@ -1,3 +1,5 @@ +"""FastAPI routes for session resources and session-scoped operations.""" + import logging from contextlib import suppress from time import perf_counter @@ -248,6 +250,7 @@ async def get_sessions( options: schemas.SessionGet | None = Body( None, description="Filtering and pagination options for the sessions list" ), + reverse: bool = Query(False, description="Whether to reverse the order of results"), db: AsyncSession = db, ): """Get all Sessions for a Workspace, paginated with optional filters.""" @@ -259,7 +262,12 @@ async def get_sessions( filter_param = None return await apaginate( - db, await crud.get_sessions(workspace_name=workspace_id, filters=filter_param) + db, + await crud.get_sessions( + workspace_name=workspace_id, + filters=filter_param, + reverse=reverse, + ), ) diff --git a/src/routers/workspaces.py b/src/routers/workspaces.py index 6671afbc..a2298480 100644 --- a/src/routers/workspaces.py +++ b/src/routers/workspaces.py @@ -1,3 +1,5 @@ +"""FastAPI routes for workspace resources and workspace-scoped operations.""" + import logging from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Response @@ -64,6 +66,7 @@ async def get_all_workspaces( options: schemas.WorkspaceGet | None = Body( None, description="Filtering and pagination options for the workspaces list" ), + reverse: bool = Query(False, description="Whether to reverse the order of results"), db: AsyncSession = db, ): """Get all Workspaces, paginated with optional filters.""" @@ -75,7 +78,7 @@ async def get_all_workspaces( return await apaginate( db, - await crud.get_all_workspaces(filters=filter_param), + await crud.get_all_workspaces(filters=filter_param, reverse=reverse), ) diff --git a/tests/routes/test_peers.py b/tests/routes/test_peers.py index 9626ea10..dd00eff9 100644 --- a/tests/routes/test_peers.py +++ b/tests/routes/test_peers.py @@ -1,3 +1,4 @@ +import datetime from typing import Any import pytest @@ -5,7 +6,7 @@ from fastapi.testclient import TestClient from nanoid import generate as generate_nanoid from sqlalchemy.ext.asyncio import AsyncSession -from src import crud +from src import crud, models from src.models import Peer, Workspace @@ -169,6 +170,148 @@ def test_get_peers_with_null_filter( assert isinstance(data["items"], list) +def test_get_peers_with_reverse( + client: TestClient, sample_data: tuple[Workspace, Peer] +): + """Test peer listing with reverse creation-time ordering.""" + test_workspace, _ = sample_data + reverse_group = f"reverse-peers-{generate_nanoid()}" + first_name = f"reverse-peer-a-{generate_nanoid()}" + second_name = f"reverse-peer-b-{generate_nanoid()}" + + first_response = client.post( + f"/v3/workspaces/{test_workspace.name}/peers", + json={"name": first_name, "metadata": {"reverse_group": reverse_group}}, + ) + assert first_response.status_code in [200, 201] + + second_response = client.post( + f"/v3/workspaces/{test_workspace.name}/peers", + json={"name": second_name, "metadata": {"reverse_group": reverse_group}}, + ) + assert second_response.status_code in [200, 201] + + normal_response = client.post( + f"/v3/workspaces/{test_workspace.name}/peers/list", + json={"filters": {"metadata": {"reverse_group": reverse_group}}}, + ) + assert normal_response.status_code == 200 + + reverse_response = client.post( + f"/v3/workspaces/{test_workspace.name}/peers/list?reverse=true", + json={"filters": {"metadata": {"reverse_group": reverse_group}}}, + ) + assert reverse_response.status_code == 200 + + assert [item["id"] for item in normal_response.json()["items"]] == [ + first_name, + second_name, + ] + assert [item["id"] for item in reverse_response.json()["items"]] == [ + second_name, + first_name, + ] + + +@pytest.mark.asyncio +async def test_get_peers_reverse_uses_id_tiebreaker( + client: TestClient, + db_session: AsyncSession, + sample_data: tuple[Workspace, Peer], +): + """Peers with identical created_at fall back to ordering by id (nanoid PK).""" + test_workspace, _ = sample_data + reverse_group = f"tiebreaker-peers-{generate_nanoid()}" + shared_created_at = datetime.datetime( + 2026, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc + ) + + low_id = "A" * 21 + high_id = "z" * 21 + low_name = f"tie-low-peer-{generate_nanoid()}" + high_name = f"tie-high-peer-{generate_nanoid()}" + + db_session.add( + models.Peer( + id=low_id, + name=low_name, + workspace_name=test_workspace.name, + created_at=shared_created_at, + h_metadata={"reverse_group": reverse_group}, + ) + ) + db_session.add( + models.Peer( + id=high_id, + name=high_name, + workspace_name=test_workspace.name, + created_at=shared_created_at, + h_metadata={"reverse_group": reverse_group}, + ) + ) + await db_session.commit() + + normal_response = client.post( + f"/v3/workspaces/{test_workspace.name}/peers/list", + json={"filters": {"metadata": {"reverse_group": reverse_group}}}, + ) + assert normal_response.status_code == 200 + + reverse_response = client.post( + f"/v3/workspaces/{test_workspace.name}/peers/list?reverse=true", + json={"filters": {"metadata": {"reverse_group": reverse_group}}}, + ) + assert reverse_response.status_code == 200 + + # When created_at ties, ordering falls back to the nanoid id: low_id < high_id + # lexicographically, so low sorts first ascending and last descending. + assert [item["id"] for item in normal_response.json()["items"]] == [ + low_name, + high_name, + ] + assert [item["id"] for item in reverse_response.json()["items"]] == [ + high_name, + low_name, + ] + + +def test_get_peers_reverse_with_pagination( + client: TestClient, sample_data: tuple[Workspace, Peer] +): + """Paged reverse listing returns newest-first across consecutive pages.""" + test_workspace, _ = sample_data + reverse_group = f"paged-reverse-peers-{generate_nanoid()}" + peer_names = [f"paged-reverse-peer-{i}-{generate_nanoid()}" for i in range(3)] + + for peer_name in peer_names: + response = client.post( + f"/v3/workspaces/{test_workspace.name}/peers", + json={"name": peer_name, "metadata": {"reverse_group": reverse_group}}, + ) + assert response.status_code in [200, 201] + + page_one = client.post( + f"/v3/workspaces/{test_workspace.name}/peers/list?reverse=true&page=1&size=1", + json={"filters": {"metadata": {"reverse_group": reverse_group}}}, + ) + assert page_one.status_code == 200 + page_two = client.post( + f"/v3/workspaces/{test_workspace.name}/peers/list?reverse=true&page=2&size=1", + json={"filters": {"metadata": {"reverse_group": reverse_group}}}, + ) + assert page_two.status_code == 200 + page_three = client.post( + f"/v3/workspaces/{test_workspace.name}/peers/list?reverse=true&page=3&size=1", + json={"filters": {"metadata": {"reverse_group": reverse_group}}}, + ) + assert page_three.status_code == 200 + + assert page_one.json()["total"] == 3 + assert [item["id"] for item in page_one.json()["items"]] == [peer_names[2]] + assert [item["id"] for item in page_two.json()["items"]] == [peer_names[1]] + assert [item["id"] for item in page_three.json()["items"]] == [peer_names[0]] + + def test_update_peer(client: TestClient, sample_data: tuple[Workspace, Peer]): test_workspace, test_peer = sample_data response = client.put( @@ -308,6 +451,159 @@ def test_get_sessions_for_peer_with_empty_filter( assert isinstance(data["items"], list) +def test_get_sessions_for_peer_with_reverse( + client: TestClient, sample_data: tuple[Workspace, Peer] +): + """Test peer session listing with reverse creation-time ordering.""" + test_workspace, test_peer = sample_data + reverse_group = f"reverse-peer-sessions-{generate_nanoid()}" + first_session = f"reverse-peer-session-a-{generate_nanoid()}" + second_session = f"reverse-peer-session-b-{generate_nanoid()}" + + first_response = client.post( + f"/v3/workspaces/{test_workspace.name}/sessions", + json={ + "id": first_session, + "peer_names": {test_peer.name: {}}, + "metadata": {"reverse_group": reverse_group}, + }, + ) + assert first_response.status_code in [200, 201] + + second_response = client.post( + f"/v3/workspaces/{test_workspace.name}/sessions", + json={ + "id": second_session, + "peer_names": {test_peer.name: {}}, + "metadata": {"reverse_group": reverse_group}, + }, + ) + assert second_response.status_code in [200, 201] + + normal_response = client.post( + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/sessions", + json={"filters": {"metadata": {"reverse_group": reverse_group}}}, + ) + assert normal_response.status_code == 200 + + reverse_response = client.post( + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/sessions?reverse=true", + json={"filters": {"metadata": {"reverse_group": reverse_group}}}, + ) + assert reverse_response.status_code == 200 + + assert [item["id"] for item in normal_response.json()["items"]] == [ + first_session, + second_session, + ] + assert [item["id"] for item in reverse_response.json()["items"]] == [ + second_session, + first_session, + ] + + +@pytest.mark.asyncio +async def test_get_sessions_for_peer_reverse_uses_id_tiebreaker( + client: TestClient, + db_session: AsyncSession, + sample_data: tuple[Workspace, Peer], +): + """Peer-scoped sessions with identical created_at fall back to ordering by id.""" + test_workspace, test_peer = sample_data + reverse_group = f"tiebreaker-peer-sessions-{generate_nanoid()}" + shared_created_at = datetime.datetime( + 2026, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc + ) + + low_id = "A" * 21 + high_id = "z" * 21 + low_name = f"tie-low-peer-session-{generate_nanoid()}" + high_name = f"tie-high-peer-session-{generate_nanoid()}" + + for session_id, session_name in ((low_id, low_name), (high_id, high_name)): + db_session.add( + models.Session( + id=session_id, + name=session_name, + workspace_name=test_workspace.name, + created_at=shared_created_at, + h_metadata={"reverse_group": reverse_group}, + ) + ) + db_session.add( + models.SessionPeer( + workspace_name=test_workspace.name, + session_name=session_name, + peer_name=test_peer.name, + ) + ) + await db_session.commit() + + normal_response = client.post( + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/sessions", + json={"filters": {"metadata": {"reverse_group": reverse_group}}}, + ) + assert normal_response.status_code == 200 + + reverse_response = client.post( + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/sessions?reverse=true", + json={"filters": {"metadata": {"reverse_group": reverse_group}}}, + ) + assert reverse_response.status_code == 200 + + assert [item["id"] for item in normal_response.json()["items"]] == [ + low_name, + high_name, + ] + assert [item["id"] for item in reverse_response.json()["items"]] == [ + high_name, + low_name, + ] + + +def test_get_sessions_for_peer_reverse_with_pagination( + client: TestClient, sample_data: tuple[Workspace, Peer] +): + """Paged reverse listing of a peer's sessions returns newest-first across pages.""" + test_workspace, test_peer = sample_data + reverse_group = f"paged-reverse-peer-sessions-{generate_nanoid()}" + session_names = [ + f"paged-reverse-peer-session-{i}-{generate_nanoid()}" for i in range(3) + ] + + for session_name in session_names: + response = client.post( + f"/v3/workspaces/{test_workspace.name}/sessions", + json={ + "id": session_name, + "peer_names": {test_peer.name: {}}, + "metadata": {"reverse_group": reverse_group}, + }, + ) + assert response.status_code in [200, 201] + + page_one = client.post( + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/sessions?reverse=true&page=1&size=1", + json={"filters": {"metadata": {"reverse_group": reverse_group}}}, + ) + assert page_one.status_code == 200 + page_two = client.post( + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/sessions?reverse=true&page=2&size=1", + json={"filters": {"metadata": {"reverse_group": reverse_group}}}, + ) + assert page_two.status_code == 200 + page_three = client.post( + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/sessions?reverse=true&page=3&size=1", + json={"filters": {"metadata": {"reverse_group": reverse_group}}}, + ) + assert page_three.status_code == 200 + + assert page_one.json()["total"] == 3 + assert [item["id"] for item in page_one.json()["items"]] == [session_names[2]] + assert [item["id"] for item in page_two.json()["items"]] == [session_names[1]] + assert [item["id"] for item in page_three.json()["items"]] == [session_names[0]] + + def test_chat( client: TestClient, sample_data: tuple[Workspace, Peer], diff --git a/tests/routes/test_sessions.py b/tests/routes/test_sessions.py index c7025253..181336e1 100644 --- a/tests/routes/test_sessions.py +++ b/tests/routes/test_sessions.py @@ -1,8 +1,12 @@ +import datetime from typing import Any +import pytest from fastapi.testclient import TestClient from nanoid import generate as generate_nanoid +from sqlalchemy.ext.asyncio import AsyncSession +from src import models from src.models import Peer, Workspace @@ -223,6 +227,160 @@ def test_get_sessions_with_empty_filter( assert isinstance(data["items"], list) +def test_get_sessions_with_reverse( + client: TestClient, sample_data: tuple[Workspace, Peer] +): + """Test session listing with reverse creation-time ordering.""" + test_workspace, test_peer = sample_data + reverse_group = f"reverse-sessions-{generate_nanoid()}" + first_session = f"reverse-session-a-{generate_nanoid()}" + second_session = f"reverse-session-b-{generate_nanoid()}" + + first_response = client.post( + f"/v3/workspaces/{test_workspace.name}/sessions", + json={ + "id": first_session, + "peer_names": {test_peer.name: {}}, + "metadata": {"reverse_group": reverse_group}, + }, + ) + assert first_response.status_code in [200, 201] + + second_response = client.post( + f"/v3/workspaces/{test_workspace.name}/sessions", + json={ + "id": second_session, + "peer_names": {test_peer.name: {}}, + "metadata": {"reverse_group": reverse_group}, + }, + ) + assert second_response.status_code in [200, 201] + + normal_response = client.post( + f"/v3/workspaces/{test_workspace.name}/sessions/list", + json={"filters": {"metadata": {"reverse_group": reverse_group}}}, + ) + assert normal_response.status_code == 200 + + reverse_response = client.post( + f"/v3/workspaces/{test_workspace.name}/sessions/list?reverse=true", + json={"filters": {"metadata": {"reverse_group": reverse_group}}}, + ) + assert reverse_response.status_code == 200 + + assert [item["id"] for item in normal_response.json()["items"]] == [ + first_session, + second_session, + ] + assert [item["id"] for item in reverse_response.json()["items"]] == [ + second_session, + first_session, + ] + + +@pytest.mark.asyncio +async def test_get_sessions_reverse_uses_id_tiebreaker( + client: TestClient, + db_session: AsyncSession, + sample_data: tuple[Workspace, Peer], +): + """Sessions with identical created_at fall back to ordering by id (nanoid PK).""" + test_workspace, _ = sample_data + reverse_group = f"tiebreaker-sessions-{generate_nanoid()}" + shared_created_at = datetime.datetime( + 2026, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc + ) + + low_id = "A" * 21 + high_id = "z" * 21 + low_name = f"tie-low-{generate_nanoid()}" + high_name = f"tie-high-{generate_nanoid()}" + + db_session.add( + models.Session( + id=low_id, + name=low_name, + workspace_name=test_workspace.name, + created_at=shared_created_at, + h_metadata={"reverse_group": reverse_group}, + ) + ) + db_session.add( + models.Session( + id=high_id, + name=high_name, + workspace_name=test_workspace.name, + created_at=shared_created_at, + h_metadata={"reverse_group": reverse_group}, + ) + ) + await db_session.commit() + + normal_response = client.post( + f"/v3/workspaces/{test_workspace.name}/sessions/list", + json={"filters": {"metadata": {"reverse_group": reverse_group}}}, + ) + assert normal_response.status_code == 200 + + reverse_response = client.post( + f"/v3/workspaces/{test_workspace.name}/sessions/list?reverse=true", + json={"filters": {"metadata": {"reverse_group": reverse_group}}}, + ) + assert reverse_response.status_code == 200 + + # When created_at ties, ordering falls back to the nanoid id: low_id < high_id + # lexicographically, so low sorts first ascending and last descending. + assert [item["id"] for item in normal_response.json()["items"]] == [ + low_name, + high_name, + ] + assert [item["id"] for item in reverse_response.json()["items"]] == [ + high_name, + low_name, + ] + + +def test_get_sessions_reverse_with_pagination( + client: TestClient, sample_data: tuple[Workspace, Peer] +): + """Paged reverse listing returns newest-first across consecutive pages.""" + test_workspace, test_peer = sample_data + reverse_group = f"paged-reverse-sessions-{generate_nanoid()}" + session_names = [f"paged-reverse-session-{i}-{generate_nanoid()}" for i in range(3)] + + for session_name in session_names: + response = client.post( + f"/v3/workspaces/{test_workspace.name}/sessions", + json={ + "id": session_name, + "peer_names": {test_peer.name: {}}, + "metadata": {"reverse_group": reverse_group}, + }, + ) + assert response.status_code in [200, 201] + + page_one = client.post( + f"/v3/workspaces/{test_workspace.name}/sessions/list?reverse=true&page=1&size=1", + json={"filters": {"metadata": {"reverse_group": reverse_group}}}, + ) + assert page_one.status_code == 200 + page_two = client.post( + f"/v3/workspaces/{test_workspace.name}/sessions/list?reverse=true&page=2&size=1", + json={"filters": {"metadata": {"reverse_group": reverse_group}}}, + ) + assert page_two.status_code == 200 + page_three = client.post( + f"/v3/workspaces/{test_workspace.name}/sessions/list?reverse=true&page=3&size=1", + json={"filters": {"metadata": {"reverse_group": reverse_group}}}, + ) + assert page_three.status_code == 200 + + assert page_one.json()["total"] == 3 + assert [item["id"] for item in page_one.json()["items"]] == [session_names[2]] + assert [item["id"] for item in page_two.json()["items"]] == [session_names[1]] + assert [item["id"] for item in page_three.json()["items"]] == [session_names[0]] + + def test_update_delete_metadata( client: TestClient, sample_data: tuple[Workspace, Peer] ): diff --git a/tests/routes/test_workspaces.py b/tests/routes/test_workspaces.py index 031e5a90..0350b729 100644 --- a/tests/routes/test_workspaces.py +++ b/tests/routes/test_workspaces.py @@ -1,3 +1,4 @@ +import datetime from typing import Any from unittest.mock import AsyncMock, patch @@ -125,6 +126,135 @@ async def test_get_all_workspaces_with_null_filter(client: TestClient): assert isinstance(data["items"], list) +@pytest.mark.asyncio +async def test_get_all_workspaces_with_reverse(client: TestClient): + """Test workspace listing with reverse creation-time ordering.""" + first_name = f"reverse-workspace-{generate_nanoid()}" + second_name = f"reverse-workspace-{generate_nanoid()}" + + first_response = client.post( + "/v3/workspaces", + json={"name": first_name, "metadata": {"reverse_group": first_name}}, + ) + assert first_response.status_code in [200, 201] + + second_response = client.post( + "/v3/workspaces", + json={"name": second_name, "metadata": {"reverse_group": first_name}}, + ) + assert second_response.status_code in [200, 201] + + normal_response = client.post( + "/v3/workspaces/list", + json={"filters": {"metadata": {"reverse_group": first_name}}}, + ) + assert normal_response.status_code == 200 + + reverse_response = client.post( + "/v3/workspaces/list?reverse=true", + json={"filters": {"metadata": {"reverse_group": first_name}}}, + ) + assert reverse_response.status_code == 200 + + assert [item["id"] for item in normal_response.json()["items"]] == [ + first_name, + second_name, + ] + assert [item["id"] for item in reverse_response.json()["items"]] == [ + second_name, + first_name, + ] + + +@pytest.mark.asyncio +async def test_get_all_workspaces_reverse_uses_id_tiebreaker( + client: TestClient, db_session: AsyncSession +): + """Workspaces with identical created_at fall back to ordering by id (nanoid PK).""" + reverse_group = f"tiebreaker-{generate_nanoid()}" + shared_created_at = datetime.datetime( + 2026, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc + ) + + low_id = "A" * 21 + high_id = "z" * 21 + low_name = f"tie-low-{generate_nanoid()}" + high_name = f"tie-high-{generate_nanoid()}" + + db_session.add( + models.Workspace( + id=low_id, + name=low_name, + created_at=shared_created_at, + h_metadata={"reverse_group": reverse_group}, + ) + ) + db_session.add( + models.Workspace( + id=high_id, + name=high_name, + created_at=shared_created_at, + h_metadata={"reverse_group": reverse_group}, + ) + ) + await db_session.commit() + + normal_response = client.post( + "/v3/workspaces/list", + json={"filters": {"metadata": {"reverse_group": reverse_group}}}, + ) + assert normal_response.status_code == 200 + + reverse_response = client.post( + "/v3/workspaces/list?reverse=true", + json={"filters": {"metadata": {"reverse_group": reverse_group}}}, + ) + assert reverse_response.status_code == 200 + + normal_items = [item["id"] for item in normal_response.json()["items"]] + reverse_items = [item["id"] for item in reverse_response.json()["items"]] + + # When created_at ties, ordering falls back to the nanoid id: low_id < high_id + # lexicographically, so the workspace with id="AAA..." sorts first ascending. + assert normal_items == [low_name, high_name] + assert reverse_items == [high_name, low_name] + + +@pytest.mark.asyncio +async def test_get_all_workspaces_reverse_with_pagination(client: TestClient): + """Paged reverse listing returns newest-first across consecutive pages.""" + reverse_group = f"paged-reverse-{generate_nanoid()}" + names = [f"paged-reverse-{i}-{generate_nanoid()}" for i in range(3)] + + for name in names: + response = client.post( + "/v3/workspaces", + json={"name": name, "metadata": {"reverse_group": reverse_group}}, + ) + assert response.status_code in [200, 201] + + page_one = client.post( + "/v3/workspaces/list?reverse=true&page=1&size=1", + json={"filters": {"metadata": {"reverse_group": reverse_group}}}, + ) + assert page_one.status_code == 200 + page_two = client.post( + "/v3/workspaces/list?reverse=true&page=2&size=1", + json={"filters": {"metadata": {"reverse_group": reverse_group}}}, + ) + assert page_two.status_code == 200 + page_three = client.post( + "/v3/workspaces/list?reverse=true&page=3&size=1", + json={"filters": {"metadata": {"reverse_group": reverse_group}}}, + ) + assert page_three.status_code == 200 + + assert page_one.json()["total"] == 3 + assert [item["id"] for item in page_one.json()["items"]] == [names[2]] + assert [item["id"] for item in page_two.json()["items"]] == [names[1]] + assert [item["id"] for item in page_three.json()["items"]] == [names[0]] + + def test_update_workspace(client: TestClient, sample_data: tuple[Workspace, Peer]): test_workspace, _ = sample_data _new_name = str(generate_nanoid())