fix(desktop): stage first-contact profile-build onboarding in tui_gateway

Desktop chat goes through tui_gateway, which never applied the gateway's
first-message profile-build sidecar note. Add shared first_contact_turn_note
helper and wire it into the prompt turn so fresh installs get the same
opt-in profile setup offer as messaging surfaces.

Adapted to current main (salvage of open PR #82765, fixes #82750):
_run_prompt_submit now lives in tui_gateway/prompt_turn.py; the note is
staged on agent._gateway_turn_context_notes (the existing sidecar channel,
consumed by agent.turn_context on the user message) so the system prompt
stays byte-stable; the prior-session probe rides _session_db(session).
The low-value onboarding test classes purged from main were not re-added —
only new coverage for first_contact_turn_note and the staging path.

Co-authored-by: Noa <rainbowgore@users.noreply.github.com>
(cherry picked from commit b843900ca818912f0480f6926a059d292b63ffe6)
This commit is contained in:
Cursor Agent
2026-08-09 22:26:05 +00:00
committed by brooklyn!
parent d10afb5a88
commit 6814eb4c08
4 changed files with 265 additions and 0 deletions

View File

@@ -119,6 +119,43 @@ def profile_build_mode(config: Mapping[str, Any]) -> str:
return "off" if isinstance(mode, str) and mode.strip().lower() == "off" else "ask"
PLAIN_INTRO_NOTE = (
"[System note: This is the user's very first message ever. "
"Briefly introduce yourself and mention that /help shows available commands. "
"Keep the introduction concise -- one or two sentences max.]"
)
def first_contact_turn_note(
config: Mapping[str, Any],
config_path: Path,
*,
session_history_empty: bool,
install_has_prior_sessions: bool,
) -> Optional[str]:
"""Return a one-shot sidecar note for the install's first-ever message.
Matches the gateway first-contact path: when ``profile_build`` is ``ask``
and the offer has not been latched yet, return the opt-in profile-build
directive and persist ``onboarding.seen.profile_build_offered``. Otherwise
return the plain intro note. Returns ``None`` when this is not the first
contact (non-empty session history or prior sessions exist on the install).
"""
if not session_history_empty or install_has_prior_sessions:
return None
try:
if (
profile_build_mode(config) == "ask"
and not is_seen(config, PROFILE_BUILD_FLAG)
):
mark_seen(config_path, PROFILE_BUILD_FLAG)
return profile_build_directive().strip()
return PLAIN_INTRO_NOTE
except Exception as e:
logger.debug("first_contact_turn_note failed, using plain intro: %s", e)
return PLAIN_INTRO_NOTE
def profile_build_directive() -> str:
"""System-note directive appended to the very first message ever.
@@ -175,6 +212,7 @@ def mark_seen(config_path: Path, flag: str) -> bool:
__all__ = [
"BUSY_INPUT_FLAG", "TOOL_PROGRESS_FLAG", "OPENCLAW_RESIDUE_FLAG", "PROFILE_BUILD_FLAG",
"PLAIN_INTRO_NOTE", "first_contact_turn_note",
"busy_input_hint_gateway", "busy_input_hint_cli", "tool_progress_hint_gateway", "tool_progress_hint_cli",
"openclaw_residue_hint_cli", "detect_openclaw_residue", "profile_build_mode", "profile_build_directive",
"is_seen", "mark_seen",

View File

@@ -95,3 +95,48 @@ class TestProfileBuildMode:
assert profile_build_mode("not a dict") == "ask" # type: ignore[arg-type]
assert profile_build_mode({"onboarding": "nope"}) == "ask"
class TestFirstContactTurnNote:
def test_returns_profile_directive_and_marks_seen(self, tmp_path):
from agent.onboarding import (
PROFILE_BUILD_FLAG,
first_contact_turn_note,
profile_build_directive,
)
cfg_path = tmp_path / "config.yaml"
cfg = {"onboarding": {"profile_build": "ask"}}
note = first_contact_turn_note(
cfg,
cfg_path,
session_history_empty=True,
install_has_prior_sessions=False,
)
assert note == profile_build_directive().strip()
loaded = yaml.safe_load(cfg_path.read_text())
assert loaded["onboarding"]["seen"][PROFILE_BUILD_FLAG] is True
def test_returns_none_when_not_first_contact(self, tmp_path):
from agent.onboarding import first_contact_turn_note
cfg_path = tmp_path / "config.yaml"
assert (
first_contact_turn_note(
{},
cfg_path,
session_history_empty=False,
install_has_prior_sessions=False,
)
is None
)
assert (
first_contact_turn_note(
{},
cfg_path,
session_history_empty=True,
install_has_prior_sessions=True,
)
is None
)
assert not cfg_path.exists()

View File

@@ -0,0 +1,131 @@
"""Desktop/TUI first-contact profile-build onboarding via tui_gateway (#82750).
The messaging gateway stages the consent-gated profile-build offer on the
install's very first message (gateway/run_turn.py ``_hmwa_first_contact_notes``);
the TUI/Desktop surface must do the same through
``_stage_first_contact_onboarding_note`` in the prompt turn.
"""
from __future__ import annotations
import threading
import types
import pytest
import yaml
from agent.onboarding import PROFILE_BUILD_FLAG, profile_build_directive
from tui_gateway import server
def _session(agent, history=None):
return {
"agent": agent,
"session_key": "session-key",
"history": list(history or []),
"history_lock": threading.Lock(),
}
@pytest.fixture()
def onboarding_home(monkeypatch, tmp_path):
"""A HERMES_HOME whose config.yaml offers profile builds (the default mode)."""
(tmp_path / "config.yaml").write_text(
yaml.safe_dump({"onboarding": {"profile_build": "ask"}})
)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
return tmp_path
def _stage(session, agent, history_empty):
server._stage_first_contact_onboarding_note(session, agent, history_empty)
def test_stages_profile_build_directive_on_first_contact(monkeypatch, onboarding_home):
"""Fresh install + empty history: the opt-in directive is staged on the agent
and the offered flag is persisted before the turn runs."""
monkeypatch.setattr(server, "_install_has_prior_sessions", lambda _s: False)
agent = types.SimpleNamespace()
_stage(_session(agent), agent, history_empty=True)
assert agent._gateway_turn_context_notes == profile_build_directive().strip()
loaded = yaml.safe_load((onboarding_home / "config.yaml").read_text())
assert loaded["onboarding"]["seen"][PROFILE_BUILD_FLAG] is True
def test_skips_first_contact_when_prior_sessions_exist(monkeypatch, onboarding_home):
"""An install that already holds conversations must not re-offer; nothing is
staged and the config is untouched."""
monkeypatch.setattr(server, "_install_has_prior_sessions", lambda _s: True)
agent = types.SimpleNamespace()
_stage(_session(agent), agent, history_empty=True)
assert getattr(agent, "_gateway_turn_context_notes", "") == ""
loaded = yaml.safe_load((onboarding_home / "config.yaml").read_text())
assert "seen" not in loaded.get("onboarding", {})
def test_skips_first_contact_when_history_not_empty(monkeypatch, onboarding_home):
"""A continuing conversation (history present) is not first contact."""
monkeypatch.setattr(server, "_install_has_prior_sessions", lambda _s: False)
agent = types.SimpleNamespace()
_stage(
_session(agent, history=[{"role": "user", "content": "prior"}]),
agent,
history_empty=False,
)
assert getattr(agent, "_gateway_turn_context_notes", "") == ""
def test_skips_when_offer_already_latched(monkeypatch, onboarding_home):
"""``onboarding.seen.profile_build_offered`` set: the plain intro rides
instead of the directive, exactly as the gateway path behaves."""
(onboarding_home / "config.yaml").write_text(
yaml.safe_dump(
{"onboarding": {"profile_build": "ask", "seen": {PROFILE_BUILD_FLAG: True}}}
)
)
monkeypatch.setattr(server, "_install_has_prior_sessions", lambda _s: False)
agent = types.SimpleNamespace()
_stage(_session(agent), agent, history_empty=True)
from agent.onboarding import PLAIN_INTRO_NOTE
assert agent._gateway_turn_context_notes == PLAIN_INTRO_NOTE.strip()
def test_installs_prior_sessions_probe_counts_the_install(monkeypatch):
"""The DB probe is real: a state.db holding two rows means prior sessions,
one row (the persisted current session) means a fresh install."""
calls = {}
class _DB:
def __init__(self, n):
self._n = n
def session_count_ge(self, minimum):
calls["minimum"] = minimum
return self._n >= minimum
class _Ctx:
def __init__(self, db):
self._db = db
def __enter__(self):
return self._db
def __exit__(self, *exc):
return False
fresh = _session(types.SimpleNamespace())
monkeypatch.setattr(server, "_session_db", lambda _s: _Ctx(_DB(1)))
assert server._install_has_prior_sessions(fresh) is False
assert calls["minimum"] == 2
monkeypatch.setattr(server, "_session_db", lambda _s: _Ctx(_DB(2)))
assert server._install_has_prior_sessions(fresh) is True

View File

@@ -542,6 +542,54 @@ def _adopt_out_of_band_turns(session: dict) -> None:
session["history_version"] = version + 1
def _install_has_prior_sessions(session: dict) -> bool:
"""True when this install already has session rows beyond the current one.
Mirrors ``gateway.session.SessionStore.has_any_sessions`` (the messaging
first-contact gate): ``_run_prompt_submit`` persists the session's own row
before the turn runs (``_ensure_session_db_row``), so a fresh install on
its first-ever message holds exactly one row.
"""
try:
with _session_db(session) as db:
return db is not None and db.session_count_ge(2)
except Exception:
logger.debug("session count probe failed for first-contact check", exc_info=True)
return False
def _stage_first_contact_onboarding_note(session: dict, agent, history_empty: bool) -> None:
"""Stage the install's first-message onboarding note for THIS turn (#82750).
The messaging gateway appends the consent-gated profile-build directive to
the very first message ever (``_hmwa_first_contact_notes``); the
TUI/Desktop surface never did, so a fresh install's first Desktop chat
skipped the opt-in profile flow entirely. Stage the same note through
``agent._gateway_turn_context_notes`` — consumed by
``agent.turn_context`` on the user message — never the ephemeral system
prompt, which must stay byte-stable for the conversation (prompt-cache
invariant). Fires at most once per install: the directive path persists
``onboarding.seen.profile_build_offered`` before the turn runs.
"""
try:
from agent.onboarding import first_contact_turn_note
from hermes_cli.config import load_config as _load_onboarding_config
from hermes_constants import get_hermes_home
note = first_contact_turn_note(
_load_onboarding_config() or {},
get_hermes_home() / "config.yaml",
session_history_empty=history_empty,
install_has_prior_sessions=_install_has_prior_sessions(session),
)
if not note:
return
prior = getattr(agent, "_gateway_turn_context_notes", "") or ""
agent._gateway_turn_context_notes = f"{prior}\n\n{note}" if prior else note
except Exception:
logger.debug("first-contact onboarding note failed", exc_info=True)
def _prepare_turn_input(sid: str, session: dict, st: _TurnRun, text: Any, images: list[str]):
"""Bind scopes, sync the agent, snapshot history, build the run message; returns
``(prompt, run_message, cols, streamer)`` or None when @-expansion was refused.
@@ -582,6 +630,9 @@ def _prepare_turn_input(sid: str, session: dict, st: _TurnRun, text: Any, images
with session["history_lock"]:
st.history = list(session["history"])
st.history_version = int(session.get("history_version", 0))
# Install-first-message onboarding (#82750): gateway parity for the TUI/Desktop
# surface — no-op unless this is the install's very first message ever.
_stage_first_contact_onboarding_note(session, agent, not st.history)
cwd = _session_cwd(session)
_register_session_cwd(session)
cols = session.get("cols", 80)