diff --git a/agent/title_generator.py b/agent/title_generator.py index c8f771f9ac..a726bed37d 100644 --- a/agent/title_generator.py +++ b/agent/title_generator.py @@ -606,6 +606,18 @@ def _persist_session_title(session_db, session_id, title, *, source, dedupe=True return _set(deduped) +def apply_subagent_title(session_db, session_id: str, goal: str) -> Optional[str]: + """Title a delegate run ``Subagent: `` at ``derived`` authority. No model call: + runs fan out in bulk, and the prefix alone is what tells them apart from conversations wherever + ``sessions.show_subagents`` lists them (#97202). Collisions get ``#N``. Never raises.""" + try: + derived = derive_title(goal) if is_titleable_user_message(goal) else None + return _persist_session_title(session_db, session_id, f"Subagent: {derived}", source="derived") if derived else None + except Exception: + logger.debug("Subagent title failed for %s", session_id, exc_info=True) + return None + + def apply_instant_title( session_db, session_id: str, user_message: str, title_callback: Optional[TitleCallback] = None, title_preview: str | None = None, diff --git a/agent/turn_context.py b/agent/turn_context.py index 113527c540..452a2f519f 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -157,9 +157,9 @@ def append_notes_to_multimodal_content(content: Any, notes: Optional[str]) -> bo return False -# Surfaces whose sessions must not be auto-titled: cron names its own session and -# its opener is a delivery hint; subagent sessions are hidden from every picker. -_UNTITLED_PLATFORMS = frozenset({"cron", "subagent"}) +# Cron sessions are never auto-titled: cron names its own session and its opener is a delivery hint. +# Subagent runs get a cheap ``Subagent: `` title instead of a model call (apply_subagent_title). +_UNTITLED_PLATFORMS = frozenset({"cron"}) def _maybe_title_session_at_turn_start(agent: Any, messages: List[Any]) -> None: @@ -168,11 +168,12 @@ def _maybe_title_session_at_turn_start(agent: Any, messages: List[Any]) -> None: session_id = getattr(agent, "session_id", None) if not session_db or not session_id: return - if str(getattr(agent, "platform", "") or "").lower() in _UNTITLED_PLATFORMS: + platform = str(getattr(agent, "platform", "") or "").lower() + if platform in _UNTITLED_PLATFORMS: return try: from agent.message_content import flatten_message_text - from agent.title_generator import maybe_auto_title + from agent.title_generator import apply_subagent_title, maybe_auto_title # Turn's user message as text; image-only turns yield "" and are skipped. user_text = "" @@ -194,6 +195,9 @@ def _maybe_title_session_at_turn_start(agent: Any, messages: List[Any]) -> None: ensure() if not getattr(agent, "_session_db_created", False): return + if platform == "subagent": + apply_subagent_title(session_db, session_id, user_text) + return # Snapshot runtime identity so the background titler can skip if the user # switches models before it fires. # ``session_id`` rides along so the background titler's OpenCode request carries the diff --git a/tests/agent/test_turn_context.py b/tests/agent/test_turn_context.py index 26495c2384..41c3ecfb0a 100644 --- a/tests/agent/test_turn_context.py +++ b/tests/agent/test_turn_context.py @@ -506,6 +506,29 @@ def test_prologue_does_not_title_machine_driven_runs(platform): assert not _title_turn(platform).called +def test_prologue_names_a_subagent_run_after_its_goal_without_a_model_call(): + """A delegate run gets ``Subagent: `` at derived authority so it reads as machinery + wherever ``sessions.show_subagents`` lists it, instead of staying untitled (#97202).""" + from agent import turn_context + + agent = _TitlingAgent("subagent") + agent._session_db.set_auto_title.return_value = True + with patch("agent.title_generator.maybe_auto_title") as titler: + turn_context._maybe_title_session_at_turn_start( + agent, [{"role": "user", "content": "Audit the billing module\n\nContext: ..."}]) + assert not titler.called + agent._session_db.set_auto_title.assert_called_once_with( + "sess-1", "Subagent: Audit the billing module", source="derived") + + +def test_prologue_leaves_cron_runs_untitled(): + agent = _TitlingAgent("cron") + from agent import turn_context + + turn_context._maybe_title_session_at_turn_start(agent, [{"role": "user", "content": "Run the job"}]) + assert not agent._session_db.set_auto_title.called + + def test_prologue_forwards_the_submit_title_preview_to_the_titler(): """A paste-shrunk ``display_metadata.title_preview`` from prompt.submit is the text the titler should read, not the full pasted body."""