Files
amc/tests/test_conversation.py
teernisse abbede923d feat(server): filter system-injected messages from Claude conversations
Add _is_system_injected() filter to conversation.py that drops user
messages starting with known system-injected prefixes. These messages
(hook outputs, system reminders, teammate notifications) appear in
JSONL session logs as type: "user" with string content but are not
human-typed input.

Filtered prefixes:
- <system-reminder>    — Claude Code system context injection
- <local-command-caveat> — local command hook output
- <available-deferred-tools> — deferred tool discovery messages
- <teammate-message    — team agent message delivery (no closing >
                         because tag has attributes)

This brings Claude parsing to parity with the Codex parser, which
already filters system-injected content via SKIP_PREFIXES (line 222).
The filter uses str.startswith(tuple) with lstrip() to handle leading
whitespace. Applied at line 75 in the existing content-type guard chain.

Affects both chat display and input history navigation — system noise
is removed at the source so all consumers benefit.

tests/test_conversation.py:
- TestIsSystemInjected: 10 unit tests for the filter function covering
  each prefix, leading whitespace, normal messages, mid-string tags,
  empty strings, slash commands, and multiline content
- TestClaudeSystemInjectedFiltering: 5 integration tests through the
  full parser verifying exclusion, sequential ID preservation after
  filtering, and all-system-message edge case

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:51:28 -05:00

586 lines
24 KiB
Python

