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:
@@ -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"
|
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:
|
def profile_build_directive() -> str:
|
||||||
"""System-note directive appended to the very first message ever.
|
"""System-note directive appended to the very first message ever.
|
||||||
|
|
||||||
@@ -175,6 +212,7 @@ def mark_seen(config_path: Path, flag: str) -> bool:
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"BUSY_INPUT_FLAG", "TOOL_PROGRESS_FLAG", "OPENCLAW_RESIDUE_FLAG", "PROFILE_BUILD_FLAG",
|
"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",
|
"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",
|
"openclaw_residue_hint_cli", "detect_openclaw_residue", "profile_build_mode", "profile_build_directive",
|
||||||
"is_seen", "mark_seen",
|
"is_seen", "mark_seen",
|
||||||
|
|||||||
@@ -95,3 +95,48 @@ class TestProfileBuildMode:
|
|||||||
|
|
||||||
assert profile_build_mode("not a dict") == "ask" # type: ignore[arg-type]
|
assert profile_build_mode("not a dict") == "ask" # type: ignore[arg-type]
|
||||||
assert profile_build_mode({"onboarding": "nope"}) == "ask"
|
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()
|
||||||
|
|||||||
131
tests/tui_gateway/test_first_contact_onboarding.py
Normal file
131
tests/tui_gateway/test_first_contact_onboarding.py
Normal 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
|
||||||
@@ -542,6 +542,54 @@ def _adopt_out_of_band_turns(session: dict) -> None:
|
|||||||
session["history_version"] = version + 1
|
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]):
|
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
|
"""Bind scopes, sync the agent, snapshot history, build the run message; returns
|
||||||
``(prompt, run_message, cols, streamer)`` or None when @-expansion was refused.
|
``(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"]:
|
with session["history_lock"]:
|
||||||
st.history = list(session["history"])
|
st.history = list(session["history"])
|
||||||
st.history_version = int(session.get("history_version", 0))
|
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)
|
cwd = _session_cwd(session)
|
||||||
_register_session_cwd(session)
|
_register_session_cwd(session)
|
||||||
cols = session.get("cols", 80)
|
cols = session.get("cols", 80)
|
||||||
|
|||||||
Reference in New Issue
Block a user