Skip to content

Commit 799f5f9

Browse files
authored
fix(session): 剔除 Session 标题中的 System Instruction 噪声; (#246)
Claude Code 会在首条 user 消息 content 中拼接多个 <system-reminder> / <user-preferences> 等系统注入块,导致原 _extract_session_title 抽取出的 标题在不同会话间高度同质,丧失辨识度。 - 新增 _sanitize_user_text,基于白名单剥离 system-reminder、user-preferences、 local-command-stdout/stderr、bash-input/stdout/stderr、ide_selection、 stdin、system_instruction 等 CC 注入标签,折叠空白后返回真实用户输入; - 优先识别 slash command (<command-name>/<command-args>),合成"命令 + 参数" 式标题,避免命令式会话标题为空; - 重写 _extract_session_title,跳过清洗后为空的 user 文本 part,自动 fallback 到下一个有真实内容的 part; - 补充 20 个单元测试覆盖噪声剥离、slash command、空白折叠、截断、边界场景。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist)
1 parent 5a7450e commit 799f5f9

2 files changed

Lines changed: 225 additions & 7 deletions

File tree

src/coding/proxy/routing/executor.py

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from __future__ import annotations
88

99
import logging
10+
import re
1011
import time
1112
from collections.abc import AsyncIterator
1213
from typing import Any
@@ -54,16 +55,71 @@
5455

5556
_SESSION_TITLE_MAX_LEN = 30
5657

58+
# Claude Code 注入的"噪声"标签 — 系统级上下文,不应进入 Session 标题。
59+
# 这些标签由 CC harness 在首个 user 消息 content 中拼接,高度同质,
60+
# 直接用作标题会导致跨会话标题无差异化,丧失辨识度。
61+
_NOISE_TAG_PATTERN = re.compile(
62+
r"<(?P<tag>system-reminder|user-preferences|"
63+
r"local-command-stdout|local-command-stderr|"
64+
r"bash-input|bash-stdout|bash-stderr|"
65+
r"ide_selection|stdin|system_instruction)\b[^>]*>"
66+
r".*?</(?P=tag)>",
67+
flags=re.DOTALL | re.IGNORECASE,
68+
)
69+
70+
# Slash command 子标签:用于识别 /commit、/review 等命令式调用,
71+
# 合成"命令 + 参数"式标题。
72+
_CMD_NAME_PATTERN = re.compile(r"<command-name>(.*?)</command-name>", flags=re.DOTALL)
73+
_CMD_ARGS_PATTERN = re.compile(r"<command-args>(.*?)</command-args>", flags=re.DOTALL)
74+
# 残留 command-* 包裹标签清除(command-message/command-stdout 等次要标签)。
75+
_CMD_WRAPPER_PATTERN = re.compile(
76+
r"<command-[\w-]+>.*?</command-[\w-]+>", flags=re.DOTALL
77+
)
78+
79+
80+
def _sanitize_user_text(raw: str) -> str:
81+
"""剔除 Claude Code 注入的系统级 XML 块,还原真实用户输入。
82+
83+
处理顺序:
84+
1. Slash command 优先识别 — 若检测到 <command-name>,合成"命令 + 参数"
85+
式标题(因为残留文本通常为空,直接取标签内容更有意义)。
86+
2. 通用噪声剥离 — 移除已知白名单内的 system-reminder 等标签。
87+
3. 残留 command-* 包裹清除 — 兜底去除 command-message 等次要标签。
88+
4. 前后空白归一化 — 折叠连续空白为单空格,便于 30 字截断。
89+
"""
90+
if not raw:
91+
return ""
92+
93+
# 阶段一: slash command 短路
94+
cmd = _CMD_NAME_PATTERN.search(raw)
95+
if cmd:
96+
name = cmd.group(1).strip()
97+
args_match = _CMD_ARGS_PATTERN.search(raw)
98+
args = args_match.group(1).strip() if args_match else ""
99+
composed = f"{name} {args}".strip() if args else name
100+
if composed:
101+
return composed
102+
103+
# 阶段二: 通用噪声剥离
104+
cleaned = _NOISE_TAG_PATTERN.sub("", raw)
105+
cleaned = _CMD_WRAPPER_PATTERN.sub("", cleaned)
106+
107+
# 阶段三: 空白折叠
108+
return re.sub(r"\s+", " ", cleaned).strip()
109+
57110