"""Tests for mixins/conversation.py edge cases.
Unit tests for conversation parsing from Claude Code and Codex JSONL files.
"""
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from amc_server.mixins.conversation import ConversationMixin, _is_system_injected
from amc_server.mixins.parsing import SessionParsingMixin
class DummyConversationHandler(ConversationMixin, SessionParsingMixin):
"""Minimal handler for testing conversation mixin."""
def __init__(self):
self.sent_responses = []
def _send_json(self, code, payload):
self.sent_responses.append((code, payload))
class TestParseCodexArguments(unittest.TestCase):
"""Tests for _parse_codex_arguments edge cases."""
def setUp(self):
self.handler = DummyConversationHandler()
def test_dict_input_returned_as_is(self):
result = self.handler._parse_codex_arguments({"key": "value"})
self.assertEqual(result, {"key": "value"})
def test_empty_dict_returned_as_is(self):
result = self.handler._parse_codex_arguments({})
self.assertEqual(result, {})
def test_json_string_parsed(self):
result = self.handler._parse_codex_arguments('{"key": "value"}')
self.assertEqual(result, {"key": "value"})
def test_invalid_json_string_returns_raw(self):
result = self.handler._parse_codex_arguments("not valid json")
self.assertEqual(result, {"raw": "not valid json"})
def test_empty_string_returns_raw(self):
result = self.handler._parse_codex_arguments("")
self.assertEqual(result, {"raw": ""})
def test_none_returns_empty_dict(self):
result = self.handler._parse_codex_arguments(None)
self.assertEqual(result, {})
def test_int_returns_empty_dict(self):
result = self.handler._parse_codex_arguments(42)
self.assertEqual(result, {})
def test_list_returns_empty_dict(self):
result = self.handler._parse_codex_arguments([1, 2, 3])
self.assertEqual(result, {})
class TestServeEvents(unittest.TestCase):
"""Tests for _serve_events edge cases."""
def setUp(self):
self.handler = DummyConversationHandler()
def test_path_traversal_sanitized(self):
with tempfile.TemporaryDirectory() as tmpdir:
events_dir = Path(tmpdir)
# Create a file that path traversal might try to access (unused - documents intent)
_secret_file = Path(tmpdir).parent / "secret.jsonl"
with patch("amc_server.mixins.conversation.EVENTS_DIR", events_dir):
# Try path traversal
self.handler._serve_events("../secret")
# Should have served response with sanitized id
self.assertEqual(len(self.handler.sent_responses), 1)
code, payload = self.handler.sent_responses[0]
self.assertEqual(code, 200)
self.assertEqual(payload["session_id"], "secret")
self.assertEqual(payload["events"], [])
def test_nonexistent_file_returns_empty_events(self):
with tempfile.TemporaryDirectory() as tmpdir:
with patch("amc_server.mixins.conversation.EVENTS_DIR", Path(tmpdir)):
self.handler._serve_events("nonexistent")
code, payload = self.handler.sent_responses[0]
self.assertEqual(payload["events"], [])
def test_empty_file_returns_empty_events(self):
with tempfile.TemporaryDirectory() as tmpdir:
events_dir = Path(tmpdir)
event_file = events_dir / "session123.jsonl"
event_file.write_text("")
with patch("amc_server.mixins.conversation.EVENTS_DIR", events_dir):
self.handler._serve_events("session123")
code, payload = self.handler.sent_responses[0]
self.assertEqual(payload["events"], [])
def test_invalid_json_lines_skipped(self):
with tempfile.TemporaryDirectory() as tmpdir:
events_dir = Path(tmpdir)
event_file = events_dir / "session123.jsonl"
event_file.write_text('{"valid": "event"}\nnot json\n{"another": "event"}\n')
with patch("amc_server.mixins.conversation.EVENTS_DIR", events_dir):
self.handler._serve_events("session123")
code, payload = self.handler.sent_responses[0]
self.assertEqual(len(payload["events"]), 2)
self.assertEqual(payload["events"][0], {"valid": "event"})
self.assertEqual(payload["events"][1], {"another": "event"})
class TestParseClaudeConversation(unittest.TestCase):
"""Tests for _parse_claude_conversation edge cases."""
def setUp(self):
self.handler = DummyConversationHandler()
def test_user_message_with_array_content_skipped(self):
# Array content is tool results, not human messages
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write(json.dumps({
"type": "user",
"message": {"content": [{"type": "tool_result"}]}
}) + "\n")
path = Path(f.name)
try:
with patch.object(self.handler, "_get_claude_conversation_file", return_value=path):
messages = self.handler._parse_claude_conversation("session123", "/project")
self.assertEqual(messages, [])
finally:
path.unlink()
def test_user_message_with_string_content_included(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write(json.dumps({
"type": "user",
"timestamp": "2024-01-01T00:00:00Z",
"message": {"content": "Hello, Claude!"}
}) + "\n")
path = Path(f.name)
try:
with patch.object(self.handler, "_get_claude_conversation_file", return_value=path):
messages = self.handler._parse_claude_conversation("session123", "/project")
self.assertEqual(len(messages), 1)
self.assertEqual(messages[0]["role"], "user")
self.assertEqual(messages[0]["content"], "Hello, Claude!")
finally:
path.unlink()
def test_assistant_message_with_text_parts(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write(json.dumps({
"type": "assistant",
"timestamp": "2024-01-01T00:00:00Z",
"message": {
"content": [
{"type": "text", "text": "Part 1"},
{"type": "text", "text": "Part 2"},
]
}
}) + "\n")
path = Path(f.name)
try:
with patch.object(self.handler, "_get_claude_conversation_file", return_value=path):
messages = self.handler._parse_claude_conversation("session123", "/project")
self.assertEqual(len(messages), 1)
self.assertEqual(messages[0]["content"], "Part 1\nPart 2")
finally:
path.unlink()
def test_assistant_message_with_tool_use(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write(json.dumps({
"type": "assistant",
"message": {
"content": [
{"type": "tool_use", "name": "Read", "input": {"file_path": "/test"}},
]
}
}) + "\n")
path = Path(f.name)
try:
with patch.object(self.handler, "_get_claude_conversation_file", return_value=path):
messages = self.handler._parse_claude_conversation("session123", "/project")
self.assertEqual(len(messages), 1)
self.assertEqual(messages[0]["tool_calls"][0]["name"], "Read")
self.assertEqual(messages[0]["tool_calls"][0]["input"]["file_path"], "/test")
finally:
path.unlink()
def test_assistant_message_with_thinking(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write(json.dumps({
"type": "assistant",
"message": {
"content": [
{"type": "thinking", "thinking": "Let me consider..."},
{"type": "text", "text": "Here's my answer"},
]
}
}) + "\n")
path = Path(f.name)
try:
with patch.object(self.handler, "_get_claude_conversation_file", return_value=path):
messages = self.handler._parse_claude_conversation("session123", "/project")
self.assertEqual(len(messages), 1)
self.assertEqual(messages[0]["thinking"], "Let me consider...")
self.assertEqual(messages[0]["content"], "Here's my answer")
finally:
path.unlink()
def test_assistant_message_content_as_string_parts(self):
# Some entries might have string content parts instead of dicts
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write(json.dumps({
"type": "assistant",
"message": {
"content": ["plain string", {"type": "text", "text": "structured"}]
}
}) + "\n")
path = Path(f.name)
try:
with patch.object(self.handler, "_get_claude_conversation_file", return_value=path):
messages = self.handler._parse_claude_conversation("session123", "/project")
self.assertEqual(messages[0]["content"], "plain string\nstructured")
finally:
path.unlink()
def test_missing_conversation_file_returns_empty(self):
with patch.object(self.handler, "_get_claude_conversation_file", return_value=None):
messages = self.handler._parse_claude_conversation("session123", "/project")
self.assertEqual(messages, [])
def test_non_dict_entry_skipped(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write('"just a string"\n')
f.write('123\n')
f.write('{"type": "user", "message": {"content": "valid"}}\n')
path = Path(f.name)
try:
with patch.object(self.handler, "_get_claude_conversation_file", return_value=path):
messages = self.handler._parse_claude_conversation("session123", "/project")
self.assertEqual(len(messages), 1)
finally:
path.unlink()
def test_non_list_content_in_assistant_skipped(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write(json.dumps({
"type": "assistant",
"message": {"content": "not a list"}
}) + "\n")
path = Path(f.name)
try:
with patch.object(self.handler, "_get_claude_conversation_file", return_value=path):
messages = self.handler._parse_claude_conversation("session123", "/project")
self.assertEqual(messages, [])
finally:
path.unlink()
class TestIsSystemInjected(unittest.TestCase):
"""Tests for _is_system_injected filter."""
def test_system_reminder(self):
self.assertTrue(_is_system_injected("<system-reminder>\nSome reminder text\n</system-reminder>"))
def test_local_command_caveat(self):
self.assertTrue(_is_system_injected("<local-command-caveat>Caveat: The messages below...</local-command-caveat>"))
def test_available_deferred_tools(self):
self.assertTrue(_is_system_injected("<available-deferred-tools>\nAgent\nBash\n</available-deferred-tools>"))
def test_teammate_message(self):
self.assertTrue(_is_system_injected('<teammate-message teammate_id="reviewer" color="yellow">Review complete</teammate-message>'))
def test_leading_whitespace_stripped(self):
self.assertTrue(_is_system_injected(" \n <system-reminder>content</system-reminder>"))
def test_normal_user_message(self):
self.assertFalse(_is_system_injected("Hello, Claude!"))
def test_message_containing_tag_not_at_start(self):
self.assertFalse(_is_system_injected("Please check this <system-reminder> thing"))
def test_empty_string(self):
self.assertFalse(_is_system_injected(""))
def test_slash_command(self):
self.assertFalse(_is_system_injected("/commit"))
def test_multiline_user_message(self):
self.assertFalse(_is_system_injected("Fix this bug\n\nHere's the error:\nTypeError: foo is not a function"))
class TestClaudeSystemInjectedFiltering(unittest.TestCase):
"""Integration tests: system-injected messages filtered from Claude conversation."""
def setUp(self):
self.handler = DummyConversationHandler()
def _parse_with_messages(self, *user_contents):
"""Helper: write JSONL with user messages, parse, return results."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
for content in user_contents:
f.write(json.dumps({
"type": "user",
"timestamp": "2024-01-01T00:00:00Z",
"message": {"content": content}
}) + "\n")
path = Path(f.name)
try:
with patch.object(self.handler, "_get_claude_conversation_file", return_value=path):
return self.handler._parse_claude_conversation("session123", "/project")
finally:
path.unlink()
def test_system_reminder_excluded(self):
messages = self._parse_with_messages(
"real question",
"<system-reminder>\nHook success\n</system-reminder>",
)
self.assertEqual(len(messages), 1)
self.assertEqual(messages[0]["content"], "real question")
def test_local_command_caveat_excluded(self):
messages = self._parse_with_messages(
"<local-command-caveat>Caveat: generated by local commands</local-command-caveat>",
"what does this function do?",
)
self.assertEqual(len(messages), 1)
self.assertEqual(messages[0]["content"], "what does this function do?")
def test_teammate_message_excluded(self):
messages = self._parse_with_messages(
'<teammate-message teammate_id="impl" color="green">Task done</teammate-message>',
"looks good, commit it",
)
self.assertEqual(len(messages), 1)
self.assertEqual(messages[0]["content"], "looks good, commit it")
def test_all_system_messages_excluded_preserves_ids(self):
"""Message IDs should be sequential with no gaps from filtering."""
messages = self._parse_with_messages(
"first real message",
"<system-reminder>noise</system-reminder>",
"<available-deferred-tools>\nAgent\n</available-deferred-tools>",
"second real message",
)
self.assertEqual(len(messages), 2)
self.assertEqual(messages[0]["content"], "first real message")
self.assertEqual(messages[1]["content"], "second real message")
# IDs should be sequential (0, 1) not (0, 3)
self.assertTrue(messages[0]["id"].endswith("-0"))
self.assertTrue(messages[1]["id"].endswith("-1"))
def test_only_system_messages_returns_empty(self):
messages = self._parse_with_messages(
"<system-reminder>reminder</system-reminder>",
"<local-command-caveat>caveat</local-command-caveat>",
)
self.assertEqual(messages, [])
class TestParseCodexConversation(unittest.TestCase):
"""Tests for _parse_codex_conversation edge cases."""
def setUp(self):
self.handler = DummyConversationHandler()
def test_developer_role_skipped(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write(json.dumps({
"type": "response_item",
"payload": {
"type": "message",
"role": "developer",
"content": [{"text": "System instructions"}]
}
}) + "\n")
path = Path(f.name)
try:
with patch.object(self.handler, "_find_codex_transcript_file", return_value=path):
messages = self.handler._parse_codex_conversation("session123")
self.assertEqual(messages, [])
finally:
path.unlink()
def test_injected_context_skipped(self):
skip_prefixes = [
"<INSTRUCTIONS>",
"<environment_context>",
"<permissions instructions>",
"# AGENTS.md instructions",
]
for prefix in skip_prefixes:
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write(json.dumps({
"type": "response_item",
"payload": {
"type": "message",
"role": "user",
"content": [{"text": f"{prefix} more content here"}]
}
}) + "\n")
path = Path(f.name)
try:
with patch.object(self.handler, "_find_codex_transcript_file", return_value=path):
messages = self.handler._parse_codex_conversation("session123")
self.assertEqual(messages, [], f"Should skip content starting with {prefix}")
finally:
path.unlink()
def test_function_call_accumulated_to_next_assistant(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
# Tool call
f.write(json.dumps({
"type": "response_item",
"payload": {
"type": "function_call",
"name": "shell",
"arguments": '{"command": "ls"}'
}
}) + "\n")
# Assistant message
f.write(json.dumps({
"type": "response_item",
"payload": {
"type": "message",
"role": "assistant",
"content": [{"text": "Here are the files"}]
}
}) + "\n")
path = Path(f.name)
try:
with patch.object(self.handler, "_find_codex_transcript_file", return_value=path):
messages = self.handler._parse_codex_conversation("session123")
self.assertEqual(len(messages), 1)
self.assertEqual(messages[0]["tool_calls"][0]["name"], "shell")
self.assertEqual(messages[0]["content"], "Here are the files")
finally:
path.unlink()
def test_function_calls_flushed_before_user_message(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
# Tool call
f.write(json.dumps({
"type": "response_item",
"payload": {"type": "function_call", "name": "tool1", "arguments": "{}"}
}) + "\n")
# User message (tool calls should be flushed first)
f.write(json.dumps({
"type": "response_item",
"payload": {
"type": "message",
"role": "user",
"content": [{"text": "User response"}]
}
}) + "\n")
path = Path(f.name)
try:
with patch.object(self.handler, "_find_codex_transcript_file", return_value=path):
messages = self.handler._parse_codex_conversation("session123")
# First message should be assistant with tool_calls (flushed)
# Second should be user
self.assertEqual(len(messages), 2)
self.assertEqual(messages[0]["role"], "assistant")
self.assertEqual(messages[0]["tool_calls"][0]["name"], "tool1")
self.assertEqual(messages[1]["role"], "user")
finally:
path.unlink()
def test_reasoning_creates_thinking_message(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write(json.dumps({
"type": "response_item",
"payload": {
"type": "reasoning",
"summary": [
{"type": "summary_text", "text": "Let me think..."},
{"type": "summary_text", "text": "I'll try this approach."},
]
}
}) + "\n")
path = Path(f.name)
try:
with patch.object(self.handler, "_find_codex_transcript_file", return_value=path):
messages = self.handler._parse_codex_conversation("session123")
self.assertEqual(len(messages), 1)
self.assertEqual(messages[0]["thinking"], "Let me think...\nI'll try this approach.")
finally:
path.unlink()
def test_pending_tool_calls_flushed_at_end(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
# Tool call with no following message
f.write(json.dumps({
"type": "response_item",
"payload": {"type": "function_call", "name": "final_tool", "arguments": "{}"}
}) + "\n")
path = Path(f.name)
try:
with patch.object(self.handler, "_find_codex_transcript_file", return_value=path):
messages = self.handler._parse_codex_conversation("session123")
# Should flush pending tool calls at end
self.assertEqual(len(messages), 1)
self.assertEqual(messages[0]["tool_calls"][0]["name"], "final_tool")
finally:
path.unlink()
def test_non_response_item_types_skipped(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write('{"type": "session_meta"}\n')
f.write('{"type": "event_msg"}\n')
f.write(json.dumps({
"type": "response_item",
"payload": {"type": "message", "role": "user", "content": [{"text": "Hello"}]}
}) + "\n")
path = Path(f.name)
try:
with patch.object(self.handler, "_find_codex_transcript_file", return_value=path):
messages = self.handler._parse_codex_conversation("session123")
self.assertEqual(len(messages), 1)
finally:
path.unlink()
def test_missing_transcript_file_returns_empty(self):
with patch.object(self.handler, "_find_codex_transcript_file", return_value=None):
messages = self.handler._parse_codex_conversation("session123")
self.assertEqual(messages, [])
class TestServeConversation(unittest.TestCase):
"""Tests for _serve_conversation routing."""
def setUp(self):
self.handler = DummyConversationHandler()
def test_routes_to_codex_parser(self):
with patch.object(self.handler, "_parse_codex_conversation", return_value=[]) as mock:
self.handler._serve_conversation("session123", "/project", agent="codex")
mock.assert_called_once_with("session123")
def test_routes_to_claude_parser_by_default(self):
with patch.object(self.handler, "_parse_claude_conversation", return_value=[]) as mock:
self.handler._serve_conversation("session123", "/project")
mock.assert_called_once_with("session123", "/project")
def test_sanitizes_session_id(self):
with patch.object(self.handler, "_parse_claude_conversation", return_value=[]):
self.handler._serve_conversation("../../../etc/passwd", "/project")
code, payload = self.handler.sent_responses[0]
self.assertEqual(payload["session_id"], "passwd")
if __name__ == "__main__":
unittest.main()