Auto-title at turn start, not turn completion

The title rides the user message the moment it lands — a long agentic turn no longer holds the session name hostage; an opener signature keeps the completion hook (background turns) from burning duplicate attempts.
This commit is contained in:
Rohit C Prasad 2026-08-24 15:12:56 -07:00
parent 5c8a60e7ee
commit eae1ed315f
4 changed files with 44 additions and 0 deletions

View File

@ -2494,6 +2494,10 @@ def create_app(manager: SessionManager) -> FastAPI:
)
if event.type.value in _CHECKPOINTS:
manager.save(session_id, engine)
if event.type.value == "turn_start":
# Title on the user's words the moment they land — never behind
# a long agentic turn (owner catch 2026-08-24).
manager._maybe_autotitle(session_id)
finally:
manager.mark_idle(session_id)
manager.save(session_id, engine)

View File

@ -223,6 +223,12 @@ class SessionManager:
self._autotitle_inflight: set[str] = set()
self._autotitle_tasks: set[asyncio.Task] = set()
self._autotitle_attempts: dict[str, int] = {}
# Opener-count signature of the last attempt: titling fires at TURN START (owner
# catch 2026-08-24 — waiting for an agentic turn to COMPLETE left sessions
# untitled for however long the scan ran), and the completion hook still covers
# background turns; this guard keeps the two trigger points from burning
# duplicate attempts on the same openers.
self._autotitle_sig: dict[str, int] = {}
self.workspace_trust = WorkspaceTrustStore()
self.secrets = SecretStore()
# No explicit provider injected → route by the model's `provider:` prefix (OpenAI default,
@ -5093,6 +5099,11 @@ class SessionManager:
][:2]
if not openers:
return
# Same openers as the last attempt → nothing new to say; skip WITHOUT burning an
# attempt (this is how the turn-start and turn-end triggers coexist).
if self._autotitle_sig.get(session_id) == len(openers):
return
self._autotitle_sig[session_id] = len(openers)
self._autotitle_attempts[session_id] = (
self._autotitle_attempts.get(session_id, 0) + 1
)

View File

@ -146,3 +146,27 @@ async def test_sanitizes_and_rejects_absurd_output(tmp_path):
mgr2, _ = _mgr(tmp_path / "b", [_text("ok")], ["x" * 90])
await _turn(mgr2, "s8", "the login page 500s")
assert mgr2.session_store.title_state("s8")["auto_title"] is None
def test_turn_start_titles_before_the_turn_completes(tmp_path):
# Owner catch 2026-08-24: titling used to ride ONLY turn completion, so a long
# agentic turn left the session titled by its first message for the whole run. The
# turn-start hook titles on the user's words the moment they land.
async def go():
mgr, provider = _mgr(tmp_path, [_text("working on it…")], ["Scan the API for secrets"])
sid = "early1"
engine = mgr.get_engine(sid, agent="chat")
engine.messages.append({"role": "user", "content": "scan code for vuln and secrets"})
mgr.save(sid, engine)
mgr.mark_running(sid) # the turn is RUNNING — no completion in sight
mgr._maybe_autotitle(sid) # the app's turn_start hook
deadline = time.time() + 5.0
while sid in mgr._autotitle_inflight and time.time() < deadline:
await asyncio.sleep(0.005)
state = mgr.session_store.title_state(sid)
assert state["auto_title"] == "Scan the API for secrets"
# The completion hook later, same openers: skipped WITHOUT burning attempt 2.
mgr.mark_idle(sid)
assert mgr._autotitle_attempts[sid] == 1
asyncio.run(go())

View File

@ -423,6 +423,11 @@ def test_ws_allows_only_one_inflight_turn_per_session(tmp_path):
self.max_active = 0
def complete(self, *, model, messages, tools=None, **settings):
# The fire-and-forget auto-title completion legitimately runs CONCURRENTLY
# with the chat turn (it fires at turn start, owner catch 2026-08-24) — the
# invariant under test is one CHAT turn at a time, so exclude title calls.
if messages and "title chat sessions" in str(messages[0].get("content", "")):
return _text("A Title")
with self._lock:
self.active += 1
self.max_active = max(self.max_active, self.active)