58111
def _extract_session_title(request: CanonicalRequest) -> str:
59-
"""从规范化请求中提取首个用户消息文本作为 session 标题."""
112+
"""从规范化请求中提取首个用户消息文本作为 session 标题。
113+
114+
跳过 Claude Code 注入的系统级 XML 块(system-reminder、user-preferences 等),
115+
确保标题反映用户真实输入而非高同质化的系统模板。
116+
"""
60117
for part in request.messages:
61-
if (
62-
part.role == "user"
63-
and part.type == CanonicalPartType.TEXT
64-
and part.text.strip()
65-
):
66-
return part.text.strip()[:_SESSION_TITLE_MAX_LEN]
118+
if part.role != "user" or part.type != CanonicalPartType.TEXT:
119+
continue
120+
cleaned = _sanitize_user_text(part.text)
121+
if cleaned:
122+
return cleaned[:_SESSION_TITLE_MAX_LEN]
67123
return ""
68124

69125

tests/test_router_executor.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,14 @@
2020
build_canonical_request,
2121
)
2222
from coding.proxy.routing.executor import (
23+
_SESSION_TITLE_MAX_LEN,
2324
_VENDOR_PROTOCOL_LABEL_MAP,
25+
_extract_session_title,
2426
_has_tool_results,
2527
_is_likely_request_format_error,
2628
_log_vendor_response_error,
2729
_RouteExecutor,
30+
_sanitize_user_text,
2831
)
2932
from coding.proxy.routing.session_manager import RouteSessionManager
3033
from coding.proxy.routing.tier import VendorTier
@@ -1949,3 +1952,162 @@ def test_returns_body_for_unknown_tier(self):
19491952
result = exec_inst._prepare_body_for_tier(body, tier, source_vendor="zhipu")
19501953

