From 5b063df8868ef8cdc2a6aab7a670f911bc0ea9a4 Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Sat, 29 Aug 2026 09:15:24 +0900 Subject: feat: read_messagesに本文範囲取得viewを追加 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 大きなメッセージをMCPホストのtool result上限に触れず取得できるよう、read_messagesへcontent viewを追加する。UTF-8 byte単位のcontent_offset/content_limitとnext_content_offsetにより、文字境界を壊さず反復取得できる。 各chunkにrevisionとtotal_bytesを返し、expected_revisionで取得途中の更新をCONFLICTとして検出する。未知または削除済みmessageはNOT_FOUND、不正な範囲はVALIDATION_ERRORとする。読み取りには既存memory indexを利用し、永続data、revision、I/O counterを変更しない。 新規MCP toolは増やさず、従来の7 tool構成とhistory/context/sessionsの契約を維持する。storage recordとsnapshot形式も変更しないためmigrationとrebuildは不要。 README、設計書、MCPリファレンスを更新し、多言語UTF-8本文の完全再構成、境界・limit・revision・削除済みmessage、read-only I/O、既存view回帰をprotocol testで検証する。 Tests: ctest --test-dir build/range-read-test -C Release --output-on-failure (20/20 passed) --- tests/function-coverage.md | 2 +- tests/mcp_protocol_test.py | 228 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 226 insertions(+), 4 deletions(-) (limited to 'tests') diff --git a/tests/function-coverage.md b/tests/function-coverage.md index 8976580..9f5808d 100644 --- a/tests/function-coverage.md +++ b/tests/function-coverage.md @@ -15,5 +15,5 @@ byte/encoding logic is exposed through internal headers and tested directly. | `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 | -| `mcp/server.c` | line/JSON/parser/result/search helpers, `handle_call`, `tiny_convmem_server_run` | `smoke`, `lifecycle`: initialize, list/call, malformed/EOF behavior, persistence | +| `mcp/server.c`, `mcp/tools/read_messages.c` | line/JSON/parser/result/search helpers, range read, `handle_call`, `tiny_convmem_server_run` | `smoke`, `lifecycle`, `protocol`: initialize, list/call, malformed/EOF behavior, persistence, UTF-8 range boundaries, revision conflict, read-only I/O | | `app/main.c` | `usage`, `run`, `main` | `requires-data-dir`, `smoke`: missing CLI argument, normal startup and EOF | diff --git a/tests/mcp_protocol_test.py b/tests/mcp_protocol_test.py index c206515..3efbca0 100644 --- a/tests/mcp_protocol_test.py +++ b/tests/mcp_protocol_test.py @@ -34,7 +34,11 @@ class Server: def send(self, message: Mapping[str, Any] | str) -> dict[str, Any]: assert self.process.stdin and self.process.stdout - line = message if isinstance(message, str) else json.dumps(message, separators=(",", ":")) + line = ( + message + if isinstance(message, str) + else json.dumps(message, separators=(",", ":"), ensure_ascii=False) + ) self.process.stdin.write(line + "\n") self.process.stdin.flush() response_line = self.process.stdout.readline() @@ -81,6 +85,7 @@ def tool_call(server: Server, request_id: int, name: str, arguments: Mapping[str def tool_payload(response: Mapping[str, Any], expect_error: bool = False) -> dict[str, Any]: + assert "result" in response, response result = response["result"] assert bool(result.get("isError", False)) is expect_error, response return json.loads(result["content"][0]["text"]) @@ -157,9 +162,12 @@ def main() -> None: "metadata_json": "Optional JSON object string containing tool_call_id, tags, memory_tier, memory_kind, importance, summary, relations, and/or source_message_ids; defaults to {}.", }, "read_messages": { - "view": "Read view: history (default), context, or sessions.", + "view": "Read view: history (default), content, context, or sessions.", "session_id": "Conversation session whose chronological history to read.", - "message_id": "Message ULID required by the context view.", + "message_id": "Message ULID required by the content and context views.", + "content_offset": "UTF-8 byte offset for the content view; defaults to 0.", + "content_limit": "Target content chunk bytes from 1 to 65536; defaults to 4096.", + "expected_revision": "Optional positive revision for a consistent multi-call content read.", "before": "Context messages before the target, from 0 to 10.", "after": "Context messages after the target, from 0 to 10.", "relation_depth": "Relation traversal depth from 0 to 2; defaults to 1.", @@ -195,6 +203,8 @@ def main() -> None: "content", ] assert tools["read_messages"]["inputSchema"].get("required", []) == [] + assert tools["read_messages"]["inputSchema"]["properties"]["content_offset"]["type"] == "integer" + assert tools["read_messages"]["inputSchema"]["properties"]["content_limit"]["type"] == "integer" 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"] @@ -205,6 +215,218 @@ def main() -> None: next_id += 1 assert empty_health["session_count"] == 0 + range_content = ("Aあ𐍈B\n" * 1000) + "range-end" + range_message = tool_payload( + tool_call( + server, + next_id, + "write_message", + {"session_id": "range-read", "role": "assistant", "content": range_content}, + ) + )["message"] + next_id += 1 + range_health_before = tool_payload( + tool_call(server, next_id, "get_system_health", {}) + )["io"] + next_id += 1 + single_codepoint = tool_payload( + tool_call( + server, + next_id, + "read_messages", + { + "view": "content", + "message_id": range_message["message_id"], + "content_offset": 1, + "content_limit": 1, + }, + ) + )["content_range"] + next_id += 1 + assert single_codepoint["content"] == "あ" + assert single_codepoint["content_offset"] == 1 + assert single_codepoint["next_content_offset"] == 4 + assert single_codepoint["complete"] is False + reconstructed: list[str] = [] + range_offset = 0 + while True: + chunk = tool_payload( + tool_call( + server, + next_id, + "read_messages", + { + "view": "content", + "message_id": range_message["message_id"], + "content_offset": range_offset, + "content_limit": 4097, + "expected_revision": range_message["revision"], + }, + ) + )["content_range"] + next_id += 1 + assert chunk["content_offset"] == range_offset + assert chunk["revision"] == range_message["revision"] + assert chunk["total_bytes"] == len(range_content.encode("utf-8")) + assert chunk["next_content_offset"] > range_offset + reconstructed.append(chunk["content"]) + range_offset = chunk["next_content_offset"] + if chunk["complete"]: + break + assert "".join(reconstructed) == range_content + end_chunk = tool_payload( + tool_call( + server, + next_id, + "read_messages", + { + "view": "content", + "message_id": range_message["message_id"], + "content_offset": range_offset, + }, + ) + )["content_range"] + next_id += 1 + assert end_chunk["content"] == "" + assert end_chunk["complete"] is True + maximum_chunk = tool_payload( + tool_call( + server, + next_id, + "read_messages", + { + "view": "content", + "message_id": range_message["message_id"], + "content_limit": 65536, + }, + ) + )["content_range"] + next_id += 1 + assert maximum_chunk["content"] == range_content + assert maximum_chunk["complete"] is True + for arguments, expected_code in [ + ( + { + "view": "content", + "message_id": range_message["message_id"], + "content_offset": 2, + }, + "VALIDATION_ERROR", + ), + ( + { + "view": "content", + "message_id": range_message["message_id"], + "content_offset": len(range_content.encode("utf-8")) + 1, + }, + "VALIDATION_ERROR", + ), + ( + { + "view": "content", + "message_id": range_message["message_id"], + "content_offset": -1, + }, + "VALIDATION_ERROR", + ), + ( + { + "view": "content", + "message_id": range_message["message_id"], + "content_limit": 0, + }, + "VALIDATION_ERROR", + ), + ( + { + "view": "content", + "message_id": range_message["message_id"], + "expected_revision": 0, + }, + "VALIDATION_ERROR", + ), + ( + { + "view": "content", + "message_id": range_message["message_id"], + "content_limit": 65537, + }, + "VALIDATION_ERROR", + ), + ( + { + "view": "content", + "message_id": range_message["message_id"], + "expected_revision": range_message["revision"] + 1, + }, + "CONFLICT", + ), + ( + { + "view": "content", + "message_id": "01ARZ3NDEKTSV4RRFFQ69G5FAW", + }, + "NOT_FOUND", + ), + ]: + error = tool_payload( + tool_call(server, next_id, "read_messages", arguments), True + ) + next_id += 1 + assert error["error"]["code"] == expected_code + range_health_after = tool_payload( + tool_call(server, next_id, "get_system_health", {}) + )["io"] + next_id += 1 + assert range_health_after == range_health_before + range_history = tool_payload( + tool_call( + server, + next_id, + "read_messages", + {"view": "history", "session_id": "range-read"}, + ) + )["history"] + next_id += 1 + persisted_range_message = next( + item for item in range_history if item["message_id"] == range_message["message_id"] + ) + assert persisted_range_message["revision"] == range_message["revision"] + assert persisted_range_message["content"] == range_content + + deleted_range_message = tool_payload( + tool_call( + server, + next_id, + "write_message", + {"session_id": "range-read", "role": "user", "content": "delete me"}, + ) + )["message"] + next_id += 1 + assert tool_payload( + tool_call( + server, + next_id, + "delete_message", + {"message_id": deleted_range_message["message_id"]}, + ) + )["status"] == "OK" + next_id += 1 + deleted_range_error = tool_payload( + tool_call( + server, + next_id, + "read_messages", + { + "view": "content", + "message_id": deleted_range_message["message_id"], + }, + ), + True, + ) + next_id += 1 + assert deleted_range_error["error"]["code"] == "NOT_FOUND" + message_ids: list[str] = [] for index in range(20): payload = tool_payload( -- cgit v1.2.3