diff --git a/agent/title_generator.py b/agent/title_generator.py index 5f6635ce15e21..f2ac6c9f1a35e 100644 --- a/agent/title_generator.py +++ b/agent/title_generator.py @@ -132,6 +132,15 @@ _MACHINE_PREFIXES = ( "[Runtime note:", "[System note:", "[SYSTEM]", + # Model-switch marker from tui_gateway.server._append_model_switch_marker. + # It is persisted with role="user" (strict OpenAI-compatible providers + # reject a system message that is not first — #48338), so without this + # entry it looks like a real opening turn: switching models before the + # first real message titled the session + # "[System: The active model for this chat has…" instead of the user's + # actual question. Keep in sync with + # tui_gateway.server._MODEL_SWITCH_MARKER_PREFIX. + "[System: The active model for this chat has changed to ", ) @@ -673,10 +682,19 @@ def maybe_auto_title( # turn prologue, and after it when called post-response, so accept both. # Entries are dicts; anything else means a caller passed the wrong # positional and titling must degrade quietly rather than raise. + # + # Machine-authored openers are excluded from the count. They are persisted + # with role="user" (see _MACHINE_PREFIXES), so counting them would make a + # session that opened with e.g. a model-switch marker look like it was + # already past its opening turn — the real first question then arrives at + # count 2 and never gets titled at all, leaving the session permanently + # NULL-titled. user_msg_count = sum( 1 for m in (conversation_history or []) - if isinstance(m, dict) and m.get("role") == "user" + if isinstance(m, dict) + and m.get("role") == "user" + and is_titleable_user_message(m.get("content") or "") ) if user_msg_count > 1 and not _session_is_untitled(session_db, session_id): return diff --git a/tests/agent/test_title_generator.py b/tests/agent/test_title_generator.py index 1cdf0950bf783..72c70fa6ed212 100644 --- a/tests/agent/test_title_generator.py +++ b/tests/agent/test_title_generator.py @@ -452,3 +452,88 @@ class TestRuntimeValidator: assert called.wait(timeout=10), "auto_title thread never ran" kwargs = mock_auto.call_args.kwargs assert kwargs["runtime_validator"] is _v + + +class TestModelSwitchMarkerNotTitleable: + """Regression: a model-switch marker must never become the session title. + + ``_append_model_switch_marker`` (tui_gateway/server.py) persists its notice + with ``role="user"`` because strict OpenAI-compatible providers reject a + system message that is not first (#48338). Titling therefore has to + recognise it as machine-authored, or switching models before asking the + first real question titles the session + "[System: The active model for this chat has…". + """ + + MARKER = ( + "[System: The active model for this chat has changed to " + "deepseek-v4-flash via provider 94mei. From this point forward, use " + "this runtime metadata when answering questions about what " + "model/provider is active.]" + ) + + def test_marker_prefix_matches_gateway_constant(self): + """The guard must stay in sync with the gateway's marker builder.""" + from tui_gateway.server import _MODEL_SWITCH_MARKER_PREFIX + from agent.title_generator import _MACHINE_PREFIXES + + assert _MODEL_SWITCH_MARKER_PREFIX in _MACHINE_PREFIXES + assert self.MARKER.startswith(_MODEL_SWITCH_MARKER_PREFIX) + + def test_marker_is_not_titleable(self): + from agent.title_generator import is_titleable_user_message + + assert is_titleable_user_message(self.MARKER) is False + + def test_derive_title_is_unguarded_by_design(self): + """``derive_title`` is a dumb formatter; the guard lives in the callers. + + Documents the contract deliberately: every caller checks + ``is_titleable_user_message`` first, so ``derive_title`` itself is + allowed to format a marker. If a future caller forgets that check, the + marker leaks into the title — which is exactly the bug this class + guards against. + """ + from agent.title_generator import derive_title + + assert derive_title(self.MARKER) is not None + + def test_unrelated_system_bracket_text_still_titleable(self): + """The guard is narrow: real user text starting "[System:" still titles.""" + from agent.title_generator import is_titleable_user_message + + assert is_titleable_user_message("[System: my own note] how do I ...") is True + + def test_real_question_after_marker_still_titles(self): + """The marker must not consume the session's one titling opportunity. + + The marker is a role="user" row, so counting it made the first real + question look like turn 2 — and titling bailed out entirely, leaving + the session permanently untitled. + """ + db = MagicMock() + db.get_session_title.return_value = None + db.get_session_title_source.return_value = None + history = [ + {"role": "user", "content": self.MARKER}, + {"role": "user", "content": "南京市秦淮区 小时级天气预报"}, + ] + + with patch("agent.title_generator.auto_title_session") as mock_auto: + import threading + + called = threading.Event() + mock_auto.side_effect = lambda *a, **k: called.set() + maybe_auto_title(db, "sess-1", "南京市秦淮区 小时级天气预报", history) + assert called.wait(timeout=10), "auto_title never ran after marker" + + def test_instant_title_skips_marker_uses_real_message(self): + from agent.title_generator import apply_instant_title + + db = MagicMock() + db.get_session_title_source.return_value = None + + assert apply_instant_title(db, "sess-1", self.MARKER) is None + assert apply_instant_title(db, "sess-1", "南京市秦淮区 小时级天气预报") == ( + "南京市秦淮区 小时级天气预报" + )