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:
parent
53a4003208
commit
b684cbb094
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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", "南京市秦淮区 小时级天气预报") == (
|
||||
"南京市秦淮区 小时级天气预报"
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue