Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions hooks/mempal_save_hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,15 @@ with open(sys.argv[1]) as f:
msg = entry.get('message', {})
if isinstance(msg, dict) and msg.get('role') == 'user':
content = msg.get('content', '')
# Skip system/command messages
if isinstance(content, str) and '<command-message>' in content:
continue
# Skip tool results (role: "user" but not human input)
if isinstance(content, list) and all(
isinstance(b, dict) and b.get('type') == 'tool_result'
for b in content
):
continue
count += 1
except:
pass
Expand Down
6 changes: 6 additions & 0 deletions mempalace/hooks_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@ def _count_human_messages(transcript_path: str) -> int:
if "<command-message>" in content:
continue
elif isinstance(content, list):
# Skip tool results (role: "user" but not human input)
if all(
isinstance(b, dict) and b.get("type") == "tool_result"
for b in content
):
continue
text = " ".join(
b.get("text", "") for b in content if isinstance(b, dict)
)
Expand Down
99 changes: 92 additions & 7 deletions tests/test_hooks_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,12 @@ def test_count_skips_command_messages(tmp_path):
_write_transcript(
transcript,
[
{"message": {"role": "user", "content": "<command-message>status</command-message>"}},
{
"message": {
"role": "user",
"content": "<command-message>status</command-message>",
}
},
{"message": {"role": "user", "content": "real question"}},
],
)
Expand All @@ -99,7 +104,12 @@ def test_count_handles_list_content(tmp_path):
_write_transcript(
transcript,
[
{"message": {"role": "user", "content": [{"type": "text", "text": "hello"}]}},
{
"message": {
"role": "user",
"content": [{"type": "text", "text": "hello"}],
}
},
{
"message": {
"role": "user",
Expand All @@ -111,6 +121,63 @@ def test_count_handles_list_content(tmp_path):
assert _count_human_messages(str(transcript)) == 1


def test_count_skips_tool_results(tmp_path):
"""Tool results arrive as role: 'user' but should not count as human messages."""
transcript = tmp_path / "t.jsonl"
_write_transcript(
transcript,
[
{"message": {"role": "user", "content": "real human message"}},
{
"message": {
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "tu_1",
"content": "file contents",
},
],
}
},
{
"message": {
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "tu_2", "content": "ok"},
{"type": "tool_result", "tool_use_id": "tu_3", "content": "ok"},
],
}
},
{"message": {"role": "user", "content": "another real message"}},
],
)
assert _count_human_messages(str(transcript)) == 2


def test_count_mixed_content_not_skipped(tmp_path):
"""Messages with both tool_result and text blocks should still count."""
transcript = tmp_path / "t.jsonl"
_write_transcript(
transcript,
[
{
"message": {
"role": "user",
# Mixed content: a tool_result block alongside a text block.
# The text block means a human actually typed something, so
# this message should still count as a human exchange.
"content": [
{"type": "tool_result", "tool_use_id": "tu_1", "content": "ok"},
{"type": "text", "text": "and here is my follow-up"},
],
}
},
],
)
assert _count_human_messages(str(transcript)) == 1


def test_count_missing_file():
assert _count_human_messages("/nonexistent/path.jsonl") == 0

Expand Down Expand Up @@ -170,7 +237,12 @@ def _capture_hook_output(hook_fn, data, harness="claude-code", state_dir=None):
from unittest.mock import PropertyMock

buf = io.StringIO()
patches = [patch("mempalace.hooks_cli._output", side_effect=lambda d: buf.write(json.dumps(d)))]
patches = [
patch(
"mempalace.hooks_cli._output",
side_effect=lambda d: buf.write(json.dumps(d)),
)
]
if state_dir:
patches.append(patch("mempalace.hooks_cli.STATE_DIR", state_dir))
# Mock MempalaceConfig so tests don't depend on user's ~/.mempalace/config.json
Expand Down Expand Up @@ -213,7 +285,11 @@ def test_stop_hook_passthrough_below_interval(tmp_path):
)
result = _capture_hook_output(
hook_stop,
{"session_id": "test", "stop_hook_active": False, "transcript_path": str(transcript)},
{
"session_id": "test",
"stop_hook_active": False,
"transcript_path": str(transcript),
},
state_dir=tmp_path,
)
assert result == {}
Expand Down Expand Up @@ -264,7 +340,11 @@ def test_stop_hook_tracks_save_point(tmp_path):
transcript,
[{"message": {"role": "user", "content": f"msg {i}"}} for i in range(SAVE_INTERVAL)],
)
data = {"session_id": "test", "stop_hook_active": False, "transcript_path": str(transcript)}
data = {
"session_id": "test",
"stop_hook_active": False,
"transcript_path": str(transcript),
}

# First call saves silently with systemMessage notification
save_result = {"count": 15, "themes": ["hooks"]}
Expand Down Expand Up @@ -678,7 +758,11 @@ def test_parse_harness_input_unknown():

def test_parse_harness_input_valid():
result = _parse_harness_input(
{"session_id": "abc-123", "stop_hook_active": True, "transcript_path": "/tmp/t.jsonl"},
{
"session_id": "abc-123",
"stop_hook_active": True,
"transcript_path": "/tmp/t.jsonl",
},
"claude-code",
)
assert result["session_id"] == "abc-123"
Expand Down Expand Up @@ -828,7 +912,8 @@ def test_run_hook_dispatches_session_start(tmp_path):
def test_run_hook_dispatches_stop(tmp_path):
transcript = tmp_path / "t.jsonl"
_write_transcript(
transcript, [{"message": {"role": "user", "content": f"msg {i}"}} for i in range(3)]
transcript,
[{"message": {"role": "user", "content": f"msg {i}"}} for i in range(3)],
)
stdin_data = json.dumps(
{
Expand Down