From 12e2ed5e4e2c3c2a147fa116bfeb783adda01cb9 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Thu, 13 Aug 2026 14:21:29 +0800 Subject: [PATCH] emrg: list_history pagination limit/offset/has_more (rant 2026-08-13T14:15:12) --- Agent.md | 2 +- emrg/server/daemon.py | 13 +++++ tests/test_ws_e2e.py | 124 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 1 deletion(-) diff --git a/Agent.md b/Agent.md index c67ce4e..6eb1abc 100644 --- a/Agent.md +++ b/Agent.md @@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` -Python: `uv run pytest tests/ -v` (764) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (768) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (231: 44 daemon_client + 19 conn-manager + 22 app-commands + 109 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index 5e41c28..e26ba42 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -1747,10 +1747,23 @@ async def _process_message( "preview": preview, "timestamp": r.get("timestamp", ""), }) + # Optional pagination (rant 2026-08-13T14:15:12): limit/offset + # count from the NEWEST message backwards (offset=0 = latest). + # Absent limit = full list (backward compatible, used by /rewind). + limit = msg.get("limit") + offset = msg.get("offset", 0) + has_more = False + if limit is not None: + total = len(user_messages) + end = max(0, total - offset) + start = max(0, end - limit) + has_more = start > 0 + user_messages = user_messages[start:end] await self._send(ws, { "type": "history_list", "session_id": session_id, "messages": user_messages, + "has_more": has_more, }) elif msg_type == "rewind_session": diff --git a/tests/test_ws_e2e.py b/tests/test_ws_e2e.py index 31e3a54..7035f6f 100644 --- a/tests/test_ws_e2e.py +++ b/tests/test_ws_e2e.py @@ -1127,3 +1127,127 @@ async def _test(): finally: await cleanup() asyncio.run(_test()) + + +class TestWSHistoryPagination: + """list_history pagination (rant 2026-08-13T14:15:12). + + limit/offset count from the NEWEST message backwards (offset=0 = latest); + absent limit keeps the full list (backward compatible, used by /rewind); + response includes has_more. + """ + + @staticmethod + async def _cmd(ws, payload): + await ws.send(json.dumps(payload)) + return json.loads(await asyncio.wait_for(ws.recv(), timeout=5)) + + @staticmethod + def _seed_history(cwd: Path, sid: str, n: int) -> None: + from emrg.session import Session + sess = Session(sid, cwd) + records = [] + for i in range(n): + records.append({ + "type": "message", + "role": "user", + "content": f"msg-{i:02d}", + "timestamp": f"2026-08-13T{i:02d}:00:00", + }) + sess._write_history(records) + + def test_full_list_without_limit(self): + """Absent limit → full list (backward compatible).""" + async def _test(): + with tempfile.TemporaryDirectory() as tmp: + cwd = Path(tmp) + sid = "s_hist" + self._seed_history(cwd, sid, 3) + _, _, cleanup = await _boot_server(cwd) + try: + ws = await connect_to_server() + try: + resp = await self._cmd(ws, { + "type": "list_history", "session_id": sid, "cwd": str(cwd), + }) + assert resp["type"] == "history_list" + msgs = resp["messages"] + assert [m["content"] for m in msgs] == ["msg-00", "msg-01", "msg-02"] + # has_more present (False for full list), no error + assert resp.get("has_more") is False + finally: + await ws.close() + finally: + await cleanup() + asyncio.run(_test()) + + def test_limit_returns_newest(self): + """limit=2 → newest 2 messages in time order.""" + async def _test(): + with tempfile.TemporaryDirectory() as tmp: + cwd = Path(tmp) + sid = "s_hist" + self._seed_history(cwd, sid, 5) + _, _, cleanup = await _boot_server(cwd) + try: + ws = await connect_to_server() + try: + resp = await self._cmd(ws, { + "type": "list_history", "session_id": sid, + "cwd": str(cwd), "limit": 2, + }) + msgs = resp["messages"] + assert [m["content"] for m in msgs] == ["msg-03", "msg-04"] + assert resp.get("has_more") is True + finally: + await ws.close() + finally: + await cleanup() + asyncio.run(_test()) + + def test_offset_pages_older(self): + """limit=2 offset=2 → the 2 messages before the newest 2.""" + async def _test(): + with tempfile.TemporaryDirectory() as tmp: + cwd = Path(tmp) + sid = "s_hist" + self._seed_history(cwd, sid, 5) + _, _, cleanup = await _boot_server(cwd) + try: + ws = await connect_to_server() + try: + resp = await self._cmd(ws, { + "type": "list_history", "session_id": sid, + "cwd": str(cwd), "limit": 2, "offset": 2, + }) + msgs = resp["messages"] + assert [m["content"] for m in msgs] == ["msg-01", "msg-02"] + assert resp.get("has_more") is True + finally: + await ws.close() + finally: + await cleanup() + asyncio.run(_test()) + + def test_offset_beyond_all_has_more_false(self): + """offset beyond available messages → empty + has_more False.""" + async def _test(): + with tempfile.TemporaryDirectory() as tmp: + cwd = Path(tmp) + sid = "s_hist" + self._seed_history(cwd, sid, 3) + _, _, cleanup = await _boot_server(cwd) + try: + ws = await connect_to_server() + try: + resp = await self._cmd(ws, { + "type": "list_history", "session_id": sid, + "cwd": str(cwd), "limit": 10, "offset": 5, + }) + assert resp["messages"] == [] + assert resp.get("has_more") is False + finally: + await ws.close() + finally: + await cleanup() + asyncio.run(_test())