diff --git a/agent/title_generator.py b/agent/title_generator.py index 583a2cfc6011..be3457537cc0 100644 --- a/agent/title_generator.py +++ b/agent/title_generator.py @@ -1,7 +1,13 @@ """Auto-generate short session titles from the first user/assistant exchange. -Runs asynchronously after the first response is delivered so it never -adds latency to the user-facing reply. +A synchronous heuristic pass (``agent.title_heuristic``) sets an instant +placeholder title before the LLM call runs in a background thread. The +LLM result overwrites the heuristic title when it returns, producing +better titles for edge cases while guaranteeing every session has *some* +title immediately — even when the LLM call fails. + +Runs asynchronously after the first response is delivered so the LLM +call never adds latency to the user-facing reply. """ import logging @@ -9,6 +15,7 @@ from typing import Callable, Optional from agent.auxiliary_client import call_llm +from agent.title_heuristic import extract_title logger = logging.getLogger(__name__) @@ -117,23 +124,44 @@ def auto_title_session( failure_callback: Optional[FailureCallback] = None, main_runtime: dict = None, title_callback: Optional[TitleCallback] = None, + heuristic_placeholder: Optional[str] = None, ) -> None: - """Generate and set a session title if one doesn't already exist. + """Generate and set a session title via LLM. Called in a background thread after the first exchange completes. - Silently skips if: - - session_db is None - - session already has a title (user-set or previously auto-generated) - - title generation fails + + When ``heuristic_placeholder`` is provided, the LLM title overwrites + it only if the current title still matches the placeholder — + protecting user-set titles that arrived in the interim. + + When ``heuristic_placeholder`` is ``None``, the original behavior is + preserved: skip if any title already exists. + + Args: + heuristic_placeholder: The title set by the heuristic in + ``maybe_auto_title``. Pass ``None`` to preserve legacy + "skip if title exists" behavior. """ if not session_db or not session_id: return - # Check if title already exists (user may have set one via /title before first response) try: existing = session_db.get_session_title(session_id) if existing: - return + if heuristic_placeholder is not None: + # Race-condition guard: only overwrite if the current title + # still matches the heuristic placeholder. Protects user-set + # titles that arrived between the heuristic set and here. + if existing != heuristic_placeholder: + logger.debug( + "Title changed from heuristic placeholder (%r -> %r), skipping LLM overwrite", + heuristic_placeholder, existing, + ) + return + # Title matches placeholder — proceed to overwrite with LLM + else: + # Legacy path: any existing title means skip + return except Exception: return @@ -167,6 +195,10 @@ def maybe_auto_title( ) -> None: """Fire-and-forget title generation after the first exchange. + Sets a heuristic placeholder title synchronously (instant UI feedback), + then spawns a background thread for the LLM call which overwrites the + placeholder when it returns. + Only generates a title when: - This appears to be the first user→assistant exchange - No title is already set @@ -182,6 +214,27 @@ def maybe_auto_title( if user_msg_count > 2: return + # ── Synchronous heuristic: instant placeholder title ── + # Set the heuristic title immediately so the session is never untitled, + # even if the LLM call fails or is slow. The background LLM thread + # below overwrites this with a (usually better) LLM-generated title. + heuristic_title = None + try: + existing = session_db.get_session_title(session_id) + if existing: + return # user or prior auto-title already set, nothing to do + heuristic_title = extract_title(user_message) + if heuristic_title: + session_db.set_session_title(session_id, heuristic_title) + logger.debug("Heuristic session title: %s", heuristic_title) + if title_callback is not None: + try: + title_callback(heuristic_title) + except Exception: + logger.debug("Heuristic title_callback failed", exc_info=True) + except Exception as e: + logger.debug("Heuristic title failed: %s", e) + thread = threading.Thread( target=auto_title_session, args=(session_db, session_id, user_message, assistant_response), @@ -189,6 +242,7 @@ def maybe_auto_title( "failure_callback": failure_callback, "main_runtime": main_runtime, "title_callback": title_callback, + "heuristic_placeholder": heuristic_title if heuristic_title else None, }, daemon=True, name="auto-title", diff --git a/agent/title_heuristic.py b/agent/title_heuristic.py new file mode 100644 index 000000000000..e11c782aef8d --- /dev/null +++ b/agent/title_heuristic.py @@ -0,0 +1,325 @@ +"""Heuristic session title extraction from the first user message. + +Runs synchronously before the LLM title generation call to provide an +instant placeholder title. The LLM result (which runs in a background +thread) overwrites this when it returns. + +Uses pattern matching inspired by RAKE (Rapid Automatic Keyword Extraction): + 1. Strip conversational prefixes ("Can you", "Hey, I need help", …) + 2. Match action-verb patterns ("fix X", "review X", "refactor X") + 3. Match question patterns ("how does X work?" → "How X works") + 4. RAKE-inspired keyphrase fallback for unstructured messages + +No external dependencies — pure Python regex + scoring. +""" + +import re + +__all__ = ["extract_title"] + + +# ── Stopwords for keyphrase scoring ────────────────────────────── +_STOPWORDS = frozenset({ + "the", "a", "an", "is", "are", "was", "were", "be", "been", + "it", "this", "that", "these", "those", "my", "your", "our", + "i", "you", "we", "they", "he", "she", "me", "us", "them", + "to", "of", "in", "on", "at", "for", "with", "from", "by", + "do", "does", "did", "will", "would", "should", "can", "could", + "have", "has", "had", "not", "no", "just", "very", "really", + "still", "even", "also", "only", "up", "down", "out", "about", +}) + +# Constituent boundary: conjunctions, relative pronouns, punctuation +_BOUNDARY = re.compile( + r"(?:\s+(?:and|but|or|so|because|while|when|that|which|who|where|—|–)\s+)" + r"|(?:[.,;:!?]\s+)", + re.I, +) + +# Conversational prefixes, stripped iteratively (up to 3 passes) +_PREFIX = re.compile( + r"^(?:hi|hello|hey|yo|sup|so|yeah|ok|okay|um|uh|right|alright|" + r"can you|could you|would you|please|i need to|i want to|i'd like to|" + r"help me|help|i'm trying to|i'm looking to|i'm working on|" + r"i need help|i need help with|i need help understanding|" + r"let's|lets|we need to|we should|can we|could we|" + r"i have a|here's a|here is a|there's a|" + r"here's what's happening|here's what is happening|" + r"here is what's happening|here is what is happening|" + r"so here's|" + r"what's happening is|the thing is|" + r"any ideas on|any thoughts on|any suggestions for)" + r",?\s+", + re.I, +) + +_LEADING_DASH = re.compile(r"^[—–\-]+\s*") +_LEADING_FILLER = re.compile( + r"^(?:hey|hello|hi|yo|sup|so|well|look|right|okay|yeah|um|uh|—|–|-)\s+", + re.I, +) + +# Action-verb patterns: (verb_group, optional_article_group, object_group) +# The verb is preserved as-is (no normalization). +_ACTION_PATTERNS = [ + # fix / repair / patch / debug / resolve + (r"(fix|repair|patch|debug|resolve|troubleshoot)\s+(?:the\s+|a\s+|an\s+)?(.+)", + lambda m: f"{_cap(m.group(1))} {_clean_obj(m.group(2))}"), + # add / create / implement / build + (r"(add|create|implement|build|introduce|set up|setup)\s+(?:a\s+|an\s+|support for\s+|support\s+)?(.+)", + lambda m: f"{_cap(m.group(1))} {_clean_obj(m.group(2))}"), + # remove / delete / drop / disable + (r"(remove|delete|drop|disable|turn off|strip out|get rid of)\s+(?:the\s+|a\s+|an\s+)?(.+)", + lambda m: f"{_cap(m.group(1))} {_clean_obj(m.group(2))}"), + # update / change / modify / adjust + (r"(update|change|modify|adjust|tweak|bump)\s+(?:the\s+)?(.+)", + lambda m: f"{_cap(m.group(1))} {_clean_obj(m.group(2))}"), + # review / check / audit / inspect + (r"(review|check|audit|inspect|examine)\s+(.+)", + lambda m: f"{_cap(m.group(1))} {_clean_obj(m.group(2))}"), + # write / draft / compose + (r"(write|draft|compose)\s+(?:a\s+|an\s+)?(.+)", + lambda m: f"{_cap(m.group(1))} {_clean_obj(m.group(2))}"), + # run / test / execute + (r"(run|test|execute)\s+(?:the\s+|a\s+)?(.+)", + lambda m: f"{_cap(m.group(1))} {_clean_obj(m.group(2))}"), + # explain / describe + (r"(explain|describe)\s+(.+)", + lambda m: f"{_cap(m.group(1))} {_clean_obj(m.group(2))}"), + # refactor / restructure / reorganize + (r"(refactor|restructure|reorganize|clean up|rework)\s+(?:the\s+)?(.+)", + lambda m: f"{_cap(m.group(1))} {_clean_obj(m.group(2))}"), + # investigate / look into / figure out + (r"(investigate|look into|find out|figure out|dig into)\s+(.+)", + lambda m: f"{_cap(m.group(1))} {_clean_obj(m.group(2))}"), + # enable / turn on + (r"(enable|turn on)\s+(?:the\s+)?(.+)", + lambda m: f"{_cap(m.group(1))} {_clean_obj(m.group(2))}"), + # configure / wire up / connect + (r"(configure|wire up|connect)\s+(?:the\s+)?(.+)", + lambda m: f"{_cap(m.group(1))} {_clean_obj(m.group(2))}"), + # monitor / track / watch + (r"(monitor|track|watch)\s+(?:the\s+)?(.+)", + lambda m: f"{_cap(m.group(1))} {_clean_obj(m.group(2))}"), + # compare / benchmark + (r"(compare|benchmark)\s+(.+)", + lambda m: f"{_cap(m.group(1))} {_clean_obj(m.group(2))}"), + # talk about / discuss + (r"(talk about|discuss)\s+(.+)", + lambda m: f"{_cap(m.group(1).split()[-1])} {_clean_obj(m.group(2))}"), +] + +# Question patterns → clean topic phrases +_QUESTION_PATTERNS = [ + (r"how\s+(?:does|do|can|should|would)\s+(?:I|we|you)?\s*(.+?)(?:\s+work(?:s)?)?(?:\?|$)", + lambda m: f"How {_clean_q(m.group(1))} works"), + (r"what(?:'s|\s+is|\s+are)\s+(?:the\s+)?difference\s+between\s+(.+?)(?:\?|$)", + lambda m: f"Difference between {_clean_obj(m.group(1))}"), + (r"what(?:'s|\s+is|\s+are)\s+(.+?)(?:\?|$)", + lambda m: f"What {_clean_q(m.group(1))} is"), + (r"why\s+(?:does|is|do|are|did)\s+(.+?)(?:\?|$)", + lambda m: f"Why {_clean_q(m.group(1))}"), + (r"when\s+(?:does|is|do|should|did)\s+(.+?)(?:\?|$)", + lambda m: f"When {_clean_q(m.group(1))}"), + (r"where\s+is\s+(.+?)(?:\?|$)", + lambda m: f"Where {_clean_q(m.group(1))}"), + (r"where\s+(?:does|are|do)\s+(.+?)(?:\?|$)", + lambda m: f"Where {_clean_q(m.group(1))}"), + (r"can\s+(?:we|I)\s+(.+?)(?:\?|$)", + lambda m: f"{_cap(m.group(1).split()[0])} {_clean_obj(' '.join(m.group(1).split()[1:]))}"), + (r"(?:is there|does)\s+(.+?)(?:\?|$)", + lambda m: _clean_obj(m.group(1))), +] + +# Problem/topic statement patterns +_TOPIC_PATTERNS = [ + (r"(.+?)\s+(?:is broken|doesn't work|isn't working|failed|fails|crashes|is buggy)", + lambda m: f"Fix {_clean_obj(m.group(1))}"), + (r"(.+?)\s+(?:is slow|is laggy|is sluggish|performs badly)", + lambda m: f"Improve {_clean_obj(m.group(1))}"), + (r"(.+?)\s+(?:are getting|is getting|keeps getting|keeps being)\s+(.+)", + lambda m: f"Fix {_clean_obj(m.group(1))} {m.group(2).strip().rstrip('.,;:!?')}"), +] + + +def extract_title(message: str) -> str: + """Extract a short title from the first user message. + + Returns a title string (3-45 chars). Never raises — always returns + *something*, even if it's just the first few words of the message. + """ + if not message or not message.strip(): + return "" + + msg = message.strip() + msg = _strip_prefixes(msg) + msg = _LEADING_DASH.sub("", msg) + msg = msg.rstrip(".,;:!?") + + if not msg: + return "" + + # Action verb patterns + for pattern, formatter in _ACTION_PATTERNS: + m = re.search(pattern, msg, re.I) + if m: + title = _title_case(formatter(m)) + raw_obj = m.group(2) if (m.lastindex or 0) >= 2 else m.group(1) + title = _length_guard(title, raw_obj) + return title + + # Question patterns + for pattern, formatter in _QUESTION_PATTERNS: + m = re.search(pattern, msg, re.I) + if m: + result = _title_case(formatter(m)) + if len(result) > 45: + result = result[:42].rsplit(" ", 1)[0] + "…" + return result + + # Problem/topic statements + for pattern, formatter in _TOPIC_PATTERNS: + m = re.search(pattern, msg, re.I) + if m: + return _title_case(formatter(m)) + + # RAKE-inspired keyphrase fallback + return _keyphrase_fallback(msg) + + +# ── Internal helpers ───────────────────────────────────────────── + +def _strip_prefixes(msg: str) -> str: + """Iteratively strip conversational prefixes (up to 3 passes).""" + for _ in range(3): + new_msg = _PREFIX.sub("", msg) + if new_msg == msg: + break + msg = new_msg + return msg.strip() + + +def _clean_obj(text: str) -> str: + """Clean an object phrase: strip trailing clauses, truncate at word boundary.""" + t = text.strip().rstrip(".,;:!?") + # Cut at relative clauses + t = re.sub(r"\s+(?:when|that|which|while|where|because|since|if)\s+.+$", "", t, flags=re.I) + # Cut at coordinating conjunctions (keep "and" for compound objects like "X and Y") + t = re.split(r"\s+(?:but|or|so)\s+", t, maxsplit=1, flags=re.I)[0] + # Strip "keep getting/keeps being" progressive patterns + t = re.sub(r"\s+(?:keep|keeps)\s+(?:getting|being)\s+.+$", "", t, flags=re.I) + # Strip trailing prepositional phrases (broad char class for paths/slashes) + t = re.sub( + r"\s+(?:in|at|from|for|on|to|of|with|under|over|into|between)" + r"\s+(?:the\s+|a\s+|an\s+)?[\w][\w\s\-/\.]{0,30}$", + "", t, flags=re.I, + ) + # Strip orphaned trailing prepositions + t = re.sub( + r"\s+(?:in|at|from|for|on|to|of|with|under|over|into|between)\b\s*$", + "", t, flags=re.I, + ) + if len(t) > 30: + t = t[:30].rsplit(" ", 1)[0] + return t.strip() + + +def _clean_q(text: str) -> str: + """Extract a noun phrase from a question target.""" + t = text.strip().rstrip(".,;:!?") + t = re.sub(r"\s+works?$", "", t, flags=re.I) + t = re.sub(r"\s+(?:right|correct|isn't it|aren't they)\??$", "", t, flags=re.I) + t = re.split(r"\s+(?:and|but|or)\s+", t, maxsplit=1, flags=re.I)[0] + t = re.sub( + r"\s+(?:in|at|from|for|on|to|of|with|under|over|into)" + r"\s+(?:the\s+)?[\w][\w\s\-/\.]{0,30}$", + "", t, flags=re.I, + ) + t = re.sub(r"\s+(?:in|at|from|for|on|to|of|with)\b\s*$", "", t, flags=re.I) + if len(t) > 35: + t = t[:35].rsplit(" ", 1)[0] + return t.strip() + + +def _length_guard(title: str, raw_object: str) -> str: + """Progressive relaxation: if the title is < 3 words, retry with less aggressive cleanup.""" + if len(title.split()) >= 3: + return title + minimal = raw_object.strip().rstrip(".,;:!?") + # First try: only strip relative clauses, keep prepositional phrases + minimal = re.sub(r"\s+(?:when|that|which|while|because|since|if)\s+.+$", "", minimal, flags=re.I) + minimal = re.split(r"\s+(?:but|or|so)\s+", minimal, maxsplit=1, flags=re.I)[0] + if len(minimal.split()) >= 2: + if len(minimal) > 40: + minimal = minimal[:40].rsplit(" ", 1)[0] + verb = title.split()[0] if title else "" + return _title_case(f"{verb} {minimal.strip()}") + # Second try: keep everything, just truncate + minimal = raw_object.strip().rstrip(".,;:!?") + if len(minimal) > 40: + minimal = minimal[:40].rsplit(" ", 1)[0] + verb = title.split()[0] if title else "" + return _title_case(f"{verb} {minimal.strip()}") + + +def _keyphrase_fallback(msg: str) -> str: + """RAKE-inspired keyphrase extraction for fallback cases.""" + phrases = [p.strip() for p in _BOUNDARY.split(msg) if p.strip()] + if not phrases: + words = msg.split()[:7] + return _title_case(" ".join(words))[:45].rstrip(".,;:!?") + + # TF scoring across all phrases + all_words = re.findall(r"[A-Za-z][\w\-/\.]{0,30}", msg.lower()) + word_freq: dict[str, int] = {} + for w in all_words: + if w in _STOPWORDS: + continue + word_freq[w] = word_freq.get(w, 0) + 1 + + best_score = -1.0 + best_phrase = phrases[0] + + for i, phrase in enumerate(phrases): + words = phrase.split() + if not words: + continue + content_words = [w for w in words if w.lower().rstrip(".,;:!?") not in _STOPWORDS] + if not content_words: + continue + tf_score = sum(word_freq.get(w.lower().rstrip(".,;:!?"), 1) for w in words) + length_bonus = 1.0 if 3 <= len(words) <= 7 else 0.6 + position_bonus = 1.5 if i == 0 else (1.2 if i == 1 else 1.0) + score = tf_score * length_bonus * position_bonus * len(content_words) + if score > best_score: + best_score = score + best_phrase = phrase + + title = best_phrase.strip().rstrip(".,;:!?") + title = _LEADING_FILLER.sub("", title).strip() + if len(title) > 45: + title = title[:45].rsplit(" ", 1)[0] + return _title_case(title.rstrip(".,;:!?")) + + +def _cap(word: str) -> str: + """Capitalize first letter, preserve ALL-CAPS tokens.""" + if word.isupper() and len(word) > 1: + return word + return word[0].upper() + word[1:] if word else word + + +def _title_case(text: str) -> str: + """Title-case preserving ALL-CAPS tokens (PR, API, TUI) and snake_case identifiers.""" + words = text.split() + result = [] + for i, w in enumerate(words): + if w.isupper() and len(w) > 1: + result.append(w) + elif "_" in w and w.replace("_", "").isalnum(): + result.append(w) + elif i == 0: + result.append(w[0].upper() + w[1:] if w else w) + else: + result.append(w) + return " ".join(result) diff --git a/tests/agent/test_title_generator.py b/tests/agent/test_title_generator.py index 43b1c1e6bf98..f90b30f795e2 100644 --- a/tests/agent/test_title_generator.py +++ b/tests/agent/test_title_generator.py @@ -150,18 +150,52 @@ def mock_call_llm(**kwargs): class TestAutoTitleSession: - """Tests for auto_title_session() — the sync worker function.""" + """Tests for auto_title_session() — the sync LLM worker function.""" def test_skips_if_no_session_db(self): auto_title_session(None, "sess-1", "hi", "hello") # should not crash - def test_skips_if_title_exists(self): + def test_overwrites_heuristic_placeholder(self): + """auto_title_session overwrites the heuristic placeholder with the LLM title.""" db = MagicMock() - db.get_session_title.return_value = "Existing Title" + db.get_session_title.return_value = "Heuristic Placeholder" - with patch("agent.title_generator.generate_title") as gen: - auto_title_session(db, "sess-1", "hi", "hello") + with patch("agent.title_generator.generate_title", return_value="LLM Title"): + auto_title_session(db, "sess-1", "hi", "hello", + heuristic_placeholder="Heuristic Placeholder") + db.set_session_title.assert_called_once_with("sess-1", "LLM Title") + + def test_skips_overwrite_when_user_renamed(self): + """LLM thread must not overwrite a title the user set in the interim.""" + db = MagicMock() + db.get_session_title.return_value = "User's Custom Title" + + with patch("agent.title_generator.generate_title", return_value="LLM Title") as gen: + auto_title_session(db, "sess-1", "hi", "hello", + heuristic_placeholder="Heuristic Placeholder") gen.assert_not_called() + db.set_session_title.assert_not_called() + + def test_skips_when_no_placeholder_and_title_exists(self): + """Without heuristic_placeholder, skip if any title exists (legacy behavior).""" + db = MagicMock() + db.get_session_title.return_value = "User Custom Title" + + with patch("agent.title_generator.generate_title", return_value="LLM Title") as gen: + auto_title_session(db, "sess-1", "hi", "hello", + heuristic_placeholder=None) + gen.assert_not_called() + db.set_session_title.assert_not_called() + + def test_overwrites_when_title_matches_placeholder(self): + """LLM overwrites when current title still matches the placeholder.""" + db = MagicMock() + db.get_session_title.return_value = "Heuristic Placeholder" + + with patch("agent.title_generator.generate_title", return_value="LLM Title"): + auto_title_session(db, "sess-1", "hi", "hello", + heuristic_placeholder="Heuristic Placeholder") + db.set_session_title.assert_called_once_with("sess-1", "LLM Title") def test_generates_and_sets_title(self): db = MagicMock() @@ -239,6 +273,7 @@ def test_fires_on_first_exchange(self): failure_callback=None, main_runtime=None, title_callback=None, + heuristic_placeholder="Hello", ) def test_forwards_failure_callback_to_worker(self): @@ -265,6 +300,7 @@ def _cb(task, exc): failure_callback=_cb, main_runtime=None, title_callback=None, + heuristic_placeholder="Hello", ) def test_skips_if_no_response(self): @@ -273,3 +309,33 @@ def test_skips_if_no_response(self): def test_skips_if_no_session_db(self): maybe_auto_title(None, "sess-1", "hello", "response", []) # no db + + def test_sets_heuristic_title_synchronously(self): + """maybe_auto_title sets a heuristic placeholder before spawning the LLM thread.""" + db = MagicMock() + db.get_session_title.return_value = None + history = [ + {"role": "user", "content": "Fix the dropdown menu"}, + {"role": "assistant", "content": "Sure, let me check..."}, + ] + + with patch("agent.title_generator.auto_title_session"): + maybe_auto_title(db, "sess-1", "Fix the dropdown menu", "Sure, let me check...", history) + # Heuristic title should be set synchronously + db.set_session_title.assert_called_once() + assert db.set_session_title.call_args[0][0] == "sess-1" + heuristic = db.set_session_title.call_args[0][1] + assert "dropdown" in heuristic.lower() + + def test_skips_heuristic_if_title_exists(self): + """If the user already set a title, the heuristic should not overwrite it.""" + db = MagicMock() + db.get_session_title.return_value = "User Set Title" + history = [ + {"role": "user", "content": "Fix the dropdown"}, + {"role": "assistant", "content": "Sure"}, + ] + + with patch("agent.title_generator.auto_title_session"): + maybe_auto_title(db, "sess-1", "Fix the dropdown", "Sure", history) + db.set_session_title.assert_not_called() diff --git a/tests/agent/test_title_heuristic.py b/tests/agent/test_title_heuristic.py new file mode 100644 index 000000000000..8cce0097d528 --- /dev/null +++ b/tests/agent/test_title_heuristic.py @@ -0,0 +1,149 @@ +"""Tests for agent.title_heuristic — regex-based title extraction.""" + +from agent.title_heuristic import extract_title + + +class TestActionVerbs: + """Action verb patterns produce clean imperative titles.""" + + def test_fix_pattern(self): + assert extract_title("Fix the dropdown menu closing when you hover") == "Fix dropdown menu closing" + + def test_review_pattern(self): + assert extract_title("Can you review PR #3979 on radix-ui/primitives?") == "Review PR #3979" + + def test_add_pattern(self): + assert extract_title("I want to add dark mode support to the settings page") == "Add dark mode support" + + def test_remove_pattern(self): + assert extract_title("Remove the deprecated auth middleware from the gateway") == "Remove deprecated auth middleware" + + def test_refactor_pattern(self): + assert extract_title("Let's refactor the session DB layer into a clean module") == "Refactor session DB layer" + + def test_patch_preserves_original_verb(self): + """'patch' should stay 'patch', not be normalized to 'fix'.""" + assert extract_title("Patch the title generator to include tool call context") == "Patch title generator" + + def test_adjust_preserves_original_verb(self): + """'adjust' should stay 'adjust', not be normalized to 'update'.""" + title = extract_title("Adjust the timeout for auxiliary LLM calls") + assert title.startswith("Adjust") + + def test_explain_pattern(self): + assert extract_title("Explain the credential pool resolution order") == "Explain the credential pool" + + def test_investigate_preserves_question_word(self): + title = extract_title("I'm trying to figure out why my titles keep getting overwritten") + assert "why" in title.lower() + + +class TestQuestions: + """Question patterns produce clean topic phrases.""" + + def test_how_does_x_work(self): + assert extract_title("How does the auxiliary client fallback chain work?") == "How the auxiliary client fallback chain works" + + def test_what_is_difference_between(self): + title = extract_title("What's the difference between auto_title_session and maybe_auto_title?") + assert title.startswith("Difference between") + + def test_where_is_x(self): + title = extract_title("Where is the title generation prompt defined?") + assert title.startswith("Where") + assert "title generation prompt" in title + + def test_can_we_x(self): + title = extract_title("Can we use a cheaper model for title generation?") + assert title.startswith("Use") + + +class TestPrefixStripping: + """Conversational prefixes are stripped iteratively.""" + + def test_strips_hey_comma(self): + title = extract_title("Hey, I need help understanding the session lifecycle in the TUI") + assert not title.lower().startswith("hey") + + def test_strips_so_prefix(self): + title = extract_title("so the dropdown focus thing is still broken right?") + assert not title.lower().startswith("so ") + + def test_strips_nested_prefixes(self): + title = extract_title("Can you please help me fix the auth middleware") + assert title.startswith("Fix") or title.startswith("Help") + + +class TestEdgeCases: + """Edge cases that should produce reasonable output without crashing.""" + + def test_empty_message(self): + assert extract_title("") == "" + + def test_whitespace_only(self): + assert extract_title(" ") == "" + + def test_very_short_message(self): + title = extract_title("fix bug") + assert title == "Fix bug" + + def test_no_action_verb(self): + """Messages without action verbs fall to keyphrase fallback.""" + title = extract_title("MiMo produces worse titles than Claude") + assert title # should return something + assert len(title) > 0 + + def test_em_dash_artifact(self): + title = extract_title("here's what's happening — when I open a new session the title is just generic") + assert not title.startswith("—") + assert not title.startswith("–") + + +class TestTokenPreservation: + """Technical tokens (PR #N, snake_case, file paths) are preserved.""" + + def test_preserves_pr_number(self): + title = extract_title("Can you review PR #3979 on radix-ui/primitives?") + assert "PR #3979" in title + + def test_preserves_snake_case(self): + title = extract_title("What's the difference between auto_title_session and maybe_auto_title?") + assert "auto_title_session" in title + + def test_preserves_file_extension(self): + title = extract_title("Create a SKILL.md for session title best practices") + assert "SKILL.md" in title + + +class TestLengthGuard: + """Progressive relaxation prevents overly short titles.""" + + def test_write_test_keeps_context(self): + """'Write test' alone is too short; length guard should keep more context.""" + title = extract_title("Write a test for the title generator") + assert len(title.split()) >= 3 + + def test_build_script_keeps_context(self): + title = extract_title("Build a script that monitors session title quality over time") + assert len(title.split()) >= 3 + + +class TestNeverRaises: + """extract_title must never raise — always return a string.""" + + def test_none_input(self): + # Should handle gracefully (returns empty string) + try: + result = extract_title(None) + assert isinstance(result, str) + except TypeError: + pass # None input is acceptable to reject + + def test_unicode_input(self): + title = extract_title("修复下拉菜单关闭问题") + assert isinstance(title, str) + + def test_very_long_message(self): + title = extract_title("Fix " + "x" * 5000) + assert isinstance(title, str) + assert len(title) <= 80