fix(title): stop model-switch marker from becoming the session title

Switching models before sending the first real message titled the session
"[System: The active model for this chat has…" instead of the user's actual
question.

`_append_model_switch_marker` persists its notice with `role="user"` because
strict OpenAI-compatible providers reject a system message that is not first
(#48338). Titling had no way to tell that apart from a genuine opening turn,
which caused two distinct failures:

1. `_MACHINE_PREFIXES` did not cover the marker. Its `[System: ` prefix
   matches none of `[CONTEXT COMPACTION`, `[Runtime note:`, or `[SYSTEM]`
   (different case, no closing bracket), so `is_titleable_user_message()`
   returned True and the marker was formatted into the title.

2. `maybe_auto_title()` counted the marker as a user message. With the marker
   present, the first real question arrived at `user_msg_count == 2` and the
   `> 1` guard returned early, so the session was never titled at all and its
   `title` column stayed NULL. Fixing only (1) would therefore have traded a
   wrong title for a permanently missing one.

Add the marker prefix to `_MACHINE_PREFIXES` (kept in sync with
`tui_gateway.server._MODEL_SWITCH_MARKER_PREFIX`) and count only titleable
user messages when detecting the opening turn.

The guard stays narrow: ordinary user text that happens to start with
"[System:" still titles normally.

Adds 6 regression tests, verified to fail without the fix.
This commit is contained in:
yy28 2026-08-09 11:23:41 +08:00 committed by Brooklyn Nicholson
parent 53a4003208
commit b684cbb094
2 changed files with 104 additions and 1 deletions

View File

@ -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

View File

@ -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", "南京市秦淮区 小时级天气预报") == (
"南京市秦淮区 小时级天气预报"
)