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>
This commit is contained in:
@@ -3,6 +3,21 @@ import os
|
|||||||
|
|
||||||
from amc_server.config import EVENTS_DIR
|
from amc_server.config import EVENTS_DIR
|
||||||
|
|
||||||
|
# Prefixes for system-injected content that appears as user messages
|
||||||
|
# but was not typed by the human (hook outputs, system reminders, etc.)
|
||||||
|
_SYSTEM_INJECTED_PREFIXES = (
|
||||||
|
"<system-reminder>",
|
||||||
|
"<local-command-caveat>",
|
||||||
|
"<available-deferred-tools>",
|
||||||
|
"<teammate-message",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_system_injected(content):
|
||||||
|
"""Return True if user message content is system-injected, not human-typed."""
|
||||||
|
stripped = content.lstrip()
|
||||||
|
return stripped.startswith(_SYSTEM_INJECTED_PREFIXES)
|
||||||
|
|
||||||
|
|
||||||
class ConversationMixin:
|
class ConversationMixin:
|
||||||
def _serve_events(self, session_id):
|
def _serve_events(self, session_id):
|
||||||
@@ -57,7 +72,7 @@ class ConversationMixin:
|
|||||||
if msg_type == "user":
|
if msg_type == "user":
|
||||||
content = entry.get("message", {}).get("content", "")
|
content = entry.get("message", {}).get("content", "")
|
||||||
# Only include actual human messages (strings), not tool results (arrays)
|
# Only include actual human messages (strings), not tool results (arrays)
|
||||||
if content and isinstance(content, str):
|
if content and isinstance(content, str) and not _is_system_injected(content):
|
||||||
messages.append({
|
messages.append({
|
||||||
"id": f"claude-{session_id[:8]}-{msg_id}",
|
"id": f"claude-{session_id[:8]}-{msg_id}",
|
||||||
"role": "user",
|
"role": "user",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import unittest
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from amc_server.mixins.conversation import ConversationMixin
|
from amc_server.mixins.conversation import ConversationMixin, _is_system_injected
|
||||||
from amc_server.mixins.parsing import SessionParsingMixin
|
from amc_server.mixins.parsing import SessionParsingMixin
|
||||||
|
|
||||||
|
|
||||||
@@ -278,6 +278,110 @@ class TestParseClaudeConversation(unittest.TestCase):
|
|||||||
path.unlink()
|
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):
|
class TestParseCodexConversation(unittest.TestCase):
|
||||||
"""Tests for _parse_codex_conversation edge cases."""
|
"""Tests for _parse_codex_conversation edge cases."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user