diff --git a/api/streaming.py b/api/streaming.py index e0bb13a0..51a89280 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -2839,13 +2839,19 @@ def _sanitize_generated_title(text: str) -> str: s = re.sub(r'^\s*title\s*:\s*', '', s, flags=re.IGNORECASE) s = s.strip(" \t\r\n\"'`*_~") s = re.sub(r'\s+', ' ', s).strip() - # Guard against chain-of-thought leakage and meta-reasoning patterns. - if _looks_invalid_generated_title(s): + # Guard against chain-of-thought leakage, meta-reasoning, and trivial echo. + if _is_bad_new_title(s): return '' return s[:80] def _looks_invalid_generated_title(text: str) -> bool: + """True when an existing/persisted title is structurally invalid (CoT leak). + + Intentionally does NOT reject short conversational words like "Done" or + "Cool" — those can be legitimate stored titles. Use ``_is_bad_new_title`` + when validating a freshly generated candidate. + """ s = str(text or '') if not s.strip(): return True @@ -2857,7 +2863,31 @@ def _looks_invalid_generated_title(text: str) -> bool: or re.search(r'^\s*(i|we)\s+(should|need to|will|can)\b', s, flags=re.IGNORECASE) or re.search(r'^\s*let me\b', s, flags=re.IGNORECASE) or re.search(r"^\s*here(?:'s| is) (?:a |my )?(?:thinking|thought)", s, flags=re.IGNORECASE) - or re.search(r'^\s*(ok|okay|done|all set|complete|completed|finished)\b[\s.!?]*$', s, flags=re.IGNORECASE) + ) + + +def _is_bad_new_title(text: str) -> bool: + """True when a freshly generated title candidate should be discarded. + + Includes structural CoT rejection plus trivial single-token echo replies + (pong, yes, done, cool, …) that are useless as first-generation titles. + """ + if _looks_invalid_generated_title(text): + return True + s = str(text or '').strip() + _token = re.sub(r'[\s.!?]+$', '', s, flags=re.IGNORECASE) + if re.fullmatch( + r'(?:pong|ping|yes|no|yep|nope|hi|hello|hey|thanks|thank you|sure|k|kk|cool|nice|lol|ok|okay|done)', + _token, + flags=re.IGNORECASE, + ): + return True + return bool( + re.search( + r'^\s*(ok|okay|done|all set|complete|completed|finished)\b[\s.!?]*$', + s, + flags=re.IGNORECASE, + ) ) @@ -2992,7 +3022,7 @@ def _first_exchange_snippets(messages): # is asking...", etc.). Assistant rows that carry tool_calls but # also contain a substantive answer text are kept — those are # agentic first-turn plans that are legitimate title candidates. - if m.get('tool_calls') and (not candidate or _looks_invalid_generated_title(candidate)): + if m.get('tool_calls') and (not candidate or _is_bad_new_title(candidate)): continue if candidate: asst_text = candidate @@ -3016,7 +3046,7 @@ def _latest_exchange_snippets(messages): if role == 'assistant' and not asst_text: candidate = _message_text(m.get('content')) # Skip tool-call-only preambles - if m.get('tool_calls') and (not candidate or _looks_invalid_generated_title(candidate)): + if m.get('tool_calls') and (not candidate or _is_bad_new_title(candidate)): continue if candidate: asst_text = candidate diff --git a/tests/test_issues_853_857.py b/tests/test_issues_853_857.py index 7e8b34a0..8541642c 100644 --- a/tests/test_issues_853_857.py +++ b/tests/test_issues_853_857.py @@ -98,3 +98,54 @@ class TestThinkingPreambleStripping: assert not _looks_invalid_generated_title("Python import debugging"), ( "Real titles must still pass the invalid-title guard" ) + + def test_looks_invalid_generated_title_rejects_trivial_echo(self): + """Trivial echo rejection applies to NEW titles only, not existing titles.""" + from api.streaming import _is_bad_new_title, _looks_invalid_generated_title + assert _is_bad_new_title("pong") + assert _is_bad_new_title("Yes!") + assert _is_bad_new_title("Done") + assert _is_bad_new_title("Cool") + # Existing-title validity must accept short conversational titles so + # every subsequent turn does not re-fire the title LLM. + assert not _looks_invalid_generated_title("pong") + assert not _looks_invalid_generated_title("Done") + assert not _looks_invalid_generated_title("Cool") + assert not _looks_invalid_generated_title("Repo Metrics Overview") + + def test_snippet_extraction_skips_tool_preamble_echo(self): + """First/latest exchange helpers skip tool rows whose content is echo/CoT.""" + from api.streaming import _first_exchange_snippets, _latest_exchange_snippets + + messages = [ + {"role": "user", "content": "ping"}, + { + "role": "assistant", + "content": "Let me check my memory first.", + "tool_calls": [{"id": "1", "function": {"name": "memory"}}], + }, + { + "role": "assistant", + "content": "pong", + "tool_calls": [{"id": "2", "function": {"name": "echo"}}], + }, + {"role": "assistant", "content": "Repo metrics look healthy overall."}, + ] + u, a = _first_exchange_snippets(messages) + assert u == "ping" + assert "healthy" in a.lower() + assert "pong" not in a.lower() + assert "let me" not in a.lower() + + latest = [ + {"role": "user", "content": "status?"}, + { + "role": "assistant", + "content": "Done", + "tool_calls": [{"id": "9", "function": {"name": "status"}}], + }, + {"role": "assistant", "content": "All services green."}, + ] + u2, a2 = _latest_exchange_snippets(latest) + assert u2 == "status?" + assert "green" in a2.lower()