From a44fd4cf3a0c3d7ae33b7664341a0a9bff07aaaa Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Wed, 1 Jul 2026 00:11:08 +0900 Subject: 大規模履歴向けread_messages pagination対応 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/CMakeLists.txt | 1 + tests/function-coverage.md | 1 + tests/mcp_protocol_test.py | 114 +++++++++++++++++++++++++++++++++++++++++++++ tests/storage_test.c | 28 +++++++++++ 4 files changed, 144 insertions(+) (limited to 'tests') diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9a02ee0..0bc5d89 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -94,6 +94,7 @@ add_executable(tiny-convmem-storage-test "${PROJECT_SOURCE_DIR}/src/domain/message.c" "${PROJECT_SOURCE_DIR}/src/storage/codec.c" "${PROJECT_SOURCE_DIR}/src/storage/store.c" + "${PROJECT_SOURCE_DIR}/src/mcp/tools/common/history.c" ) target_include_directories(tiny-convmem-storage-test PRIVATE "${PROJECT_SOURCE_DIR}/src") target_compile_features(tiny-convmem-storage-test PRIVATE c_std_17) diff --git a/tests/function-coverage.md b/tests/function-coverage.md index 8787918..8976580 100644 --- a/tests/function-coverage.md +++ b/tests/function-coverage.md @@ -11,6 +11,7 @@ byte/encoding logic is exposed through internal headers and tested directly. | `sys/system.c` | test-hook helpers; UTF adapters; `native_file`; argument, path, directory, file, clock and random APIs | `boundaries`, `storage`, `smoke`: NULL/invalid handle, Unicode paths, short I/O, operation failure, cleanup | | `domain/message.c` | `duplicate_string`, `copy_failed`, all `tiny_message_*`, role/tier parse/name, `tiny_utf8_valid`, `tiny_message_id_valid` | `domain`: enum failure, UTF-8 invalidity, ULID alphabet/length, deep copy and ownership | | `domain/search.c` | Unicode decode/score、open-addressing lookup/rehash、transactional delta merge、candidate union | `domain`, `search-index`: ASCII/日本語、8-bit hash衝突、複数rehash、allocation failure全位置、leak balance | +| `mcp/tools/common/history.c` | session hash map、安定順序vector、startup/write/rebuild lifecycle | `storage`, `protocol`: allocation failure掃引、100/105件pagination、restart/rebuild cursor継続 | | `domain/search.c` | `decode_codepoints`, `gram_hash`, `find_bucket`, `add_gram`, `index_text`, all `tiny_search_*` | `domain`: ASCII case folding, Unicode grams/tags, candidates, scoring and ownership | | `storage/store.c` | error/I/O helpers, ULID, entry growth/search, record codec, replay/snapshot/recovery, all `tiny_store_*` | `storage`, `lifecycle`: lock, append/reopen, corruption, rebuild rollback, Unicode path | | `mcp/json.c` | `reserve`, all `tiny_json_*` | `boundaries`: empty append, growth, control escaping, signed/unsigned integer limits, OOM | diff --git a/tests/mcp_protocol_test.py b/tests/mcp_protocol_test.py index f739168..4d1deb2 100644 --- a/tests/mcp_protocol_test.py +++ b/tests/mcp_protocol_test.py @@ -15,6 +15,7 @@ from typing import Any class Server: def __init__(self, executable: Path, data_dir: Path) -> None: self.stderr: list[str] = [] + self.last_response_size = 0 self.process = subprocess.Popen( [str(executable), "--data-dir", str(data_dir)], stdin=subprocess.PIPE, @@ -41,6 +42,7 @@ class Server: raise AssertionError( f"server closed: returncode={self.process.poll()} stderr={''.join(self.stderr)[-4000:]}" ) + self.last_response_size = len(response_line.encode("utf-8")) response = json.loads(response_line) assert response.get("jsonrpc") == "2.0", response return response @@ -132,6 +134,8 @@ def main() -> None: "content", ] assert tools["read_messages"]["inputSchema"]["required"] == ["session_id"] + assert tools["read_messages"]["inputSchema"]["properties"]["limit"]["type"] == "integer" + assert tools["read_messages"]["inputSchema"]["properties"]["cursor"]["type"] == "string" assert tools["search_messages"]["inputSchema"]["required"] == ["query"] assert tools["search_messages"]["inputSchema"]["properties"]["limit"]["type"] == "integer" assert tools["delete_message"]["inputSchema"]["required"] == ["message_id"] @@ -165,6 +169,89 @@ def main() -> None: next_id += 1 assert len(history) == 20 + paged_ids: list[str] = [] + for index in range(105): + payload = tool_payload( + tool_call( + server, + next_id, + "write_message", + {"session_id": "paged", "role": "user", "content": f"page item {index}"}, + ) + ) + next_id += 1 + paged_ids.append(payload["message"]["message_id"]) + first_page = tool_payload( + tool_call(server, next_id, "read_messages", {"session_id": "paged"}) + ) + next_id += 1 + assert len(first_page["history"]) == 100 and first_page.get("nextCursor") + second_page = tool_payload( + tool_call( + server, + next_id, + "read_messages", + {"session_id": "paged", "cursor": first_page["nextCursor"]}, + ) + ) + next_id += 1 + assert len(second_page["history"]) == 5 and "nextCursor" not in second_page + assert [item["message_id"] for item in first_page["history"] + second_page["history"]] == paged_ids + for invalid_limit in (0, 1001): + assert tool_payload( + tool_call( + server, + next_id, + "read_messages", + {"session_id": "paged", "limit": invalid_limit}, + ), + True, + )["status"] == "ERROR" + next_id += 1 + assert tool_payload( + tool_call( + server, + next_id, + "read_messages", + {"session_id": "other", "cursor": first_page["nextCursor"]}, + ), + True, + )["status"] == "ERROR" + next_id += 1 + + large_content = "\\" * (512 * 1024) + for index in range(6): + tool_payload( + tool_call( + server, + next_id, + "write_message", + {"session_id": "large-page", "role": "user", "content": large_content}, + ) + ) + next_id += 1 + large_page = tool_payload( + tool_call( + server, + next_id, + "read_messages", + {"session_id": "large-page", "limit": 100}, + ) + ) + next_id += 1 + assert 0 < len(large_page["history"]) < 6 and large_page.get("nextCursor") + assert server.last_response_size <= 8 * 1024 * 1024 + assert tool_payload( + tool_call( + server, + next_id, + "read_messages", + {"session_id": "paged", "cursor": "broken"}, + ), + True, + )["status"] == "ERROR" + next_id += 1 + ranked = tool_payload( tool_call( server, @@ -265,6 +352,33 @@ def main() -> None: raise else: server.close() + restarted = Server(args.server.resolve(), data_dir) + try: + restarted.send( + request( + 1, + "initialize", + { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "pagination-restart", "version": "1"}, + }, + ) + ) + restarted.notify( + {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}} + ) + resumed = tool_payload( + tool_call( + restarted, + 2, + "read_messages", + {"session_id": "paged", "cursor": first_page["nextCursor"]}, + ) + ) + assert [item["message_id"] for item in resumed["history"]] == paged_ids[100:] + finally: + restarted.close() finally: shutil.rmtree(data_dir, ignore_errors=True) diff --git a/tests/storage_test.c b/tests/storage_test.c index 3217095..0e93ad9 100644 --- a/tests/storage_test.c +++ b/tests/storage_test.c @@ -1,10 +1,13 @@ #include "storage/store.h" #include "sys/system.h" +#include "mcp/tools/common/history.h" #include #include #include +#include "base/allocator.h" + static int failures; #define CHECK(value) \ do \ @@ -103,6 +106,31 @@ main(void) CHECK(tiny_store_open(&store, directory) == TINY_STORE_OK); CHECK(tiny_store_entry_count(store) == 2); + { + size_t failure_index; + bool built = false; + for (failure_index = 0; failure_index < 64; ++failure_index) + { + tiny_history_index_t *history; + size_t baseline = tiny_allocator_outstanding(); + tiny_allocator_fail_after(failure_index); + history = tiny_history_index_build(store); + tiny_allocator_reset(); + if (history) + { + const size_t *entries = NULL; + size_t entry_count = 0; + CHECK(tiny_history_index_session(history, "unit", &entries, &entry_count)); + CHECK(entry_count == 2 && entries[0] == 0 && entries[1] == 1); + tiny_history_index_free(history); + CHECK(tiny_allocator_outstanding() == baseline); + built = true; + break; + } + CHECK(tiny_allocator_outstanding() == baseline); + } + CHECK(built); + } for (operation = 0; operation < sizeof(rebuild_failures) / sizeof(rebuild_failures[0]); ++operation) { -- cgit v1.2.3