From 597fb0e5f9233c41e8ac23d8ba9997129439550e Mon Sep 17 00:00:00 2001 From: rayhpeng Date: Sun, 12 Apr 2026 16:14:14 +0800 Subject: [PATCH] feat(api): retrofit cursor pagination onto GET /threads/{tid}/runs/{rid}/messages Replace bare list[dict] response with {data: [...], has_more: bool} envelope, forwarding limit/before_seq/after_seq query params to the event store. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/gateway/routers/thread_runs.py | 24 +++- .../test_thread_run_messages_pagination.py | 128 ++++++++++++++++++ 2 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 backend/tests/test_thread_run_messages_pagination.py diff --git a/backend/app/gateway/routers/thread_runs.py b/backend/app/gateway/routers/thread_runs.py index e414801ee..e21375ab9 100644 --- a/backend/app/gateway/routers/thread_runs.py +++ b/backend/app/gateway/routers/thread_runs.py @@ -325,10 +325,28 @@ async def list_thread_messages( @router.get("/{thread_id}/runs/{run_id}/messages") @require_permission("runs", "read", owner_check=True) -async def list_run_messages(thread_id: str, run_id: str, request: Request) -> list[dict]: - """Return displayable messages for a specific run.""" +async def list_run_messages( + thread_id: str, + run_id: str, + request: Request, + limit: int = Query(default=50, le=200, ge=1), + before_seq: int | None = Query(default=None), + after_seq: int | None = Query(default=None), +) -> dict: + """Return paginated messages for a specific run. + + Response: { data: [...], has_more: bool } + """ event_store = get_run_event_store(request) - return await event_store.list_messages_by_run(thread_id, run_id) + rows = await event_store.list_messages_by_run( + thread_id, run_id, + limit=limit + 1, + before_seq=before_seq, + after_seq=after_seq, + ) + has_more = len(rows) > limit + data = rows[:limit] if has_more else rows + return {"data": data, "has_more": has_more} @router.get("/{thread_id}/runs/{run_id}/events") diff --git a/backend/tests/test_thread_run_messages_pagination.py b/backend/tests/test_thread_run_messages_pagination.py new file mode 100644 index 000000000..f00100cad --- /dev/null +++ b/backend/tests/test_thread_run_messages_pagination.py @@ -0,0 +1,128 @@ +"""Tests for paginated GET /api/threads/{thread_id}/runs/{run_id}/messages endpoint.""" +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from _router_auth_helpers import make_authed_test_app +from fastapi.testclient import TestClient + +from app.gateway.routers import thread_runs + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_app(event_store=None): + """Build a test FastAPI app with stub auth and mocked state.""" + app = make_authed_test_app() + app.include_router(thread_runs.router) + + if event_store is not None: + app.state.run_event_store = event_store + + return app + + +def _make_event_store(rows: list[dict]): + """Return an AsyncMock event store whose list_messages_by_run() returns rows.""" + store = MagicMock() + store.list_messages_by_run = AsyncMock(return_value=rows) + return store + + +def _make_message(seq: int) -> dict: + return {"seq": seq, "event_type": "ai_message", "category": "message", "content": f"msg-{seq}"} + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_returns_paginated_envelope(): + """GET /api/threads/{tid}/runs/{rid}/messages returns {data: [...], has_more: bool}.""" + rows = [_make_message(i) for i in range(1, 4)] + app = _make_app(event_store=_make_event_store(rows)) + with TestClient(app) as client: + response = client.get("/api/threads/thread-1/runs/run-1/messages") + assert response.status_code == 200 + body = response.json() + assert "data" in body + assert "has_more" in body + assert body["has_more"] is False + assert len(body["data"]) == 3 + + +def test_has_more_true_when_extra_row_returned(): + """has_more=True when event store returns limit+1 rows.""" + # Default limit is 50; provide 51 rows + rows = [_make_message(i) for i in range(1, 52)] # 51 rows + app = _make_app(event_store=_make_event_store(rows)) + with TestClient(app) as client: + response = client.get("/api/threads/thread-2/runs/run-2/messages") + assert response.status_code == 200 + body = response.json() + assert body["has_more"] is True + assert len(body["data"]) == 50 # trimmed to limit + + +def test_after_seq_forwarded_to_event_store(): + """after_seq query param is forwarded to event_store.list_messages_by_run.""" + rows = [_make_message(10)] + event_store = _make_event_store(rows) + app = _make_app(event_store=event_store) + with TestClient(app) as client: + response = client.get("/api/threads/thread-3/runs/run-3/messages?after_seq=5") + assert response.status_code == 200 + event_store.list_messages_by_run.assert_awaited_once_with( + "thread-3", "run-3", + limit=51, # default limit(50) + 1 + before_seq=None, + after_seq=5, + ) + + +def test_before_seq_forwarded_to_event_store(): + """before_seq query param is forwarded to event_store.list_messages_by_run.""" + rows = [_make_message(3)] + event_store = _make_event_store(rows) + app = _make_app(event_store=event_store) + with TestClient(app) as client: + response = client.get("/api/threads/thread-4/runs/run-4/messages?before_seq=10") + assert response.status_code == 200 + event_store.list_messages_by_run.assert_awaited_once_with( + "thread-4", "run-4", + limit=51, + before_seq=10, + after_seq=None, + ) + + +def test_custom_limit_forwarded_to_event_store(): + """Custom limit is forwarded as limit+1 to the event store.""" + rows = [_make_message(i) for i in range(1, 6)] + event_store = _make_event_store(rows) + app = _make_app(event_store=event_store) + with TestClient(app) as client: + response = client.get("/api/threads/thread-5/runs/run-5/messages?limit=10") + assert response.status_code == 200 + event_store.list_messages_by_run.assert_awaited_once_with( + "thread-5", "run-5", + limit=11, # 10 + 1 + before_seq=None, + after_seq=None, + ) + + +def test_empty_data_when_no_messages(): + """Returns empty data list with has_more=False when no messages exist.""" + app = _make_app(event_store=_make_event_store([])) + with TestClient(app) as client: + response = client.get("/api/threads/thread-6/runs/run-6/messages") + assert response.status_code == 200 + body = response.json() + assert body["data"] == [] + assert body["has_more"] is False