feat(api): restore reverse pagination (#685)

* feat(api): restore v3 reverse pagination

* docs: add reverse pagination docstrings

* docs: document session reverse parameter

* fix: add fallback column for ties

* refactor: tighten reverse query typing

* chore: pre-commit styling

* chore(tests): Add additional validation tests and update changelogs

---------

Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com>
This commit is contained in:
adavyas 2026-05-21 10:40:47 -07:00 committed by GitHub
parent 4f579d5c66
commit 0cf63c10da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 815 additions and 17 deletions

View File

@ -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

View File

@ -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",

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -232,6 +232,7 @@ export class Honcho {
filters?: Record<string, unknown>
page?: number
size?: number
reverse?: boolean
}): Promise<PageResponse<WorkspaceResponse>> {
return this._http.post<PageResponse<WorkspaceResponse>>(
`/${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<Page<string, WorkspaceResponse>> {
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,
})
}

View File

@ -35,6 +35,7 @@ export interface WorkspaceListParams {
filters?: Record<string, unknown>
page?: number
size?: number
reverse?: boolean
}
// =============================================================================
@ -64,6 +65,7 @@ export interface PeerListParams {
filters?: Record<string, unknown>
page?: number
size?: number
reverse?: boolean
}
export interface PeerChatParams {
@ -141,6 +143,7 @@ export interface SessionListParams {
filters?: Record<string, unknown>
page?: number
size?: number
reverse?: boolean
}
export interface SessionCloneParams {
@ -226,6 +229,7 @@ export interface MessageListParams {
filters?: Record<string, unknown>
page?: number
size?: number
reverse?: boolean
}
export interface MessageSearchParams {
@ -262,6 +266,7 @@ export interface ConclusionListParams {
filters?: Record<string, unknown>
page?: number
size?: number
reverse?: boolean
}
export interface ConclusionQueryParams {

View File

@ -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

View File

@ -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(

View File

@ -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

View File

@ -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,
),
)

View File

@ -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,
),
)

View File

@ -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),
)

View File

@ -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],

View File

@ -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]
):

View File

@ -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())