summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/function-coverage.md2
-rw-r--r--tests/mcp_protocol_test.py228
2 files changed, 226 insertions, 4 deletions
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(