19511954
assert result is body
1955+
1956+
1957+
# ── Session 标题清洗与抽取测试 ─────────────────────────────────
1958+
1959+
1960+
class TestSanitizeUserText:
1961+
"""``_sanitize_user_text`` — 剥离 CC 注入的系统级 XML 块.
1962+
1963+
覆盖典型 system-reminder/user-preferences 噪声、slash command
1964+
短路、空白折叠与边界场景。
1965+
"""
1966+
1967+
def test_strips_system_reminder(self):
1968+
raw = "<system-reminder>MCP 指令</system-reminder>这是用户真实输入"
1969+
assert _sanitize_user_text(raw) == "这是用户真实输入"
1970+
1971+
def test_strips_user_preferences(self):
1972+
raw = "用户问题<user-preferences>遵循 AGENTS.md</user-preferences>"
1973+
assert _sanitize_user_text(raw) == "用户问题"
1974+
1975+
def test_strips_multiple_noise_blocks(self):
1976+
raw = (
1977+
"<system-reminder>A</system-reminder>"
1978+
"<system-reminder>B</system-reminder>"
1979+
"<system-reminder>C</system-reminder>"
1980+
"<system-reminder>D</system-reminder>"
1981+
"真实输入文本"
1982+
"<user-preferences>P</user-preferences>"
1983+
)
1984+
assert _sanitize_user_text(raw) == "真实输入文本"
1985+
1986+
def test_strips_multiline_system_reminder(self):
1987+
"""多行 system-reminder 块需被 DOTALL 完整匹配剥离."""
1988+
raw = (
1989+
"<system-reminder>\n"
1990+
"# MCP Server Instructions\n"
1991+
"Use this server to fetch ...\n"
1992+
"</system-reminder>\n"
1993+
"TITLE 中的 Session 标题应当取自用户输入"
1994+
)
1995+
assert _sanitize_user_text(raw) == "TITLE 中的 Session 标题应当取自用户输入"
1996+
1997+
def test_strips_tag_with_attributes(self):
1998+
"""容忍标签携带属性(如 <system-reminder type="x">)."""
1999+
raw = '<system-reminder type="x">noise</system-reminder>真实'
2000+
assert _sanitize_user_text(raw) == "真实"
2001+
2002+
def test_slash_command_with_args(self):
2003+
raw = (
2004+
"<command-message>commit (user)</command-message>"
2005+
"<command-name>/commit</command-name>"
2006+
"<command-args>修复标题</command-args>"
2007+
)
2008+
assert _sanitize_user_text(raw) == "/commit 修复标题"
2009+
2010+
def test_slash_command_no_args(self):
2011+
raw = "<command-name>/review</command-name>"
2012+
assert _sanitize_user_text(raw) == "/review"
2013+
2014+
def test_collapses_whitespace(self):
2015+
raw = "<system-reminder>X</system-reminder>\n\n 多余 空白\t\t折叠 "
2016+
assert _sanitize_user_text(raw) == "多余 空白 折叠"
2017+
2018+
def test_empty_after_strip(self):
2019+
raw = "<system-reminder>仅噪声</system-reminder>"
2020+
assert _sanitize_user_text(raw) == ""
2021+
2022+
def test_empty_input(self):
2023+
assert _sanitize_user_text("") == ""
2024+
2025+
def test_preserves_user_xml_like_content(self):
2026+
"""用户输入中合法的 XML/HTML 片段(非白名单标签)需完整保留."""
2027+
raw = "请帮我审查这段代码:<div>hello</div> 是否符合规范?"
2028+
assert _sanitize_user_text(raw) == raw
2029+
2030+
def test_strips_local_command_output(self):
2031+
raw = "<local-command-stdout>build ok</local-command-stdout>构建后的下一步问题"
2032+
assert _sanitize_user_text(raw) == "构建后的下一步问题"
2033+
2034+
2035+
class TestExtractSessionTitle:
2036+
"""``_extract_session_title`` — 端到端从 CanonicalRequest 抽取标题."""
2037+
2038+
@staticmethod
2039+
def _build_request(messages: list[dict]):
2040+
return build_canonical_request({"model": "test", "messages": messages}, {})
2041+
2042+
def test_truncates_to_max_len(self):
2043+
long_text = "用户输入文本" * 20
2044+
req = self._build_request([{"role": "user", "content": long_text}])
2045+
title = _extract_session_title(req)
2046+
assert len(title) == _SESSION_TITLE_MAX_LEN
2047+
assert title == long_text[:_SESSION_TITLE_MAX_LEN]
2048+
2049+
def test_strips_noise_from_first_user_message(self):
2050+
raw = (
2051+
"<system-reminder>MCP 指令</system-reminder>"
2052+
"<user-preferences>偏好</user-preferences>"
2053+
"测试标题 ABC"
2054+
)
2055+
req = self._build_request([{"role": "user", "content": raw}])
2056+
assert _extract_session_title(req) == "测试标题 ABC"
2057+
2058+
def test_handles_real_cc_first_message_shape(self):
2059+
"""模拟 CC 真实首条消息(多个连续 system-reminder + 用户文本)."""
2060+
raw = (
2061+
"<system-reminder>\n# MCP Server Instructions\n...</system-reminder>"
2062+
"<system-reminder>\nThe following skills...\n</system-reminder>"
2063+
"<system-reminder>\nPlan mode is active...\n</system-reminder>"
2064+
"\n\nTITLE 中的 Session 标题应当取自用户输入的信息前 30 个字\n\n"
2065+
"<user-preferences>始终遵循 AGENTS.md</user-preferences>"
2066+
)
2067+
req = self._build_request([{"role": "user", "content": raw}])
2068+
title = _extract_session_title(req)
2069+
assert title.startswith("TITLE 中的 Session")
2070+
assert len(title) <= _SESSION_TITLE_MAX_LEN
2071+
2072+
def test_extracts_slash_command(self):
2073+
raw = (
2074+
"<command-name>/commit</command-name>"
2075+
"<command-args>feat: 新增标题清洗</command-args>"
2076+
)
2077+
req = self._build_request([{"role": "user", "content": raw}])
2078+
assert _extract_session_title(req) == "/commit feat: 新增标题清洗"
2079+
2080+
def test_returns_empty_when_only_noise(self):
2081+
raw = "<system-reminder>纯噪声</system-reminder>"
2082+
req = self._build_request([{"role": "user", "content": raw}])
2083+
assert _extract_session_title(req) == ""
2084+
2085+
def test_returns_empty_for_no_user_messages(self):
2086+
req = self._build_request([{"role": "assistant", "content": "你好"}])
2087+
assert _extract_session_title(req) == ""
2088+
2089+
def test_skips_noise_only_part_to_find_real_input(self):
2090+
"""首个 user text part 全噪声时,fallback 到下一个非空 user part."""
2091+
messages = [
2092+
{
2093+
"role": "user",
2094+
"content": [
2095+
{
2096+
"type": "text",
2097+
"text": "<system-reminder>noise</system-reminder>",
2098+
},
2099+
{"type": "text", "text": "真实问题"},
2100+
],
2101+
}
2102+
]
2103+
req = self._build_request(messages)
2104+
assert _extract_session_title(req) == "真实问题"
2105+
2106+
def test_skips_assistant_role(self):
2107+
"""assistant 角色的文本不应被作为标题候选."""
2108+
messages = [
2109+
{"role": "assistant", "content": "上一轮回答"},
2110+
{"role": "user", "content": "新的用户问题"},
2111+
]
2112+
req = self._build_request(messages)
2113+
assert _extract_session_title(req) == "新的用户问题"

0 commit comments

Comments
 (0)