* feat: MCP reliability — inode detection, WAL rotation, metadata cache, search limits Infrastructure hardening for the MCP server: - Detect palace DB replacement via inode tracking (repair command support) - WAL rotation to prevent unbounded WAL growth - _fetch_all_metadata() + _get_cached_metadata() with 60s TTL for taxonomy/status - _MAX_RESULTS cap (100) with limit clamping [1, _MAX_RESULTS] - max_distance parameter for similarity threshold in search - Handle all notifications/* methods, null arguments, method=None - Remove duplicate _client_cache = None declarations - searcher.py max_distance parameter passthrough Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: new MCP tools (get/list/update drawer, hook settings, memories filed), export, normalize New MCP tools: - mempalace_get_drawer: fetch single drawer by ID with full content - mempalace_list_drawers: paginated listing with wing/room filter - mempalace_update_drawer: update content/wing/room on existing drawers - mempalace_hook_settings: get/set hook behavior (silent_save, desktop_toast) - mempalace_memories_filed_away: check latest checkpoint status Also includes: - exporter.py: export palace as browsable markdown files - normalize.py: tool_use/tool_result capture for richer transcript mining - layers.py: updated for new tool integration - config.py: hook settings properties (hook_silent_save, hook_desktop_toast) Depends on PR 3 (reliability) for _MAX_RESULTS, _metadata_cache, WAL logging. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: normalize.py handles string messages and Read offset type mismatch Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: params null guard, L2→cosine docs, empty tool_use_map key guard - Handle explicit null in MCP params (request.get("params") or {}) - Fix search tool description: L2 → cosine distance (collection uses hnsw:space=cosine) - Guard against empty string key in tool_use_map from malformed JSONL entries Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: rename ambiguous var 'l' to 'line' (E741 lint) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address code review findings (5 issues) 1. min_similarity backwards-compat: convert similarity to distance scale (1.0 - similarity) instead of passing raw value as max_distance 2. Restore structured error reporting (error + partial fields) in tool_status, tool_list_wings, tool_list_rooms, tool_get_taxonomy — reverts silent except:pass that dropped #647 security hardening 3. inode cache: remove falsy-zero short-circuit so missing DB file triggers reconnect instead of reusing stale client 4. _fetch_all_metadata: check for empty batch before extending/advancing offset to prevent infinite loop on concurrent deletion 5. KG initialization: only override path when --palace is explicit; default runs use KnowledgeGraph's built-in default path Co-authored-by: jphein <jphein@users.noreply.github.com> --------- Co-authored-by: jp <jp@jphein.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: jphein <jphein@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from mempalace.miner import mine
|
||||
from mempalace.exporter import export_palace
|
||||
|
||||
|
||||
def write_file(path: Path, content: str):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def _setup_palace(tmpdir):
|
||||
"""Create a small palace with drawers across two wings for testing."""
|
||||
project_a = Path(tmpdir) / "project_a"
|
||||
project_b = Path(tmpdir) / "project_b"
|
||||
palace_path = str(Path(tmpdir) / "palace")
|
||||
|
||||
# Project A: wing=alpha, rooms=backend,frontend
|
||||
os.makedirs(project_a / "backend")
|
||||
os.makedirs(project_a / "frontend")
|
||||
write_file(project_a / "backend" / "server.py", "def serve():\n return 'ok'\n" * 20)
|
||||
write_file(project_a / "frontend" / "app.js", "function render() { return 'hi'; }\n" * 20)
|
||||
with open(project_a / "mempalace.yaml", "w") as f:
|
||||
yaml.dump(
|
||||
{
|
||||
"wing": "alpha",
|
||||
"rooms": [
|
||||
{"name": "backend", "description": "Backend code"},
|
||||
{"name": "frontend", "description": "Frontend code"},
|
||||
],
|
||||
},
|
||||
f,
|
||||
)
|
||||
|
||||
# Project B: wing=beta, rooms=docs
|
||||
os.makedirs(project_b / "docs")
|
||||
write_file(project_b / "docs" / "guide.md", "# Guide\n\nThis explains things.\n" * 20)
|
||||
with open(project_b / "mempalace.yaml", "w") as f:
|
||||
yaml.dump(
|
||||
{
|
||||
"wing": "beta",
|
||||
"rooms": [{"name": "docs", "description": "Documentation"}],
|
||||
},
|
||||
f,
|
||||
)
|
||||
|
||||
mine(str(project_a), palace_path)
|
||||
mine(str(project_b), palace_path)
|
||||
|
||||
return palace_path
|
||||
|
||||
|
||||
def test_export_creates_structure():
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
try:
|
||||
palace_path = _setup_palace(tmpdir)
|
||||
output_dir = os.path.join(tmpdir, "export")
|
||||
|
||||
stats = export_palace(palace_path, output_dir)
|
||||
|
||||
# Should have two wings
|
||||
assert stats["wings"] == 2
|
||||
assert stats["rooms"] >= 2
|
||||
assert stats["drawers"] >= 3
|
||||
|
||||
# Directory structure
|
||||
assert os.path.isfile(os.path.join(output_dir, "index.md"))
|
||||
assert os.path.isdir(os.path.join(output_dir, "alpha"))
|
||||
assert os.path.isdir(os.path.join(output_dir, "beta"))
|
||||
|
||||
# Room files exist
|
||||
assert os.path.isfile(os.path.join(output_dir, "alpha", "backend.md"))
|
||||
assert os.path.isfile(os.path.join(output_dir, "alpha", "frontend.md"))
|
||||
assert os.path.isfile(os.path.join(output_dir, "beta", "docs.md"))
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
def test_export_markdown_content():
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
try:
|
||||
palace_path = _setup_palace(tmpdir)
|
||||
output_dir = os.path.join(tmpdir, "export")
|
||||
|
||||
export_palace(palace_path, output_dir)
|
||||
|
||||
# Check that room files contain expected markdown elements
|
||||
backend_md = Path(output_dir) / "alpha" / "backend.md"
|
||||
content = backend_md.read_text(encoding="utf-8")
|
||||
|
||||
assert content.startswith("# alpha / backend\n")
|
||||
assert "## drawer_" in content
|
||||
assert "| Field | Value |" in content
|
||||
assert "| Source |" in content
|
||||
assert "| Filed |" in content
|
||||
assert "| Added by |" in content
|
||||
assert "---" in content
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
def test_export_index_content():
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
try:
|
||||
palace_path = _setup_palace(tmpdir)
|
||||
output_dir = os.path.join(tmpdir, "export")
|
||||
|
||||
export_palace(palace_path, output_dir)
|
||||
|
||||
index_md = Path(output_dir) / "index.md"
|
||||
content = index_md.read_text(encoding="utf-8")
|
||||
|
||||
assert "# Palace Export" in content
|
||||
assert "| Wing | Rooms | Drawers |" in content
|
||||
assert "[alpha](alpha/)" in content
|
||||
assert "[beta](beta/)" in content
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
def test_export_empty_palace():
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
try:
|
||||
palace_path = os.path.join(tmpdir, "empty_palace")
|
||||
output_dir = os.path.join(tmpdir, "export")
|
||||
|
||||
stats = export_palace(palace_path, output_dir)
|
||||
|
||||
assert stats == {"wings": 0, "rooms": 0, "drawers": 0}
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
@@ -145,6 +145,42 @@ class TestHandleRequest:
|
||||
resp = handle_request({"method": "unknown/method", "id": 4, "params": {}})
|
||||
assert resp["error"]["code"] == -32601
|
||||
|
||||
def test_any_notification_returns_none(self):
|
||||
"""All notifications/* methods should return None (no response)."""
|
||||
from mempalace.mcp_server import handle_request
|
||||
|
||||
for method in [
|
||||
"notifications/initialized",
|
||||
"notifications/cancelled",
|
||||
"notifications/progress",
|
||||
"notifications/roots/list_changed",
|
||||
]:
|
||||
resp = handle_request({"method": method, "params": {}})
|
||||
assert resp is None, f"{method} should return None"
|
||||
|
||||
def test_unknown_method_no_id_returns_none(self):
|
||||
"""Messages without id (notifications) must never get a response."""
|
||||
from mempalace.mcp_server import handle_request
|
||||
|
||||
resp = handle_request({"method": "unknown/thing", "params": {}})
|
||||
assert resp is None
|
||||
|
||||
def test_malformed_method_none(self):
|
||||
"""method=None or missing should not crash."""
|
||||
from mempalace.mcp_server import handle_request
|
||||
|
||||
# Explicit None
|
||||
resp = handle_request({"method": None, "params": {}})
|
||||
assert resp is None # no id → no response
|
||||
|
||||
# Missing method entirely
|
||||
resp = handle_request({"params": {}})
|
||||
assert resp is None
|
||||
|
||||
# method=None with id → should return error, not crash
|
||||
resp = handle_request({"method": None, "id": 99, "params": {}})
|
||||
assert resp["error"]["code"] == -32601
|
||||
|
||||
def test_tools_call_dispatches(self, monkeypatch, config, palace_path, seeded_kg):
|
||||
_patch_mcp_server(monkeypatch, config, seeded_kg)
|
||||
from mempalace.mcp_server import handle_request
|
||||
@@ -259,6 +295,20 @@ class TestSearchTool:
|
||||
result = tool_search(query="database", room="backend")
|
||||
assert all(r["room"] == "backend" for r in result["results"])
|
||||
|
||||
def test_search_min_similarity_backwards_compat(self, monkeypatch, config, palace_path, seeded_collection, kg):
|
||||
"""Old min_similarity param still works via backwards-compat shim."""
|
||||
_patch_mcp_server(monkeypatch, config, kg)
|
||||
from mempalace.mcp_server import tool_search
|
||||
|
||||
# Old name should work
|
||||
result = tool_search(query="JWT", min_similarity=1.5)
|
||||
assert "results" in result
|
||||
|
||||
# Old name takes precedence when both provided
|
||||
result_strict = tool_search(query="JWT", max_distance=999.0, min_similarity=0.01)
|
||||
result_loose = tool_search(query="JWT", max_distance=0.01, min_similarity=999.0)
|
||||
assert len(result_strict["results"]) <= len(result_loose["results"])
|
||||
|
||||
|
||||
# ── Write Tools ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -328,6 +378,97 @@ class TestWriteTools:
|
||||
)
|
||||
assert result["is_duplicate"] is False
|
||||
|
||||
def test_get_drawer(self, monkeypatch, config, palace_path, seeded_collection, kg):
|
||||
_patch_mcp_server(monkeypatch, config, kg)
|
||||
from mempalace.mcp_server import tool_get_drawer
|
||||
|
||||
result = tool_get_drawer("drawer_proj_backend_aaa")
|
||||
assert result["drawer_id"] == "drawer_proj_backend_aaa"
|
||||
assert result["wing"] == "project"
|
||||
assert result["room"] == "backend"
|
||||
assert "JWT tokens" in result["content"]
|
||||
|
||||
def test_get_drawer_not_found(self, monkeypatch, config, palace_path, seeded_collection, kg):
|
||||
_patch_mcp_server(monkeypatch, config, kg)
|
||||
from mempalace.mcp_server import tool_get_drawer
|
||||
|
||||
result = tool_get_drawer("nonexistent_drawer")
|
||||
assert "error" in result
|
||||
|
||||
def test_list_drawers(self, monkeypatch, config, palace_path, seeded_collection, kg):
|
||||
_patch_mcp_server(monkeypatch, config, kg)
|
||||
from mempalace.mcp_server import tool_list_drawers
|
||||
|
||||
result = tool_list_drawers()
|
||||
assert result["count"] == 4
|
||||
assert len(result["drawers"]) == 4
|
||||
|
||||
def test_list_drawers_with_wing_filter(self, monkeypatch, config, palace_path, seeded_collection, kg):
|
||||
_patch_mcp_server(monkeypatch, config, kg)
|
||||
from mempalace.mcp_server import tool_list_drawers
|
||||
|
||||
result = tool_list_drawers(wing="project")
|
||||
assert result["count"] == 3
|
||||
assert all(d["wing"] == "project" for d in result["drawers"])
|
||||
|
||||
def test_list_drawers_with_room_filter(self, monkeypatch, config, palace_path, seeded_collection, kg):
|
||||
_patch_mcp_server(monkeypatch, config, kg)
|
||||
from mempalace.mcp_server import tool_list_drawers
|
||||
|
||||
result = tool_list_drawers(wing="project", room="backend")
|
||||
assert result["count"] == 2
|
||||
assert all(d["room"] == "backend" for d in result["drawers"])
|
||||
|
||||
def test_list_drawers_pagination(self, monkeypatch, config, palace_path, seeded_collection, kg):
|
||||
_patch_mcp_server(monkeypatch, config, kg)
|
||||
from mempalace.mcp_server import tool_list_drawers
|
||||
|
||||
result = tool_list_drawers(limit=2, offset=0)
|
||||
assert result["count"] == 2
|
||||
assert result["limit"] == 2
|
||||
assert result["offset"] == 0
|
||||
|
||||
def test_list_drawers_negative_offset_clamped(self, monkeypatch, config, palace_path, seeded_collection, kg):
|
||||
_patch_mcp_server(monkeypatch, config, kg)
|
||||
from mempalace.mcp_server import tool_list_drawers
|
||||
|
||||
result = tool_list_drawers(offset=-5)
|
||||
assert result["offset"] == 0
|
||||
|
||||
def test_update_drawer_content(self, monkeypatch, config, palace_path, seeded_collection, kg):
|
||||
_patch_mcp_server(monkeypatch, config, kg)
|
||||
from mempalace.mcp_server import tool_update_drawer, tool_get_drawer
|
||||
|
||||
result = tool_update_drawer("drawer_proj_backend_aaa", content="Updated content about auth.")
|
||||
assert result["success"] is True
|
||||
|
||||
fetched = tool_get_drawer("drawer_proj_backend_aaa")
|
||||
assert fetched["content"] == "Updated content about auth."
|
||||
|
||||
def test_update_drawer_wing_and_room(self, monkeypatch, config, palace_path, seeded_collection, kg):
|
||||
_patch_mcp_server(monkeypatch, config, kg)
|
||||
from mempalace.mcp_server import tool_update_drawer
|
||||
|
||||
result = tool_update_drawer("drawer_proj_backend_aaa", wing="new_wing", room="new_room")
|
||||
assert result["success"] is True
|
||||
assert result["wing"] == "new_wing"
|
||||
assert result["room"] == "new_room"
|
||||
|
||||
def test_update_drawer_not_found(self, monkeypatch, config, palace_path, seeded_collection, kg):
|
||||
_patch_mcp_server(monkeypatch, config, kg)
|
||||
from mempalace.mcp_server import tool_update_drawer
|
||||
|
||||
result = tool_update_drawer("nonexistent_drawer", content="hello")
|
||||
assert result["success"] is False
|
||||
|
||||
def test_update_drawer_noop(self, monkeypatch, config, palace_path, seeded_collection, kg):
|
||||
_patch_mcp_server(monkeypatch, config, kg)
|
||||
from mempalace.mcp_server import tool_update_drawer
|
||||
|
||||
result = tool_update_drawer("drawer_proj_backend_aaa")
|
||||
assert result["success"] is True
|
||||
assert result.get("noop") is True
|
||||
|
||||
|
||||
# ── KG Tools ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
+328
-2
@@ -3,6 +3,8 @@ from unittest.mock import patch
|
||||
|
||||
from mempalace.normalize import (
|
||||
_extract_content,
|
||||
_format_tool_result,
|
||||
_format_tool_use,
|
||||
_messages_to_transcript,
|
||||
_try_chatgpt_json,
|
||||
_try_claude_ai_json,
|
||||
@@ -81,7 +83,7 @@ def test_extract_content_string():
|
||||
|
||||
|
||||
def test_extract_content_list_of_strings():
|
||||
assert _extract_content(["hello", "world"]) == "hello world"
|
||||
assert _extract_content(["hello", "world"]) == "hello\nworld"
|
||||
|
||||
|
||||
def test_extract_content_list_of_blocks():
|
||||
@@ -99,7 +101,198 @@ def test_extract_content_none():
|
||||
|
||||
def test_extract_content_mixed_list():
|
||||
blocks = ["plain", {"type": "text", "text": "block"}]
|
||||
assert _extract_content(blocks) == "plain block"
|
||||
assert _extract_content(blocks) == "plain\nblock"
|
||||
|
||||
|
||||
# ── _format_tool_use ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_format_tool_use_bash():
|
||||
block = {"type": "tool_use", "id": "t1", "name": "Bash",
|
||||
"input": {"command": "lsusb | grep razer", "description": "Check USB"}}
|
||||
result = _format_tool_use(block)
|
||||
assert result == "[Bash] lsusb | grep razer"
|
||||
|
||||
|
||||
def test_format_tool_use_bash_truncates_long_command():
|
||||
block = {"type": "tool_use", "id": "t1", "name": "Bash",
|
||||
"input": {"command": "x" * 300}}
|
||||
result = _format_tool_use(block)
|
||||
assert len(result) <= len("[Bash] ") + 200 + len("...")
|
||||
assert result.endswith("...")
|
||||
|
||||
|
||||
def test_format_tool_use_read():
|
||||
block = {"type": "tool_use", "id": "t1", "name": "Read",
|
||||
"input": {"file_path": "/home/jp/file.py"}}
|
||||
result = _format_tool_use(block)
|
||||
assert result == "[Read /home/jp/file.py]"
|
||||
|
||||
|
||||
def test_format_tool_use_read_with_range():
|
||||
block = {"type": "tool_use", "id": "t1", "name": "Read",
|
||||
"input": {"file_path": "/home/jp/file.py", "offset": 10, "limit": 50}}
|
||||
result = _format_tool_use(block)
|
||||
assert result == "[Read /home/jp/file.py:10-60]"
|
||||
|
||||
|
||||
def test_format_tool_use_grep():
|
||||
block = {"type": "tool_use", "id": "t1", "name": "Grep",
|
||||
"input": {"pattern": "firmware", "path": "/home/jp/proj"}}
|
||||
result = _format_tool_use(block)
|
||||
assert result == "[Grep] firmware in /home/jp/proj"
|
||||
|
||||
|
||||
def test_format_tool_use_grep_with_glob():
|
||||
block = {"type": "tool_use", "id": "t1", "name": "Grep",
|
||||
"input": {"pattern": "TODO", "glob": "*.py"}}
|
||||
result = _format_tool_use(block)
|
||||
assert result == "[Grep] TODO in *.py"
|
||||
|
||||
|
||||
def test_format_tool_use_glob():
|
||||
block = {"type": "tool_use", "id": "t1", "name": "Glob",
|
||||
"input": {"pattern": "/home/jp/proj/**/*.py"}}
|
||||
result = _format_tool_use(block)
|
||||
assert result == "[Glob] /home/jp/proj/**/*.py"
|
||||
|
||||
|
||||
def test_format_tool_use_edit():
|
||||
block = {"type": "tool_use", "id": "t1", "name": "Edit",
|
||||
"input": {"file_path": "/home/jp/file.py", "old_string": "x", "new_string": "y"}}
|
||||
result = _format_tool_use(block)
|
||||
assert result == "[Edit /home/jp/file.py]"
|
||||
|
||||
|
||||
def test_format_tool_use_write():
|
||||
block = {"type": "tool_use", "id": "t1", "name": "Write",
|
||||
"input": {"file_path": "/home/jp/file.py", "content": "..."}}
|
||||
result = _format_tool_use(block)
|
||||
assert result == "[Write /home/jp/file.py]"
|
||||
|
||||
|
||||
def test_format_tool_use_unknown_tool():
|
||||
block = {"type": "tool_use", "id": "t1", "name": "mcp__mempalace__search",
|
||||
"input": {"query": "firmware probe", "limit": 5}}
|
||||
result = _format_tool_use(block)
|
||||
assert result.startswith("[mcp__mempalace__search]")
|
||||
assert "firmware probe" in result
|
||||
|
||||
|
||||
def test_format_tool_use_unknown_tool_truncates():
|
||||
block = {"type": "tool_use", "id": "t1", "name": "SomeTool",
|
||||
"input": {"data": "x" * 300}}
|
||||
result = _format_tool_use(block)
|
||||
assert result.endswith("...")
|
||||
assert len(result) <= len("[SomeTool] ") + 200 + len("...")
|
||||
|
||||
|
||||
# ── _format_tool_result ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_format_tool_result_bash_short():
|
||||
"""Short Bash output is preserved in full."""
|
||||
content = "Bus 002 Device 005: ID 1532:0e05 Razer Kiyo Pro"
|
||||
result = _format_tool_result(content, "Bash")
|
||||
assert result == "→ Bus 002 Device 005: ID 1532:0e05 Razer Kiyo Pro"
|
||||
|
||||
|
||||
def test_format_tool_result_bash_head_tail():
|
||||
"""Long Bash output gets head+tail with gap marker."""
|
||||
lines = [f"line {i}" for i in range(60)]
|
||||
content = "\n".join(lines)
|
||||
result = _format_tool_result(content, "Bash")
|
||||
assert "line 0" in result
|
||||
assert "line 19" in result
|
||||
assert "line 40" in result
|
||||
assert "line 59" in result
|
||||
assert "20 lines omitted" in result
|
||||
# Lines 20-39 should be gone
|
||||
assert "line 20\n" not in result
|
||||
|
||||
|
||||
def test_format_tool_result_bash_exactly_40_lines():
|
||||
"""Bash output at exactly 40 lines is not truncated."""
|
||||
lines = [f"line {i}" for i in range(40)]
|
||||
content = "\n".join(lines)
|
||||
result = _format_tool_result(content, "Bash")
|
||||
assert "omitted" not in result
|
||||
assert "line 0" in result
|
||||
assert "line 39" in result
|
||||
|
||||
|
||||
def test_format_tool_result_read_omitted():
|
||||
"""Read results are omitted (content already in palace from project mining)."""
|
||||
result = _format_tool_result("lots of file content here...", "Read")
|
||||
assert result == ""
|
||||
|
||||
|
||||
def test_format_tool_result_edit_omitted():
|
||||
"""Edit results are omitted (diff is in git)."""
|
||||
result = _format_tool_result("file updated", "Edit")
|
||||
assert result == ""
|
||||
|
||||
|
||||
def test_format_tool_result_write_omitted():
|
||||
"""Write results are omitted."""
|
||||
result = _format_tool_result("file created", "Write")
|
||||
assert result == ""
|
||||
|
||||
|
||||
def test_format_tool_result_grep_short():
|
||||
"""Short Grep output is kept."""
|
||||
content = "src/foo.py\nsrc/bar.py\nsrc/baz.py"
|
||||
result = _format_tool_result(content, "Grep")
|
||||
assert "→ src/foo.py" in result
|
||||
assert "→ src/baz.py" in result
|
||||
|
||||
|
||||
def test_format_tool_result_grep_caps_at_20():
|
||||
"""Grep output beyond 20 lines is truncated."""
|
||||
lines = [f"match_{i}.py" for i in range(30)]
|
||||
content = "\n".join(lines)
|
||||
result = _format_tool_result(content, "Grep")
|
||||
assert "match_19.py" in result
|
||||
assert "match_20.py" not in result
|
||||
assert "10 more matches" in result
|
||||
|
||||
|
||||
def test_format_tool_result_glob_caps_at_20():
|
||||
"""Glob output beyond 20 lines is truncated."""
|
||||
lines = [f"/path/file_{i}.py" for i in range(25)]
|
||||
content = "\n".join(lines)
|
||||
result = _format_tool_result(content, "Glob")
|
||||
assert "file_19.py" in result
|
||||
assert "file_20.py" not in result
|
||||
assert "5 more matches" in result
|
||||
|
||||
|
||||
def test_format_tool_result_unknown_short():
|
||||
"""Unknown tool with short output is kept."""
|
||||
result = _format_tool_result("some output", "mcp__mempalace__search")
|
||||
assert result == "→ some output"
|
||||
|
||||
|
||||
def test_format_tool_result_unknown_truncates():
|
||||
"""Unknown tool output over 2KB is truncated."""
|
||||
content = "x" * 3000
|
||||
result = _format_tool_result(content, "SomeTool")
|
||||
assert result.endswith("... [truncated, 3000 chars]")
|
||||
assert len(result) < 2200
|
||||
|
||||
|
||||
def test_format_tool_result_list_content():
|
||||
"""tool_result content can be a list of text blocks."""
|
||||
content = [{"type": "text", "text": "result line 1"}, {"type": "text", "text": "result line 2"}]
|
||||
result = _format_tool_result(content, "Bash")
|
||||
assert "result line 1" in result
|
||||
assert "result line 2" in result
|
||||
|
||||
|
||||
def test_format_tool_result_empty():
|
||||
"""Empty result returns empty string."""
|
||||
result = _format_tool_result("", "Bash")
|
||||
assert result == ""
|
||||
|
||||
|
||||
# ── _try_claude_code_jsonl ─────────────────────────────────────────────
|
||||
@@ -501,6 +694,139 @@ def test_messages_to_transcript_assistant_first():
|
||||
assert "> Q" in result
|
||||
|
||||
|
||||
# ── Tool block integration (Task 3) ───────────────────────────────────
|
||||
|
||||
|
||||
def test_extract_content_with_tool_use():
|
||||
"""_extract_content includes formatted tool_use blocks."""
|
||||
content = [
|
||||
{"type": "text", "text": "Let me check."},
|
||||
{"type": "tool_use", "id": "t1", "name": "Bash",
|
||||
"input": {"command": "lsusb"}},
|
||||
]
|
||||
result = _extract_content(content)
|
||||
assert "Let me check." in result
|
||||
assert "[Bash] lsusb" in result
|
||||
|
||||
|
||||
def test_extract_content_with_tool_result():
|
||||
"""_extract_content includes formatted tool_result blocks (needs tool_use_map)."""
|
||||
content = [
|
||||
{"type": "tool_result", "tool_use_id": "t1", "content": "some output"},
|
||||
]
|
||||
result = _extract_content(content, tool_use_map={"t1": "Bash"})
|
||||
assert "→ some output" in result
|
||||
|
||||
|
||||
def test_extract_content_tool_result_without_map_uses_fallback():
|
||||
"""tool_result without a map entry uses fallback strategy."""
|
||||
content = [
|
||||
{"type": "tool_result", "tool_use_id": "t1", "content": "some output"},
|
||||
]
|
||||
result = _extract_content(content)
|
||||
assert "→ some output" in result
|
||||
|
||||
|
||||
def test_claude_code_jsonl_captures_tool_output():
|
||||
"""Full integration: tool_use + tool_result appear in normalized transcript."""
|
||||
lines = [
|
||||
json.dumps({"type": "human", "message": {"content": "Check the camera"}}),
|
||||
json.dumps({"type": "assistant", "message": {"content": [
|
||||
{"type": "text", "text": "Let me check."},
|
||||
{"type": "tool_use", "id": "t1", "name": "Bash",
|
||||
"input": {"command": "lsusb | grep razer"}},
|
||||
]}}),
|
||||
json.dumps({"type": "human", "message": {"content": [
|
||||
{"type": "tool_result", "tool_use_id": "t1",
|
||||
"content": "Bus 002 Device 005: ID 1532:0e05 Razer Kiyo Pro"},
|
||||
]}}),
|
||||
json.dumps({"type": "assistant", "message": {"content": "Found it."}}),
|
||||
]
|
||||
result = _try_claude_code_jsonl("\n".join(lines))
|
||||
assert result is not None
|
||||
assert "> Check the camera" in result
|
||||
assert "[Bash] lsusb | grep razer" in result
|
||||
assert "→ Bus 002 Device 005" in result
|
||||
assert "Found it." in result
|
||||
|
||||
|
||||
def test_claude_code_jsonl_read_result_omitted():
|
||||
"""Read tool results are omitted but the path breadcrumb is kept."""
|
||||
lines = [
|
||||
json.dumps({"type": "human", "message": {"content": "Show me the file"}}),
|
||||
json.dumps({"type": "assistant", "message": {"content": [
|
||||
{"type": "text", "text": "Reading it."},
|
||||
{"type": "tool_use", "id": "t1", "name": "Read",
|
||||
"input": {"file_path": "/home/jp/file.py"}},
|
||||
]}}),
|
||||
json.dumps({"type": "human", "message": {"content": [
|
||||
{"type": "tool_result", "tool_use_id": "t1",
|
||||
"content": "entire file contents here that should not appear"},
|
||||
]}}),
|
||||
json.dumps({"type": "assistant", "message": {"content": "Here it is."}}),
|
||||
]
|
||||
result = _try_claude_code_jsonl("\n".join(lines))
|
||||
assert result is not None
|
||||
assert "[Read /home/jp/file.py]" in result
|
||||
assert "entire file contents here" not in result
|
||||
|
||||
|
||||
def test_claude_code_jsonl_tool_only_user_message_not_counted():
|
||||
"""A user message containing ONLY tool_results (no text) should not
|
||||
be added as a separate user turn with '>'."""
|
||||
lines = [
|
||||
json.dumps({"type": "human", "message": {"content": "Do it"}}),
|
||||
json.dumps({"type": "assistant", "message": {"content": [
|
||||
{"type": "text", "text": "Running."},
|
||||
{"type": "tool_use", "id": "t1", "name": "Bash",
|
||||
"input": {"command": "echo hi"}},
|
||||
]}}),
|
||||
json.dumps({"type": "human", "message": {"content": [
|
||||
{"type": "tool_result", "tool_use_id": "t1", "content": "hi"},
|
||||
]}}),
|
||||
json.dumps({"type": "assistant", "message": {"content": "Done."}}),
|
||||
]
|
||||
result = _try_claude_code_jsonl("\n".join(lines))
|
||||
assert result is not None
|
||||
# Only one user turn marker — the original "Do it"
|
||||
user_turns = [line for line in result.split("\n") if line.strip().startswith(">")]
|
||||
assert len(user_turns) == 1
|
||||
assert "> Do it" in result
|
||||
|
||||
|
||||
def test_extract_content_text_only_backward_compat():
|
||||
"""Text-only content blocks still work (backward compat)."""
|
||||
content = [
|
||||
{"type": "text", "text": "Hello"},
|
||||
{"type": "text", "text": "World"},
|
||||
]
|
||||
result = _extract_content(content)
|
||||
assert "Hello" in result
|
||||
assert "World" in result
|
||||
|
||||
|
||||
def test_extract_content_string_unchanged():
|
||||
"""Plain string content still works."""
|
||||
result = _extract_content("just a string")
|
||||
assert result == "just a string"
|
||||
|
||||
|
||||
def test_claude_code_jsonl_thinking_blocks_ignored():
|
||||
"""Thinking blocks are still ignored."""
|
||||
lines = [
|
||||
json.dumps({"type": "human", "message": {"content": "Q"}}),
|
||||
json.dumps({"type": "assistant", "message": {"content": [
|
||||
{"type": "thinking", "thinking": "", "signature": "abc"},
|
||||
{"type": "text", "text": "A"},
|
||||
]}}),
|
||||
]
|
||||
result = _try_claude_code_jsonl("\n".join(lines))
|
||||
assert result is not None
|
||||
assert "thinking" not in result.lower()
|
||||
assert "signature" not in result
|
||||
assert "A" in result
|
||||
|
||||
|
||||
def test_normalize_rejects_large_file():
|
||||
"""Files over 500 MB should raise IOError before reading."""
|
||||
with patch("mempalace.normalize.os.path.getsize", return_value=600 * 1024 * 1024):
|
||||
|
||||
Reference in New Issue
Block a user