fix(title): an attachment-only opener stays untitled, not @file: path garbage (#92068)

The manual-attach path (composer attach chip / hand-typed @file:) sends no
Desktop paste preview, so the build_title_input ref-only shortcut never fires
and derive_title names the session after the truncated file path
(title_source='derived' DB rows from the issue).

An opener that reduces to nothing but @file:/@folder: context references
(and their expansion footer) is a file drop, not a request: refuse it at
every title entry point — is_titleable_user_message, derive_title, and
generate_title (reached via auto_title_session, which lacks the
instant-title guard). Prose around an attachment keeps titling from the
prose; the paste-preview path is untouched.

Co-authored-by: andyst-dev <andy@example.com>
Co-authored-by: fangliquanflq <fangliquan@example.com>
This commit is contained in:
Brooklyn Nicholson
2026-09-24 16:20:29 -05:00
committed by brooklyn!
parent 5eeee479f8
commit afad9bb8ec
2 changed files with 59 additions and 2 deletions

View File

@@ -268,6 +268,25 @@ def _strip_one_wrapper(text: str) -> str:
return text
# Matches an ``@file:``/``@folder:`` context reference the way the canonical
# ``agent.context_references`` reference pattern does: an unquoted ``\S+``
# value, or a backtick/double/single-quoted value (a space-bearing path is
# written backtick-quoted). Kept local so the titler doesn't import the
# context-reference machinery (circularity / startup cost) just to detect the
# attachment-only shape (#92068).
_CONTEXT_REFERENCE_TOKEN_RE = re.compile(
r"(?<![\w/])@(?:file|folder):(?:(?:`[^`\n]+`|\"[^\"\n]+\"|'[^'\n]+')|\S+)"
)
def _attachment_only_opener(message: str) -> bool:
"""True when nothing but context references (and their expansion footer)
remain — a manual-attach opener with no typed request and no paste
preview has no topic of its own to title (#92068)."""
residual = _CONTEXT_REFERENCE_TOKEN_RE.sub("", _CONTEXT_FOOTER_RE.sub("", message))
return not residual.strip()
def _summarize_user_message(user_message: str) -> str:
"""Text worth titling: describe a ``/skill`` invocation (it embeds the whole skill body), then strip wrappers."""
if not user_message:
@@ -310,11 +329,19 @@ def build_title_input(user_message: str, title_preview: str | None = None) -> st
def is_titleable_user_message(user_message: str) -> bool:
"""False for machine-authored openers and turns that reduce to nothing once scaffolding is stripped."""
return (isinstance(user_message, str) and bool(user_message.strip()) and not user_message.lstrip().startswith(_MACHINE_PREFIXES)
and bool(_summarize_user_message(user_message).strip()))
and bool(_summarize_user_message(user_message).strip())
# An attachment-only opener (manual attach, no paste preview) is a
# file drop, not a request: deriving its "title" from the message
# names the session after the truncated file path (#92068).
and not _attachment_only_opener(user_message))
def derive_title(user_message: str, title_preview: str | None = None) -> Optional[str]:
"""Instant title: first meaningful line trimmed to a word boundary. No model, never fails."""
# Attachment-only opener, no paste preview: a file drop has no topic —
# refuse rather than name the session after the truncated path (#92068).
if not title_preview and _attachment_only_opener(user_message):
return None
line = " ".join(_first_line(build_title_input(user_message, title_preview)).split())
if len(line) > MAX_DERIVED_TITLE_CHARS:
cut = line[:MAX_DERIVED_TITLE_CHARS]
@@ -462,7 +489,12 @@ def generate_title(
except Exception: # fail open: a broken validator must not disable titling
logger.debug("Title runtime validator raised; proceeding", exc_info=True)
user_snippet = build_title_input(user_message, title_preview)
if not user_snippet.strip():
if not user_snippet.strip() or (
# An attachment-only opener (manual attach, no paste preview) reaches
# here via auto_title_session, which lacks the instant-title guard:
# refuse it rather than titling the session after the file path (#92068).
not title_preview and _attachment_only_opener(user_snippet)
):
return None
language = _title_language()
# str.replace, not str.format: the prompt embeds literal JSON braces.

View File

@@ -11,6 +11,7 @@ from agent.title_generator import (
derive_title,
generate_title,
auto_title_session,
is_titleable_user_message,
maybe_auto_title,
wait_for_title_upgrades,
_title_language,
@@ -55,6 +56,30 @@ class TestGenerateTitle:
assert "---" not in title_input and "@file:" not in title_input
assert derive_title(ref + footer, preview).startswith("Quarterly incident analysis")
def test_manual_attachment_only_opener_yields_no_path_title(self):
"""#92068: the manual-attach path (composer attach chip / hand-typed
``@file:``) sends NO Desktop paste preview, so the build_title_input
ref-only shortcut cannot rescue it. The titler must still refuse to
name the session after the truncated file path."""
msg = "@file:AppData/Local/hermes/profiles/local/attachments/report.pdf"
assert is_titleable_user_message(msg) is False
assert derive_title(msg) is None
assert derive_title(msg + "\n\n--- Attached Context ---\n(file content)") is None
# Backtick-quoted (space-bearing) paths and @folder: refs too.
assert is_titleable_user_message("@file:`my file.txt`") is False
assert is_titleable_user_message("@folder:/some/dir") is False
# The background model-title path refuses the same opener.
assert generate_title(msg) is None
def test_attachment_plus_instruction_titles_from_the_instruction(self):
"""Prose around a manual attachment keeps driving the title — an
attachment WITH a typed request is a real question, not a file drop."""
msg = "Review @file:notes.txt and fix the off-by-one"
assert is_titleable_user_message(msg) is True
assert derive_title(msg) == "Review @file:notes.txt and fix the off-by-one"
def test_title_input_budget_and_manual_attachments_stay_unread(self):
title_input = build_title_input("Describe the release plan", "p" * MAX_TITLE_INPUT_CHARS)