import json
import os
import subprocess
import sys
import threading
import time
import types
from datetime import datetime
from pathlib import Path
from unittest.mock import Mock, patch

import pytest

from hermes_constants import reset_hermes_home_override, set_hermes_home_override
from hermes_cli.active_sessions import active_session_registry_snapshot
from hermes_cli.browser_connect import ChromeDebugLaunch
from tools import async_delegation as ad
from tui_gateway import server


@pytest.fixture(autouse=True)
def _neuter_agent_prewarm_timer(request, monkeypatch):
    """Stub the deferred agent pre-warm timer for every test in this module.

    ``session.create`` and non-eager ``session.resume`` fire a 50 ms
    background ``threading.Timer`` (``_schedule_agent_build``) that calls
    whatever ``server._make_agent`` is patched in AT FIRE TIME. Left live,
    a timer armed by one test outlives it and lands in the NEXT test's
    ``_make_agent`` mock, racily corrupting its captured state (the
    ``'tip' == 'cont_tip'`` flakes in the session_resume tests). Tests that
    exercise the deferred build itself opt back in with
    ``@pytest.mark.real_agent_prewarm``.
    """
    if request.node.get_closest_marker("real_agent_prewarm"):
        yield
        return
    monkeypatch.setattr(server, "_schedule_agent_build", lambda *a, **k: None)
    yield


def test_session_slot_is_claimed_on_first_turn_not_on_create(monkeypatch, tmp_path):
    home = tmp_path / ".hermes"
    home.mkdir()
    (home / "config.yaml").write_text("max_concurrent_sessions: 1\n", encoding="utf-8")
    token = set_hermes_home_override(home)

    def _clear_server_sessions():
        for session in list(server._sessions.values()):
            server._teardown_session(session)
        server._sessions.clear()

    try:
        server._cfg_cache = None
        server._cfg_mtime = None
        server._cfg_path = None
        _clear_server_sessions()
        monkeypatch.setattr(server, "_start_agent_build", lambda *args, **kwargs: None)
        monkeypatch.setattr(server, "_completion_cwd", lambda params=None: str(tmp_path))

        # Opening a chat must NOT take a slot. Every tile paint and every
        # background reconnect-resume calls session.create, and an unprompted
        # draft has no DB row and is filtered out of the sidebar — so a slot
        # held here is invisible to the user while still starving the other
        # surfaces that share this cap.
        first = server._methods["session.create"]("r1", {"cols": 80})
        second = server._methods["session.create"]("r2", {"cols": 80})
        assert "result" in first and "result" in second
        sid = first["result"]["session_id"]
        other = second["result"]["session_id"]
        assert active_session_registry_snapshot() == []

        # The first turn is what claims the slot, and is re-entrant.
        assert server._ensure_active_session_slot(sid, server._sessions[sid]) is None
        assert server._ensure_active_session_slot(sid, server._sessions[sid]) is None
        assert len(active_session_registry_snapshot()) == 1

        blocked = server._ensure_active_session_slot(other, server._sessions[other])
        assert "active session limit (1/1)" in blocked

        closed = server._methods["session.close"]("r3", {"session_id": sid})
        assert closed["result"]["closed"] is True
        assert active_session_registry_snapshot() == []

        assert server._ensure_active_session_slot(other, server._sessions[other]) is None
    finally:
        _clear_server_sessions()
        server._cfg_cache = None
        server._cfg_mtime = None
        server._cfg_path = None
        reset_hermes_home_override(token)


def test_session_context_uses_session_cwd(monkeypatch, tmp_path):
    """Desktop/TUI sessions must pin the agent cwd per session.

    The gateway process itself is often launched from apps/desktop in dev, so
    falling back to os.getcwd() makes agents answer from the desktop app folder
    even when the sidebar/session cwd is a real project.
    """
    from agent.runtime_cwd import resolve_agent_cwd

    sid = "cwd-sid"
    session_key = "cwd-key"
    project = tmp_path / "project"
    project.mkdir()
    (project / ".git").mkdir()
    launcher = tmp_path / "apps" / "desktop"
    launcher.mkdir(parents=True)

    server._sessions[sid] = {"session_key": session_key, "cwd": str(project)}
    monkeypatch.delenv("TERMINAL_CWD", raising=False)
    monkeypatch.chdir(launcher)

    tokens = server._set_session_context(session_key)
    try:
        assert resolve_agent_cwd() == project
    finally:
        server._clear_session_context(tokens)
        server._sessions.pop(sid, None)


def test_handoff_fail_marks_only_inflight_rows(monkeypatch):
    class DbContext:
        def __init__(self, db):
            self.db = db

        def __enter__(self):
            return self.db

        def __exit__(self, *_args):
            return False

    class FakeDb:
        def __init__(self, state):
            self.state = state
            self.failed_with = None

        def get_handoff_state(self, _key):
            return {"state": self.state, "platform": "telegram", "error": None}

        def fail_handoff(self, _key, error):
            self.failed_with = error
            self.state = "failed"

    sid = "rt-handoff"
    server._sessions[sid] = {"session_key": "stored-handoff"}
    try:
        pending = FakeDb("pending")
        monkeypatch.setattr(server, "_session_db", lambda _session: DbContext(pending))
        result = server._methods["handoff.fail"]("r1", {"session_id": sid, "error": "timed out"})
        assert result["result"] == {"failed": True, "state": "failed"}
        assert pending.failed_with == "timed out"

        completed = FakeDb("completed")
        monkeypatch.setattr(server, "_session_db", lambda _session: DbContext(completed))
        result = server._methods["handoff.fail"]("r2", {"session_id": sid, "error": "late timeout"})
        assert result["result"] == {"failed": False, "state": "completed"}
        assert completed.failed_with is None
    finally:
        server._sessions.pop(sid, None)


def test_dashboard_process_isolation_config_defaults_without_default_merge(monkeypatch):
    """tui_gateway.server::_load_cfg is raw YAML, so defaults live at read site."""
    monkeypatch.setattr(server, "_load_cfg", lambda: {})

    assert server._load_dashboard_process_isolation_config() == {
        "turn_isolation": False,
        "compute_host_heartbeat_secs": 15,
        "compute_host_respawn_max": 3,
    }


def test_dashboard_process_isolation_config_coerces_raw_values():
    cfg = {
        "dashboard": {
            "turn_isolation": "yes",
            "compute_host_heartbeat_secs": "30",
            "compute_host_respawn_max": "0",
        }
    }

    assert server._load_dashboard_process_isolation_config(cfg) == {
        "turn_isolation": True,
        "compute_host_heartbeat_secs": 30,
        "compute_host_respawn_max": 0,
    }

    malformed = {"dashboard": "enabled"}
    assert server._load_dashboard_process_isolation_config(malformed) == {
        "turn_isolation": False,
        "compute_host_heartbeat_secs": 15,
        "compute_host_respawn_max": 3,
    }


def test_default_config_seeds_dashboard_process_isolation_keys():
    from hermes_cli.config import DEFAULT_CONFIG

    dashboard = DEFAULT_CONFIG["dashboard"]
    assert dashboard["turn_isolation"] is False
    assert dashboard["compute_host_heartbeat_secs"] == 15
    assert dashboard["compute_host_respawn_max"] == 3


def test_prompt_submit_dispatches_to_compute_host_when_turn_isolation_enabled(monkeypatch):
    class FakeSupervisor:
        def __init__(self):
            self.frames = []
            self.callback = None

        def submit_turn(self, frame, *, on_complete=None):
            self.frames.append(frame)
            self.callback = on_complete
            return frame["request_id"]

    fake_supervisor = FakeSupervisor()
    seed_history = [{"role": "user", "content": "previous"}]
    server._sessions["iso-sid"] = _session(history=list(seed_history))
    server._sessions["iso-sid"]["agent"] = None
    server._sessions["iso-sid"]["agent_ready"] = threading.Event()
    parent_writes = {"ensure_session": 0, "persist_seed": 0}
    monkeypatch.setattr(
        server,
        "_load_cfg",
        lambda: {"dashboard": {"turn_isolation": True}},
    )
    monkeypatch.setattr(
        server,
        "_ensure_session_db_row",
        lambda _session: parent_writes.__setitem__(
            "ensure_session", parent_writes["ensure_session"] + 1
        ),
    )
    monkeypatch.setattr(
        server,
        "_persist_branch_seed",
        lambda _session: parent_writes.__setitem__(
            "persist_seed", parent_writes["persist_seed"] + 1
        ),
    )
    monkeypatch.setattr(server, "_get_compute_host_supervisor", lambda _cfg=None: fake_supervisor)

    try:
        resp = server.handle_request(
            {
                "id": "submit",
                "method": "prompt.submit",
                "params": {"session_id": "iso-sid", "text": "hello"},
            }
        )
        assert resp["result"] == {"status": "streaming", "turn_isolation": True}
        assert fake_supervisor.frames[0]["type"] == "turn.start"
        assert fake_supervisor.frames[0]["sid"] == "iso-sid"
        assert fake_supervisor.frames[0]["text"] == "hello"
        assert fake_supervisor.frames[0]["history"] == seed_history
        assert server._sessions["iso-sid"]["history"] == seed_history
        assert parent_writes == {"ensure_session": 0, "persist_seed": 0}
        assert server._sessions["iso-sid"]["running"] is True

        fake_supervisor.callback(
            {
                "type": "turn.end",
                "sid": "iso-sid",
                "request_id": "submit",
                "history_version": 1,
            }
        )
        assert server._sessions["iso-sid"]["running"] is False
        assert server._sessions["iso-sid"]["history_version"] == 1
    finally:
        server._sessions.pop("iso-sid", None)


def test_compute_host_explicit_images_do_not_clear_later_attachment(monkeypatch):
    class _Supervisor:
        def submit_turn(self, _frame, *, on_complete=None):
            session["attached_images"].append("/tmp/c.png")

    session = _session(attached_images=[])
    monkeypatch.setattr(server, "_get_compute_host_supervisor", lambda _cfg=None: _Supervisor())

    response = server._submit_prompt_to_compute_host(
        "r1", "sid", session, "B", image_paths=["/tmp/b.png"]
    )

    assert response["result"]["status"] == "streaming"
    assert session["attached_images"] == ["/tmp/c.png"]


def test_prompt_submit_fails_open_inline_when_compute_host_dispatch_breaks(monkeypatch):
    class _BrokenSupervisor:
        def submit_turn(self, frame, *, on_complete=None):
            if on_complete is not None:
                on_complete(
                    {
                        "type": "turn.error",
                        "request_id": frame["request_id"],
                        "reason": "send_failed",
                        "message": "broken pipe",
                    }
                )
            raise BrokenPipeError("broken pipe")

    class _ImmediateThread:
        def __init__(self, target=None, **_kwargs):
            self._target = target

        def start(self):
            assert self._target is not None
            self._target()

    session = _session(agent=None, agent_ready=threading.Event())
    server._sessions["iso-fallback"] = session
    inline_calls = []
    monkeypatch.setattr(server, "_load_cfg", lambda: {"dashboard": {"turn_isolation": True}})
    monkeypatch.setattr(server, "_get_compute_host_supervisor", lambda _cfg=None: _BrokenSupervisor())
    monkeypatch.setattr(server, "_ensure_session_db_row", lambda _session: None)
    monkeypatch.setattr(server, "_persist_branch_seed", lambda _session: None)
    monkeypatch.setattr(server, "_start_agent_build", lambda _sid, _session: None)
    monkeypatch.setattr(server, "_wait_agent", lambda _session, _rid: None)
    # The deferred inline-fallback thread now waits via the patient variant.
    monkeypatch.setattr(server, "_wait_agent_for_prompt", lambda _session, _rid, _sid: None)
    monkeypatch.setattr(
        server,
        "_run_prompt_submit",
        lambda rid, sid, _session, text: inline_calls.append((rid, sid, text)),
    )
    monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)

    try:
        resp = server.handle_request(
            {
                "id": "fallback-turn",
                "method": "prompt.submit",
                "params": {"session_id": "iso-fallback", "text": "hello"},
            }
        )
    finally:
        server._sessions.pop("iso-fallback", None)

    assert resp == {
        "jsonrpc": "2.0",
        "id": "fallback-turn",
        "result": {"status": "streaming"},
    }
    assert inline_calls == [("fallback-turn", "iso-fallback", "hello")]
    assert session.get("_compute_host_active") is not True


def test_compute_host_turn_end_updates_metadata_mirror(monkeypatch):
    session = _session(
        agent=None,
        agent_ready=threading.Event(),
        history=[{"role": "user", "content": "serving process must not read this"}],
        _compute_host_active=True,
    )
    server._sessions["iso-sid"] = session
    emitted = []
    monkeypatch.setattr(server, "_emit", lambda event, sid, payload=None: emitted.append((event, sid, payload)))

    try:
        server._on_compute_host_turn_done(
            "turn-1",
            "iso-sid",
            session,
            {
                "type": "turn.end",
                "sid": "iso-sid",
                "request_id": "turn-1",
                "session_key": "rotated-session-key",
                "history_version": 4,
                "message_count": 3,
                "session_info": {
                    "model": "host-model",
                    "provider": "host-provider",
                    "system_prompt": "host system prompt",
                    "tools": {"core": ["terminal"]},
                    "usage": {"total": 140, "context_used": 80, "context_max": 1000},
                },
            },
        )

        assert session["session_key"] == "rotated-session-key"
        assert session["history_version"] == 4
        assert session["_metadata_mirror"]["model"] == "host-model"
        info = server._session_info(None, session)
        assert info["model"] == "host-model"
        assert info["provider"] == "host-provider"
        assert info["system_prompt"] == "host system prompt"
        assert info["tools"] == {"core": ["terminal"]}
        assert info["usage"]["total"] == 140
        assert "credential_warning" not in info
        assert emitted[-1] == ("session.info", "iso-sid", info)
    finally:
        server._sessions.pop("iso-sid", None)


def test_slash_exec_compress_flag_on_applies_host_control_mirror(monkeypatch):
    class _ExplodingWorker:
        def __init__(self, *args, **kwargs):
            raise AssertionError("slash worker should not run for isolated /compress")

    class _FakeSupervisor:
        def __init__(self):
            self.controls = []

        def control(self, sid, *, route_name, payload=None, wait=True, timeout=30.0):
            self.controls.append((sid, route_name, dict(payload or {}), wait))
            return {
                "type": "control.ack",
                "sid": sid,
                "request_id": (payload or {}).get("request_id", "control-1"),
                "route_name": route_name,
                "output": "Compressed 4 → 2 messages",
                "session_key": "host-rotated-key",
                "history_version": 9,
                "message_count": 2,
                "session_info": {
                    "model": "host-model",
                    "provider": "host-provider",
                    "usage": {"total": 42},
                },
            }

    fake = _FakeSupervisor()
    session = _session(agent=None, agent_ready=threading.Event(), _compute_host_active=True)
    server._sessions["sid"] = session
    monkeypatch.setattr(server, "_load_cfg", lambda: {"dashboard": {"turn_isolation": True}})
    monkeypatch.setattr(server, "_get_compute_host_supervisor", lambda _cfg=None: fake)
    monkeypatch.setattr(server, "_SlashWorker", _ExplodingWorker)
    monkeypatch.setattr(server, "_compress_session_history", lambda *a, **k: (_ for _ in ()).throw(AssertionError("parent compressed")))
    monkeypatch.setattr(server, "_sync_session_key_after_compress", lambda *a, **k: (_ for _ in ()).throw(AssertionError("parent identity guard ran")))

    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "slash.exec",
                "params": {"command": "compress focus", "session_id": "sid"},
            }
        )
    finally:
        server._sessions.pop("sid", None)

    assert resp["result"]["output"] == "Compressed 4 → 2 messages"
    assert fake.controls[0][1] == "slash.compress"
    assert fake.controls[0][2]["command"] == "/compress focus"
    assert session["session_key"] == "host-rotated-key"
    assert session["history_version"] == 9
    assert server._session_info(None, session)["model"] == "host-model"


def test_prompt_submit_golden_transcript_matches_flag_off_and_on(monkeypatch):
    class _ImmediateThread:
        def __init__(self, target=None, daemon=None, **_kwargs):
            self._target = target

        def start(self):
            assert self._target is not None
            self._target()

    class _Agent:
        model = "gold-model"
        provider = "gold-provider"
        session_id = "session-key"
        session_input_tokens = 10
        session_output_tokens = 5
        session_prompt_tokens = 10
        session_completion_tokens = 5
        session_total_tokens = 15
        session_api_calls = 1
        context_compressor = None

        def clear_interrupt(self):
            return None

        def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
            if stream_callback is not None:
                stream_callback("hi")
            return {
                "final_response": "hi",
                "messages": [
                    {"role": "user", "content": prompt},
                    {"role": "assistant", "content": "hi"},
                ],
            }

    fixed_info = {"model": "gold-model", "provider": "gold-provider", "usage": {"total": 15}}
    usage = server._get_usage(_Agent())
    monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
    monkeypatch.setattr(server, "_ensure_session_db_row", lambda _session: None)
    monkeypatch.setattr(server, "_persist_branch_seed", lambda _session: None)
    monkeypatch.setattr(server, "_session_info", lambda _agent, _session=None: dict(fixed_info))
    monkeypatch.setattr(server, "make_stream_renderer", lambda _cols: None)
    monkeypatch.setattr(server, "render_message", lambda _raw, _cols: None)
    fake_title = types.ModuleType("agent.title_generator")
    setattr(fake_title, "maybe_auto_title", lambda *args, **kwargs: None)
    monkeypatch.setitem(sys.modules, "agent.title_generator", fake_title)

    def run_flag_off():
        events = []
        monkeypatch.setattr(server, "_emit", lambda event, sid, payload=None: events.append((event, sid, payload)))
        monkeypatch.setattr(server, "_load_cfg", lambda: {"dashboard": {"turn_isolation": False}})
        server._sessions["sid"] = _session(
            agent=_Agent(), model_override={"model": "gold-model", "provider": "gold-provider"}
        )
        try:
            response = server.handle_request(
                {"id": "turn-1", "method": "prompt.submit", "params": {"session_id": "sid", "text": "hello"}}
            )
            assert response["result"]["status"] == "streaming"
            return events
        finally:
            server._sessions.pop("sid", None)

    def run_flag_on():
        events = []
        monkeypatch.setattr(server, "_emit", lambda event, sid, payload=None: events.append((event, sid, payload)))
        monkeypatch.setattr(server, "_load_cfg", lambda: {"dashboard": {"turn_isolation": True}})

        class _FakeSupervisor:
            def submit_turn(self, frame, *, on_complete=None):
                sid = frame["sid"]
                server._emit("message.start", sid)
                server._emit("message.delta", sid, {"text": "hi"})
                server._emit("message.complete", sid, {"text": "hi", "usage": usage, "status": "complete"})
                server._emit("session.info", sid, dict(fixed_info))
                if on_complete is not None:
                    on_complete(
                        {
                            "type": "turn.end",
                            "sid": sid,
                            "request_id": frame["request_id"],
                            "session_key": "session-key",
                            "history_version": 1,
                            "message_count": 2,
                            "session_info": dict(fixed_info),
                            "session_info_emitted": True,
                        }
                    )
                return frame["request_id"]

        monkeypatch.setattr(server, "_get_compute_host_supervisor", lambda _cfg=None: _FakeSupervisor())
        session = _session(
            agent=None,
            agent_ready=threading.Event(),
            _compute_host_active=True,
            model_override={"model": "gold-model", "provider": "gold-provider"},
        )
        session["agent"] = None
        server._sessions["sid"] = session
        try:
            response = server.handle_request(
                {"id": "turn-1", "method": "prompt.submit", "params": {"session_id": "sid", "text": "hello"}}
            )
            assert response["result"]["status"] == "streaming"
            return events
        finally:
            server._sessions.pop("sid", None)

    assert run_flag_on() == run_flag_off()


def test_session_context_explicit_cwd_for_ephemeral_task(monkeypatch, tmp_path):
    """Background/preview tasks use ephemeral ids absent from `_sessions`, so the
    parent workspace is passed explicitly; it must pin instead of clearing back
    to the gateway launch dir."""
    from agent.runtime_cwd import resolve_agent_cwd

    project = tmp_path / "project"
    project.mkdir()
    launcher = tmp_path / "apps" / "desktop"
    launcher.mkdir(parents=True)

    monkeypatch.delenv("TERMINAL_CWD", raising=False)
    monkeypatch.chdir(launcher)

    tokens = server._set_session_context("bg_deadbe", cwd=str(project))
    try:
        assert resolve_agent_cwd() == project
    finally:
        server._clear_session_context(tokens)


def _write_profile_cfg(home: Path, cwd: str | None) -> Path:
    import yaml

    home.mkdir(parents=True, exist_ok=True)
    cfg = {"terminal": {"cwd": cwd}} if cwd is not None else {}
    (home / "config.yaml").write_text(yaml.safe_dump(cfg), encoding="utf-8")
    return home


def test_profile_scoped_mcp_discovery_uses_target_home(monkeypatch, tmp_path):
    """MCP discovery must start under the selected profile's HERMES_HOME."""
    from hermes_cli import mcp_startup
    from hermes_constants import get_hermes_home
    from tui_gateway import entry

    profile_home = tmp_path / "profiles" / "sheepyr"
    profile_home.mkdir(parents=True)

    (profile_home / "config.yaml").write_text(
        "mcp_servers:\n"
        "  bluesky_sheepyr:\n"
        "    command: test-command\n",
        encoding="utf-8",
    )

    monkeypatch.setenv("HERMES_HOME", str(tmp_path / "default"))
    token = set_hermes_home_override(str(profile_home))

    seen = []

    monkeypatch.setattr(mcp_startup, "_mcp_discovery_started", False)
    monkeypatch.setattr(mcp_startup, "_mcp_discovery_thread", None)
    # ensure_mcp_discovery_started flips this module global; monkeypatch it so
    # the enablement doesn't leak into sibling tests in this file.
    monkeypatch.setattr(entry, "_mcp_discovery_enabled", False)
    monkeypatch.setattr(
        mcp_startup,
        "_discover_mcp_tools_without_interactive_oauth",
        lambda: seen.append(str(get_hermes_home())),
    )

    try:
        entry.ensure_mcp_discovery_started()
        thread = mcp_startup._mcp_discovery_thread
        assert thread is not None
        thread.join(timeout=2)
    finally:
        reset_hermes_home_override(token)
        mcp_startup._mcp_discovery_thread = None
        mcp_startup._mcp_discovery_started = False

    assert seen == [str(profile_home)]


def test_profile_scoped_agent_build_starts_mcp_discovery_in_profile_home(
    monkeypatch, tmp_path
):
    """Agent construction must start MCP discovery under the selected profile."""
    import threading

    from hermes_constants import get_hermes_home

    profile_home = tmp_path / "profiles" / "sheepyr"
    profile_home.mkdir(parents=True)

    monkeypatch.setenv("HERMES_HOME", str(tmp_path / "default"))

    seen = []
    built = threading.Event()

    monkeypatch.setattr(
        server,
        "_make_agent",
        lambda *args, **kwargs: built.set()
        or type("Agent", (), {"model": "test"})(),
    )
    monkeypatch.setattr(
        "tui_gateway.entry.ensure_mcp_discovery_started",
        lambda: seen.append(str(get_hermes_home())),
    )
    monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None)
    monkeypatch.setattr(server, "_SlashWorker", lambda *args: None)
    monkeypatch.setattr(server, "_attach_worker", lambda *args: None)
    monkeypatch.setattr(server, "_config_model_target", lambda: ("", ""))

    ready = threading.Event()
    sid = "test-sid"
    session = {
        "agent_ready": ready,
        "session_key": "test-key",
        "profile_home": str(profile_home),
    }

    server._sessions[sid] = session
    try:
        server._start_agent_build(sid, session)
        assert built.wait(timeout=2)
    finally:
        server._sessions.pop(sid, None)

    assert seen == [str(profile_home)]


def test_profile_scoped_agent_build_installs_secret_scope(monkeypatch, tmp_path):
    """Agent construction must install the selected profile's secret scope.

    Without it, get_secret() falls through to process os.environ, so a session
    "switched" to profile X resolves credentials from the LAUNCH profile's
    .env (#67605 item 2).
    """
    import threading

    from agent.secret_scope import current_secret_scope

    profile_home = tmp_path / "profiles" / "grace"
    profile_home.mkdir(parents=True)
    (profile_home / ".env").write_text(
        "PROXMOX_TOKEN=grace-secret\n", encoding="utf-8"
    )

    monkeypatch.setenv("HERMES_HOME", str(tmp_path / "default"))

    scopes = []
    built = threading.Event()

    def _fake_make_agent(*args, **kwargs):
        scope = current_secret_scope()
        scopes.append(dict(scope) if scope else None)
        built.set()
        return type("Agent", (), {"model": "test"})()

    monkeypatch.setattr(server, "_make_agent", _fake_make_agent)
    monkeypatch.setattr(
        "tui_gateway.entry.ensure_mcp_discovery_started", lambda: None
    )
    monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None)
    monkeypatch.setattr(server, "_SlashWorker", lambda *args: None)
    monkeypatch.setattr(server, "_attach_worker", lambda *args: None)
    monkeypatch.setattr(server, "_config_model_target", lambda: ("", ""))

    ready = threading.Event()
    sid = "test-secret-sid"
    session = {
        "agent_ready": ready,
        "session_key": "test-secret-key",
        "profile_home": str(profile_home),
    }

    server._sessions[sid] = session
    try:
        server._start_agent_build(sid, session)
        assert built.wait(timeout=2)
    finally:
        server._sessions.pop(sid, None)

    assert scopes == [{"PROXMOX_TOKEN": "grace-secret"}]


def test_profile_configured_cwd_reads_target_profile(tmp_path):
    """A profile's own terminal.cwd is read from its config.yaml."""
    project = tmp_path / "proj"
    project.mkdir()
    home = _write_profile_cfg(tmp_path / "home", str(project))
    assert server._profile_configured_cwd(home) == str(project)


def test_profile_configured_cwd_skips_placeholders_and_missing(tmp_path):
    """Placeholder values, missing config, and bad paths fall through to None."""
    assert server._profile_configured_cwd(None) is None
    assert server._profile_configured_cwd(tmp_path / "nope") is None
    for placeholder in (".", "auto", "cwd", ""):
        home = _write_profile_cfg(tmp_path / placeholder.strip("."), placeholder)
        assert server._profile_configured_cwd(home) is None
    home = _write_profile_cfg(tmp_path / "ghost", str(tmp_path / "does-not-exist"))
    assert server._profile_configured_cwd(home) is None


def test_completion_cwd_prefers_profile_over_stale_env(monkeypatch, tmp_path):
    """Issue #40334: a new session bound to another profile must use THAT
    profile's terminal.cwd, not the launch profile's stale TERMINAL_CWD."""
    profile_b = tmp_path / "ef-design"
    profile_b.mkdir()
    home = _write_profile_cfg(tmp_path / "home-b", str(profile_b))
    stale = tmp_path / "mahjong"
    stale.mkdir()

    monkeypatch.setenv("TERMINAL_CWD", str(stale))
    monkeypatch.setattr(server, "_load_cfg", lambda: {})
    monkeypatch.setattr(server, "_profile_home", lambda name: home if name else None)

    assert server._completion_cwd({"profile": "ef-design"}) == str(profile_b)
    # No profile and no launch config → fallback to the launch env var.
    assert server._completion_cwd({}) == str(stale)


def test_completion_cwd_prefers_launch_config_over_stale_env(monkeypatch, tmp_path):
    """Dashboard /chat's launch-profile in-memory gateway must honor config.

    The embedded Node TUI child gets TERMINAL_CWD from the dashboard PTY bridge,
    but the default-profile chat attaches to the dashboard process's already
    running in-memory gateway. That process may not have TERMINAL_CWD in its own
    environment (or has a stale one), so config.yaml is read directly and wins
    over the process env before falling back to the launch directory.
    """
    configured = tmp_path / "omni"
    configured.mkdir()
    stale = tmp_path / "hermes-agent"
    stale.mkdir()

    monkeypatch.setenv("TERMINAL_CWD", str(stale))
    monkeypatch.setattr(server, "_load_cfg", lambda: {"terminal": {"cwd": str(configured)}})
    monkeypatch.setattr(server, "_profile_home", lambda _name: None)

    assert server._completion_cwd({}) == str(configured)


def test_default_session_cwd_prefers_launch_config(monkeypatch, tmp_path):
    """A freshly created / resumed session with no explicit cwd lands in the
    configured terminal.cwd, not os.getcwd(), even when the in-memory gateway
    process env carries a stale TERMINAL_CWD."""
    configured = tmp_path / "workspace"
    configured.mkdir()
    stale = tmp_path / "launch-dir"
    stale.mkdir()

    monkeypatch.setenv("TERMINAL_CWD", str(stale))
    monkeypatch.setattr(server, "_load_cfg", lambda: {"terminal": {"cwd": str(configured)}})

    assert server._default_session_cwd() == str(configured)

    # No launch config → fall back to the process env var.
    monkeypatch.setattr(server, "_load_cfg", lambda: {})
    assert server._default_session_cwd() == str(stale)


def test_completion_cwd_explicit_cwd_wins_over_profile(monkeypatch, tmp_path):
    """An explicit client-provided cwd still beats the profile config."""
    explicit = tmp_path / "explicit"
    explicit.mkdir()
    profile_b = tmp_path / "configured"
    profile_b.mkdir()
    home = _write_profile_cfg(tmp_path / "home-c", str(profile_b))

    monkeypatch.setattr(server, "_profile_home", lambda name: home if name else None)
    result = server._completion_cwd({"cwd": str(explicit), "profile": "ef-design"})
    assert result == str(explicit)


def test_terminal_task_cwd_local_backend_uses_session_cwd(monkeypatch, tmp_path):
    """A local terminal backend must keep host-validated session cwd behaviour."""
    project = tmp_path / "project"
    project.mkdir()
    monkeypatch.setenv("TERMINAL_ENV", "local")
    monkeypatch.delenv("TERMINAL_CWD", raising=False)

    assert server._terminal_task_cwd({"cwd": str(project)}) == str(project)


def test_terminal_task_cwd_ssh_uses_remote_path_unvalidated(monkeypatch):
    """SSH (non-local) backend: the configured remote cwd is used verbatim even
    though it does not exist on the local host. This is the jonbohz fix — host
    `isdir()` validation would otherwise discard the remote path and fall back
    to os.getcwd(), running commands against the wrong machine."""
    remote = "/home/jonboh/workspace/proj"  # does not exist on this host
    assert not os.path.isdir(remote)
    monkeypatch.setenv("TERMINAL_ENV", "ssh")
    monkeypatch.setenv("TERMINAL_CWD", remote)

    assert server._terminal_task_cwd({"cwd": "/some/host/dir"}) == remote


def test_terminal_task_cwd_ssh_falls_back_to_config(monkeypatch):
    """When TERMINAL_CWD is unset, the SSH path reads terminal.cwd from config."""
    remote = "/home/jonboh/workspace/from-config"
    monkeypatch.setenv("TERMINAL_ENV", "ssh")
    monkeypatch.delenv("TERMINAL_CWD", raising=False)
    monkeypatch.setattr(server, "_load_cfg", lambda: {"terminal": {"cwd": remote}})

    assert server._terminal_task_cwd({"cwd": "/some/host/dir"}) == remote


def test_terminal_task_cwd_ssh_sentinel_cwd_uses_remote_home(monkeypatch):
    """An SSH placeholder must not register the TUI host's session cwd."""
    monkeypatch.setenv("TERMINAL_ENV", "ssh")
    monkeypatch.setenv("TERMINAL_CWD", "auto")
    monkeypatch.setattr(server, "_load_cfg", lambda: {"terminal": {"cwd": "."}})

    assert server._terminal_task_cwd({"cwd": "/host/session/dir"}) == "~"


class _ChunkyStdout:
    def __init__(self):
        self.parts: list[str] = []

    def write(self, text: str) -> int:
        for ch in text:
            self.parts.append(ch)
            time.sleep(0.0001)
        return len(text)

    def flush(self) -> None:
        return None


class _BrokenStdout:
    def write(self, text: str) -> int:
        raise BrokenPipeError

    def flush(self) -> None:
        return None


def test_write_json_serializes_concurrent_writes(monkeypatch):
    out = _ChunkyStdout()
    monkeypatch.setattr(server, "_real_stdout", out)

    threads = [
        threading.Thread(target=server.write_json, args=({"seq": i, "text": "x" * 24},))
        for i in range(8)
    ]

    for t in threads:
        t.start()

    for t in threads:
        t.join()

    lines = "".join(out.parts).splitlines()

    assert len(lines) == 8
    assert {json.loads(line)["seq"] for line in lines} == set(range(8))


def test_write_json_returns_false_on_broken_pipe(monkeypatch):
    monkeypatch.setattr(server, "_real_stdout", _BrokenStdout())

    assert server.write_json({"ok": True}) is False


def test_write_json_drops_detached_ws_frames(monkeypatch):
    out = _ChunkyStdout()
    monkeypatch.setattr(server, "_real_stdout", out)
    server._sessions["detached-sid"] = {"transport": server._detached_ws_transport}
    try:
        assert server.write_json({
            "jsonrpc": "2.0",
            "method": "event",
            "params": {"session_id": "detached-sid", "type": "message.delta"},
        }) is False
        assert out.parts == []
    finally:
        server._sessions.pop("detached-sid", None)


def test_tui_verbose_tool_details_fail_closed_when_redaction_fails(monkeypatch):
    redact_module = types.ModuleType("agent.redact")

    def fail_redaction(*_args, **_kwargs):
        raise RuntimeError("redaction unavailable")

    setattr(redact_module, "redact_sensitive_text", fail_redaction)
    monkeypatch.setitem(sys.modules, "agent.redact", redact_module)

    assert server._redact_tui_verbose_text("api_key=secret") == ""
    assert server._tool_args_text({"api_key": "secret"}) == ""
    assert server._tool_result_text("token=secret") == ""


def test_tui_verbose_tool_details_are_capped_before_emit(monkeypatch):
    monkeypatch.setattr(server, "_TUI_VERBOSE_TEXT_MAX_CHARS", 12)
    monkeypatch.setattr(server, "_TUI_VERBOSE_TEXT_MAX_LINES", 2)

    capped = server._cap_tui_verbose_text("one\ntwo\nthree\nfour")

    assert capped.startswith("[showing verbose tail; omitted ")
    assert capped.endswith("three\nfour")
    assert "one" not in capped


def test_tui_verbose_default_cap_stays_small(monkeypatch):
    # Regression guard for #34095: the verbose tool text shipped to the TUI is
    # rendered into a persisted, expanded-by-default trail block for the whole
    # session. Raising this cap back toward the old 16KB re-introduces the Ink
    # render-tree blowup that silently OOM-killed the TUI. Keep it small.
    assert server._TUI_VERBOSE_TEXT_MAX_CHARS <= 2_000

    huge = "x" * 40_000
    capped = server._cap_tui_verbose_text(huge)

    assert len(capped) < 2_000
    assert capped.startswith("[showing verbose tail; omitted ")


def test_tui_verbose_tool_events_omit_details_when_redaction_fails(monkeypatch):
    redact_module = types.ModuleType("agent.redact")

    def fail_redaction(*_args, **_kwargs):
        raise RuntimeError("redaction unavailable")

    setattr(redact_module, "redact_sensitive_text", fail_redaction)
    monkeypatch.setitem(sys.modules, "agent.redact", redact_module)

    events: list[tuple[str, str, dict]] = []
    monkeypatch.setattr(
        server, "_emit", lambda event_type, sid, payload: events.append((event_type, sid, payload))
    )
    monkeypatch.setitem(
        server._sessions,
        "redaction-test",
        {"tool_progress_mode": "verbose", "tool_started_at": {}},
    )

    server._on_tool_start("redaction-test", "tool-1", "terminal", {"command": "pwd"})
    server._on_tool_complete("redaction-test", "tool-1", "terminal", {"command": "pwd"}, "done")

    assert events[0][0] == "tool.start"
    assert events[1][0] == "tool.complete"
    assert "args_text" not in events[0][2]
    assert "result_text" not in events[1][2]


def test_tui_tool_output_risk_event_exposes_metadata_without_raw_output(monkeypatch):
    events: list[tuple[str, str, dict]] = []
    monkeypatch.setattr(
        server, "_emit", lambda event_type, sid, payload: events.append((event_type, sid, payload))
    )
    monkeypatch.setitem(
        server._sessions,
        "risk-test",
        {"tool_progress_mode": "all"},
    )

    server._on_tool_progress(
        "risk-test",
        "tool.output_risk",
        "web_extract",
        tool_call_id="tool-1",
        risk_metadata={
            "risk": "high",
            "findings": ["prompt_injection"],
            "redacted": False,
        },
    )

    assert events == [(
        "tool.output_risk",
        "risk-test",
        {
            "tool_id": "tool-1",
            "name": "web_extract",
            "risk": "high",
            "findings": ["prompt_injection"],
            "redacted": False,
        },
    )]
    assert "result" not in events[0][2]


def test_tui_clarify_lifecycle_events_emit_when_tool_progress_off(monkeypatch):
    events: list[tuple[str, str, dict]] = []
    monkeypatch.setattr(
        server, "_emit", lambda event_type, sid, payload: events.append((event_type, sid, payload))
    )
    monkeypatch.setitem(
        server._sessions,
        "clarify-off-test",
        {"tool_progress_mode": "off", "tool_started_at": {}},
    )

    args = {"question": "Pick one", "choices": ["A", "B"]}
    result = '{"question":"Pick one","choices_offered":["A","B"],"user_response":"A"}'

    server._on_tool_start("clarify-off-test", "tool-clarify", "clarify", args)
    server._on_tool_complete("clarify-off-test", "tool-clarify", "clarify", args, result)

    assert [event[0] for event in events] == ["tool.start", "tool.complete"]
    assert events[0][2]["name"] == "clarify"
    assert events[0][2]["tool_id"] == "tool-clarify"
    assert events[1][2]["result"]["user_response"] == "A"


def test_tui_non_interactive_tool_lifecycle_stays_hidden_when_tool_progress_off(monkeypatch):
    events: list[tuple[str, str, dict]] = []
    monkeypatch.setattr(
        server, "_emit", lambda event_type, sid, payload: events.append((event_type, sid, payload))
    )
    monkeypatch.setitem(
        server._sessions,
        "terminal-off-test",
        {"tool_progress_mode": "off", "tool_started_at": {}},
    )

    server._on_tool_start("terminal-off-test", "tool-1", "terminal", {"command": "pwd"})
    server._on_tool_complete("terminal-off-test", "tool-1", "terminal", {"command": "pwd"}, "done")

    assert events == []


def test_dispatch_rejects_non_object_request():
    resp = server.dispatch([])

    assert resp == {
        "jsonrpc": "2.0",
        "id": None,
        "error": {"code": -32600, "message": "invalid request: expected an object"},
    }


def test_dispatch_rejects_non_object_params():
    resp = server.dispatch({"id": "1", "method": "session.create", "params": []})

    assert resp == {
        "jsonrpc": "2.0",
        "id": "1",
        "error": {"code": -32602, "message": "invalid params: expected an object"},
    }


def test_system_battery_returns_reading(monkeypatch):
    monkeypatch.setitem(
        sys.modules,
        "agent.battery",
        types.SimpleNamespace(
            read_battery=lambda: types.SimpleNamespace(
                available=True, percent=77, plugged=False
            ),
            battery_category=lambda _s: "good",
        ),
    )

    resp = server.dispatch({"id": "b1", "method": "system.battery", "params": {}})

    assert resp["result"] == {
        "available": True,
        "percent": 77,
        "plugged": False,
        "category": "good",
    }


def test_system_battery_fails_open(monkeypatch):
    def boom():
        raise RuntimeError("no battery subsystem")

    monkeypatch.setitem(
        sys.modules,
        "agent.battery",
        types.SimpleNamespace(read_battery=boom, battery_category=lambda _s: "dim"),
    )

    resp = server.dispatch({"id": "b2", "method": "system.battery", "params": {}})

    assert resp["result"]["available"] is False
    assert resp["result"]["percent"] is None


def test_config_set_battery_toggles_and_persists(monkeypatch):
    writes: dict[str, object] = {}
    monkeypatch.setattr(server, "_load_cfg", lambda: {"display": {"battery": False}})
    monkeypatch.setattr(
        server, "_write_config_key", lambda k, v: writes.__setitem__(k, v)
    )

    resp = server.dispatch(
        {"id": "c1", "method": "config.set", "params": {"key": "battery", "value": ""}}
    )

    assert resp["result"] == {"key": "battery", "value": "on"}
    assert writes == {"display.battery": True}


def test_config_set_battery_explicit_off(monkeypatch):
    writes: dict[str, object] = {}
    monkeypatch.setattr(server, "_load_cfg", lambda: {"display": {"battery": True}})
    monkeypatch.setattr(
        server, "_write_config_key", lambda k, v: writes.__setitem__(k, v)
    )

    resp = server.dispatch(
        {
            "id": "c2",
            "method": "config.set",
            "params": {"key": "battery", "value": "off"},
        }
    )

    assert resp["result"] == {"key": "battery", "value": "off"}
    assert writes == {"display.battery": False}


def test_voice_toggle_returns_configured_record_key(monkeypatch):
    monkeypatch.setattr(
        server,
        "_load_cfg",
        lambda: {"voice": {"record_key": "ctrl+o"}},
    )
    monkeypatch.setitem(
        sys.modules,
        "tools.voice_mode",
        types.SimpleNamespace(
            check_voice_requirements=lambda: {"available": True, "details": ""}
        ),
    )
    # ``voice.toggle`` action=on mutates ``os.environ["HERMES_VOICE"]``
    # directly (CLI parity, runtime-only flag). Take monkeypatch
    # ownership of the var so the change is reverted at teardown and
    # later tests don't inherit a stale ON state (Copilot round-5
    # review on #19835).
    monkeypatch.setenv("HERMES_VOICE", "0")

    on_resp = server.dispatch(
        {"id": "voice-on", "method": "voice.toggle", "params": {"action": "on"}}
    )
    status_resp = server.dispatch(
        {"id": "voice-status", "method": "voice.toggle", "params": {"action": "status"}}
    )

    assert on_resp["result"]["record_key"] == "ctrl+o"
    assert status_resp["result"]["record_key"] == "ctrl+o"


def test_voice_toggle_on_carries_stop_hint(monkeypatch):
    """voice.toggle action=on returns the spoken-stop hint for clients to
    render — sourced from voice.stop_phrases so a custom phrase shows
    correctly, and empty when the feature is disabled (stop_phrases: [])."""
    monkeypatch.setattr(server, "_load_cfg", lambda: {"voice": {}})
    monkeypatch.setitem(
        sys.modules,
        "tools.voice_mode",
        types.SimpleNamespace(
            check_voice_requirements=lambda: {"available": True, "details": ""},
            voice_stop_hint=lambda: 'Say "halt" to end the voice chat.',
        ),
    )
    monkeypatch.setenv("HERMES_VOICE", "0")

    on_resp = server.dispatch(
        {"id": "voice-on", "method": "voice.toggle", "params": {"action": "on"}}
    )
    assert on_resp["result"]["stop_hint"] == 'Say "halt" to end the voice chat.'

    # Disabled stop phrases → empty hint, clients show nothing.
    monkeypatch.setitem(
        sys.modules,
        "tools.voice_mode",
        types.SimpleNamespace(
            check_voice_requirements=lambda: {"available": True, "details": ""},
            voice_stop_hint=lambda: "",
        ),
    )
    on_resp = server.dispatch(
        {"id": "voice-on2", "method": "voice.toggle", "params": {"action": "on"}}
    )
    assert on_resp["result"]["stop_hint"] == ""

    # off carries no hint text (mode is ending).
    off_resp = server.dispatch(
        {"id": "voice-off", "method": "voice.toggle", "params": {"action": "off"}}
    )
    assert off_resp["result"]["stop_hint"] == ""


def test_voice_toggle_handles_non_dict_voice_cfg(monkeypatch):
    """Round-3 Copilot review regression on #19835.

    ``_load_cfg()`` is raw ``yaml.safe_load()`` output — a hand-edited
    ``voice: true`` / ``voice: cmd+b`` / ``voice: null`` leaves ``voice``
    as a bool/str/None, not a dict. Previously ``.get("record_key")``
    on a non-dict broke every ``voice.toggle`` branch. Now it falls
    back to the documented default.
    """
    monkeypatch.setitem(
        sys.modules,
        "tools.voice_mode",
        types.SimpleNamespace(
            check_voice_requirements=lambda: {"available": True, "details": ""}
        ),
    )

    for bad in (True, "cmd+b", None, 42, ["ctrl+b"]):
        monkeypatch.setattr(server, "_load_cfg", lambda b=bad: {"voice": b})

        status_resp = server.dispatch(
            {
                "id": "voice-status",
                "method": "voice.toggle",
                "params": {"action": "status"},
            }
        )

        assert (
            status_resp["result"]["record_key"] == "ctrl+b"
        ), f"voice.record_key fell back to default for voice={bad!r}"

    # Round-4 follow-up: the YAML root itself may be a non-dict. A
    # hand-edit that collapses config.yaml to a scalar / list would
    # otherwise crash ``.get("voice")`` before the inner isinstance
    # guard gets a chance to run.
    for bad_root in (True, None, [], "ctrl+b", 42):
        monkeypatch.setattr(server, "_load_cfg", lambda r=bad_root: r)

        status_resp = server.dispatch(
            {
                "id": "voice-status-root",
                "method": "voice.toggle",
                "params": {"action": "status"},
            }
        )

        assert (
            status_resp["result"]["record_key"] == "ctrl+b"
        ), f"voice.record_key fell back to default for root={bad_root!r}"


def test_voice_record_start_handles_non_dict_voice_cfg(monkeypatch):
    """Round-7 Copilot review regression on #19835.

    The ``voice.record`` start path previously read
    ``_load_cfg().get("voice", {}).get(...)`` without any shape checks.
    When ``voice`` is a non-dict (bool/scalar/list) ``get`` raises
    AttributeError and the handler returns 5025 instead of falling
    back to the VAD defaults. Now it uses ``_voice_cfg_dict()`` and
    non-numeric silence values are coerced to the documented defaults.
    """
    captured: dict = {}

    def fake_start_continuous(**kwargs):
        captured.update(kwargs)

    monkeypatch.setitem(
        sys.modules,
        "hermes_cli.voice",
        types.SimpleNamespace(
            start_continuous=fake_start_continuous, stop_continuous=lambda: None
        ),
    )
    monkeypatch.setenv("HERMES_VOICE", "1")

    for bad in (True, "cmd+b", None, 42, ["ctrl+b"], {"silence_threshold": "loud"}):
        captured.clear()
        monkeypatch.setattr(server, "_load_cfg", lambda b=bad: {"voice": b})

        resp = server.dispatch(
            {
                "id": "voice-record",
                "method": "voice.record",
                "params": {"action": "start"},
            }
        )

        assert (
            "result" in resp
        ), f"voice.record raised for voice={bad!r}: {resp.get('error')}"
        assert resp["result"]["status"] == "recording"
        assert captured["silence_threshold"] == 200
        assert captured["silence_duration"] == 3.0
        assert captured["auto_restart"] is False


    # Round-12 Copilot review regression on #19835: ``bool`` is a subclass
    # of ``int``, so the naive ``isinstance(threshold, (int, float))``
    # guard would forward ``silence_threshold: true`` as ``1`` instead
    # of falling back to the documented 200 default.
    for bad_bool_cfg in (
        {"silence_threshold": True, "silence_duration": False},
        {"silence_threshold": False},
        {"silence_duration": True},
    ):
        captured.clear()
        monkeypatch.setattr(server, "_load_cfg", lambda c=bad_bool_cfg: {"voice": c})

        resp = server.dispatch(
            {
                "id": "voice-record-bool",
                "method": "voice.record",
                "params": {"action": "start"},
            }
        )

        assert "result" in resp, f"voice.record raised for bool cfg={bad_bool_cfg!r}"
        assert (
            captured["silence_threshold"] == 200
        ), f"bool silence_threshold leaked through for {bad_bool_cfg!r}"
        assert (
            captured["silence_duration"] == 3.0
        ), f"bool silence_duration leaked through for {bad_bool_cfg!r}"
        assert captured["auto_restart"] is False


def test_prompt_submit_typed_stop_phrase_ends_voice_chat(monkeypatch):
    """Typed bare stop phrase during an active voice chat is consumed at the
    prompt.submit choke point: voice mode flips off, a distinct
    voice.transcript {stop_phrase, typed} event fires, and NO turn starts.
    """
    calls = {"stop_continuous": 0}
    emitted = []
    monkeypatch.setattr(
        server, "_emit", lambda event, sid, payload=None: emitted.append((event, payload))
    )
    monkeypatch.setitem(
        sys.modules,
        "tools.voice_mode",
        types.SimpleNamespace(
            is_voice_stop_phrase=lambda t: t.strip().lower().strip(".!?") == "stop"
        ),
    )
    monkeypatch.setitem(
        sys.modules,
        "hermes_cli.voice",
        types.SimpleNamespace(
            stop_continuous=lambda force_transcribe=False: calls.__setitem__(
                "stop_continuous", calls["stop_continuous"] + 1
            )
        ),
    )
    monkeypatch.setattr(server, "_tts_stream_stop", lambda user_barge=False: None)
    monkeypatch.setenv("HERMES_VOICE", "1")
    monkeypatch.setenv("HERMES_VOICE_TTS", "1")

    resp = server.dispatch(
        {
            "id": "typed-stop",
            "method": "prompt.submit",
            "params": {"session_id": "any-sid", "text": "Stop."},
        }
    )

    assert resp["result"] == {"voice_stopped": True}
    assert os.environ["HERMES_VOICE"] == "0"
    assert os.environ["HERMES_VOICE_TTS"] == "0"
    assert calls["stop_continuous"] == 1
    assert ("voice.transcript", {"stop_phrase": True, "typed": True}) in emitted


def test_prompt_submit_typed_stop_passes_through_when_voice_off(monkeypatch):
    """Outside a voice chat, typed "stop" is a normal message — the stop
    matcher must not even be consulted (guard is on voice mode)."""
    monkeypatch.setitem(
        sys.modules,
        "tools.voice_mode",
        types.SimpleNamespace(
            is_voice_stop_phrase=lambda t: (_ for _ in ()).throw(
                AssertionError("stop matcher must not run when voice is off")
            )
        ),
    )
    monkeypatch.setenv("HERMES_VOICE", "0")

    resp = server.dispatch(
        {
            "id": "typed-stop-off",
            "method": "prompt.submit",
            "params": {"session_id": "missing-sid", "text": "stop"},
        }
    )

    # The submit proceeds into normal handling (here: unknown session error),
    # NOT the voice_stopped consumption path.
    assert resp.get("result") != {"voice_stopped": True}


def test_prompt_submit_longer_text_not_consumed_in_voice_mode(monkeypatch):
    """"stop the build" while voice is on must reach the agent path."""
    monkeypatch.setitem(
        sys.modules,
        "tools.voice_mode",
        types.SimpleNamespace(
            is_voice_stop_phrase=lambda t: t.strip().lower().strip(".!?") == "stop"
        ),
    )
    monkeypatch.setenv("HERMES_VOICE", "1")

    resp = server.dispatch(
        {
            "id": "typed-long",
            "method": "prompt.submit",
            "params": {"session_id": "missing-sid", "text": "stop the build"},
        }
    )

    assert resp.get("result") != {"voice_stopped": True}


def test_wake_owner_is_sticky_and_routes_detection_to_first_transport(monkeypatch):
    from tools import wake_word

    state = {"owner": None, "callback": None, "paused": False}
    voice_callbacks = {}

    def start_listening(callback, *, owner, config, external_audio=False):
        if state["owner"] is not None and state["owner"] is not owner:
            raise wake_word.WakeWordInUse
        state.update(
            owner=owner,
            callback=callback,
            paused=False,
            external_audio=bool(external_audio),
        )

    def pause_listening(*, owner):
        if state["owner"] is not owner:
            return False
        state["paused"] = True
        return True

    def stop_listening(*, owner):
        if state["owner"] is not owner:
            return False
        state.update(owner=None, callback=None, paused=False)
        return True

    def resume_listening(*, owner):
        if state["owner"] is not owner:
            return False
        state["paused"] = False
        return True

    def start_continuous(**callbacks):
        voice_callbacks.update(callbacks)
        return True

    monkeypatch.setattr(wake_word, "load_wake_word_config", lambda: {
        "enabled": True,
        "phrase": "hey hermes",
        "surface": "auto",
        "start_new_session": True,
    })
    monkeypatch.setattr(wake_word, "check_wake_word_requirements", lambda _cfg: {
        "available": True,
        "phrase": "hey hermes",
        "provider": "test",
        "hint": "",
    })
    monkeypatch.setattr(wake_word, "start_listening", start_listening)
    monkeypatch.setattr(wake_word, "pause_listening", pause_listening)
    monkeypatch.setattr(wake_word, "stop_listening", stop_listening)
    monkeypatch.setattr(wake_word, "owns_listener", lambda owner: state["owner"] is owner)
    monkeypatch.setattr(
        wake_word,
        "is_listening",
        lambda: state["owner"] is not None and not state["paused"],
    )
    monkeypatch.setattr(
        wake_word,
        "resume_listening",
        resume_listening,
    )
    monkeypatch.setitem(
        sys.modules,
        "hermes_cli.voice",
        types.SimpleNamespace(
            start_continuous=start_continuous,
            stop_continuous=lambda **_kwargs: None,
        ),
    )
    monkeypatch.setenv("HERMES_VOICE", "1")

    first = types.SimpleNamespace(_closed=False)
    second = types.SimpleNamespace(_closed=False)
    emitted = []
    monkeypatch.setattr(
        server,
        "_emit",
        lambda event, sid, payload: emitted.append(
            (event, sid, payload, server.current_transport())
        ),
    )
    server._wake_owner_transport = None
    server._wake_owner_surface = ""
    try:
        started = server.dispatch({
            "id": "wake-1",
            "method": "wake.start",
            "params": {"surface": "gui", "session_id": "first-session"},
        }, transport=first)
        denied = server.dispatch({
            "id": "wake-2",
            "method": "wake.start",
            "params": {"surface": "tui", "session_id": "second-session"},
        }, transport=second)
        denied_stop = server.dispatch({
            "id": "wake-stop-2",
            "method": "wake.stop",
            "params": {},
        }, transport=second)
        denied_voice_stop = server.dispatch({
            "id": "voice-stop-2",
            "method": "voice.record",
            "params": {"action": "stop"},
        }, transport=second)

        assert started["result"]["started"] is True
        assert denied["result"] == {
            "started": False,
            "reason": "owned",
            "owner_surface": "gui",
        }
        assert denied_stop["result"] == {
            "stopped": False,
            "reason": "not_owner",
            "disabled_persisted": False,
        }
        assert denied_voice_stop["result"] == {
            "status": "busy",
            "reason": "wake_owned",
        }

        state["callback"]()
        assert emitted == [(
            "wake.detected",
            "first-session",
            {"phrase": "hey hermes", "profile": None, "start_new_session": True},
            first,
        )]
        assert state["paused"] is True

        voice_started = server.dispatch({
            "id": "voice-start-1",
            "method": "voice.record",
            "params": {"action": "start", "session_id": "first-session"},
        }, transport=first)
        assert voice_started["result"]["status"] == "recording"
        voice_callbacks["on_status"]("idle")
        assert state["paused"] is False

        stopped = server.dispatch({
            "id": "wake-stop-1",
            "method": "wake.stop",
            "params": {},
        }, transport=first)
        assert stopped["result"] == {
            "stopped": True,
            "reason": None,
            "disabled_persisted": False,
        }

        reclaimed = server.dispatch({
            "id": "wake-reclaim-2",
            "method": "wake.start",
            "params": {"surface": "tui", "session_id": "second-session"},
        }, transport=second)
        assert reclaimed["result"]["started"] is True
        assert state["owner"] is second

        state["callback"]()
        assert emitted[-1] == (
            "wake.detected",
            "second-session",
            {"phrase": "hey hermes", "profile": None, "start_new_session": True},
            second,
        )

        stopped_again = server.dispatch({
            "id": "wake-stop-2-after-reclaim",
            "method": "wake.stop",
            "params": {},
        }, transport=second)
        assert stopped_again["result"] == {
            "stopped": True,
            "reason": None,
            "disabled_persisted": False,
        }
    finally:
        server._wake_owner_transport = None
        server._wake_owner_surface = ""


def test_wake_toggle_persists_enabled_flag_only_on_explicit_gesture(monkeypatch):
    """The ear toggle / /wake on|off write wake_word.enabled; auto-arm never does."""
    from tools import wake_word

    config = {"enabled": False, "phrase": "hey hermes", "surface": "auto",
              "start_new_session": True}
    persisted = []

    def fake_persist(enabled):
        persisted.append(enabled)
        config["enabled"] = enabled
        return True

    monkeypatch.setattr(server, "_persist_wake_enabled", fake_persist)
    monkeypatch.setattr(wake_word, "load_wake_word_config", lambda: dict(config))
    monkeypatch.setattr(wake_word, "check_wake_word_requirements", lambda _cfg: {
        "available": True,
        "phrase": "hey hermes",
        "provider": "test",
        "hint": "",
    })
    listener = {"owner": None}
    monkeypatch.setattr(
        wake_word, "start_listening",
        lambda callback, *, owner, config, external_audio=False: listener.update(
            owner=owner, external_audio=bool(external_audio)
        ),
    )
    monkeypatch.setattr(
        wake_word, "stop_listening",
        lambda *, owner: listener["owner"] is owner and not listener.update(owner=None),
    )
    monkeypatch.setattr(wake_word, "owns_listener", lambda owner: listener["owner"] is owner)

    transport = types.SimpleNamespace(_closed=False)
    server._wake_owner_transport = None
    server._wake_owner_surface = ""
    try:
        # Passive auto-arm (no persist): refused, config untouched.
        passive = server.dispatch({
            "id": "wake-passive",
            "method": "wake.start",
            "params": {"surface": "gui"},
        }, transport=transport)
        assert passive["result"] == {"started": False, "reason": "disabled"}
        assert persisted == []

        # Explicit gesture: enables in config AND arms.
        clicked = server.dispatch({
            "id": "wake-click",
            "method": "wake.start",
            "params": {"surface": "gui", "persist": True},
        }, transport=transport)
        assert clicked["result"]["started"] is True
        assert clicked["result"]["enabled_persisted"] is True
        assert persisted == [True]

        # Explicit stop: disables in config.
        stopped = server.dispatch({
            "id": "wake-click-off",
            "method": "wake.stop",
            "params": {"persist": True},
        }, transport=transport)
        assert stopped["result"]["stopped"] is True
        assert stopped["result"]["disabled_persisted"] is True
        assert persisted == [True, False]

        # persist does NOT override an explicit surface scoping.
        config.update(enabled=True, surface="tui")
        scoped = server.dispatch({
            "id": "wake-scoped",
            "method": "wake.start",
            "params": {"surface": "gui", "persist": True},
        }, transport=transport)
        assert scoped["result"] == {"started": False, "reason": "disabled_for_surface"}
        assert persisted == [True, False]
    finally:
        server._wake_owner_transport = None
        server._wake_owner_surface = ""


def test_wake_status_reports_configured_input_device_and_windows_silence_hint(monkeypatch):
    from tools import wake_word

    config = {
        "enabled": True,
        "phrase": "hey hermes",
        "provider": "openwakeword",
        "surface": "gui",
        "input_device": "Microphone Array",
    }
    device = {
        "selector": "Microphone Array",
        "name": "Microphone Array",
        "hostapi": "Windows WASAPI",
        "default_samplerate": 48000.0,
    }
    transport = types.SimpleNamespace(_closed=False)

    monkeypatch.setattr(wake_word, "load_wake_word_config", lambda: config)
    monkeypatch.setattr(
        wake_word,
        "check_wake_word_requirements",
        lambda cfg: {
            "available": True,
            "hint": "",
            "phrase": "hey hermes",
            "provider": "openwakeword",
        },
    )
    monkeypatch.setattr(wake_word, "get_input_device_status", lambda cfg: device)
    monkeypatch.setattr(wake_word, "owns_listener", lambda owner: owner is transport)
    monkeypatch.setattr(wake_word, "is_listening", lambda: True)
    monkeypatch.setattr(wake_word, "audio_is_silent", lambda: True)
    monkeypatch.setattr(
        wake_word,
        "silent_audio_hint",
        lambda details: f"silent input: {details['name']} ({details['hostapi']})",
    )

    server._wake_owner_transport = transport
    server._wake_owner_surface = "gui"
    try:
        response = server.dispatch(
            {"id": "wake-status", "method": "wake.status", "params": {}},
            transport=transport,
        )
        assert response["result"]["configured_surface"] == "gui"
        assert response["result"]["input_device"] == device
        assert response["result"]["audio_silent"] is True
        assert response["result"]["hint"] == (
            "silent input: Microphone Array (Windows WASAPI)"
        )
    finally:
        server._wake_owner_transport = None
        server._wake_owner_surface = ""


def test_voice_record_start_forwards_max_recording_seconds(monkeypatch):
    """voice.max_recording_seconds must reach start_continuous from the TUI.

    The CLI wiring alone doesn't cover TUI recordings: the gateway forwards
    recorder params explicitly, so a missing kwarg here silently leaves the
    cap dead in the TUI while CLI tests stay green. Semantics mirror the
    silence params: non-numeric / bool / missing falls back to the documented
    120 default, an explicit numeric value <= 0 disables the cap.
    """
    captured: dict = {}

    def fake_start_continuous(**kwargs):
        captured.update(kwargs)

    monkeypatch.setitem(
        sys.modules,
        "hermes_cli.voice",
        types.SimpleNamespace(
            start_continuous=fake_start_continuous, stop_continuous=lambda: None
        ),
    )
    monkeypatch.setenv("HERMES_VOICE", "1")

    for cfg, expected in (
        ({"max_recording_seconds": 45}, 45),        # explicit cap forwarded as-is
        ({"max_recording_seconds": 0}, 0.0),        # explicit 0 = disabled
        ({"max_recording_seconds": -5}, 0.0),       # negative = disabled
        ({}, 120.0),                                # missing = documented default
        ({"max_recording_seconds": True}, 120.0),   # bool must not become 1s cap
        ({"max_recording_seconds": "long"}, 120.0), # garbage = documented default
    ):
        captured.clear()
        monkeypatch.setattr(server, "_load_cfg", lambda c=cfg: {"voice": c})

        resp = server.dispatch(
            {
                "id": "voice-record-cap",
                "method": "voice.record",
                "params": {"action": "start"},
            }
        )

        assert "result" in resp, f"voice.record raised for cfg={cfg!r}: {resp.get('error')}"
        assert resp["result"]["status"] == "recording"
        assert (
            captured["max_recording_seconds"] == expected
        ), f"cfg={cfg!r} forwarded {captured.get('max_recording_seconds')!r}, expected {expected!r}"


def test_voice_record_stop_forces_transcription(monkeypatch):
    captured: dict = {}

    def fake_stop_continuous(**kwargs):
        captured.update(kwargs)

    monkeypatch.setitem(
        sys.modules,
        "hermes_cli.voice",
        types.SimpleNamespace(
            start_continuous=lambda **_kwargs: None,
            stop_continuous=fake_stop_continuous,
        ),
    )

    resp = server.dispatch(
        {
            "id": "voice-record-stop",
            "method": "voice.record",
            "params": {"action": "stop"},
        }
    )

    assert resp["result"]["status"] == "stopped"
    assert captured["force_transcribe"] is True


def test_voice_record_stop_updates_event_session_id(monkeypatch):
    monkeypatch.setitem(
        sys.modules,
        "hermes_cli.voice",
        types.SimpleNamespace(
            start_continuous=lambda **_kwargs: True,
            stop_continuous=lambda **_kwargs: None,
        ),
    )
    monkeypatch.setattr(server, "_voice_event_sid", "old-session")

    resp = server.dispatch(
        {
            "id": "voice-record-stop-session",
            "method": "voice.record",
            "params": {"action": "stop", "session_id": "new-session"},
        }
    )

    assert resp["result"]["status"] == "stopped"
    assert server._voice_event_sid == "new-session"


def test_voice_record_start_reports_busy_when_stop_is_in_progress(monkeypatch):
    monkeypatch.setitem(
        sys.modules,
        "hermes_cli.voice",
        types.SimpleNamespace(
            start_continuous=lambda **_kwargs: False,
            stop_continuous=lambda **_kwargs: None,
        ),
    )
    monkeypatch.setenv("HERMES_VOICE", "1")
    monkeypatch.setattr(server, "_load_cfg", lambda: {"voice": {}})

    resp = server.dispatch(
        {
            "id": "voice-record-busy",
            "method": "voice.record",
            "params": {"action": "start"},
        }
    )

    assert resp["result"]["status"] == "busy"


def test_voice_toggle_tts_branch_also_carries_record_key(monkeypatch):
    """Round-2 Copilot review regression on #19835.

    The ``tts`` branch used to omit ``record_key`` from its response, so a
    TUI client would parse ``r.record_key ?? 'ctrl+b'`` and reset a
    custom binding to the default on every TTS toggle. Every branch of
    ``voice.toggle`` now carries the configured key so frontend state
    stays authoritative.
    """
    monkeypatch.setattr(
        server,
        "_load_cfg",
        lambda: {"voice": {"record_key": "ctrl+space"}},
    )
    monkeypatch.setitem(
        sys.modules,
        "tools.voice_mode",
        types.SimpleNamespace(
            check_voice_requirements=lambda: {"available": True, "details": ""}
        ),
    )
    monkeypatch.setenv("HERMES_VOICE", "1")
    # setenv (not delenv) — the handler writes HERMES_VOICE_TTS directly, and
    # delenv on an absent var registers no teardown, leaking TTS=1 into every
    # later test in the file (which now spins up the streaming TTS pipeline).
    monkeypatch.setenv("HERMES_VOICE_TTS", "0")

    tts_resp = server.dispatch(
        {"id": "voice-tts", "method": "voice.toggle", "params": {"action": "tts"}}
    )

    assert tts_resp["result"]["record_key"] == "ctrl+space"
    assert tts_resp["result"]["tts"] is True


def test_load_enabled_toolsets_prefers_tui_env(monkeypatch):
    monkeypatch.setenv("HERMES_TUI_TOOLSETS", "web, terminal, ,memory")

    assert server._load_enabled_toolsets() == ["web", "terminal", "memory"]


def test_load_enabled_toolsets_filters_invalid_tui_env(monkeypatch, capsys):
    monkeypatch.setenv("HERMES_TUI_TOOLSETS", "web, nope")
    monkeypatch.setitem(
        sys.modules,
        "hermes_cli.plugins",
        types.SimpleNamespace(discover_plugins=lambda: None),
    )

    assert server._load_enabled_toolsets() == ["web"]
    assert "nope" in capsys.readouterr().err


def test_load_enabled_toolsets_accepts_plugin_env_after_discovery(monkeypatch):
    monkeypatch.setenv("HERMES_TUI_TOOLSETS", "plugin_demo")

    import toolsets

    discovered = {"ready": False}
    original_validate = toolsets.validate_toolset

    def fake_validate(name):
        return name == "plugin_demo" and discovered["ready"] or original_validate(name)

    monkeypatch.setattr(toolsets, "validate_toolset", fake_validate)
    monkeypatch.setitem(
        sys.modules,
        "hermes_cli.plugins",
        types.SimpleNamespace(
            discover_plugins=lambda: discovered.update({"ready": True})
        ),
    )

    assert server._load_enabled_toolsets() == ["plugin_demo"]


def test_load_enabled_toolsets_folds_project_into_focus_posture(monkeypatch):
    # Focus-mode coding posture returns before the config fallback, but it's
    # still a GUI-only resolver — `project` must come along so the desktop keeps
    # the project tools while sitting in a repo.
    monkeypatch.delenv("HERMES_TUI_TOOLSETS", raising=False)

    import agent.coding_context as cc

    monkeypatch.setattr(cc, "coding_selection", lambda **_: ["coding", "figma"])

    assert server._load_enabled_toolsets("tui") == ["coding", "figma", "project"]


def test_load_enabled_toolsets_rejects_disabled_mcp_env(monkeypatch, capsys):
    monkeypatch.setenv("HERMES_TUI_TOOLSETS", "mcp-off")
    monkeypatch.setitem(
        sys.modules,
        "hermes_cli.plugins",
        types.SimpleNamespace(discover_plugins=lambda: None),
    )

    import hermes_cli.config as config_mod

    monkeypatch.setattr(
        config_mod,
        "read_raw_config",
        lambda: {"mcp_servers": {"mcp-off": {"enabled": False}}},
    )
    monkeypatch.setattr(
        config_mod, "load_config", lambda: {"platform_toolsets": {"cli": ["memory"]}}
    )

    # Sorted: ["kanban", "memory", "project"]. `kanban` is auto-recovered by
    # _get_platform_tools (a non-configurable platform toolset in hermes-cli's
    # universe); `project` is GUI-only, folded in by _load_enabled_toolsets.
    # Toolsets inside their first release (_RECENTLY_SHIPPED_TOOLSETS) are
    # back-filled onto saved lists that never offered them — allow those too.
    from hermes_cli.tools_config import _RECENTLY_SHIPPED_TOOLSETS

    result = server._load_enabled_toolsets()
    assert result is not None
    assert {"kanban", "memory", "project"} <= set(result)
    assert set(result) - {"kanban", "memory", "project"} <= _RECENTLY_SHIPPED_TOOLSETS
    err = capsys.readouterr().err
    assert "ignoring disabled MCP servers" in err
    assert "mcp-off" in err
    assert "using configured CLI toolsets" in err


def test_load_enabled_toolsets_falls_back_when_tui_env_invalid(monkeypatch, capsys):
    monkeypatch.setenv("HERMES_TUI_TOOLSETS", "nope")
    monkeypatch.setitem(
        sys.modules,
        "hermes_cli.plugins",
        types.SimpleNamespace(discover_plugins=lambda: None),
    )

    import hermes_cli.config as config_mod

    monkeypatch.setattr(
        config_mod, "load_config", lambda: {"platform_toolsets": {"cli": ["memory"]}}
    )

    from hermes_cli.tools_config import _RECENTLY_SHIPPED_TOOLSETS

    result = server._load_enabled_toolsets()
    assert result is not None
    assert {"kanban", "memory", "project"} <= set(result)
    assert set(result) - {"kanban", "memory", "project"} <= _RECENTLY_SHIPPED_TOOLSETS
    assert "using configured CLI toolsets" in capsys.readouterr().err


def test_load_enabled_toolsets_warns_when_config_fallback_fails(monkeypatch, capsys):
    monkeypatch.setenv("HERMES_TUI_TOOLSETS", "nope")
    monkeypatch.setitem(
        sys.modules,
        "hermes_cli.plugins",
        types.SimpleNamespace(discover_plugins=lambda: None),
    )

    import hermes_cli.config as config_mod

    monkeypatch.setattr(
        config_mod, "load_config", lambda: (_ for _ in ()).throw(RuntimeError("boom"))
    )

    assert server._load_enabled_toolsets() is None
    assert "could not be loaded" in capsys.readouterr().err


def test_load_enabled_toolsets_honors_builtin_env_if_config_fails(monkeypatch):
    monkeypatch.setenv("HERMES_TUI_TOOLSETS", "web")

    import hermes_cli.config as config_mod

    monkeypatch.setattr(
        config_mod, "load_config", lambda: (_ for _ in ()).throw(RuntimeError("boom"))
    )

    assert server._load_enabled_toolsets() == ["web"]


def test_load_enabled_toolsets_all_env_means_all(monkeypatch):
    monkeypatch.setenv("HERMES_TUI_TOOLSETS", "all")

    assert server._load_enabled_toolsets() is None


def test_load_enabled_toolsets_all_env_warns_about_ignored_extra_entries(
    monkeypatch, capsys
):
    monkeypatch.setenv("HERMES_TUI_TOOLSETS", "all,nope")

    assert server._load_enabled_toolsets() is None
    assert "ignoring additional entries: nope" in capsys.readouterr().err


def test_load_enabled_toolsets_reports_disabled_mcp_separately(monkeypatch, capsys):
    monkeypatch.setenv("HERMES_TUI_TOOLSETS", "web,mcp-off,nope")
    monkeypatch.setitem(
        sys.modules,
        "hermes_cli.plugins",
        types.SimpleNamespace(discover_plugins=lambda: None),
    )

    import hermes_cli.config as config_mod

    monkeypatch.setattr(
        config_mod,
        "read_raw_config",
        lambda: {"mcp_servers": {"mcp-off": {"enabled": False}}},
    )

    assert server._load_enabled_toolsets() == ["web"]
    err = capsys.readouterr().err
    assert "ignoring unknown HERMES_TUI_TOOLSETS entries: nope" in err
    assert "ignoring disabled MCP servers" in err
    assert "mcp-off" in err


def test_history_to_messages_preserves_tool_calls_for_resume_display():
    history = [
        {"role": "user", "content": "first prompt"},
        {
            "role": "assistant",
            "content": "",
            "tool_calls": [
                {
                    "id": "call_1",
                    "function": {
                        "name": "search_files",
                        "arguments": json.dumps({"pattern": "resume"}),
                    },
                }
            ],
        },
        {"role": "tool", "content": "{}", "tool_call_id": "call_1"},
        {"role": "assistant", "content": "first answer"},
        {"role": "user", "content": "second prompt"},
    ]

    assert server._history_to_messages(history) == [
        {"role": "user", "text": "first prompt"},
        {"context": "resume", "name": "search_files", "role": "tool"},
        {"role": "assistant", "text": "first answer"},
        {"role": "user", "text": "second prompt"},
    ]


def test_tool_ctx_sends_an_arg_preview_not_a_phrased_label():
    # Clients phrase their own verb around this string: the TUI renders
    # `Terminal("<ctx>")` and the desktop prepends "Running"/"Ran". Sending a
    # pre-phrased label made both stutter ("Ran Running sleep 70 + 2 commands")
    # and stood in for the real command in the desktop's `$` transcript.
    assert server._tool_ctx("terminal", {"command": 'sleep 70; echo "a"; echo "b"'}) == (
        "sleep 70 + 2 commands"
    )
    assert server._tool_ctx("read_file", {"path": "/tmp/demo/package.json"}) == "package.json"
    assert server._tool_ctx("web_search", {"query": "weather in NYC"}) == "weather in NYC"


def test_history_to_messages_keeps_reasoning_only_assistant_turn():
    # A thinking-only assistant turn (reasoning present, no visible text) is
    # persisted and recallable, but was dropped from the resumed session view
    # as "empty" -- so it vanished while the agent could still recall it from
    # the transcript. Keep it (with reasoning) so the desktop "Thinking…"
    # disclosure renders. (#44022)
    history = [
        {"role": "user", "content": "think about this"},
        {"role": "assistant", "content": "", "reasoning": "step-by-step thoughts"},
        {"role": "assistant", "content": "here is the answer"},
    ]

    assert server._history_to_messages(history) == [
        {"role": "user", "text": "think about this"},
        {"role": "assistant", "text": "", "reasoning": "step-by-step thoughts"},
        {"role": "assistant", "text": "here is the answer"},
    ]


def test_history_to_messages_still_drops_empty_assistant_without_reasoning():
    # A genuinely empty assistant turn (no text, no reasoning, no tool calls)
    # remains filtered out -- the fix only spares reasoning-bearing turns.
    history = [
        {"role": "user", "content": "hi"},
        {"role": "assistant", "content": "", "reasoning": ""},
        {"role": "assistant", "content": "   "},
        {"role": "assistant", "content": "real reply"},
    ]

    assert server._history_to_messages(history) == [
        {"role": "user", "text": "hi"},
        {"role": "assistant", "text": "real reply"},
    ]


def test_history_to_messages_renders_multimodal_content():
    # bb/gui preserves image URLs in the resume payload so the desktop
    # renderer's extractEmbeddedImages can pull them back out and display
    # the actual image instead of a placeholder. This also keeps the
    # resume payload in sync with the cached message.
    history = [
        {
            "role": "user",
            "content": [
                {"type": "text", "text": "look here"},
                {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}},
            ],
        },
        {"role": "assistant", "content": "saw it"},
    ]

    assert server._history_to_messages(history) == [
        {"role": "user", "text": "look here\ndata:image/png;base64,abc"},
        {"role": "assistant", "text": "saw it"},
    ]


def test_history_to_messages_hides_gateway_system_markers():
    # Model-switch / personality notices are persisted as role=user [System: …]
    # rows so strict providers accept them mid-history, but they are model-facing
    # metadata -- never a user turn. They must not render as a user bubble on any
    # surface, and dropping them from the display projection also stops the
    # stored marker from shifting the desktop's user-message ordinals and
    # duplicating the optimistic prompt (#67603).
    history = [
        {"role": "user", "content": "first question"},
        {"role": "assistant", "content": "first answer"},
        {
            "role": "user",
            "content": "[System: The active model for this chat has changed to k3.]",
        },
        {"role": "user", "content": "second question"},
        {"role": "assistant", "content": "second answer"},
        {
            "role": "user",
            "content": (
                "[System: The user has changed the assistant's personality. "
                "Adopt the new persona going forward.]"
            ),
        },
    ]

    assert server._history_to_messages(history) == [
        {"role": "user", "text": "first question"},
        {"role": "assistant", "text": "first answer"},
        {"role": "user", "text": "second question"},
        {"role": "assistant", "text": "second answer"},
    ]


def test_history_to_messages_drops_display_hidden_scaffolding():
    # A mid-stream steer persists an interrupted-turn checkpoint. When nothing
    # reached the screen the row carries only model-facing scaffolding and is
    # marked display_kind="hidden"; the scaffolded bytes live in the server-only
    # api_content sidecar for provider replay. This projection -- the single
    # display source every client reads -- must drop the row by its declared
    # display_kind, not just the "[System:" string convention, or the raw
    # "[This response was interrupted by a user correction.]" paints as an
    # assistant bubble (and api_content must never ship to a client).
    history = [
        {"role": "user", "content": "go"},
        {
            "role": "assistant",
            "content": "[This response was interrupted by a user correction.]",
            "api_content": "[This response was interrupted by a user correction.]",
            "display_kind": "hidden",
        },
        {"role": "user", "content": "i love you"},
        {
            "role": "assistant",
            "content": "Love you too",
            "api_content": (
                "[This response was interrupted by a user correction.]\n\n"
                "Visible response before the interruption:\n\nLove you too"
            ),
        },
    ]

    projected = server._history_to_messages(history)

    assert projected == [
        {"role": "user", "text": "go"},
        {"role": "user", "text": "i love you"},
        {"role": "assistant", "text": "Love you too"},
    ]
    # Server-only sidecar never crosses the wire.
    assert all("api_content" not in m for m in projected)


def test_history_to_messages_projects_a_skill_turn_to_its_invocation():
    # A /skill invocation is persisted EXPANDED: the activation note plus the
    # entire skill body. That payload is model-facing scaffolding -- this
    # projection is the single display source every client reads, so it must
    # hand back the invocation the user typed and never the body. Without it a
    # chat bubble renders the whole skill as if the user had written it.
    scaffolded = (
        '[IMPORTANT: The user has invoked the "work" skill, indicating they '
        "want you to follow its instructions. The full skill content is "
        "loaded below.]\n\n"
        "# /work\n\nSPIN UP A WORKTREE, never the primary checkout.\n\n"
        "The user has provided the following instruction alongside the skill "
        "invocation: fix the title leak"
    )

    history = [
        {"role": "user", "content": scaffolded},
        {"role": "assistant", "content": "on it"},
    ]

    assert server._history_to_messages(history) == [
        {
            "role": "user",
            "text": "/work fix the title leak",
            "display_kind": "skill_invocation",
        },
        {"role": "assistant", "text": "on it"},
    ]


def test_history_to_messages_projects_a_bare_skill_turn_to_the_command():
    scaffolded = (
        '[IMPORTANT: The user has invoked the "work" skill, indicating they '
        "want you to follow its instructions. The full skill content is "
        "loaded below.]\n\n# /work\n\nSPIN UP A WORKTREE."
    )

    assert server._history_to_messages([{"role": "user", "content": scaffolded}]) == [
        {"role": "user", "text": "/work", "display_kind": "skill_invocation"}
    ]


def test_expand_skill_invocation_for_replay_round_trips_the_projection(
    tmp_path, monkeypatch
):
    # Rewind/regenerate replays a turn from what the transcript SHOWS, and a
    # skill turn shows its invocation. Re-running that verbatim would send the
    # agent the literal "/work fix it" instead of the skill, so the server
    # re-expands it — the exact inverse of _skill_scaffold_projection, with the
    # body never leaving the server.
    import agent.skill_commands as skill_commands
    import agent.skill_utils as skill_utils
    import tools.skills_tool as skills_tool

    skills_dir = tmp_path / "skills"
    (skills_dir / "worktree-kickoff").mkdir(parents=True)
    (skills_dir / "worktree-kickoff" / "SKILL.md").write_text(
        "---\nname: worktree-kickoff\ndescription: Spin up a worktree\n---\n\n"
        "# kickoff\n\nSPIN UP A WORKTREE, never the primary checkout.\n"
    )
    monkeypatch.setattr(skills_tool, "SKILLS_DIR", skills_dir)
    monkeypatch.setattr(skill_utils, "get_external_skills_dirs", lambda *a, **k: [])
    monkeypatch.setattr(skill_commands, "_skill_commands", {})
    monkeypatch.setattr(skill_commands, "_skill_commands_platform", None)
    skill_commands.scan_skill_commands()

    expanded = server._expand_skill_invocation_for_replay(
        "/worktree-kickoff fix it", "task-1"
    )

    assert "SPIN UP A WORKTREE" in expanded
    assert server._skill_scaffold_projection(expanded) == "/worktree-kickoff fix it"


def test_expand_skill_invocation_for_replay_leaves_ordinary_text_alone(monkeypatch):
    import agent.skill_commands as skill_commands
    import agent.skill_utils as skill_utils

    monkeypatch.setattr(skill_utils, "get_external_skills_dirs", lambda *a, **k: [])
    monkeypatch.setattr(skill_commands, "_skill_commands", {})
    monkeypatch.setattr(skill_commands, "_skill_commands_platform", None)

    assert server._expand_skill_invocation_for_replay("just words", "t") == "just words"
    # A core slash command is not a skill — nothing to expand.
    assert server._expand_skill_invocation_for_replay("/status", "t") == "/status"


def test_history_to_messages_types_a_legacy_auto_continue_row():
    # A crash-interrupted turn used to be typed only AFTER it finished, so a
    # turn killed a second time (or any row written before turn-start typing
    # landed) sits in the DB untyped and painted the raw recovery note as a
    # user bubble. The projection recognizes the synthetic note's fixed
    # prefix so those rows still read as a timeline event.
    history = [
        {"role": "user", "content": "keep going"},
        {"role": "user", "content": server._auto_continue_note("keep going")},
    ]

    projected = server._history_to_messages(history)

    assert projected == [
        {"role": "user", "text": "keep going"},
        {
            "role": "user",
            "text": server._auto_continue_note("keep going"),
            "display_kind": "auto_continue",
        },
    ]


def test_history_to_messages_keeps_real_user_bracket_text():
    # Only role=user rows whose text OPENS with the [System: marker sentinel are
    # bookkeeping notices. A genuine user turn that merely mentions the token is
    # a real message and stays visible.
    history = [
        {"role": "user", "content": "why does [System: ...] show up in my chat?"},
        {"role": "assistant", "content": "it should not"},
    ]

    assert server._history_to_messages(history) == [
        {"role": "user", "text": "why does [System: ...] show up in my chat?"},
        {"role": "assistant", "text": "it should not"},
    ]


@pytest.mark.parametrize("omit_messages", [False, True])
def test_session_resume_uses_parent_lineage_for_display(monkeypatch, omit_messages):
    captured = {}
    target = "tip-omit" if omit_messages else "tip-full"

    class FakeDB:
        def get_session(self, target):
            return {"id": target}

        def reopen_session(self, target):
            captured["reopened"] = target

        def get_resume_conversations(self, session_id):
            return (
                self.get_messages_as_conversation(session_id, repair_alternation=True),
                self.get_messages_as_conversation(session_id, include_ancestors=True),
            )

        def get_ancestor_display_prefix(self, _sid):
            return []

        def get_messages_as_conversation(self, target, include_ancestors=False, repair_alternation=False, **_kwargs):
            captured.setdefault("history_calls", []).append((target, include_ancestors))
            return (
                [
                    {"role": "user", "content": "root prompt"},
                    {"role": "assistant", "content": "root answer"},
                ]
                if include_ancestors
                else [{"role": "user", "content": "tip prompt"}]
            )

    monkeypatch.setattr(server, "_get_db", lambda: FakeDB())
    monkeypatch.setattr(server, "_enable_gateway_prompts", lambda: None)
    monkeypatch.setattr(server, "_set_session_context", lambda target: [])
    monkeypatch.setattr(server, "_clear_session_context", lambda tokens: None)
    monkeypatch.setattr(
        server,
        "_make_agent",
        lambda *args, **kwargs: types.SimpleNamespace(model="test"),
    )
    monkeypatch.setattr(
        server,
        "_session_info",
        lambda agent, *a: {"model": "test", "tools": {}, "skills": {}},
    )
    monkeypatch.setattr(
        server, "_init_session", lambda sid, key, agent, history, cols=80, **_kwargs: None
    )
    # The deferred pre-warm timer is neutered module-wide by the autouse
    # _neuter_agent_prewarm_timer fixture; this test only asserts the
    # returned display history.

    params = {"session_id": target}
    if omit_messages:
        params["omit_messages"] = True
    resp = server.handle_request(
        {"id": "1", "method": "session.resume", "params": params}
    )

    expected = [] if omit_messages else [
        {"role": "user", "text": "root prompt"},
        {"role": "assistant", "text": "root answer"},
    ]
    assert resp["result"]["messages"] == expected
    assert resp["result"]["message_count"] == (1 if omit_messages else 2)
    assert resp["result"]["messages_omitted"] is omit_messages
    expected_calls = [(target, False)] if omit_messages else [
        (target, False),
        (target, True),
    ]
    assert captured["history_calls"] == expected_calls


def test_live_visible_history_prefers_db_display_with_candidate():
    """A warm/live session must serve the persisted DISPLAY lineage, not the
    collapsed in-memory model history.

    Regression for #65919's cross-session fallout: verification candidates
    (finish_reason=verification_required) are persisted but collapsed out of the
    model working history by repair_message_sequence. Building the live-reuse
    payload from ``display_history_prefix + history`` therefore dropped the
    substantive answer, while the eager session.resume path still showed it —
    the two payloads for the same session disagreed. This asserts the live path
    now matches the eager/REST display projection by construction.
    """
    # In-memory model history: the candidate has been collapsed away.
    in_memory = [
        {"role": "user", "content": "do the thing"},
        {"role": "assistant", "content": "terse verified reply"},
    ]
    # Persisted display lineage: the candidate (substantive answer) survives.
    display_with_candidate = [
        {"role": "user", "content": "do the thing"},
        {"role": "assistant", "content": "long substantive answer",
         "finish_reason": "verification_required"},
        {"role": "assistant", "content": "terse verified reply"},
    ]

    class DB:
        def get_messages_as_conversation(
            self, key, include_ancestors=False, repair_alternation=False, **_kwargs
        ):
            assert key == "s1"
            assert include_ancestors is True
            return list(display_with_candidate)

    result = server._live_visible_history({"session_key": "s1"}, DB(), in_memory)
    assert result == display_with_candidate


def test_live_visible_history_falls_back_without_db_or_key():
    in_memory = [{"role": "user", "content": "hi"}]
    # No DB handle available.
    assert server._live_visible_history({"session_key": "s"}, None, in_memory) == in_memory

    # DB available but the session has no persist key yet.
    class DB:
        def get_messages_as_conversation(self, *a, **k):  # pragma: no cover - not reached
            raise AssertionError("must not query without a session_key")

    assert server._live_visible_history({}, DB(), in_memory) == in_memory


def test_live_visible_history_falls_back_when_db_empty():
    """A brand-new live session whose first turn hasn't been flushed keeps its
    in-memory history rather than rendering empty."""
    in_memory = [{"role": "user", "content": "fresh turn not flushed yet"}]

    class EmptyDB:
        def get_messages_as_conversation(self, *a, **k):
            return []

    assert server._live_visible_history({"session_key": "s"}, EmptyDB(), in_memory) == in_memory


def test_live_visible_history_falls_back_when_db_raises():
    in_memory = [{"role": "user", "content": "hi"}, {"role": "assistant", "content": "yo"}]

    class BrokenDB:
        def get_messages_as_conversation(self, *a, **k):
            raise RuntimeError("db exploded")

    assert server._live_visible_history({"session_key": "s"}, BrokenDB(), in_memory) == in_memory


def test_live_visible_history_keeps_candidate_and_fresh_tail():
    """The hard case: the persisted candidate (missing from in-memory) AND a
    not-yet-flushed live turn (missing from the DB) must BOTH survive."""
    # Persisted display: has the verification candidate, lags the newest turn.
    db_display = [
        {"role": "user", "content": "turn 1"},
        {"role": "assistant", "content": "long substantive answer",
         "finish_reason": "verification_required"},
        {"role": "assistant", "content": "terse verified reply"},
    ]
    # In-memory model history: candidate collapsed out, but has a fresh turn 2.
    in_memory = [
        {"role": "user", "content": "turn 1"},
        {"role": "assistant", "content": "terse verified reply"},
        {"role": "user", "content": "turn 2 not flushed"},
        {"role": "assistant", "content": "turn 2 reply not flushed"},
    ]

    class DB:
        def get_messages_as_conversation(self, key, include_ancestors=False, repair_alternation=False, **_kwargs):
            return list(db_display)

    result = server._live_visible_history({"session_key": "s1"}, DB(), in_memory)
    assert result == [
        {"role": "user", "content": "turn 1"},
        {"role": "assistant", "content": "long substantive answer",
         "finish_reason": "verification_required"},
        {"role": "assistant", "content": "terse verified reply"},
        {"role": "user", "content": "turn 2 not flushed"},
        {"role": "assistant", "content": "turn 2 reply not flushed"},
    ]


def test_reconcile_display_with_live_trusts_db_when_tail_absent():
    """If the DB tail isn't in memory (DB ahead / diverged), don't duplicate —
    serve the persisted display."""
    db_display = [
        {"role": "user", "content": "a"},
        {"role": "assistant", "content": "b"},
    ]
    in_memory = [{"role": "user", "content": "unrelated"}]
    assert server._reconcile_display_with_live(db_display, in_memory) == db_display
    assert server._reconcile_display_with_live([], in_memory) == in_memory
    assert server._reconcile_display_with_live(db_display, []) == db_display


def test_live_visible_history_matches_eager_resume_with_real_db(tmp_path):
    """E2E cross-builder consistency against a real SessionDB.

    A persisted verification candidate (finish_reason=verification_required)
    is collapsed out of the model history by repair_message_sequence but kept
    in the display lineage (#65919). The warm/live projection
    (_live_visible_history) must equal the eager session.resume display
    projection — both keeping the candidate — so switching to a live session
    shows the same substantive answer a cold resume would.
    """
    from hermes_state import SessionDB

    db = SessionDB(db_path=tmp_path / "state.db")
    db.create_session("s1", source="tui")
    db.append_message("s1", role="user", content="do the thing")
    db.append_message(
        "s1", role="assistant", content="long substantive answer",
        finish_reason="verification_required",
    )
    db.append_message(
        "s1", role="assistant", content="terse verified reply", finish_reason="stop",
    )

    model_history, display_history = db.get_resume_conversations("s1")

    # The divergence #65919 introduced: candidate absent from the model
    # projection, present in the display projection.
    assert not any("long substantive" in (m.get("content") or "") for m in model_history)
    assert any("long substantive" in (m.get("content") or "") for m in display_history)

    # Eager session.resume serves the display projection.
    eager_messages = server._history_to_messages(display_history)
    # Warm/live reuse: in-memory history is the collapsed model projection.
    live_history = server._live_visible_history({"session_key": "s1"}, db, list(model_history))
    # They must agree — the candidate survives the warm switch.
    assert server._history_to_messages(live_history) == eager_messages
    assert any(m.get("text") == "long substantive answer" for m in eager_messages)


def test_live_visible_history_keeps_candidate_and_new_flushed_turn_real_db(tmp_path):
    """Real-DB variant of the combined case: a candidate from turn 1 AND a
    fully-flushed turn 2 both appear once."""
    from hermes_state import SessionDB

    db = SessionDB(db_path=tmp_path / "state.db")
    db.create_session("s1", source="tui")
    db.append_message("s1", role="user", content="turn 1")
    db.append_message(
        "s1", role="assistant", content="candidate answer",
        finish_reason="verification_required",
    )
    db.append_message("s1", role="assistant", content="verified reply", finish_reason="stop")
    db.append_message("s1", role="user", content="turn 2")
    db.append_message("s1", role="assistant", content="turn 2 reply", finish_reason="stop")

    model_history, display_history = db.get_resume_conversations("s1")
    live_history = server._live_visible_history({"session_key": "s1"}, db, list(model_history))
    texts = [m.get("text") for m in server._history_to_messages(live_history)]

    assert texts == [
        "turn 1",
        "candidate answer",
        "verified reply",
        "turn 2",
        "turn 2 reply",
    ]


def test_lazy_child_watch_resume_serves_candidate_inclusive_display(monkeypatch, tmp_path):
    """The delegated-child watch-window cold resume (lazy=True) must serve the
    verbatim display projection so a persisted verification candidate is not
    collapsed out of the watch window (#65919 sibling of the warm-payload fix).
    """
    from hermes_state import SessionDB

    db = SessionDB(db_path=tmp_path / "state.db")
    db.create_session("child1", source="tui")
    db.append_message("child1", role="user", content="child prompt")
    db.append_message(
        "child1", role="assistant", content="child substantive answer",
        finish_reason="verification_required",
    )
    db.append_message(
        "child1", role="assistant", content="child terse reply", finish_reason="stop",
    )

    lease = types.SimpleNamespace(session_id="child1", release=lambda: None)

    monkeypatch.setattr(server, "_get_db", lambda: db)
    monkeypatch.setattr(server, "_enable_gateway_prompts", lambda: None)
    monkeypatch.setattr(
        server, "_claim_active_session_slot", lambda *a, **k: (lease, None)
    )
    monkeypatch.setattr(
        server, "_deferred_session_record", lambda *a, **k: {"created_at": 123.0}
    )
    monkeypatch.setattr(server, "_claim_or_reuse_live", lambda *a, **k: None)
    monkeypatch.setattr(server, "_child_run_active", lambda *a, **k: False)
    monkeypatch.setattr(server, "_schedule_session_cap_enforcement", lambda *a, **k: None)

    resp = server.handle_request(
        {
            "id": "1",
            "method": "session.resume",
            "params": {"session_id": "child1", "lazy": True},
        }
    )

    assert "error" not in resp, resp
    texts = [m.get("text") for m in resp["result"]["messages"]]
    assert "child substantive answer" in texts
    assert texts == ["child prompt", "child substantive answer", "child terse reply"]


def test_session_resume_follows_compression_tip(monkeypatch, tmp_path):
    """Resuming a rotated-out parent id must load the continuation's messages.

    Regression for the desktop "I came back and the reply isn't there" report:
    auto-compression ends the live session and forks a continuation child, so a
    resume on the parent id (the desktop's routed id when the chat was opened
    before it rotated) used to reload the pre-compression transcript and drop
    the response generated after compression. session.resume must follow the
    compression tip via resolve_resume_session_id.
    """
    from hermes_state import SessionDB

    db = SessionDB(db_path=tmp_path / "state.db")
    base = int(time.time()) - 10_000
    db.create_session("parent_root", source="tui")
    db.append_message(
        "parent_root", role="user", content="pre-compression turn",
        timestamp=base + 10,
    )
    db.end_session("parent_root", "compression")
    db.create_session("cont_tip", source="tui", parent_session_id="parent_root")
    db.append_message(
        "cont_tip", role="assistant", content="post-compression reply",
        timestamp=base + 110,
    )
    conn = db._conn
    assert conn is not None
    conn.execute(
        "UPDATE sessions SET started_at = ?, ended_at = ? WHERE id = 'parent_root'",
        (base, base + 50),
    )
    conn.execute("UPDATE sessions SET started_at = ? WHERE id = 'cont_tip'", (base + 100,))
    conn.commit()

    captured = {}

    def fake_make_agent(sid, key, session_id=None, session_db=None, **kwargs):
        # Record only the FIRST (synchronous, eager) build. A stray background
        # build leaked from an earlier test's deferred resume could otherwise
        # overwrite this with its own session_id and corrupt the assertion.
        captured.setdefault("agent_session_id", session_id)
        return types.SimpleNamespace(model="test", provider="test")

    monkeypatch.setattr(server, "_get_db", lambda: db)
    monkeypatch.setattr(server, "_enable_gateway_prompts", lambda: None)
    monkeypatch.setattr(server, "_set_session_context", lambda target: [])
    monkeypatch.setattr(server, "_clear_session_context", lambda tokens: None)
    monkeypatch.setattr(server, "_make_agent", fake_make_agent)
    monkeypatch.setattr(
        server, "_session_info", lambda agent, *a: {"model": "test", "tools": {}, "skills": {}}
    )
    monkeypatch.setattr(
        server, "_init_session", lambda sid, key, agent, history, cols=80, **_kwargs: None
    )

    try:
        # eager_build: this asserts the synchronously-built agent binds to the
        # resolved tip (captured["agent_session_id"]); the compression-tip
        # resolution itself runs before the build and is mode-agnostic.
        resp = server.handle_request(
            {"id": "1", "method": "session.resume", "params": {"session_id": "parent_root", "eager_build": True}}
        )
    finally:
        db.close()

    # The agent must bind to the continuation tip, and the returned transcript
    # must include the post-compression reply (which lives only in the tip).
    assert resp["result"]["session_key"] == "cont_tip"
    assert captured["agent_session_id"] == "cont_tip"
    texts = [m.get("text") for m in resp["result"]["messages"]]
    assert "post-compression reply" in texts


def test_session_resume_passes_stored_runtime_to_agent(monkeypatch):
    captured = {}

    class FakeDB:
        def get_session(self, target):
            return {
                "id": target,
                "model": "gpt-5.4",
                "billing_provider": "openai-codex",
                "model_config": '{"reasoning_config":{"enabled":true,"effort":"high"},"service_tier":"priority","base_url":"https://custom.example/v1","api_mode":"chat_completions"}',
            }

        def reopen_session(self, target):
            pass

        def get_resume_conversations(self, session_id):
            return (
                self.get_messages_as_conversation(session_id, repair_alternation=True),
                self.get_messages_as_conversation(session_id, include_ancestors=True),
            )

        def get_ancestor_display_prefix(self, _sid):
            return []

        def get_messages_as_conversation(self, target, include_ancestors=False, repair_alternation=False, **_kwargs):
            return [{"role": "user", "content": "hello"}]

    def fake_make_agent(sid, key, session_id=None, session_db=None, **kwargs):
        captured.update(kwargs)
        return types.SimpleNamespace(model="gpt-5.4", provider="openai-codex")

    monkeypatch.setattr(server, "_get_db", lambda: FakeDB())
    monkeypatch.setattr(server, "_enable_gateway_prompts", lambda: None)
    monkeypatch.setattr(server, "_set_session_context", lambda target: [])
    monkeypatch.setattr(server, "_clear_session_context", lambda tokens: None)
    monkeypatch.setattr(server, "_make_agent", fake_make_agent)
    monkeypatch.setattr(server, "_session_info", lambda agent, *a: {"model": agent.model, "provider": agent.provider})

    def fake_init_session(sid, key, agent, history, cols=80, **_kwargs):
        server._sessions[sid] = {"agent": agent, "session_key": key}

    monkeypatch.setattr(server, "_init_session", fake_init_session)

    # eager_build: this asserts the synchronous build contract (stored runtime
    # overrides reach _make_agent, info comes from _session_info). The deferred
    # default restores the same overrides via _start_agent_build off-thread.
    resp = server.handle_request(
        {"id": "1", "method": "session.resume", "params": {"session_id": "stored-session", "eager_build": True}}
    )

    assert resp["result"]["info"] == {"model": "gpt-5.4", "provider": "openai-codex"}
    assert captured["model_override"] == {
        "model": "gpt-5.4",
        "provider": "openai-codex",
        "base_url": "https://custom.example/v1",
        "api_mode": "chat_completions",
    }
    assert captured["provider_override"] == "openai-codex"
    assert captured["reasoning_config_override"] == {"enabled": True, "effort": "high"}
    assert captured["service_tier_override"] == "priority"
    runtime_sid = resp["result"]["session_id"]
    assert server._sessions[runtime_sid]["model_override"] == captured["model_override"]


def test_session_resume_profile_uses_profile_db_cwd(monkeypatch, tmp_path):
    target = "stored-profile-session"
    launch_cwd = tmp_path / "launch"
    profile_cwd = tmp_path / "worker"
    profile_home = tmp_path / "profiles" / "worker"
    launch_cwd.mkdir()
    profile_cwd.mkdir()
    profile_home.mkdir(parents=True)
    captured = {}

    class ProfileDB:
        def get_session(self, _target):
            return {"id": target, "cwd": str(profile_cwd)}

        def get_session_by_title(self, _target):
            return None

        def reopen_session(self, _target):
            captured["reopened"] = _target

        def get_resume_conversations(self, session_id):
            return (
                self.get_messages_as_conversation(session_id, repair_alternation=True),
                self.get_messages_as_conversation(session_id, include_ancestors=True),
            )

        def get_ancestor_display_prefix(self, _sid):
            return []

        def get_messages_as_conversation(self, _target, include_ancestors=False, repair_alternation=False, **_kwargs):
            return [{"role": "user", "content": "hello"}]

        def update_session_cwd(self, *_args):
            raise AssertionError("profile row already has cwd")

    class LaunchDB:
        def get_session(self, _target):
            return {"id": target, "cwd": str(launch_cwd)}

        def update_session_cwd(self, *_args):
            captured["launch_update"] = True

    profile_db = ProfileDB()
    launch_db = LaunchDB()

    class FakeWorker:
        def __init__(self, *_args, **_kwargs):
            pass

        def close(self):
            pass

    def fake_make_agent(sid, key, session_id=None, session_db=None, **kwargs):
        captured["agent_db"] = session_db
        return types.SimpleNamespace(model="test/model")

    monkeypatch.setenv("TERMINAL_CWD", str(launch_cwd))
    monkeypatch.setattr(server, "_profile_home", lambda _profile: profile_home)
    monkeypatch.setattr("hermes_state.SessionDB", lambda db_path=None: profile_db)
    monkeypatch.setattr(server, "_get_db", lambda: launch_db)
    monkeypatch.setattr(server, "_enable_gateway_prompts", lambda: None)
    monkeypatch.setattr(server, "_set_session_context", lambda target: [])
    monkeypatch.setattr(server, "_clear_session_context", lambda tokens: None)
    monkeypatch.setattr(server, "_make_agent", fake_make_agent)
    monkeypatch.setattr(server, "_SlashWorker", FakeWorker)
    monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None)
    monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None)
    monkeypatch.setattr(
        server,
        "_session_info",
        lambda _agent, session=None: {"cwd": session.get("cwd") if session else ""},
    )

    import tools.approval as approval

    monkeypatch.setattr(approval, "register_gateway_notify", lambda key, cb: None)
    monkeypatch.setattr(approval, "load_permanent_allowlist", lambda: None)

    try:
        # eager_build: asserts the synchronous build receives the profile's db
        # (the deferred default builds with the same db via _start_agent_build).
        resp = server.handle_request(
            {
                "id": "1",
                "method": "session.resume",
                "params": {"session_id": target, "profile": "worker", "eager_build": True},
            }
        )

        assert "error" not in resp
        sid = resp["result"]["session_id"]
        assert captured["agent_db"] is profile_db
        assert server._sessions[sid]["cwd"] == str(profile_cwd)
        assert resp["result"]["info"]["cwd"] == str(profile_cwd)
        assert "launch_update" not in captured
    finally:
        server._sessions.clear()


def test_session_cwd_set_profile_session_updates_profile_db(monkeypatch, tmp_path):
    target = "stored-profile-session"
    profile_home = tmp_path / "profiles" / "worker"
    profile_home.mkdir(parents=True)
    new_cwd = tmp_path / "new-workspace"
    new_cwd.mkdir()
    captured = {}

    class ProfileDB:
        def update_session_cwd(self, session_id, cwd, git_branch=None, git_repo_root=None):
            captured["profile_update"] = (session_id, cwd)

        def close(self):
            captured["profile_closed"] = True

    class LaunchDB:
        def update_session_cwd(self, *_args):
            captured["launch_update"] = True

    profile_db = ProfileDB()

    import tools.terminal_tool as terminal_tool

    monkeypatch.setattr("hermes_state.SessionDB", lambda db_path=None: profile_db)
    monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
    monkeypatch.setattr(terminal_tool, "cleanup_vm", lambda _key: None)
    monkeypatch.setattr(server, "_register_session_cwd", lambda _session: None)

    session = {"session_key": target, "profile_home": str(profile_home)}
    assert server._set_session_cwd(session, str(new_cwd)) == str(new_cwd)
    assert session["cwd"] == str(new_cwd)
    assert session["explicit_cwd"] is True
    assert captured["profile_update"] == (target, str(new_cwd))
    assert captured["profile_closed"] is True
    assert "launch_update" not in captured


def test_stored_session_runtime_overrides_skips_bare_billing_provider():
    """A bare billing bucket ("custom"/"auto"/"openrouter") must not be restored as the
    provider identity on resume. A custom endpoint that never used `/model` persists only
    `billing_provider="custom"`; restoring that broke `session.resume` with "No LLM provider
    configured" (agent_init treats it as non-routable). A real provider, or an explicit
    `model_config.provider`, is still restored.
    """
    # Bare "custom" bucket, no explicit model_config.provider: no provider override restored.
    ov = server._stored_session_runtime_overrides({"model": "my-model", "billing_provider": "custom"})
    assert "provider_override" not in ov
    assert ov["model_override"]["provider"] is None

    for bare in ("auto", "openrouter", "custom"):
        ov = server._stored_session_runtime_overrides({"model": "m", "billing_provider": bare})
        assert "provider_override" not in ov

    # A real provider in billing_provider is still restored.
    ov = server._stored_session_runtime_overrides({"model": "m", "billing_provider": "anthropic"})
    assert ov["provider_override"] == "anthropic"
    assert ov["model_override"]["provider"] == "anthropic"

    # An explicit routable provider in model_config wins over the bare billing bucket.
    ov = server._stored_session_runtime_overrides(
        {"model": "m", "billing_provider": "custom", "model_config": {"provider": "custom:myendpoint"}}
    )
    assert ov["provider_override"] == "custom:myendpoint"
    assert ov["model_override"]["provider"] == "custom:myendpoint"


def test_stored_session_runtime_overrides_restores_explicit_normal_tier():
    overrides = server._stored_session_runtime_overrides(
        {
            "model": "gpt-5.4",
            "model_config": {"service_tier": "normal"},
        }
    )

    assert "service_tier_override" in overrides
    assert overrides["service_tier_override"] == ""


def test_persist_live_session_runtime_preserves_resume_metadata(monkeypatch):
    updates = {}

    class FakeDB:
        def get_session(self, session_id):
            assert session_id == "stored-session"
            return {"model_config": '{"_branched_from":"root"}'}

        def update_session_meta(self, session_id, model_config_json, model=None):
            updates["meta"] = (session_id, json.loads(model_config_json), model)

    agent = types.SimpleNamespace(
        model="gpt-5.4",
        provider="openai-codex",
        base_url="https://custom.example/v1",
        api_mode="chat_completions",
        reasoning_config={"enabled": True, "effort": "high"},
        service_tier="priority",
        _session_db=FakeDB(),
    )

    server._persist_live_session_runtime({"agent": agent, "session_key": "stored-session"})

    assert "model" not in updates
    assert updates["meta"] == (
        "stored-session",
        {
            "_branched_from": "root",
            "model": "gpt-5.4",
            "provider": "openai-codex",
            "base_url": "https://custom.example/v1",
            "api_mode": "chat_completions",
            "reasoning_config": {"enabled": True, "effort": "high"},
            "service_tier": "priority",
        },
        "gpt-5.4",
    )


def test_persist_live_session_runtime_preserves_explicit_normal_tier():
    updates = {}

    class FakeDB:
        def get_session(self, _session_id):
            return {"model_config": '{"service_tier":"priority"}'}

        def update_session_meta(self, _session_id, model_config_json, model=None):
            updates["config"] = json.loads(model_config_json)

    agent = types.SimpleNamespace(
        model="gpt-5.4",
        provider="openai-codex",
        base_url=None,
        api_mode=None,
        reasoning_config=None,
        service_tier="",
        _session_db=FakeDB(),
    )

    server._persist_live_session_runtime(
        {
            "agent": agent,
            "session_key": "stored-session",
            "create_service_tier_override": "",
        }
    )

    assert updates["config"]["service_tier"] == "normal"


def test_status_callback_emits_kind_and_text():
    with patch("tui_gateway.server._emit") as emit:
        cb = server._agent_cbs("sid")["status_callback"]
        cb("context_pressure", "85% to compaction")

    emit.assert_called_once_with(
        "status.update",
        "sid",
        {"kind": "context_pressure", "text": "85% to compaction"},
    )


def test_status_callback_accepts_single_message_argument():
    with patch("tui_gateway.server._emit") as emit:
        cb = server._agent_cbs("sid")["status_callback"]
        cb("thinking...")

    emit.assert_called_once_with(
        "status.update",
        "sid",
        {"kind": "status", "text": "thinking..."},
    )


def test_resolve_model_uses_inference_model_env(monkeypatch):
    monkeypatch.delenv("HERMES_MODEL", raising=False)
    monkeypatch.setenv("HERMES_INFERENCE_MODEL", " anthropic/claude-sonnet-4.6\n")

    assert server._resolve_model() == "anthropic/claude-sonnet-4.6"


def test_resolve_model_strips_config_model(monkeypatch):
    monkeypatch.delenv("HERMES_MODEL", raising=False)
    monkeypatch.delenv("HERMES_INFERENCE_MODEL", raising=False)
    monkeypatch.setattr(
        server, "_load_cfg", lambda: {"model": {"default": " nous/hermes-test "}}
    )

    assert server._resolve_model() == "nous/hermes-test"


def _sync_test_session(**extra):
    session = {
        "agent": types.SimpleNamespace(model="old/model"),
        "session_key": "session-key",
    }
    session.update(extra)
    return session


def _patch_config_model(monkeypatch, model, provider=""):
    monkeypatch.delenv("HERMES_MODEL", raising=False)
    monkeypatch.delenv("HERMES_INFERENCE_MODEL", raising=False)
    cfg_model = {"default": model}
    if provider:
        cfg_model["provider"] = provider
    monkeypatch.setattr(server, "_load_cfg", lambda: {"model": cfg_model})


def test_config_sync_switches_unpinned_session(monkeypatch):
    _patch_config_model(monkeypatch, "new/model", provider="nous")
    session = _sync_test_session(config_model_seen=("old/model", "nous"))
    calls = []
    monkeypatch.setattr(
        server,
        "_apply_model_switch",
        lambda sid, sess, raw, **kw: calls.append((sid, raw, kw)),
    )

    server._sync_agent_model_with_config("sid", session)

    assert calls == [
        (
            "sid",
            "new/model --provider nous",
            {
                "confirm_expensive_model": True,
                "pin_session_override": False,
                "persist_override": False,
            },
        )
    ]
    assert session["config_model_seen"] == ("new/model", "nous")


def test_config_sync_treats_auto_provider_as_unset(monkeypatch):
    _patch_config_model(monkeypatch, "new/model", provider="auto")
    session = _sync_test_session(config_model_seen=("old/model", ""))
    calls = []
    monkeypatch.setattr(
        server,
        "_apply_model_switch",
        lambda sid, sess, raw, **kw: calls.append(raw),
    )

    server._sync_agent_model_with_config("sid", session)

    assert calls == ["new/model"]


def test_config_sync_skips_session_pinned_by_model_command(monkeypatch):
    _patch_config_model(monkeypatch, "new/model")
    session = _sync_test_session(
        config_model_seen=("old/model", ""),
        model_override={"model": "pinned/model"},
    )
    monkeypatch.setattr(
        server,
        "_apply_model_switch",
        lambda *a, **k: pytest.fail("pinned session must not be switched"),
    )

    server._sync_agent_model_with_config("sid", session)


def test_config_sync_noop_when_config_unchanged(monkeypatch):
    _patch_config_model(monkeypatch, "old/model")
    session = _sync_test_session(config_model_seen=("old/model", ""))
    monkeypatch.setattr(
        server,
        "_apply_model_switch",
        lambda *a, **k: pytest.fail("unchanged config must not switch"),
    )

    server._sync_agent_model_with_config("sid", session)


def test_config_sync_adopts_baseline_when_agent_already_on_target(monkeypatch):
    # Branched/resumed sessions reach their first sync with no snapshot but
    # an agent already built from config; that must not trigger a switch.
    _patch_config_model(monkeypatch, "old/model")
    session = _sync_test_session()
    monkeypatch.setattr(
        server,
        "_apply_model_switch",
        lambda *a, **k: pytest.fail("agent already on target must not switch"),
    )

    server._sync_agent_model_with_config("sid", session)

    assert session["config_model_seen"] == ("old/model", "")


def test_config_sync_switches_when_only_provider_differs(monkeypatch):
    _patch_config_model(monkeypatch, "old/model", provider="nous")
    session = _sync_test_session(config_model_seen=("old/model", ""))
    calls = []
    monkeypatch.setattr(
        server,
        "_apply_model_switch",
        lambda sid, sess, raw, **kw: calls.append(raw),
    )

    server._sync_agent_model_with_config("sid", session)

    assert calls == ["old/model --provider nous"]


def test_config_sync_failure_emits_error_once_per_edit(monkeypatch):
    _patch_config_model(monkeypatch, "broken/model")
    session = _sync_test_session(config_model_seen=("old/model", ""))

    def boom(*a, **k):
        raise ValueError("no such model")

    monkeypatch.setattr(server, "_apply_model_switch", boom)
    emits = []
    monkeypatch.setattr(
        server, "_emit", lambda ev, sid, payload: emits.append((ev, payload))
    )

    server._sync_agent_model_with_config("sid", session)
    server._sync_agent_model_with_config("sid", session)

    assert len(emits) == 1
    assert emits[0][0] == "error"
    assert "broken/model" in emits[0][1]["message"]


def test_config_sync_config_wins_over_env_seed(monkeypatch):
    # Hosted instances set HERMES_INFERENCE_MODEL as a provision-time seed;
    # the per-turn sync must follow config.yaml edits, not stay pinned to it.
    monkeypatch.setenv("HERMES_INFERENCE_MODEL", "seed/model")
    monkeypatch.delenv("HERMES_MODEL", raising=False)
    monkeypatch.setattr(server, "_load_cfg", lambda: {"model": {"default": "new/model"}})
    session = _sync_test_session(config_model_seen=("seed/model", ""))
    calls = []
    monkeypatch.setattr(
        server,
        "_apply_model_switch",
        lambda sid, sess, raw, **kw: calls.append(raw),
    )

    server._sync_agent_model_with_config("sid", session)

    assert calls == ["new/model"]
    assert session["config_model_seen"] == ("new/model", "")


def test_config_sync_ignores_env_seed_without_config_model(monkeypatch):
    # `hermes --tui -m <model>` sets HERMES_MODEL/HERMES_INFERENCE_MODEL as a
    # launch-scoped seed. When config.yaml has NO model.default (typical
    # custom-provider-only setup), the sync must NOT adopt the env seed as a
    # config target — doing so replayed the -m flag as a /model switch and
    # (with persist_switch_by_default=True) wrote it into config.yaml
    # permanently.
    monkeypatch.setenv("HERMES_MODEL", "one-shot/model")
    monkeypatch.setenv("HERMES_INFERENCE_MODEL", "one-shot/model")
    monkeypatch.setattr(
        server, "_load_cfg", lambda: {"model": {"provider": "custom:mylocal"}}
    )
    session = _sync_test_session()
    monkeypatch.setattr(
        server,
        "_apply_model_switch",
        lambda *a, **k: pytest.fail("env seed must not trigger a config sync switch"),
    )

    server._sync_agent_model_with_config("sid", session)


def test_config_model_target_never_reads_env(monkeypatch):
    monkeypatch.setenv("HERMES_MODEL", "seed/model")
    monkeypatch.setenv("HERMES_INFERENCE_MODEL", "seed/model")
    monkeypatch.setattr(server, "_load_cfg", lambda: {"model": {"provider": "nous"}})

    assert server._config_model_target() == ("", "nous")


def test_apply_model_switch_persist_override_false_never_persists(monkeypatch):
    # Internal callers (config sync, /moa one-shot + restore) pass
    # persist_override=False; even with persist_switch_by_default=True the
    # switch must not write config.yaml.
    import types as _types

    result = _types.SimpleNamespace(
        success=True,
        new_model="new/model",
        target_provider="nous",
        base_url="",
        api_key="key",
        api_mode="chat_completions",
        warning_message="",
        model_info=None,
        error_message="",
    )
    monkeypatch.setattr(
        "hermes_cli.model_switch.switch_model", lambda **kw: result
    )
    monkeypatch.setattr(
        "hermes_cli.model_switch.resolve_persist_behavior",
        lambda *a: pytest.fail("persist_override must bypass resolve_persist_behavior"),
    )
    monkeypatch.setattr(
        server, "_persist_model_switch",
        lambda _r: pytest.fail("persist_override=False must not persist"),
    )
    monkeypatch.setattr(
        "hermes_cli.model_cost_guard.expensive_model_warning",
        lambda *a, **k: None,
    )
    session = {"agent": None}

    out = server._apply_model_switch(
        "sid", session, "new/model --provider nous", persist_override=False
    )

    assert out["value"] == "new/model"
    assert session["model_override"]["model"] == "new/model"


def test_startup_runtime_uses_tui_provider_env(monkeypatch):
    monkeypatch.setenv("HERMES_MODEL", "nous/hermes-test")
    monkeypatch.setenv("HERMES_TUI_PROVIDER", "nous")
    monkeypatch.delenv("HERMES_INFERENCE_PROVIDER", raising=False)

    assert server._resolve_startup_runtime() == ("nous/hermes-test", "nous")


def test_startup_runtime_does_not_treat_inference_provider_as_explicit(monkeypatch):
    monkeypatch.setenv("HERMES_MODEL", "nous/hermes-test")
    monkeypatch.delenv("HERMES_TUI_PROVIDER", raising=False)
    monkeypatch.setenv("HERMES_INFERENCE_PROVIDER", "nous")
    monkeypatch.setattr(
        "hermes_cli.models.detect_static_provider_for_model",
        lambda model, provider: None,
    )

    assert server._resolve_startup_runtime() == ("nous/hermes-test", None)


def test_startup_runtime_detects_provider_for_model_env(monkeypatch):
    monkeypatch.setenv("HERMES_MODEL", "sonnet")
    monkeypatch.delenv("HERMES_TUI_PROVIDER", raising=False)
    monkeypatch.delenv("HERMES_INFERENCE_PROVIDER", raising=False)
    monkeypatch.setattr(server, "_load_cfg", lambda: {"model": {"provider": "auto"}})

    def fake_detect(model, current_provider):
        assert model == "sonnet"
        assert current_provider == "auto"
        return "anthropic", "anthropic/claude-sonnet-4.6"

    monkeypatch.setattr(
        "hermes_cli.models.detect_static_provider_for_model", fake_detect
    )

    assert server._resolve_startup_runtime() == (
        "anthropic/claude-sonnet-4.6",
        "anthropic",
    )


def test_load_fallback_model_merges_chain_providers_first(monkeypatch):
    # Parity with HermesCLI / gateway: fallback_providers stays first and keeps
    # its order, with any distinct legacy fallback_model entry merged in after
    # (deduped on provider/model/base_url).
    fallback_chain = [
        {"provider": "openrouter", "model": "openai/gpt-5.5"},
        {"provider": "anthropic", "model": "claude-sonnet-4-6"},
    ]
    monkeypatch.setattr(
        server,
        "_load_cfg",
        lambda: {
            "fallback_model": {"provider": "legacy", "model": "legacy-model"},
            "fallback_providers": fallback_chain,
        },
    )

    assert server._load_fallback_model() == [
        {"provider": "openrouter", "model": "openai/gpt-5.5"},
        {"provider": "anthropic", "model": "claude-sonnet-4-6"},
        {"provider": "legacy", "model": "legacy-model"},
    ]


def test_make_agent_passes_configured_fallback_chain(monkeypatch):
    captured = {}
    fallback_chain = [
        {"provider": "openrouter", "model": "openai/gpt-5.5"},
    ]

    def fake_agent(**kwargs):
        captured.update(kwargs)
        return types.SimpleNamespace(model=kwargs.get("model"))

    monkeypatch.delenv("HERMES_MODEL", raising=False)
    monkeypatch.delenv("HERMES_INFERENCE_MODEL", raising=False)
    monkeypatch.delenv("HERMES_TUI_PROVIDER", raising=False)
    monkeypatch.delenv("HERMES_DESKTOP", raising=False)
    monkeypatch.delenv("HERMES_DESKTOP_TERMINAL", raising=False)
    monkeypatch.setattr(
        server,
        "_load_cfg",
        lambda: {
            "model": {"default": "gpt-5.5", "provider": "openai-codex"},
            "fallback_providers": fallback_chain,
        },
    )
    monkeypatch.setattr(
        "hermes_cli.runtime_provider.resolve_runtime_provider",
        lambda requested=None, target_model=None: {
            "provider": "openai-codex",
            "base_url": "https://chatgpt.com/backend-api/codex",
            "api_key": "token",
            "api_mode": "codex_responses",
            "credential_pool": None,
        },
    )
    monkeypatch.setattr("run_agent.AIAgent", fake_agent)
    monkeypatch.setattr(server, "_load_enabled_toolsets", lambda *_a, **_kw: ["file"])
    monkeypatch.setattr(server, "_get_db", lambda: None)

    agent = server._make_agent("sid", "session-key")

    assert agent.model == "gpt-5.5"
    assert captured["fallback_model"] == fallback_chain
    assert captured["platform"] == "tui"


def test_background_agent_kwargs_preserves_full_fallback_chain(monkeypatch):
    chain = [
        {"provider": "openrouter", "model": "openai/gpt-5.5"},
        {"provider": "anthropic", "model": "claude-sonnet-4-6"},
    ]
    agent = types.SimpleNamespace(
        model="gpt-5.5",
        provider="openai-codex",
        _fallback_chain=chain,
    )
    monkeypatch.setattr(server, "_load_cfg", lambda: {"max_turns": 25})
    monkeypatch.setattr(server, "_load_enabled_toolsets", lambda *_a, **_kw: ["file"])
    monkeypatch.setattr(server, "_get_db", lambda: None)

    kwargs = server._background_agent_kwargs(agent, "task-id")

    assert kwargs["fallback_model"] == chain


def test_background_agent_kwargs_preserves_empty_fallback_chain(monkeypatch):
    agent = types.SimpleNamespace(
        model="gpt-5.5",
        provider="anthropic",
        _fallback_chain=[],
    )
    monkeypatch.setattr(
        server,
        "_load_cfg",
        lambda: {
            "max_turns": 25,
            "fallback_providers": [
                {"provider": "openrouter", "model": "openai/gpt-5.5"},
            ],
        },
    )
    monkeypatch.setattr(server, "_load_enabled_toolsets", lambda *_a, **_kw: ["file"])
    monkeypatch.setattr(server, "_get_db", lambda: None)

    kwargs = server._background_agent_kwargs(agent, "task-id")

    assert kwargs["fallback_model"] == []


def test_startup_runtime_resolves_short_alias_without_network(monkeypatch):
    monkeypatch.setenv("HERMES_MODEL", "sonnet")
    monkeypatch.delenv("HERMES_TUI_PROVIDER", raising=False)
    monkeypatch.delenv("HERMES_INFERENCE_PROVIDER", raising=False)
    monkeypatch.setattr(server, "_load_cfg", lambda: {"model": {"provider": "auto"}})
    monkeypatch.setattr(
        "hermes_cli.models.fetch_openrouter_models",
        lambda *_args, **_kwargs: (_ for _ in ()).throw(
            AssertionError("network lookup should not run")
        ),
    )

    model, provider = server._resolve_startup_runtime()

    assert provider == "anthropic"
    assert model.startswith("claude-sonnet")


def test_startup_runtime_does_not_call_network_detector(monkeypatch):
    monkeypatch.setenv("HERMES_MODEL", "sonnet")
    monkeypatch.delenv("HERMES_TUI_PROVIDER", raising=False)
    monkeypatch.delenv("HERMES_INFERENCE_PROVIDER", raising=False)
    monkeypatch.setattr(server, "_load_cfg", lambda: {"model": {"provider": "auto"}})
    monkeypatch.setattr(
        "hermes_cli.models.detect_provider_for_model",
        lambda *_args, **_kwargs: (_ for _ in ()).throw(
            AssertionError("network detector called")
        ),
    )

    model, provider = server._resolve_startup_runtime()

    assert model
    assert provider in {None, "anthropic"}


def _session(agent=None, **extra):
    return {
        "agent": agent if agent is not None else types.SimpleNamespace(),
        "session_key": "session-key",
        "history": [],
        "history_lock": threading.Lock(),
        "history_version": 0,
        "running": False,
        "attached_images": [],
        "image_counter": 0,
        "cols": 80,
        "slash_worker": None,
        "show_reasoning": False,
        "tool_progress_mode": "all",
        **extra,
    }


def test_session_close_commits_memory_and_fires_finalize_hook(monkeypatch):
    calls = {"hooks": []}

    agent = types.SimpleNamespace(session_id="session-key")
    agent.commit_memory_session = lambda history: calls.setdefault("history", history)
    server._sessions["sid"] = _session(
        agent=agent, history=[{"role": "user", "content": "hello"}]
    )
    monkeypatch.setattr(
        server,
        "_notify_session_boundary",
        lambda event, session_id, *_args: calls["hooks"].append((event, session_id)),
    )

    try:
        resp = server.handle_request(
            {"id": "1", "method": "session.close", "params": {"session_id": "sid"}}
        )
        assert resp["result"]["closed"] is True
        assert calls["history"] == [{"role": "user", "content": "hello"}]
        assert ("on_session_finalize", "session-key") in calls["hooks"]
    finally:
        server._sessions.pop("sid", None)


def test_session_close_releases_resume_lock_before_slow_teardown(monkeypatch):
    """One slow session finalizer must not stall unrelated session.resume RPCs."""
    teardown_started = threading.Event()
    release_teardown = threading.Event()
    response = {}

    def _slow_teardown(_session, *, end_reason="tui_close"):
        assert end_reason == "tui_close"
        teardown_started.set()
        assert release_teardown.wait(timeout=2.0)

    monkeypatch.setattr(server, "_teardown_session", _slow_teardown)
    server._sessions["slow-close"] = _session()

    def _close():
        response.update(
            server.handle_request(
                {
                    "id": "close",
                    "method": "session.close",
                    "params": {"session_id": "slow-close"},
                }
            )
        )

    thread = threading.Thread(target=_close)
    thread.start()
    acquired = False
    try:
        assert teardown_started.wait(timeout=1.0)
        assert "slow-close" not in server._sessions
        acquired = server._session_resume_lock.acquire(timeout=0.2)
        assert acquired, "slow teardown kept the global resume lock held"
    finally:
        if acquired:
            server._session_resume_lock.release()
        release_teardown.set()
        thread.join(timeout=2.0)
        server._sessions.pop("slow-close", None)

    assert not thread.is_alive()
    assert response["result"] == {"closed": True}


def test_ws_orphan_reap_closes_worker_when_session_stays_detached(monkeypatch):
    """A detached WS session past its grace window has its slash_worker closed.

    Regression for #38591 fallout: every dashboard refresh spawned a fresh
    session + _SlashWorker but never reaped the previous one, leaking one
    python subprocess per refresh.
    """
    closed = {"worker": False}

    class _FakeWorker:
        def close(self):
            closed["worker"] = True

    server._sessions["orphan-sid"] = _session(
        transport=server._detached_ws_transport,
        slash_worker=_FakeWorker(),
        running=False,
    )
    # Run the reap body synchronously (no real timer/grace) to assert behaviour.
    monkeypatch.setattr(server, "_WS_ORPHAN_REAP_GRACE_S", 0.01)
    try:
        # Directly invoke the orphaned-check + teardown the timer would run.
        assert server._ws_session_is_orphaned(server._sessions["orphan-sid"]) is True
        session = server._sessions.pop("orphan-sid")
        server._teardown_session(session)
        assert closed["worker"] is True
    finally:
        server._sessions.pop("orphan-sid", None)


def test_ws_orphan_reap_releases_resume_lock_before_slow_teardown(monkeypatch):
    """Grace reaping claims under the lock but finalizes after releasing it."""
    scheduled = {}
    teardown_started = threading.Event()
    release_teardown = threading.Event()

    class _Timer:
        def __init__(self, _delay, callback):
            scheduled["callback"] = callback

        def start(self):
            return None

    def _slow_teardown(_session, *, end_reason="tui_close"):
        assert end_reason == "ws_orphan_reap"
        teardown_started.set()
        assert release_teardown.wait(timeout=2.0)

    monkeypatch.setattr(server, "_WS_ORPHAN_REAP_GRACE_S", 0.01)
    monkeypatch.setattr(server.threading, "Timer", _Timer)
    monkeypatch.setattr(server, "_teardown_session", _slow_teardown)
    server._sessions["slow-orphan"] = _session(
        transport=server._detached_ws_transport,
        running=False,
    )

    server._schedule_ws_orphan_reap("slow-orphan")
    thread = threading.Thread(target=scheduled["callback"])
    thread.start()
    acquired = False
    try:
        assert teardown_started.wait(timeout=1.0)
        assert "slow-orphan" not in server._sessions
        acquired = server._session_resume_lock.acquire(timeout=0.2)
        assert acquired, "orphan teardown kept the global resume lock held"
    finally:
        if acquired:
            server._session_resume_lock.release()
        release_teardown.set()
        thread.join(timeout=2.0)
        server._sessions.pop("slow-orphan", None)

    assert not thread.is_alive()


def test_ws_orphan_reap_waits_for_active_delegation_then_reaps(monkeypatch):
    from tools import async_delegation

    callbacks = []
    torn_down = []
    delegation_id = "deleg_ws_orphan_reap_test"

    class _Timer:
        def __init__(self, _delay, callback):
            callbacks.append(callback)
            self.daemon = False

        def start(self):
            return None

    monkeypatch.setattr(server, "_WS_ORPHAN_REAP_GRACE_S", 0.01)
    monkeypatch.setattr(server.threading, "Timer", _Timer)
    monkeypatch.setattr(
        server,
        "_teardown_session",
        lambda session, *, end_reason="tui_close": torn_down.append(
            (session, end_reason)
        ),
    )
    server._sessions["delegating-sid"] = _session(
        transport=server._detached_ws_transport,
        running=False,
    )
    with async_delegation._records_lock:
        async_delegation._records[delegation_id] = {
            "status": "running",
            "origin_ui_session_id": "delegating-sid",
        }

    try:
        server._schedule_ws_orphan_reap("delegating-sid")
        callbacks.pop(0)()

        assert "delegating-sid" in server._sessions
        assert len(callbacks) == 1
        assert torn_down == []

        with async_delegation._records_lock:
            async_delegation._records[delegation_id]["status"] = "completed"
        callbacks.pop(0)()

        assert "delegating-sid" not in server._sessions
        assert len(torn_down) == 1
        assert torn_down[0][1] == "ws_orphan_reap"
    finally:
        server._sessions.pop("delegating-sid", None)
        with async_delegation._records_lock:
            async_delegation._records.pop(delegation_id, None)


def test_ws_orphan_reap_retries_when_delegation_lookup_fails(monkeypatch):
    from tools import async_delegation

    callbacks = []
    torn_down = []

    class _Timer:
        def __init__(self, _delay, callback):
            callbacks.append(callback)
            self.daemon = False

        def start(self):
            return None

    def _raise_lookup_error(*_args, **_kwargs):
        raise RuntimeError("delegation registry unavailable")

    monkeypatch.setattr(server, "_WS_ORPHAN_REAP_GRACE_S", 0.01)
    monkeypatch.setattr(server.threading, "Timer", _Timer)
    monkeypatch.setattr(
        async_delegation, "has_live_for_session", _raise_lookup_error
    )
    monkeypatch.setattr(
        server,
        "_teardown_session",
        lambda session, *, end_reason="tui_close": torn_down.append(
            (session, end_reason)
        ),
    )
    server._sessions["lookup-error-sid"] = _session(
        transport=server._detached_ws_transport,
        running=False,
    )

    try:
        server._schedule_ws_orphan_reap("lookup-error-sid")
        callbacks.pop(0)()

        assert "lookup-error-sid" in server._sessions
        assert len(callbacks) == 1
        assert torn_down == []
    finally:
        server._sessions.pop("lookup-error-sid", None)


def test_finalize_session_closes_slash_worker(monkeypatch):
    """_finalize_session closes the slash_worker subprocess itself.

    Regression for #38095: the worker cleanup used to live only in the
    callers (_teardown_session / _shutdown_sessions), so any code path that
    finalized a session without going through them leaked the worker. Folding
    close() into the single _finalized-guarded chokepoint makes the cleanup
    defense-in-depth and idempotent.
    """
    closed = {"count": 0}

    class _FakeWorker:
        def close(self):
            closed["count"] += 1

    monkeypatch.setattr(server, "_notify_session_boundary", lambda *a, **k: None)
    monkeypatch.setattr(server, "_get_db", lambda: None)

    session = _session(slash_worker=_FakeWorker())

    server._finalize_session(session)
    assert closed["count"] == 1
    assert session.get("_finalized") is True

    # Idempotent: a second finalize (or a follow-up teardown) must not
    # re-close the worker — the _finalized guard short-circuits.
    server._finalize_session(session)
    server._teardown_session(session)
    assert closed["count"] == 1


def test_ws_orphan_reap_spares_reattached_session(monkeypatch):
    """A session that rebinds a live transport is NOT considered orphaned."""

    class _LiveTransport:
        def write(self, *a, **k):
            return True

    # Reattached: transport is a live (non-stdio) transport.
    reattached = _session(transport=_LiveTransport(), running=False)
    assert server._ws_session_is_orphaned(reattached) is False

    # Mid-turn sessions are also spared even if detached.
    mid_turn = _session(transport=server._detached_ws_transport, running=True)
    assert server._ws_session_is_orphaned(mid_turn) is False

    # Already finalized sessions are spared (idempotency).
    done = _session(
        transport=server._detached_ws_transport,
        running=False,
        _finalized=True,
    )
    assert server._ws_session_is_orphaned(done) is False


def test_ws_orphan_reap_spares_detached_session_with_running_async_delegation(monkeypatch):
    """A detached desktop session with live background delegation is parked.

    Regression for Desktop session switches / transient WS detaches: the parent
    turn is idle, but a background delegate_task still owns the session's
    return address. Reaping immediately interrupts the child and turns its
    completion into an unowned orphan.
    """
    timers = []
    closed = []

    class _Timer:
        def __init__(self, _delay, fn):
            self.fn = fn
            timers.append(self)

        def start(self):
            return None

    class _DB:
        def get_session(self, _session_id):
            return {"id": "sess_bg", "source": "desktop"}

    monkeypatch.setattr(server, "_WS_ORPHAN_REAP_GRACE_S", 0.01)
    monkeypatch.setattr(server.threading, "Timer", _Timer)
    monkeypatch.setattr(server, "_get_db", lambda: _DB())
    monkeypatch.setattr(
        server,
        "_teardown_popped_session",
        lambda session, *, end_reason="tui_close": (
            closed.append((session["_sid"], end_reason)) if session is not None else None
        ),
    )

    server._sessions["bg-sid"] = _session(
        transport=server._detached_ws_transport,
        running=False,
        session_key="sess_bg",
    )
    ad._reset_for_tests()
    try:
        with ad._records_lock:
            ad._records["deleg_bg"] = {
                "delegation_id": "deleg_bg",
                "status": "running",
                "session_key": "sess_bg",
                "origin_ui_session_id": "bg-sid",
                "interrupt_fn": lambda: None,
            }

        server._schedule_ws_orphan_reap("bg-sid")
        assert len(timers) == 1

        timers.pop(0).fn()

        assert closed == []
        assert "bg-sid" in server._sessions
        assert len(timers) == 1

        with ad._records_lock:
            ad._records["deleg_bg"]["status"] = "finalizing"
            ad._records["deleg_bg"]["interrupt_fn"] = None

        timers.pop(0).fn()

        assert closed == []
        assert "bg-sid" in server._sessions
        assert len(timers) == 1

        with ad._records_lock:
            ad._records["deleg_bg"]["status"] = "completed"

        timers.pop(0).fn()

        assert closed == [("bg-sid", "ws_orphan_reap")]
    finally:
        ad._reset_for_tests()
        server._sessions.pop("bg-sid", None)


def test_ws_orphan_reap_disabled_when_grace_zero(monkeypatch):
    """Grace=0 disables the reaper entirely (pre-fix park-forever behaviour)."""
    fired = {"timer": False}

    class _Timer:
        def __init__(self, *a, **k):
            fired["timer"] = True

        def start(self):
            pass

    monkeypatch.setattr(server, "_WS_ORPHAN_REAP_GRACE_S", 0.0)
    monkeypatch.setattr(server.threading, "Timer", _Timer)
    server._schedule_ws_orphan_reap("any-sid")
    assert fired["timer"] is False


def test_init_session_fires_reset_hook(monkeypatch):
    hooks = []

    class _FakeWorker:
        def __init__(self, key, model, profile_home=None):
            self.key = key

        def close(self):
            return None

    monkeypatch.setattr(server, "_SlashWorker", _FakeWorker)
    monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None)
    monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None)
    monkeypatch.setattr(
        server,
        "_notify_session_boundary",
        lambda event, session_id, *_args: hooks.append((event, session_id)),
    )

    import tools.approval as _approval

    monkeypatch.setattr(_approval, "register_gateway_notify", lambda key, cb: None)
    monkeypatch.setattr(_approval, "load_permanent_allowlist", lambda: None)

    sid = "sid"
    try:
        server._init_session(
            sid,
            "session-key",
            types.SimpleNamespace(model="x"),
            history=[],
            cols=80,
        )
        assert ("on_session_reset", "session-key") in hooks
    finally:
        server._sessions.pop(sid, None)


def test_session_title_creates_row_and_sets_immediately_when_not_ready(monkeypatch):
    """An explicit /title before the first message must persist NOW, not queue.

    Regression: the desktop deferred the DB row to the first prompt, so a
    /title typed before any message only stashed ``pending_title`` and relied
    on a post-turn apply block. When that turn never landed under the session
    key, the title was silently lost and the sidebar fell back to the message
    preview. The handler now creates the row up front (mirroring the messaging
    gateway) so an explicit /title takes effect immediately.
    """
    state = {"row": None, "title": None, "ensured": False}

    class _FakeDB:
        def get_session_title(self, _key):
            return state["title"]

        def get_session(self, _key):
            return state["row"]

        def set_session_title(self, _key, title):
            # Mirrors SessionDB: UPDATE affects 0 rows until the row exists.
            if state["row"] is None:
                return False
            state["title"] = title
            return True

    fake_db = _FakeDB()

    def _fake_ensure_row(_session):
        # The real _ensure_session_db_row does an INSERT OR IGNORE.
        state["ensured"] = True
        state["row"] = {"id": "session-key", "title": None}

    import contextlib

    @contextlib.contextmanager
    def _fake_session_db(_session):
        yield fake_db

    server._sessions["sid"] = _session(pending_title=None)
    monkeypatch.setattr(server, "_get_db", lambda: fake_db)
    monkeypatch.setattr(server, "_ensure_session_db_row", _fake_ensure_row)
    monkeypatch.setattr(server, "_session_db", _fake_session_db)
    try:
        set_resp = server.handle_request(
            {
                "id": "1",
                "method": "session.title",
                "params": {"session_id": "sid", "title": "my-custom-name"},
            }
        )

        # No longer queued — the row is created and the title set immediately.
        assert set_resp["result"]["pending"] is False
        assert set_resp["result"]["title"] == "my-custom-name"
        assert state["ensured"] is True, "the row must be created up front"
        assert state["title"] == "my-custom-name"
        assert server._sessions["sid"]["pending_title"] is None

        # A subsequent read reflects the persisted title.
        get_resp = server.handle_request(
            {"id": "2", "method": "session.title", "params": {"session_id": "sid"}}
        )
        assert get_resp["result"]["title"] == "my-custom-name"
    finally:
        server._sessions.pop("sid", None)


def test_session_title_falls_back_to_queue_when_row_create_fails(monkeypatch):
    """If row creation can't take (DB down / racing writer), keep the queue.

    The post-turn apply block is still the recovery path, so a /title that
    can't persist up front must not be dropped — it falls back to
    ``pending_title`` exactly as before.
    """

    class _FakeDB:
        def get_session_title(self, _key):
            return None

        def get_session(self, _key):
            return None

        def set_session_title(self, _key, _title):
            return False

    fake_db = _FakeDB()

    def _fake_ensure_row(_session):
        # Simulate a persist that didn't take — row still absent.
        pass

    import contextlib

    @contextlib.contextmanager
    def _fake_session_db(_session):
        yield fake_db

    server._sessions["sid"] = _session(pending_title=None)
    monkeypatch.setattr(server, "_get_db", lambda: fake_db)
    monkeypatch.setattr(server, "_ensure_session_db_row", _fake_ensure_row)
    monkeypatch.setattr(server, "_session_db", _fake_session_db)
    try:
        set_resp = server.handle_request(
            {
                "id": "1",
                "method": "session.title",
                "params": {"session_id": "sid", "title": "queued title"},
            }
        )

        assert set_resp["result"]["pending"] is True
        assert set_resp["result"]["title"] == "queued title"
        assert server._sessions["sid"]["pending_title"] == "queued title"

        get_resp = server.handle_request(
            {"id": "2", "method": "session.title", "params": {"session_id": "sid"}}
        )
        assert get_resp["result"]["title"] == "queued title"
    finally:
        server._sessions.pop("sid", None)


def test_notification_event_routing_by_session_key(monkeypatch):
    """Background-process events surface only in the session that owns them."""
    mine = _session(session_key="mine")
    other = _session(session_key="other")
    monkeypatch.setattr(server, "_sessions", {"a": mine, "b": other})

    # My own event → handle it.
    assert server._notification_event_belongs_elsewhere("a", mine, {"session_key": "mine"}) is False
    # Global/system event with no owner → handle it.
    assert server._notification_event_belongs_elsewhere("a", mine, {"session_key": ""}) is False
    assert server._notification_event_belongs_elsewhere("a", mine, {}) is False
    # Owned by another *live* session → defer to that session's poller.
    assert server._notification_event_belongs_elsewhere("a", mine, {"session_key": "other"}) is True
    # Owner is gone (not in _sessions) → handle as fallback so it isn't lost.
    assert server._notification_event_belongs_elsewhere("a", mine, {"session_key": "ghost"}) is False


def test_async_delegation_event_prefers_origin_ui_session(monkeypatch):
    """Detached subagent completions return to the commissioning TUI tab.

    Regression: when the durable session key was stale/orphaned, whichever
    desktop poller woke first could consume the async result and inject it into
    an unrelated session.
    """
    mine = _session(session_key="current-key")
    other = _session(session_key="unrelated-key")
    monkeypatch.setattr(server, "_sessions", {"origin-sid": mine, "other-sid": other})
    monkeypatch.setattr(server, "_get_db", lambda: None)
    evt = {
        "type": "async_delegation",
        "session_key": "stale-or-rotated-key",
        "origin_ui_session_id": "origin-sid",
    }

    assert server._notification_event_belongs_elsewhere("other-sid", other, evt) is True
    assert server._notification_event_belongs_elsewhere("origin-sid", mine, evt) is False


def test_notification_event_follows_compression_continuation(monkeypatch):
    """Events keyed to a compressed parent route to the live continuation."""
    old_parent = _session(session_key="old-parent")
    live_tip = _session(session_key="new-tip")
    monkeypatch.setattr(server, "_sessions", {"old-sid": old_parent, "tip-sid": live_tip})

    class _DB:
        def resolve_resume_session_id(self, session_id):
            return "new-tip" if session_id == "old-parent" else session_id

    monkeypatch.setattr(server, "_get_db", lambda: _DB())
    evt = {"type": "async_delegation", "session_key": "old-parent"}

    assert server._notification_event_belongs_elsewhere("old-sid", old_parent, evt) is True
    assert server._notification_event_belongs_elsewhere("tip-sid", live_tip, evt) is False
    # A third session must leave it alone for the continuation's poller.
    third = _session(session_key="third")
    monkeypatch.setattr(
        server,
        "_sessions",
        {"old-sid": old_parent, "tip-sid": live_tip, "third-sid": third},
    )
    assert server._notification_event_belongs_elsewhere("third-sid", third, evt) is True


def test_finalized_origin_ui_session_falls_back_to_live_continuation(monkeypatch):
    """A closed origin tab must not steal its resumed continuation's result."""
    finalized_origin = _session(session_key="old-parent", _finalized=True)
    live_tip = _session(session_key="new-tip")
    monkeypatch.setattr(
        server,
        "_sessions",
        {"origin-sid": finalized_origin, "tip-sid": live_tip},
    )

    class _DB:
        def resolve_resume_session_id(self, session_id):
            return "new-tip" if session_id == "old-parent" else session_id

    monkeypatch.setattr(server, "_get_db", lambda: _DB())
    evt = {
        "type": "async_delegation",
        "session_key": "old-parent",
        "origin_ui_session_id": "origin-sid",
    }

    assert server._notification_event_belongs_elsewhere("origin-sid", finalized_origin, evt) is True
    assert server._notification_event_belongs_elsewhere("tip-sid", live_tip, evt) is False


def test_prompt_submit_rejects_negative_truncate_ordinal(monkeypatch):
    """A negative truncate_before_user_ordinal must be rejected, not honoured.

    The handler validates the upper bound (`ordinal >= len(user_indices)`) but a
    negative ordinal would otherwise slip through and hit Python negative
    indexing: `user_indices[-1]` selects the LAST user turn, truncating history
    to everything before it and persisting that loss via replace_messages — an
    unrecoverable overwrite of the session DB. Reject it on the safe 4018 path
    and leave the in-memory history and the DB untouched.
    """
    replaced = []

    class _FakeDB:
        def replace_messages(self, key, messages, active_only=False):
            replaced.append((key, list(messages)))

    history = [
        {"role": "user", "content": "first"},
        {"role": "assistant", "content": "ok"},
        {"role": "user", "content": "second"},
        {"role": "assistant", "content": "done"},
    ]
    server._sessions["trunc-sid"] = _session(history=list(history))
    monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
    # If the guard ever lets a negative ordinal through, these would run and the
    # session would be marked busy; failing here makes that regression loud.
    monkeypatch.setattr(
        server, "_start_agent_build", lambda *a, **k: pytest.fail("must not start a turn")
    )
    monkeypatch.setattr(
        server, "_start_inflight_turn", lambda *a, **k: pytest.fail("must not start a turn")
    )

    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "prompt.submit",
                "params": {
                    "session_id": "trunc-sid",
                    "text": "next",
                    "truncate_before_user_ordinal": -1,
                    "confirm_truncate": True,
                },
            }
        )
        assert resp["error"]["code"] == 4018
        # History and the DB are left exactly as they were — no silent loss.
        assert server._sessions["trunc-sid"]["history"] == history
        assert server._sessions["trunc-sid"]["running"] is False
        assert replaced == []
    finally:
        server._sessions.pop("trunc-sid", None)


def test_prompt_submit_refuses_unconfirmed_nonempty_truncation(monkeypatch):
    """An ordinal without confirm_truncate must not drop the session tail.

    #80763: a desktop client carried a leftover truncate_before_user_ordinal
    into an ORDINARY submit. The request was indistinguishable from a real
    rewind — in-range ordinal, non-empty result — so the empty-truncation guard
    never fired and replace_messages() DELETEd 244 durable rows (296 -> 52).
    Intent has to be stated: refuse on 4029 and leave memory and DB untouched.
    """
    replaced = []

    class _FakeDB:
        def replace_messages(self, key, messages):
            replaced.append((key, list(messages)))

    history = [
        {"role": "user", "content": "first"},
        {"role": "assistant", "content": "ok"},
        {"role": "user", "content": "second"},
        {"role": "assistant", "content": "done"},
        {"role": "user", "content": "third"},
        {"role": "assistant", "content": "sure"},
    ]
    server._sessions["unconfirmed-trunc-sid"] = _session(history=list(history))
    monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
    monkeypatch.setattr(
        server, "_start_agent_build", lambda *a, **k: pytest.fail("must not start a turn")
    )
    monkeypatch.setattr(
        server, "_start_inflight_turn", lambda *a, **k: pytest.fail("must not start a turn")
    )

    def _submit(**extra):
        return server.handle_request(
            {
                "id": "1",
                "method": "prompt.submit",
                "params": {
                    "session_id": "unconfirmed-trunc-sid",
                    "text": "an ordinary typed message",
                    "truncate_before_user_ordinal": 2,
                    **extra,
                },
            }
        )

    try:
        resp = _submit()
        assert resp["error"]["code"] == 4029
        assert "confirm_truncate" in resp["error"]["message"]
        # Explicit falsey values must not satisfy the opt-in either.
        for falsey in (False, 0, "", "false", "no"):
            assert _submit(confirm_truncate=falsey)["error"]["code"] == 4029, falsey
        # confirm_empty_truncate is a different gate — it must not stand in for
        # rewind intent on a cut that leaves the transcript non-empty.
        assert _submit(confirm_empty_truncate=True)["error"]["code"] == 4029
        session = server._sessions["unconfirmed-trunc-sid"]
        assert session["history"] == history
        assert session["history_version"] == 0
        assert session["running"] is False
        assert replaced == []
    finally:
        server._sessions.pop("unconfirmed-trunc-sid", None)


def test_prompt_submit_refuses_empty_truncation_without_confirm(monkeypatch):
    """A confirmed rewind still must not wipe a non-empty transcript by accident.

    Ordinal 0 cuts at the first user message (history[:0] == []) and
    replace_messages() would DELETE every durable row. Even a submit that
    declares rewind intent needs the second opt-in for that edge.
    """
    replaced = []

    class _FakeDB:
        def replace_messages(self, key, messages, active_only=False):
            replaced.append((key, list(messages)))

    history = [
        {"role": "user", "content": "first"},
        {"role": "assistant", "content": "ok"},
        {"role": "user", "content": "second"},
        {"role": "assistant", "content": "done"},
    ]
    server._sessions["empty-trunc-sid"] = _session(history=list(history))
    monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
    monkeypatch.setattr(
        server, "_start_agent_build", lambda *a, **k: pytest.fail("must not start a turn")
    )
    monkeypatch.setattr(
        server, "_start_inflight_turn", lambda *a, **k: pytest.fail("must not start a turn")
    )

    try:
        # Missing confirm → refuse.
        resp = server.handle_request(
            {
                "id": "1",
                "method": "prompt.submit",
                "params": {
                    "session_id": "empty-trunc-sid",
                    "text": "fresh typed message",
                    "truncate_before_user_ordinal": 0,
                    "confirm_truncate": True,
                },
            }
        )
        assert resp["error"]["code"] == 4028
        assert "confirm_empty_truncate" in resp["error"]["message"]
        # Explicit falsey values must not satisfy the opt-in either.
        for falsey in (False, 0, "", "false", "no"):
            resp = server.handle_request(
                {
                    "id": "1",
                    "method": "prompt.submit",
                    "params": {
                        "session_id": "empty-trunc-sid",
                        "text": "fresh typed message",
                        "truncate_before_user_ordinal": 0,
                        "confirm_truncate": True,
                        "confirm_empty_truncate": falsey,
                    },
                }
            )
            assert resp["error"]["code"] == 4028, falsey
        assert server._sessions["empty-trunc-sid"]["history"] == history
        assert server._sessions["empty-trunc-sid"]["running"] is False
        assert server._sessions["empty-trunc-sid"]["history_version"] == 0
        assert replaced == []
    finally:
        server._sessions.pop("empty-trunc-sid", None)


def test_prompt_submit_empty_truncation_allowed_with_confirm(monkeypatch):
    """Intentional restore/regenerate of the first user turn may wipe history."""

    seen = {}
    replaced = []

    class _Agent:
        def run_conversation(
            self, prompt, conversation_history=None, stream_callback=None, **_kwargs
        ):
            seen["prompt"] = prompt
            seen["history"] = conversation_history
            return {
                "final_response": "regenerated",
                "messages": [
                    *(conversation_history or []),
                    {"role": "user", "content": prompt},
                    {"role": "assistant", "content": "regenerated"},
                ],
            }

    class _ImmediateThread:
        def __init__(self, target=None, daemon=None):
            self._target = target

        def start(self):
            self._target()

    class _FakeDB:
        def replace_messages(self, key, messages, active_only=False):
            replaced.append((key, list(messages)))

    history = [
        {"role": "user", "content": "first"},
        {"role": "assistant", "content": "ok"},
        {"role": "user", "content": "second"},
        {"role": "assistant", "content": "done"},
    ]
    server._sessions["confirm-empty-sid"] = _session(
        agent=_Agent(), history=list(history)
    )

    try:
        monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
        monkeypatch.setattr(server, "_get_usage", lambda _a: {})
        monkeypatch.setattr(server, "render_message", lambda _t, _c: "")
        monkeypatch.setattr(server, "_emit", lambda *a: None)
        monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())

        resp = server.handle_request(
            {
                "id": "1",
                "method": "prompt.submit",
                "params": {
                    "session_id": "confirm-empty-sid",
                    "text": "first",
                    "truncate_before_user_ordinal": 0,
                    "confirm_truncate": True,
                    "confirm_empty_truncate": True,
                },
            }
        )
        assert resp.get("result"), f"got error: {resp.get('error')}"
        assert seen["prompt"] == "first"
        assert seen["history"] == []
        assert replaced == [("session-key", [])]
        assert server._sessions["confirm-empty-sid"]["history"] == [
            {"role": "user", "content": "first"},
            {"role": "assistant", "content": "regenerated"},
        ]
    finally:
        server._sessions.pop("confirm-empty-sid", None)


class _StopAfterOneNotificationPoll:
    def __init__(self):
        self._checks = 0

    def is_set(self):
        self._checks += 1
        return self._checks > 1


def test_notification_poller_live_loop_requeues_foreign_completion_for_owner(
    monkeypatch,
):
    """A foreign live-loop dequeue is handed back to its proven owner."""
    import queue as _queue_mod

    from tools.process_registry import process_registry

    delivered = {"a": [], "b": []}
    emitted = []
    session_a = _session(session_key="session-a-live-handoff")
    session_b = _session(session_key="session-b-live-handoff")
    event = {
        "type": "completion",
        "session_id": "proc-live-handoff",
        "session_key": "session-a-live-handoff",
        "command": "echo owner",
        "exit_code": 0,
        "output": "owner",
    }
    isolated_queue: _queue_mod.Queue = _queue_mod.Queue()
    isolated_queue.put(event)
    monkeypatch.setattr(process_registry, "completion_queue", isolated_queue)
    monkeypatch.setattr(server, "_get_db", lambda: None)
    monkeypatch.setattr(server, "_emit", lambda *args, **_kwargs: emitted.append(args))

    def _deliver(_rid, sid, session, text):
        delivered["a" if sid == "sid-a-live-handoff" else "b"].append(text)
        session["running"] = False

    monkeypatch.setattr(server, "_run_prompt_submit", _deliver)
    server._sessions.update(
        {
            "sid-a-live-handoff": session_a,
            "sid-b-live-handoff": session_b,
        }
    )
    process_registry._completion_consumed.discard(event["session_id"])

    try:
        server._notification_poller_loop(
            _StopAfterOneNotificationPoll(), "sid-b-live-handoff", session_b
        )

        assert delivered["b"] == []
        assert emitted == []
        assert isolated_queue.qsize() == 1
        assert isolated_queue.queue[0] is event

        server._notification_poller_loop(
            _StopAfterOneNotificationPoll(), "sid-a-live-handoff", session_a
        )

        assert len(delivered["a"]) == 1
        assert "proc-live-handoff completed normally" in delivered["a"][0]
        assert delivered["b"] == []
        assert isolated_queue.empty()
    finally:
        server._sessions.pop("sid-a-live-handoff", None)
        server._sessions.pop("sid-b-live-handoff", None)
        process_registry._completion_consumed.discard(event["session_id"])
        while not isolated_queue.empty():
            isolated_queue.get_nowait()


def test_completion_ownership_lineage_lookup_failure_fails_closed(monkeypatch):
    """A provenance lookup failure cannot turn an addressed event into ours."""
    import queue as _queue_mod

    from tools.process_registry import process_registry

    class _BrokenDB:
        def resolve_resume_session_id(self, _session_key):
            raise RuntimeError("lineage database unavailable")

    session = _session(session_key="unrelated-live-session")
    event = {
        "type": "completion",
        "session_id": "proc-unknown-lineage",
        "session_key": "unknown-parent",
        "command": "echo unknown",
        "exit_code": 0,
        "output": "unknown",
    }
    isolated_queue: _queue_mod.Queue = _queue_mod.Queue()
    isolated_queue.put(event)
    monkeypatch.setattr(process_registry, "completion_queue", isolated_queue)
    monkeypatch.setattr(server, "_get_db", lambda: _BrokenDB())

    drained = process_registry.drain_notifications(
        session_key="unrelated-live-session",
        owns_event=lambda candidate: server._session_owns_notification_event(
            "sid-unrelated-live", session, candidate
        ),
    )

    assert drained == []
    assert isolated_queue.qsize() == 1
    assert isolated_queue.get_nowait() is event


@pytest.mark.parametrize(
    "routing",
    [
        {"session_key": "missing-owner-key"},
        {"origin_ui_session_id": "missing-owner-sid"},
    ],
)
def test_notification_poller_live_loop_drops_addressed_orphan(
    monkeypatch, routing
):
    """A live poll never injects an addressed event whose owner is gone."""
    import queue as _queue_mod

    from tools.process_registry import process_registry

    delivered = []
    emitted = []
    session = _session(session_key="unrelated-live-key")
    event = {
        "type": "completion",
        "session_id": "proc-live-orphan",
        "command": "echo orphan",
        "exit_code": 0,
        "output": "orphan",
        **routing,
    }
    isolated_queue: _queue_mod.Queue = _queue_mod.Queue()
    isolated_queue.put(event)
    monkeypatch.setattr(process_registry, "completion_queue", isolated_queue)
    monkeypatch.setattr(server, "_get_db", lambda: None)
    monkeypatch.setattr(server, "_emit", lambda *args, **_kwargs: emitted.append(args))
    monkeypatch.setattr(
        server,
        "_run_prompt_submit",
        lambda _rid, _sid, _session, text: delivered.append(text),
    )
    server._sessions["sid-live-orphan"] = session
    process_registry._completion_consumed.discard(event["session_id"])

    try:
        server._notification_poller_loop(
            _StopAfterOneNotificationPoll(), "sid-live-orphan", session
        )

        assert delivered == []
        assert emitted == []
        assert isolated_queue.empty()
    finally:
        server._sessions.pop("sid-live-orphan", None)
        process_registry._completion_consumed.discard(event["session_id"])
        while not isolated_queue.empty():
            isolated_queue.get_nowait()


@pytest.mark.parametrize(
    "routing",
    [
        {"session_key": "session-b"},
        {"origin_ui_session_id": "sid_gone"},
    ],
)
def test_notification_poller_drops_orphaned_events(monkeypatch, routing):
    """Addressed completions whose owner is gone are dropped, not hijacked."""
    import queue as _queue_mod

    from tools.process_registry import process_registry

    emitted = []
    delivered = []
    sess = _session(session_key="session-a")
    server._sessions["sid_a"] = sess
    monkeypatch.setattr(server, "_emit", lambda *a, **kw: emitted.append(a))
    monkeypatch.setattr(
        server,
        "_run_prompt_submit",
        lambda _rid, _sid, _session, text: delivered.append(text),
    )
    monkeypatch.setattr(server, "_get_db", lambda: None)

    isolated_queue: _queue_mod.Queue = _queue_mod.Queue()
    monkeypatch.setattr(process_registry, "completion_queue", isolated_queue)
    process_registry._completion_consumed.discard("proc_ghost")
    isolated_queue.put(
        {
            "type": "completion",
            "session_id": "proc_ghost",
            "command": "echo from ghost",
            "exit_code": 0,
            "output": "ghost output",
            **routing,
        }
    )

    stop = threading.Event()
    stop.set()

    try:
        server._notification_poller_loop(stop, "sid_a", sess)

        assert [a for a in emitted if a[0] == "status.update"] == []
        assert delivered == []
    finally:
        server._sessions.pop("sid_a", None)
        while not process_registry.completion_queue.empty():
            process_registry.completion_queue.get_nowait()


@pytest.mark.parametrize(
    ("routing", "resolved_key"),
    [
        ({"session_key": "session-a"}, None),
        (
            {
                "session_key": "stale-durable-key",
                "origin_ui_session_id": "sid_a",
            },
            None,
        ),
        ({"session_key": "old-parent-key"}, "session-a"),
    ],
)
def test_notification_poller_delivers_owned_events(
    monkeypatch, routing, resolved_key
):
    """Direct, UI-origin, and compression-lineage owners are delivered."""
    import queue as _queue_mod

    from tools.process_registry import process_registry

    class _CompressionDB:
        def resolve_resume_session_id(self, key):
            return resolved_key if key == "old-parent-key" and resolved_key else key

    delivered = []
    emitted = []
    sess = _session(session_key="session-a")
    server._sessions["sid_a"] = sess
    monkeypatch.setattr(server, "_emit", lambda *a, **kw: emitted.append(a))
    monkeypatch.setattr(
        server,
        "_run_prompt_submit",
        lambda _rid, _sid, _session, text: delivered.append(text),
    )
    monkeypatch.setattr(server, "_get_db", lambda: _CompressionDB())

    isolated_queue: _queue_mod.Queue = _queue_mod.Queue()
    monkeypatch.setattr(process_registry, "completion_queue", isolated_queue)
    process_registry._completion_consumed.discard("proc_mine")
    isolated_queue.put(
        {
            "type": "completion",
            "session_id": "proc_mine",
            "command": "echo mine",
            "exit_code": 0,
            "output": "mine",
            **routing,
        }
    )

    stop = threading.Event()
    stop.set()

    try:
        server._notification_poller_loop(stop, "sid_a", sess)

        status_calls = [a for a in emitted if a[0] == "status.update"]
        assert len(status_calls) == 1
        assert status_calls[0][2]["kind"] == "process"
        assert len(delivered) == 1
        assert "proc_mine" in delivered[0]
    finally:
        server._sessions.pop("sid_a", None)
        while not process_registry.completion_queue.empty():
            process_registry.completion_queue.get_nowait()


def _configure_immediate_prompt_run(
    monkeypatch, tmp_path, *, immediate_threads=True
):
    class _ImmediateThread:
        def __init__(self, target=None, daemon=None, **_kwargs):
            self._target = target

        def start(self):
            if self._target is not None:
                self._target()

        def is_alive(self):
            return False

    if immediate_threads:
        monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
    monkeypatch.setattr(server, "_emit", lambda *_args, **_kwargs: None)
    monkeypatch.setattr(server, "make_stream_renderer", lambda _cols: None)
    monkeypatch.setattr(server, "render_message", lambda _raw, _cols: None)
    monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None)
    monkeypatch.setattr(server, "_sync_agent_model_with_config", lambda *_args: None)
    monkeypatch.setattr(server, "_session_cwd", lambda _session: str(tmp_path))
    monkeypatch.setattr(server, "_register_session_cwd", lambda _session: None)
    monkeypatch.setattr(server, "_set_session_context", lambda *_args, **_kwargs: [])
    monkeypatch.setattr(server, "_clear_session_context", lambda _tokens: None)
    monkeypatch.setattr(server, "_session_info", lambda *_args: {})
    monkeypatch.setattr(server, "_get_usage", lambda _agent: {})
    monkeypatch.setattr(
        server, "_sync_session_key_after_compress", lambda *_args, **_kwargs: None
    )
    monkeypatch.setattr(server, "_drain_queued_prompt", lambda *_args: False)
    monkeypatch.setattr(server, "_voice_tts_enabled", lambda: False)
    monkeypatch.setattr(server, "_get_db", lambda: None)


def test_run_prompt_submit_binds_exact_steer_authority_and_resets_contextvars(
    monkeypatch, tmp_path
):
    """The turn thread commissions children with this session generation only."""
    from tools.delegate_tool import _capture_gateway_steer_authority
    from tui_gateway.transport import (
        bind_transport,
        current_transport,
        reset_transport,
    )

    class _Transport:
        def write(self, _obj):
            return True

        def close(self):
            return None

    observed = {}
    owner_transport = _Transport()
    previous_transport = _Transport()
    previous_record = {"session_key": "previous-generation"}

    class _CapturingAgent(_RecordingAgent):
        def run_conversation(self, prompt, **kwargs):
            authority = _capture_gateway_steer_authority("sid-owner")
            observed["transport"] = authority[0]
            observed["record"] = authority[1]
            return super().run_conversation(prompt, **kwargs)

    _configure_immediate_prompt_run(monkeypatch, tmp_path)
    session = _session(
        session_key="session-owner",
        agent=_CapturingAgent([]),
        running=True,
        transport=owner_transport,
    )
    server._sessions["sid-owner"] = session
    transport_token = bind_transport(previous_transport)
    record_token = server._current_runtime_session_record.set(previous_record)
    try:
        server._run_prompt_submit("rid-owner", "sid-owner", session, "commission")

        assert observed == {"transport": owner_transport, "record": session}
        assert current_transport() is previous_transport
        assert server._current_runtime_session_record.get() is previous_record
    finally:
        server._current_runtime_session_record.reset(record_token)
        reset_transport(transport_token)
        server._sessions.pop("sid-owner", None)


class _RecordingAgent:
    model = "test-model"
    provider = "test-provider"

    def __init__(self, turns):
        self._turns = turns

    def clear_interrupt(self):
        return None

    def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
        self._turns.append(prompt)
        return {"final_response": "", "messages": []}


@pytest.mark.parametrize("exit_code", [0, 7])
def test_run_prompt_submit_requeues_foreign_completion(
    monkeypatch, tmp_path, exit_code
):
    import queue as _queue_mod

    from tools.process_registry import process_registry

    _configure_immediate_prompt_run(monkeypatch, tmp_path)
    turns = []
    session_a = _session(session_key="session-a")
    session_b = _session(
        session_key="session-b",
        agent=_RecordingAgent(turns),
        running=True,
    )
    event = {
        "type": "completion",
        "session_id": f"proc_foreign_{exit_code}",
        "session_key": "session-a",
        "command": "safe-test-command",
        "exit_code": exit_code,
        "output": "foreign",
    }
    isolated_queue: _queue_mod.Queue = _queue_mod.Queue()
    isolated_queue.put(event)
    monkeypatch.setattr(process_registry, "completion_queue", isolated_queue)
    server._sessions["sid_a"] = session_a
    server._sessions["sid_b"] = session_b

    try:
        server._run_prompt_submit("rid-b", "sid_b", session_b, "session-b-turn")

        assert turns == ["session-b-turn"]
        assert isolated_queue.get_nowait() == event
        assert isolated_queue.empty()
    finally:
        server._sessions.pop("sid_a", None)
        server._sessions.pop("sid_b", None)
        process_registry._completion_consumed.discard(event["session_id"])


def test_run_prompt_submit_delivers_completion_observed_by_poll(monkeypatch, tmp_path):
    import queue as _queue_mod

    from tools.process_registry import process_registry

    _configure_immediate_prompt_run(monkeypatch, tmp_path)
    turns = []
    session = _session(
        session_key="session-a",
        agent=_RecordingAgent(turns),
        running=True,
    )
    event = {
        "type": "completion",
        "session_id": "proc_polled",
        "session_key": "session-a",
        "command": "safe-test-command",
        "exit_code": 0,
        "output": "observed but not consumed",
    }
    isolated_queue: _queue_mod.Queue = _queue_mod.Queue()
    isolated_queue.put(event)
    monkeypatch.setattr(process_registry, "completion_queue", isolated_queue)
    process_registry._completion_consumed.discard(event["session_id"])
    process_registry._poll_observed.add(event["session_id"])
    server._sessions["sid_a"] = session

    try:
        server._run_prompt_submit("rid-a", "sid_a", session, "session-a-turn")

        assert turns[0] == "session-a-turn"
        assert len(turns) == 2
        assert "proc_polled" in turns[1]
        assert isolated_queue.empty()
    finally:
        server._sessions.pop("sid_a", None)
        process_registry._completion_consumed.discard(event["session_id"])
        process_registry._poll_observed.discard(event["session_id"])


def test_run_prompt_submit_requeues_all_unstarted_notifications_with_real_threading(
    monkeypatch, tmp_path
):
    import queue as _queue_mod

    from tools.process_registry import process_registry

    _configure_immediate_prompt_run(
        monkeypatch, tmp_path, immediate_threads=False
    )
    real_thread_class = threading.Thread
    threads = []
    nested_started = threading.Event()
    release_nested = threading.Event()
    turns = []

    def _recording_thread(*args, **kwargs):
        thread = real_thread_class(*args, **kwargs)
        threads.append(thread)
        return thread

    class _BlockingNotificationAgent(_RecordingAgent):
        def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
            turns.append(prompt)
            if "proc_batch_1" in prompt:
                nested_started.set()
                if not release_nested.wait(timeout=5):
                    raise TimeoutError("notification turn was not released")
            return {"final_response": "", "messages": []}

    monkeypatch.setattr(server.threading, "Thread", _recording_thread)
    session = _session(
        session_key="session-a",
        agent=_BlockingNotificationAgent(turns),
        running=True,
    )
    events = [
        {
            "type": "completion",
            "session_id": f"proc_batch_{index}",
            "session_key": "session-a",
            "command": "safe-test-command",
            "exit_code": 0,
            "output": f"owned-{index}",
        }
        for index in range(1, 4)
    ]
    isolated_queue: _queue_mod.Queue = _queue_mod.Queue()
    for event in events:
        isolated_queue.put(event)
    monkeypatch.setattr(process_registry, "completion_queue", isolated_queue)
    server._sessions["sid_a"] = session

    try:
        server._run_prompt_submit("rid-a", "sid_a", session, "session-a-turn")

        assert nested_started.wait(timeout=5)
        threads[0].join(timeout=5)
        assert not threads[0].is_alive()
        # Membership, not order: the completion_queue is process-global, and
        # notification pollers leaked by earlier session.init tests in this
        # file legitimately steal-and-requeue foreign-session events (see
        # _notification_poller_loop's belongs-elsewhere branch), rotating the
        # queue. The requeue contract is that batch_2 and batch_3 both remain
        # queued (never consumed) while batch_1's turn is in flight — so drain
        # with a deadline (an event may be transiently held by a poller
        # mid-cycle) and assert exactly {batch_2, batch_3} come back.
        queued: dict = {}
        deadline = time.time() + 5.0
        while time.time() < deadline and set(queued) != {
            "proc_batch_2",
            "proc_batch_3",
        }:
            try:
                evt = isolated_queue.get(timeout=0.1)
            except _queue_mod.Empty:
                continue
            queued[evt["session_id"]] = evt
        assert set(queued) == {"proc_batch_2", "proc_batch_3"}
    finally:
        release_nested.set()
        for thread in threads:
            thread.join(timeout=5)
        server._sessions.pop("sid_a", None)
        while not isolated_queue.empty():
            isolated_queue.get_nowait()
        for event in events:
            process_registry._completion_consumed.discard(event["session_id"])
            process_registry._poll_observed.discard(event["session_id"])


def test_run_prompt_submit_delivers_completion_owned_through_compression_lineage(
    monkeypatch, tmp_path
):
    import queue as _queue_mod

    from tools.process_registry import process_registry

    class _CompressionDB:
        def resolve_resume_session_id(self, key):
            return "new-child-key" if key == "old-parent-key" else key

    _configure_immediate_prompt_run(monkeypatch, tmp_path)
    monkeypatch.setattr(server, "_get_db", lambda: _CompressionDB())
    ownership_checks = []
    original_owns_event = server._session_owns_notification_event

    def _record_ownership_check(sid, checked_session, checked_event):
        ownership_checks.append(checked_event["session_id"])
        return original_owns_event(sid, checked_session, checked_event)

    monkeypatch.setattr(
        server, "_session_owns_notification_event", _record_ownership_check
    )
    turns = []
    session = _session(
        session_key="new-child-key",
        agent=_RecordingAgent(turns),
        running=True,
    )
    event = {
        "type": "completion",
        "session_id": "proc_precompression",
        "session_key": "old-parent-key",
        "command": "safe-test-command",
        "exit_code": 0,
        "output": "owned",
    }
    isolated_queue: _queue_mod.Queue = _queue_mod.Queue()
    isolated_queue.put(event)
    monkeypatch.setattr(process_registry, "completion_queue", isolated_queue)
    server._sessions["sid_b"] = session

    try:
        server._run_prompt_submit("rid-b", "sid_b", session, "session-b-turn")

        assert turns[0] == "session-b-turn"
        assert len(turns) == 2
        assert "proc_precompression" in turns[1]
        assert ownership_checks == ["proc_precompression"]
        assert isolated_queue.empty()
    finally:
        server._sessions.pop("sid_b", None)
        process_registry._completion_consumed.discard(event["session_id"])


def test_run_prompt_submit_prefers_origin_ui_session_id(monkeypatch, tmp_path):
    import queue as _queue_mod

    from tools.process_registry import process_registry

    _configure_immediate_prompt_run(monkeypatch, tmp_path)
    ownership_checks = []
    original_owns_event = server._session_owns_notification_event

    def _record_ownership_check(sid, checked_session, checked_event):
        ownership_checks.append(checked_event["session_id"])
        return original_owns_event(sid, checked_session, checked_event)

    monkeypatch.setattr(
        server, "_session_owns_notification_event", _record_ownership_check
    )
    turns = []
    session = _session(
        session_key="current-key",
        agent=_RecordingAgent(turns),
        running=True,
    )
    event = {
        "type": "completion",
        "session_id": "proc_origin_owned",
        "session_key": "stale-durable-key",
        "origin_ui_session_id": "sid_b",
        "command": "safe-test-command",
        "exit_code": 0,
        "output": "owned",
    }
    isolated_queue: _queue_mod.Queue = _queue_mod.Queue()
    isolated_queue.put(event)
    monkeypatch.setattr(process_registry, "completion_queue", isolated_queue)
    server._sessions["sid_b"] = session

    try:
        server._run_prompt_submit("rid-b", "sid_b", session, "session-b-turn")

        assert turns[0] == "session-b-turn"
        assert len(turns) == 2
        assert "proc_origin_owned" in turns[1]
        assert ownership_checks == ["proc_origin_owned"]
        assert isolated_queue.empty()
    finally:
        server._sessions.pop("sid_b", None)
        process_registry._completion_consumed.discard(event["session_id"])



    """session.create must NOT eagerly write a DB row.

    Every TUI/desktop launch opens a session here just to paint the composer;
    eagerly creating a row left an empty "Untitled" session behind for every
    launch the user never typed into. The row is created lazily on first prompt.
    """
    created = []

    class _FakeDB:
        def create_session(self, *args, **kwargs):
            created.append((args, kwargs))

    monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
    monkeypatch.setattr(server, "_start_agent_build", lambda *a, **k: None)
    monkeypatch.setattr(
        server.threading,
        "Timer",
        lambda *a, **k: types.SimpleNamespace(daemon=False, start=lambda: None),
    )

    resp = server.handle_request(
        {"id": "1", "method": "session.create", "params": {"cols": 80}}
    )
    sid = resp["result"]["session_id"]
    try:
        assert resp["result"]["stored_session_id"]
        assert created == [], "session.create should not persist an empty DB row"
    finally:
        server._sessions.pop(sid, None)


def test_ensure_session_db_row_persists_explicit_cwd(monkeypatch, tmp_path):
    """An explicitly chosen workspace is persisted as the session cwd."""
    created = []

    class _FakeDB:
        def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None, profile_name=None):
            created.append(
                {"key": key, "source": source, "model": model, "model_config": model_config, "cwd": cwd}
            )

    monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
    monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")
    monkeypatch.delenv("HERMES_DESKTOP", raising=False)
    monkeypatch.delenv("HERMES_DESKTOP_TERMINAL", raising=False)

    server._ensure_session_db_row({"session_key": "k1", "cwd": str(tmp_path), "explicit_cwd": True})

    assert created == [
        {"key": "k1", "source": "tui", "model": "test-model", "model_config": None, "cwd": str(tmp_path)}
    ]


def test_ensure_session_db_row_persists_session_source(monkeypatch):
    created = []

    class _FakeDB:
        def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None, profile_name=None):
            created.append(
                {"key": key, "source": source, "model": model, "model_config": model_config, "cwd": cwd}
            )

    monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
    monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")

    server._ensure_session_db_row({"session_key": "k1", "source": "tool"})

    assert created == [
        {"key": "k1", "source": "tool", "model": "test-model", "model_config": None, "cwd": None}
    ]


def test_ensure_session_db_row_records_a_terminal_workspace(monkeypatch, tmp_path):
    """A terminal session's directory IS its workspace, so the row records it.

    The user cd'd there before running hermes. Leaving it null stranded the row
    with no cwd and no git_repo_root, so the sidebar could never place the
    session under its project.
    """
    created = []

    class _FakeDB:
        def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None, profile_name=None):
            created.append(
                {"key": key, "source": source, "model": model, "model_config": model_config, "cwd": cwd}
            )

    monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
    monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")
    monkeypatch.delenv("HERMES_DESKTOP", raising=False)
    monkeypatch.delenv("HERMES_DESKTOP_TERMINAL", raising=False)

    server._ensure_session_db_row({"session_key": "k1", "cwd": str(tmp_path)})

    assert created == [
        {"key": "k1", "source": "tui", "model": "test-model", "model_config": None, "cwd": str(tmp_path)}
    ]


def test_ensure_session_db_row_defaults_desktop_to_no_workspace(monkeypatch, tmp_path):
    """The desktop launches from wherever the bundle was opened, so an unpicked
    cwd is an artifact — those chats stay null and group under "No workspace"."""
    created = []

    class _FakeDB:
        def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None, profile_name=None):
            created.append(
                {"key": key, "source": source, "model": model, "model_config": model_config, "cwd": cwd}
            )

    monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
    monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")

    server._ensure_session_db_row({"session_key": "k1", "source": "desktop", "cwd": str(tmp_path)})

    assert created == [
        {"key": "k1", "source": "desktop", "model": "test-model", "model_config": None, "cwd": None}
    ]


def test_ensure_session_db_row_persists_session_model_override(monkeypatch):
    """The session's composer pick (model + effort + fast) must own the DB row.

    Regression for the "switched to gpt-5.5, reconnect snapped back to opus"
    bug: the row was created with the global default and won the INSERT-OR-IGNORE
    race, so resume rebuilt from the global model and silently reverted the
    chat. The override model + a model_config carrying provider/reasoning/
    service_tier must be persisted so session.resume restores all three.
    """
    created = []

    class _FakeDB:
        def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None, profile_name=None):
            created.append(
                {"key": key, "model": model, "model_config": model_config, "cwd": cwd}
            )

    monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
    monkeypatch.setattr(server, "_resolve_model", lambda: "global/default")

    server._ensure_session_db_row(
        {
            "session_key": "k1",
            "model_override": {"model": "openai/gpt-5.5", "provider": "openrouter"},
            "create_reasoning_override": {"effort": "high"},
            "create_service_tier_override": "priority",
        }
    )

    assert len(created) == 1
    row = created[0]
    assert row["model"] == "openai/gpt-5.5"
    assert row["model_config"]["model"] == "openai/gpt-5.5"
    assert row["model_config"]["provider"] == "openrouter"
    assert row["model_config"]["reasoning_config"] == {"effort": "high"}
    assert row["model_config"]["service_tier"] == "priority"


def test_ensure_session_db_row_no_override_uses_global(monkeypatch):
    """A chat that made no explicit pick falls back to the global model and
    writes no model_config (so it tracks the profile default)."""
    created = []

    class _FakeDB:
        def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None, profile_name=None):
            created.append({"model": model, "model_config": model_config})

    monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
    monkeypatch.setattr(server, "_resolve_model", lambda: "global/default")

    server._ensure_session_db_row({"session_key": "k1", "model_override": None})

    assert created == [{"model": "global/default", "model_config": None}]


def test_ensure_session_db_row_stamps_profile_name(monkeypatch, tmp_path):
    """A profile session's row carries its owning profile_name, so unified
    multi-profile aggregation never has to guess from which state.db file the
    row happened to be read (the cross-profile session-jump bug)."""
    profile_home = tmp_path / "profiles" / "mlperf"
    profile_home.mkdir(parents=True)
    created = []

    class _ProfileDB:
        def __init__(self, db_path=None):
            created.append({"db_path": db_path})

        def create_session(self, key, **kwargs):
            created[-1].update({"key": key, "profile_name": kwargs.get("profile_name")})

        def close(self):
            pass

    monkeypatch.setattr("hermes_state.SessionDB", _ProfileDB)
    monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")

    server._ensure_session_db_row(
        {"session_key": "k1", "profile_home": str(profile_home)}
    )

    assert created and created[0]["key"] == "k1"
    assert created[0]["profile_name"] == "mlperf"
    assert created[0]["db_path"] == profile_home / "state.db"


def test_session_title_clears_pending_after_persist(monkeypatch):
    class _FakeDB:
        def __init__(self):
            self.title = "old"

        def get_session_title(self, _key):
            return self.title

        def get_session(self, _key):
            return {"id": _key, "title": self.title}

        def set_session_title(self, _key, title):
            self.title = title
            return True

    db = _FakeDB()
    emitted = []
    server._sessions["sid"] = _session(pending_title="stale")
    monkeypatch.setattr(server, "_get_db", lambda: db)
    monkeypatch.setattr(server, "_emit", lambda *args: emitted.append(args))
    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "session.title",
                "params": {"session_id": "sid", "title": "fresh"},
            }
        )

        assert resp["result"]["pending"] is False
        assert resp["result"]["title"] == "fresh"
        assert server._sessions["sid"]["pending_title"] is None
        assert emitted[-1][0:2] == ("session.info", "sid")
        assert emitted[-1][2]["title"] == "fresh"
    finally:
        server._sessions.pop("sid", None)


def test_session_title_does_not_queue_noop_when_row_exists(monkeypatch):
    class _FakeDB:
        def __init__(self):
            self.title = "same title"

        def get_session_title(self, _key):
            return self.title

        def get_session(self, _key):
            return {"id": _key, "title": self.title}

        def set_session_title(self, _key, _title):
            # Simulate sqlite UPDATE rowcount==0 for no-op update.
            return False

    server._sessions["sid"] = _session(pending_title="stale")
    monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "session.title",
                "params": {"session_id": "sid", "title": "same title"},
            }
        )

        assert resp["result"]["pending"] is False
        assert resp["result"]["title"] == "same title"
        assert server._sessions["sid"]["pending_title"] is None
    finally:
        server._sessions.pop("sid", None)


def test_session_title_get_falls_back_to_pending_when_db_read_throws(monkeypatch):
    class _FakeDB:
        def get_session_title(self, _key):
            raise RuntimeError("db temporarily locked")

    server._sessions["sid"] = _session(pending_title="queued title")
    monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
    try:
        resp = server.handle_request(
            {"id": "1", "method": "session.title", "params": {"session_id": "sid"}}
        )
        assert resp["result"]["title"] == "queued title"
    finally:
        server._sessions.pop("sid", None)


def test_session_title_get_retries_persist_for_pending_title(monkeypatch):
    class _FakeDB:
        def __init__(self):
            self.title = ""

        def get_session_title(self, _key):
            return self.title

        def set_session_title(self, _key, title):
            self.title = title
            return True

        def get_session(self, _key):
            return {"id": _key, "title": self.title}

    db = _FakeDB()
    server._sessions["sid"] = _session(pending_title="queued title")
    monkeypatch.setattr(server, "_get_db", lambda: db)
    try:
        resp = server.handle_request(
            {"id": "1", "method": "session.title", "params": {"session_id": "sid"}}
        )
        assert resp["result"]["title"] == "queued title"
        assert server._sessions["sid"]["pending_title"] is None
    finally:
        server._sessions.pop("sid", None)


def test_session_title_get_retries_pending_even_when_db_has_title(monkeypatch):
    class _FakeDB:
        def __init__(self):
            self.title = "auto title"

        def get_session_title(self, _key):
            return self.title

        def set_session_title(self, _key, title):
            self.title = title
            return True

        def get_session(self, _key):
            return {"id": _key, "title": self.title}

    db = _FakeDB()
    server._sessions["sid"] = _session(pending_title="queued title")
    monkeypatch.setattr(server, "_get_db", lambda: db)
    try:
        resp = server.handle_request(
            {"id": "1", "method": "session.title", "params": {"session_id": "sid"}}
        )
        assert resp["result"]["title"] == "queued title"
        assert server._sessions["sid"]["pending_title"] is None
    finally:
        server._sessions.pop("sid", None)


def test_session_title_rejects_empty_title_with_specific_error_code(monkeypatch):
    class _FakeDB:
        def get_session_title(self, _key):
            return ""

    server._sessions["sid"] = _session()
    monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "session.title",
                "params": {"session_id": "sid", "title": "   "},
            }
        )
        assert "error" in resp
        assert resp["error"]["code"] == 4021
    finally:
        server._sessions.pop("sid", None)


def test_session_title_set_maps_valueerror_to_user_error(monkeypatch):
    class _FakeDB:
        def get_session_title(self, _key):
            return ""

        def get_session(self, _key):
            return {"id": _key}

        def set_session_title(self, _key, _title):
            raise ValueError("Title already in use")

    server._sessions["sid"] = _session()
    monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "session.title",
                "params": {"session_id": "sid", "title": "dup"},
            }
        )
        assert "error" in resp
        assert resp["error"]["code"] == 4022
        assert "already in use" in resp["error"]["message"]
    finally:
        server._sessions.pop("sid", None)


def test_session_title_set_errors_when_row_lookup_fails_after_noop(monkeypatch):
    class _FakeDB:
        def get_session_title(self, _key):
            return ""

        def get_session(self, _key):
            raise RuntimeError("row lookup failed")

        def set_session_title(self, _key, _title):
            return False

    server._sessions["sid"] = _session()
    monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "session.title",
                "params": {"session_id": "sid", "title": "fresh"},
            }
        )
        assert "error" in resp
        assert resp["error"]["code"] == 5007
        assert "row lookup failed" in resp["error"]["message"]
    finally:
        server._sessions.pop("sid", None)


def test_session_create_drops_pending_title_on_valueerror(monkeypatch):
    """When set_session_title raises ValueError during post-message title flush,
    pending_title should be dropped (non-retryable). Updated for post-#18370
    lazy session creation where title is applied post-first-message.
    """

    class _Agent:
        session_id = "test-session"
        model = "x"
        provider = "openrouter"
        base_url = ""
        api_key = ""
        _cached_system_prompt = ""

        def run_conversation(self, prompt, **kw):
            return {
                "final_response": "ok",
                "messages": [{"role": "assistant", "content": "ok"}],
            }

    class _FakeDB:
        def set_session_title(self, _key, _title):
            raise ValueError("Title already in use")

    class _ImmediateThread:
        def __init__(self, target=None, daemon=None, **kw):
            self._target = target

        def start(self):
            self._target()

    agent = _Agent()
    session = {
        "agent": agent,
        "session_key": "test-session",
        "history": [],
        "history_lock": threading.Lock(),
        "history_version": 0,
        "running": False,
        "attached_images": [],
        "image_counter": 0,
        "cols": 80,
        "slash_worker": None,
        "show_reasoning": False,
        "tool_progress_mode": "all",
        "pending_title": "duplicate title",
    }

    server._sessions["sid"] = session
    monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
    monkeypatch.setattr(server, "_emit", lambda *a, **kw: None)
    monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None)
    monkeypatch.setattr(server, "render_message", lambda raw, cols: None)
    monkeypatch.setattr(
        server, "_sync_session_key_after_compress", lambda *a, **kw: None
    )
    monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)

    try:
        server.handle_request(
            {"id": "1", "method": "prompt.submit", "params": {"session_id": "sid", "text": "hello"}}
        )
        assert session["pending_title"] is None
    finally:
        server._sessions.pop("sid", None)


def test_config_set_yolo_toggles_session_scope():
    from tools.approval import clear_session, is_session_yolo_enabled

    server._sessions["sid"] = _session()
    try:
        resp_on = server.handle_request(
            {
                "id": "1",
                "method": "config.set",
                "params": {"session_id": "sid", "key": "yolo"},
            }
        )
        assert resp_on["result"]["value"] == "1"
        assert is_session_yolo_enabled("session-key") is True

        resp_off = server.handle_request(
            {
                "id": "2",
                "method": "config.set",
                "params": {"session_id": "sid", "key": "yolo"},
            }
        )
        assert resp_off["result"]["value"] == "0"
        assert is_session_yolo_enabled("session-key") is False
    finally:
        clear_session("session-key")
        server._sessions.clear()


def test_config_set_yolo_global_scope_writes_approvals_mode(tmp_path, monkeypatch):
    """Shift+click the desktop zap -> scope="global" flips persistent approvals.mode."""
    import yaml

    cfg_path = tmp_path / "config.yaml"
    cfg_path.write_text(yaml.safe_dump({"approvals": {"mode": "manual"}}))
    monkeypatch.setattr(server, "_hermes_home", tmp_path)

    resp_on = server.handle_request(
        {
            "id": "1",
            "method": "config.set",
            "params": {"key": "yolo", "scope": "global"},
        }
    )
    assert resp_on["result"]["value"] == "1"
    assert resp_on["result"]["scope"] == "global"
    assert yaml.safe_load(cfg_path.read_text())["approvals"]["mode"] == "off"

    resp_off = server.handle_request(
        {
            "id": "2",
            "method": "config.set",
            "params": {"key": "yolo", "scope": "global"},
        }
    )
    assert resp_off["result"]["value"] == "0"
    assert yaml.safe_load(cfg_path.read_text())["approvals"]["mode"] == "manual"


def test_config_get_approval_mode_uses_smart_default_when_key_is_missing(
    tmp_path, monkeypatch
):
    import yaml

    monkeypatch.setattr(server, "_hermes_home", tmp_path)
    # Point the canonical resolver (load_config → env HERMES_HOME) at the
    # temp home too, so the smart default is asserted against THIS config
    # rather than whatever the developer's real ~/.hermes happens to hold.
    monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    (tmp_path / "config.yaml").write_text(
        yaml.safe_dump({"approvals": {"timeout": 15}})
    )

    response = server.handle_request(
        {"id": "1", "method": "config.get", "params": {"key": "approvals.mode"}}
    )
    assert response["result"]["value"] == "smart"


def test_config_get_approval_mode_fails_safe_to_manual_for_invalid_explicit_value(
    tmp_path, monkeypatch
):
    import yaml

    monkeypatch.setattr(server, "_hermes_home", tmp_path)
    # _load_approval_mode delegates to the canonical resolver in
    # tools.approval, which reads via hermes_cli.config.load_config —
    # that path resolves HERMES_HOME from the environment, not
    # server._hermes_home.
    monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    (tmp_path / "config.yaml").write_text(
        yaml.safe_dump({"approvals": {"mode": "sometimes"}})
    )

    response = server.handle_request(
        {"id": "1", "method": "config.get", "params": {"key": "approvals.mode"}}
    )
    assert response["result"]["value"] == "manual"


def test_config_get_approval_mode_normalizes_yaml_off(tmp_path, monkeypatch):
    import yaml

    monkeypatch.setattr(server, "_hermes_home", tmp_path)
    # See fail-safe test above: the canonical resolver reads via
    # load_config, which resolves HERMES_HOME from the environment.
    monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    (tmp_path / "config.yaml").write_text(
        yaml.safe_dump({"approvals": {"mode": False}})
    )

    response = server.handle_request(
        {"id": "1", "method": "config.get", "params": {"key": "approvals.mode"}}
    )
    assert response["result"]["value"] == "off"


def test_config_set_approval_mode_persists_three_way_value_and_emits_live_status(
    tmp_path, monkeypatch
):
    import yaml

    monkeypatch.setattr(server, "_hermes_home", tmp_path)
    # config.set writes via server._hermes_home, but the post-write
    # session.info emit resolves the effective mode through the canonical
    # tools.approval resolver (load_config → env HERMES_HOME).
    monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    emitted = []
    monkeypatch.setattr(server, "_emit", lambda *args: emitted.append(args))
    server._sessions["sid"] = {"agent": object(), "session_key": "profile-session"}

    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "config.set",
                "params": {"key": "approvals.mode", "value": "manual"},
            }
        )
    finally:
        server._sessions.clear()

    assert resp["result"] == {"key": "approvals.mode", "value": "manual"}
    assert yaml.safe_load((tmp_path / "config.yaml").read_text())["approvals"]["mode"] == "manual"
    assert emitted and emitted[0][0:2] == ("session.info", "sid")
    assert emitted[0][2]["approval_mode"] == "manual"


def test_desktop_contract_includes_approval_mode_rpc():
    assert server.DESKTOP_BACKEND_CONTRACT >= 3


def test_config_set_approval_mode_rejects_unknown_value():
    resp = server.handle_request(
        {
            "id": "1",
            "method": "config.set",
            "params": {"key": "approvals.mode", "value": "sometimes"},
        }
    )

    assert resp["error"]["code"] == 4002


def test_config_set_yolo_global_scope_honors_explicit_value(tmp_path, monkeypatch):
    """An explicit value pins global approvals.mode regardless of prior state."""
    import yaml

    cfg_path = tmp_path / "config.yaml"
    cfg_path.write_text(yaml.safe_dump({"approvals": {"mode": "manual"}}))
    monkeypatch.setattr(server, "_hermes_home", tmp_path)

    resp = server.handle_request(
        {
            "id": "1",
            "method": "config.set",
            "params": {"key": "yolo", "scope": "global", "value": "1"},
        }
    )
    assert resp["result"]["value"] == "1"
    assert yaml.safe_load(cfg_path.read_text())["approvals"]["mode"] == "off"

    # Setting it on again is idempotent — stays off.
    resp_again = server.handle_request(
        {
            "id": "2",
            "method": "config.set",
            "params": {"key": "yolo", "scope": "global", "value": "1"},
        }
    )
    assert resp_again["result"]["value"] == "1"
    assert yaml.safe_load(cfg_path.read_text())["approvals"]["mode"] == "off"


def test_config_set_fast_updates_live_agent_session_scoped(monkeypatch):
    """A session-targeted fast toggle updates the live agent + pins the
    per-session override, and NEVER writes global config — the desktop's
    per-model presets call this on every model pick, and a global write
    flipped the tier for every other session/profile (the "switch one
    session, switches everywhere" class)."""
    writes = []
    emits = []
    agent = types.SimpleNamespace(
        model="openai/gpt-5.4",
        request_overrides={"foo": "bar", "speed": "slow"},
        service_tier=None,
    )
    session = _session(agent=agent)
    server._sessions["sid"] = session

    monkeypatch.setattr(
        server, "_write_config_key", lambda path, value: writes.append((path, value))
    )
    monkeypatch.setattr(server, "_session_info", lambda _agent, *a: {"model": "x"})
    monkeypatch.setattr(server, "_emit", lambda *args: emits.append(args))
    monkeypatch.setattr(
        "hermes_cli.models.resolve_fast_mode_overrides",
        lambda _model_id: {"service_tier": "priority"},
    )

    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "config.set",
                "params": {"session_id": "sid", "key": "fast", "value": "fast"},
            }
        )
        assert resp["result"]["value"] == "fast"
        assert agent.service_tier == "priority"
        assert agent.request_overrides == {
            "foo": "bar",
            "service_tier": "priority",
        }
        assert session["create_service_tier_override"] == "priority"
        assert writes == []
        assert ("session.info", "sid", {"model": "x"}) in emits

        resp_normal = server.handle_request(
            {
                "id": "2",
                "method": "config.set",
                "params": {"session_id": "sid", "key": "fast", "value": "normal"},
            }
        )
        assert resp_normal["result"]["value"] == "normal"
        assert agent.service_tier is None
        assert agent.request_overrides == {"foo": "bar"}
        # "" (not absent) so a rebuild pins normal instead of falling back to
        # the global default.
        assert session["create_service_tier_override"] == ""
        assert writes == []
    finally:
        server._sessions.pop("sid", None)


def test_config_set_fast_status_is_non_mutating(monkeypatch):
    writes = []
    emits = []
    agent = types.SimpleNamespace(service_tier="priority")
    server._sessions["sid"] = _session(agent=agent)

    monkeypatch.setattr(
        server, "_write_config_key", lambda path, value: writes.append((path, value))
    )
    monkeypatch.setattr(server, "_emit", lambda *args: emits.append(args))

    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "config.set",
                "params": {"session_id": "sid", "key": "fast", "value": "status"},
            }
        )
        assert resp["result"]["value"] == "fast"
        assert writes == []
        assert emits == []
    finally:
        server._sessions.pop("sid", None)


def test_config_set_fast_rejects_unsupported_model(monkeypatch):
    writes = []
    agent = types.SimpleNamespace(
        model="unsupported-model",
        request_overrides={},
        service_tier=None,
    )
    server._sessions["sid"] = _session(agent=agent)

    monkeypatch.setattr(
        server, "_write_config_key", lambda path, value: writes.append((path, value))
    )
    monkeypatch.setattr(
        "hermes_cli.models.resolve_fast_mode_overrides",
        lambda _model_id: None,
    )

    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "config.set",
                "params": {"session_id": "sid", "key": "fast", "value": "fast"},
            }
        )
        assert resp["error"]["code"] == 4002
        assert "not available" in resp["error"]["message"]
        assert agent.service_tier is None
        assert agent.request_overrides == {}
        assert writes == []
    finally:
        server._sessions.pop("sid", None)


def test_config_set_fast_rejects_missing_model(monkeypatch):
    writes = []
    agent = types.SimpleNamespace(
        model="",
        request_overrides={},
        service_tier=None,
    )
    server._sessions["sid"] = _session(agent=agent)

    monkeypatch.setattr(
        server, "_write_config_key", lambda path, value: writes.append((path, value))
    )

    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "config.set",
                "params": {"session_id": "sid", "key": "fast", "value": "fast"},
            }
        )
        assert resp["error"]["code"] == 4002
        assert "without a selected model" in resp["error"]["message"]
        assert agent.service_tier is None
        assert agent.request_overrides == {}
        assert writes == []
    finally:
        server._sessions.pop("sid", None)


def test_config_busy_get_and_set(monkeypatch):
    writes = []

    monkeypatch.setattr(
        server,
        "_load_cfg",
        lambda: {"display": {"busy_input_mode": "steer"}},
    )
    monkeypatch.setattr(
        server, "_write_config_key", lambda path, value: writes.append((path, value))
    )

    get_resp = server.handle_request(
        {"id": "1", "method": "config.get", "params": {"key": "busy"}}
    )
    assert get_resp["result"]["value"] == "steer"

    set_resp = server.handle_request(
        {
            "id": "2",
            "method": "config.set",
            "params": {"key": "busy", "value": "interrupt"},
        }
    )
    assert set_resp["result"]["value"] == "interrupt"
    assert ("display.busy_input_mode", "interrupt") in writes


def test_config_set_yolo_process_scope_treats_false_like_env_as_disabled(monkeypatch):
    monkeypatch.setenv("HERMES_YOLO_MODE", "false")

    resp = server.handle_request(
        {
            "id": "1",
            "method": "config.set",
            "params": {"key": "yolo"},
        }
    )

    assert resp["result"]["value"] == "1"
    assert os.environ.get("HERMES_YOLO_MODE") == "1"


def test_config_get_statusbar_survives_non_dict_display(monkeypatch):
    monkeypatch.setattr(server, "_load_cfg", lambda: {"display": "broken"})

    resp = server.handle_request(
        {"id": "1", "method": "config.get", "params": {"key": "statusbar"}}
    )

    assert resp["result"]["value"] == "top"


def test_config_get_busy_survives_non_dict_display(monkeypatch):
    monkeypatch.setattr(server, "_load_cfg", lambda: {"display": "broken"})

    resp = server.handle_request(
        {"id": "1", "method": "config.get", "params": {"key": "busy"}}
    )

    assert resp["result"]["value"] == "interrupt"


def test_config_set_statusbar_survives_non_dict_display(tmp_path, monkeypatch):
    import yaml

    cfg_path = tmp_path / "config.yaml"
    cfg_path.write_text(yaml.safe_dump({"display": "broken"}))
    monkeypatch.setattr(server, "_hermes_home", tmp_path)

    resp = server.handle_request(
        {
            "id": "1",
            "method": "config.set",
            "params": {"key": "statusbar", "value": "bottom"},
        }
    )

    assert resp["result"]["value"] == "bottom"
    saved = yaml.safe_load(cfg_path.read_text())
    assert saved["display"]["tui_statusbar"] == "bottom"


def test_config_set_details_mode_pins_all_sections(tmp_path, monkeypatch):
    import yaml

    cfg_path = tmp_path / "config.yaml"
    cfg_path.write_text(
        yaml.safe_dump(
            {"display": {"sections": {"tools": "expanded", "activity": "hidden"}}}
        )
    )
    monkeypatch.setattr(server, "_hermes_home", tmp_path)

    resp = server.handle_request(
        {
            "id": "1",
            "method": "config.set",
            "params": {"key": "details_mode", "value": "collapsed"},
        }
    )

    assert resp["result"] == {"key": "details_mode", "value": "collapsed"}
    saved = yaml.safe_load(cfg_path.read_text())
    assert saved["display"]["details_mode"] == "collapsed"
    assert saved["display"]["sections"] == {
        "thinking": "collapsed",
        "tools": "collapsed",
        "subagents": "collapsed",
        "activity": "collapsed",
    }


def test_config_set_section_writes_per_section_override(tmp_path, monkeypatch):
    import yaml

    cfg_path = tmp_path / "config.yaml"
    monkeypatch.setattr(server, "_hermes_home", tmp_path)

    resp = server.handle_request(
        {
            "id": "1",
            "method": "config.set",
            "params": {"key": "details_mode.activity", "value": "hidden"},
        }
    )

    assert resp["result"] == {"key": "details_mode.activity", "value": "hidden"}
    saved = yaml.safe_load(cfg_path.read_text())
    assert saved["display"]["sections"] == {"activity": "hidden"}


def test_config_set_section_clears_override_on_empty_value(tmp_path, monkeypatch):
    import yaml

    cfg_path = tmp_path / "config.yaml"
    cfg_path.write_text(
        yaml.safe_dump(
            {"display": {"sections": {"activity": "hidden", "tools": "expanded"}}}
        )
    )
    monkeypatch.setattr(server, "_hermes_home", tmp_path)

    resp = server.handle_request(
        {
            "id": "1",
            "method": "config.set",
            "params": {"key": "details_mode.activity", "value": ""},
        }
    )

    assert resp["result"] == {"key": "details_mode.activity", "value": ""}
    saved = yaml.safe_load(cfg_path.read_text())
    assert saved["display"]["sections"] == {"tools": "expanded"}


def test_config_set_section_rejects_unknown_section_or_mode(tmp_path, monkeypatch):
    monkeypatch.setattr(server, "_hermes_home", tmp_path)

    bad_section = server.handle_request(
        {
            "id": "1",
            "method": "config.set",
            "params": {"key": "details_mode.bogus", "value": "hidden"},
        }
    )
    assert bad_section["error"]["code"] == 4002

    bad_mode = server.handle_request(
        {
            "id": "2",
            "method": "config.set",
            "params": {"key": "details_mode.tools", "value": "maximised"},
        }
    )
    assert bad_mode["error"]["code"] == 4002


def test_config_mouse_uses_documented_key_with_legacy_fallback(monkeypatch):
    cfg = {"display": {"tui_mouse": False}}
    writes = []

    monkeypatch.setattr(server, "_load_cfg", lambda: cfg)
    monkeypatch.setattr(
        server, "_write_config_key", lambda path, value: writes.append((path, value))
    )

    get_legacy = server.handle_request(
        {"id": "1", "method": "config.get", "params": {"key": "mouse"}}
    )
    assert get_legacy["result"]["value"] == "off"

    set_toggle = server.handle_request(
        {"id": "2", "method": "config.set", "params": {"key": "mouse"}}
    )
    # /mouse (no arg) toggles between 'all' and 'off'. Starting from
    # tui_mouse: False (→ 'off'), the toggle flips to 'all'.
    assert set_toggle["result"] == {"key": "mouse", "value": "all"}
    assert writes == [("display.mouse_tracking", "all")]

    cfg["display"] = {"mouse_tracking": 0, "tui_mouse": True}
    get_canonical = server.handle_request(
        {"id": "3", "method": "config.get", "params": {"key": "mouse"}}
    )
    assert get_canonical["result"]["value"] == "off"

    cfg["display"] = {"mouse_tracking": None, "tui_mouse": False}
    get_null = server.handle_request(
        {"id": "4", "method": "config.get", "params": {"key": "mouse"}}
    )
    # mouse_tracking present-but-None defers neither to tui_mouse nor to
    # the legacy off bucket: it falls through to the 'all' default.
    assert get_null["result"]["value"] == "all"


def test_config_mouse_accepts_preset_strings_and_aliases(monkeypatch):
    cfg = {"display": {"mouse_tracking": "all"}}
    writes = []

    monkeypatch.setattr(server, "_load_cfg", lambda: cfg)
    monkeypatch.setattr(
        server, "_write_config_key", lambda path, value: writes.append((path, value))
    )

    # Direct preset.
    set_wheel = server.handle_request(
        {
            "id": "1",
            "method": "config.set",
            "params": {"key": "mouse", "value": "wheel"},
        }
    )
    assert set_wheel["result"] == {"key": "mouse", "value": "wheel"}
    assert writes[-1] == ("display.mouse_tracking", "wheel")

    # Alias for buttons.
    set_click = server.handle_request(
        {
            "id": "2",
            "method": "config.set",
            "params": {"key": "mouse", "value": "click"},
        }
    )
    assert set_click["result"] == {"key": "mouse", "value": "buttons"}
    assert writes[-1] == ("display.mouse_tracking", "buttons")

    # Unknown value → 4002.
    bad = server.handle_request(
        {
            "id": "3",
            "method": "config.set",
            "params": {"key": "mouse", "value": "rainbows"},
        }
    )
    assert bad["error"]["code"] == 4002


def test_enable_gateway_prompts_sets_gateway_env(monkeypatch):
    monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
    monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
    monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)

    server._enable_gateway_prompts()

    assert server.os.environ["HERMES_GATEWAY_SESSION"] == "1"
    assert server.os.environ["HERMES_EXEC_ASK"] == "1"
    assert server.os.environ["HERMES_INTERACTIVE"] == "1"


def test_setup_status_reports_provider_config(monkeypatch):
    monkeypatch.setattr("hermes_cli.main._has_any_provider_configured", lambda: False)

    resp = server.handle_request({"id": "1", "method": "setup.status", "params": {}})

    assert resp["result"]["provider_configured"] is False


def test_probe_credentials_emits_exact_empty_key_warning():
    agent = types.SimpleNamespace(api_key="", provider="openrouter")

    assert server._probe_credentials(agent) == (
        "No API key configured for provider 'openrouter'. First message will fail."
    )


def test_probe_credentials_allows_keyless_custom_runtime():
    agent = types.SimpleNamespace(api_key="no-key-required", provider="custom")

    assert server._probe_credentials(agent) == ""


def test_setup_runtime_check_rejects_empty_runtime_key(monkeypatch):
    monkeypatch.setattr("hermes_cli.main._has_any_provider_configured", lambda: True)
    monkeypatch.setattr(
        "hermes_cli.runtime_provider.resolve_runtime_provider",
        lambda requested=None: {
            "provider": "openrouter",
            "api_key": "",
            "source": "env/config",
        },
    )

    resp = server.handle_request({"id": "1", "method": "setup.runtime_check", "params": {}})

    assert resp["result"] == {
        "ok": False,
        "provider": "openrouter",
        "model": None,
        "source": "env/config",
        "error": "No usable credentials found for openrouter.",
    }


def test_setup_runtime_check_allows_no_key_custom_runtime(monkeypatch):
    monkeypatch.setattr("hermes_cli.main._has_any_provider_configured", lambda: True)
    monkeypatch.setattr(
        "hermes_cli.runtime_provider.resolve_runtime_provider",
        lambda requested=None: {
            "provider": "custom",
            "api_key": "no-key-required",
            "source": "env/config",
        },
    )

    resp = server.handle_request({"id": "1", "method": "setup.runtime_check", "params": {}})

    assert resp["result"]["ok"] is True
    assert resp["result"]["provider"] == "custom"


def test_setup_runtime_check_rejects_implicit_bedrock_when_unconfigured(monkeypatch):
    monkeypatch.setattr("hermes_cli.main._has_any_provider_configured", lambda: False)
    monkeypatch.setattr(
        "hermes_cli.runtime_provider.resolve_runtime_provider",
        lambda requested=None: {
            "provider": "bedrock",
            "api_key": "aws-sdk",
            "source": "iam-role",
        },
    )

    resp = server.handle_request({"id": "1", "method": "setup.runtime_check", "params": {}})

    assert resp["result"]["ok"] is False
    assert resp["result"]["provider"] == "bedrock"


def test_setup_runtime_check_honors_requested_provider(monkeypatch):
    """Onboarding must be able to validate the provider the user just connected."""
    monkeypatch.setattr("hermes_cli.main._has_any_provider_configured", lambda: True)

    def fake_resolve(requested=None, **kwargs):
        if requested == "nous":
            return {
                "provider": "nous",
                "api_key": "invoke-jwt",
                "source": "portal",
            }
        return {
            "provider": "anthropic",
            "api_key": "",
            "source": "config",
        }

    monkeypatch.setattr(
        "hermes_cli.runtime_provider.resolve_runtime_provider",
        fake_resolve,
    )

    scoped = server.handle_request(
        {"id": "1", "method": "setup.runtime_check", "params": {"provider": "nous"}}
    )
    assert scoped["result"]["ok"] is True
    assert scoped["result"]["provider"] == "nous"

    default = server.handle_request({"id": "1", "method": "setup.runtime_check", "params": {}})
    assert default["result"]["ok"] is False
    assert default["result"]["provider"] == "anthropic"


def test_complete_slash_drops_removed_provider_alias():
    # `/provider` was folded into a single `/model` command, so autocomplete
    # must no longer offer the dead alias...
    resp = server.handle_request(
        {"id": "1", "method": "complete.slash", "params": {"text": "/pro"}}
    )

    assert not any(item["text"] == "provider" for item in resp["result"]["items"])

    # ...while `/model` stays the canonical command.
    resp_model = server.handle_request(
        {"id": "2", "method": "complete.slash", "params": {"text": "/mod"}}
    )

    assert any(item["text"] == "model" for item in resp_model["result"]["items"])


def test_complete_slash_returns_plain_string_fields():
    # prompt_toolkit hands us FormattedText (a list subclass) for
    # display/display_meta; the TUI's CompletionItem contract is plain
    # strings, and shipping the raw list trips Ink's row layout into
    # 1-char truncation of the next column (/goal → /goa).
    resp = server.handle_request(
        {"id": "1", "method": "complete.slash", "params": {"text": "/g"}}
    )

    items = resp["result"]["items"]
    goal = next((it for it in items if it["text"] == "goal"), None)
    assert goal is not None
    assert isinstance(goal["display"], str), goal["display"]
    assert isinstance(goal["meta"], str), goal["meta"]
    assert goal["display"] == "/goal"
    for item in items:
        assert isinstance(item["display"], str), item
        assert isinstance(item["meta"], str), item


def test_complete_slash_includes_tui_details_command():
    resp = server.handle_request(
        {"id": "1", "method": "complete.slash", "params": {"text": "/det"}}
    )

    assert any(item["text"] == "/details" for item in resp["result"]["items"])


def test_complete_slash_includes_tui_mouse_command():
    resp = server.handle_request(
        {"id": "1", "method": "complete.slash", "params": {"text": "/mou"}}
    )

    assert any(item["text"] == "/mouse" for item in resp["result"]["items"])


def test_complete_slash_details_args():
    resp_root = server.handle_request(
        {"id": "0", "method": "complete.slash", "params": {"text": "/details"}}
    )
    resp_section = server.handle_request(
        {"id": "1", "method": "complete.slash", "params": {"text": "/details t"}}
    )
    resp_mode = server.handle_request(
        {
            "id": "2",
            "method": "complete.slash",
            "params": {"text": "/details thinking e"},
        }
    )

    assert resp_root["result"]["replace_from"] == len("/details")
    assert any(item["text"] == " thinking" for item in resp_root["result"]["items"])
    assert any(item["text"] == "thinking" for item in resp_section["result"]["items"])
    assert any(item["text"] == "expanded" for item in resp_mode["result"]["items"])


def test_complete_slash_reasoning_includes_current_efforts_and_global_scope():
    resp = server.handle_request(
        {"id": "1", "method": "complete.slash", "params": {"text": "/reasoning "}}
    )

    values = {item["text"] for item in resp["result"]["items"]}
    assert {"max", "ultra", "--global"} <= values


_SLASH_FILLER_COUNT = 60


def _slash_skill_fixtures(monkeypatch):
    """Stub a skill install big enough that a flat cap would truncate it."""
    filler = {f"/filler-{i:03d}": 0 for i in range(_SLASH_FILLER_COUNT)}
    usage = {"work": 297, "research": 84, "clean": 12}

    monkeypatch.setattr(
        server,
        "_skill_usage_lookup",
        lambda: (
            lambda name: usage.get(name, 0),
            lambda name: "bundled" if name.startswith("unused-") else "local",
        ),
    )
    monkeypatch.setattr(
        "agent.skill_commands.get_skill_commands",
        lambda: {
            "/work": {"description": "Fresh worktree"},
            "/research": {"description": "Look it up"},
            "/clean": {"description": "Polish the diff"},
            "/unused-bundled": {"description": "Shipped, never opened"},
            **{cmd: {"description": "Filler"} for cmd in filler},
        },
    )
    monkeypatch.setattr("agent.skill_bundles.get_skill_bundles", lambda: {})


def _slash_completions(text: str) -> list[dict]:
    resp = server.handle_request(
        {"id": "1", "method": "complete.slash", "params": {"text": text}}
    )
    return resp["result"]["items"]


def test_complete_slash_offers_skills_alongside_commands(monkeypatch):
    """A bare `/` must reach the skills, not just the registry.

    The completer emits every registry command before the first skill, so one
    flat cap spent every row on commands and no skill was reachable at all.
    """
    _slash_skill_fixtures(monkeypatch)

    kinds = {item["kind"] for item in _slash_completions("/")}

    assert kinds == {"command", "skill"}


def test_complete_slash_ranks_skills_by_recorded_usage(monkeypatch):
    """The skills someone actually invokes lead the ones they never opened."""
    _slash_skill_fixtures(monkeypatch)

    skills = [
        item["text"].strip() for item in _slash_completions("/") if item["kind"] == "skill"
    ]

    assert skills[:3] == ["work", "research", "clean"]


def test_complete_slash_prunes_unused_builtins_only_while_browsing(monkeypatch):
    """A bare `/` is browsing and may prune; a typed query is a search.

    A search that hides a match is broken, so the never-opened bundled skill
    disappears from `/` and comes straight back the moment it is typed for.
    """
    _slash_skill_fixtures(monkeypatch)

    browsing = {item["text"].strip() for item in _slash_completions("/")}
    searching = {item["text"].strip() for item in _slash_completions("/unused")}

    assert "unused-bundled" not in browsing
    assert "unused-bundled" in searching


def test_complete_slash_leaves_argument_stages_alone(monkeypatch):
    """Ranking applies to the command token, never to a command's own args.

    `/details c` completes that command's modes; a skill named /clean also
    starts with a `c` and must not be offered as one of them.
    """
    _slash_skill_fixtures(monkeypatch)

    items = _slash_completions("/details c")

    assert [item["text"] for item in items] == ["collapsed", "cycle"]


def test_config_set_reasoning_updates_live_session_and_agent(tmp_path, monkeypatch):
    monkeypatch.setattr(server, "_hermes_home", tmp_path)
    (tmp_path / "config.yaml").write_text("agent:\n  reasoning_effort: medium\n", encoding="utf-8")
    agent = types.SimpleNamespace(reasoning_config=None)
    server._sessions["sid"] = _session(agent=agent)

    resp_effort = server.handle_request(
        {
            "id": "1",
            "method": "config.set",
            "params": {
                "session_id": "sid",
                "key": "reasoning",
                "value": "low",
            },
        }
    )
    assert resp_effort["result"]["value"] == "low"
    assert agent.reasoning_config == {"enabled": True, "effort": "low"}
    assert server._sessions["sid"]["create_reasoning_override"] == {"enabled": True, "effort": "low"}
    assert server._load_cfg()["agent"]["reasoning_effort"] == "medium"

    resp_status = server.handle_request(
        {
            "id": "5",
            "method": "config.get",
            "params": {"session_id": "sid", "key": "reasoning"},
        }
    )
    assert resp_status["result"]["value"] == "low"

    resp_global_status = server.handle_request(
        {"id": "6", "method": "config.get", "params": {"key": "reasoning"}}
    )
    assert resp_global_status["result"]["value"] == "medium"

    del server._sessions["sid"]["create_reasoning_override"]
    agent.reasoning_config = {"enabled": True, "effort": "high"}
    resp_agent_status = server.handle_request(
        {
            "id": "7",
            "method": "config.get",
            "params": {"session_id": "sid", "key": "reasoning"},
        }
    )
    assert resp_agent_status["result"]["value"] == "high"

    resp_show = server.handle_request(
        {
            "id": "2",
            "method": "config.set",
            "params": {"session_id": "sid", "key": "reasoning", "value": "show"},
        }
    )
    assert resp_show["result"]["value"] == "show"
    assert server._sessions["sid"]["show_reasoning"] is True
    assert server._load_cfg()["display"]["sections"]["thinking"] == "expanded"

    resp_hide = server.handle_request(
        {
            "id": "3",
            "method": "config.set",
            "params": {"session_id": "sid", "key": "reasoning", "value": "hide"},
        }
    )
    assert resp_hide["result"]["value"] == "hide"
    assert server._sessions["sid"]["show_reasoning"] is False
    assert server._load_cfg()["display"]["sections"]["thinking"] == "hidden"

    # /reasoning full | clamp — parity with the classic CLI reasoning_full
    # toggle. In the TUI these map to the thinking section's expand/collapse
    # rendering (no fixed 10-line recap exists here).
    resp_full = server.handle_request(
        {
            "id": "4",
            "method": "config.set",
            "params": {"session_id": "sid", "key": "reasoning", "value": "full"},
        }
    )
    assert resp_full["result"]["value"] == "full"
    cfg_full = server._load_cfg()
    assert cfg_full["display"]["reasoning_full"] is True
    assert cfg_full["display"]["sections"]["thinking"] == "expanded"

    resp_clamp = server.handle_request(
        {
            "id": "5",
            "method": "config.set",
            "params": {"session_id": "sid", "key": "reasoning", "value": "clamp"},
        }
    )
    assert resp_clamp["result"]["value"] == "clamp"
    cfg_clamp = server._load_cfg()
    assert cfg_clamp["display"]["reasoning_full"] is False
    assert cfg_clamp["display"]["sections"]["thinking"] == "collapsed"


def test_config_set_reasoning_global_scope_clears_session_override(tmp_path, monkeypatch):
    monkeypatch.setattr(server, "_hermes_home", tmp_path)
    (tmp_path / "config.yaml").write_text("agent:\n  reasoning_effort: medium\n", encoding="utf-8")
    agent = types.SimpleNamespace(reasoning_config=None)
    server._sessions["sid"] = _session(agent=agent)
    server._sessions["sid"]["create_reasoning_override"] = {"enabled": True, "effort": "low"}

    resp = server.handle_request(
        {
            "id": "1",
            "method": "config.set",
            "params": {
                "session_id": "sid",
                "key": "reasoning",
                "value": "high",
                "scope": "global",
            },
        }
    )

    assert resp["result"]["value"] == "high"
    assert server._load_cfg()["agent"]["reasoning_effort"] == "high"
    assert "create_reasoning_override" not in server._sessions["sid"]

    status = server.handle_request(
        {"id": "2", "method": "config.get", "params": {"session_id": "sid", "key": "reasoning"}}
    )
    assert status["result"]["value"] == "high"


def test_config_set_verbose_updates_session_mode_and_agent(tmp_path, monkeypatch):
    monkeypatch.setattr(server, "_hermes_home", tmp_path)
    agent = types.SimpleNamespace(verbose_logging=False)
    server._sessions["sid"] = _session(agent=agent)

    resp = server.handle_request(
        {
            "id": "1",
            "method": "config.set",
            "params": {"session_id": "sid", "key": "verbose", "value": "cycle"},
        }
    )

    assert resp["result"]["value"] == "verbose"
    assert server._sessions["sid"]["tool_progress_mode"] == "verbose"
    assert agent.verbose_logging is True



def test_config_set_model_waits_for_lazy_agent_before_switch(monkeypatch):
    """A model switch against a lazy-created live session must apply to the
    real agent, not just process env, before the prompt is dispatched.
    """

    agent_ready = threading.Event()
    agent = types.SimpleNamespace(model="old/model", provider="old-provider")
    session = _session(agent=agent)
    session["agent"] = None
    session["agent_ready"] = agent_ready
    server._sessions["sid"] = session
    calls = []

    def fake_start(sid, target):
        calls.append(("start", sid))
        target["agent"] = agent
        agent_ready.set()

    def fake_apply(sid, target, raw, **kwargs):
        calls.append(("apply", sid, target.get("agent"), raw))
        if target.get("agent") is not agent:
            raise AssertionError("model switch ran before lazy agent was ready")
        return {"value": "new/model", "warning": ""}

    monkeypatch.setattr(server, "_start_agent_build", fake_start)
    monkeypatch.setattr(server, "_apply_model_switch", fake_apply)

    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "config.set",
                "params": {"session_id": "sid", "key": "model", "value": "new/model"},
            }
        )

        assert resp["result"]["value"] == "new/model"
        assert calls == [("start", "sid"), ("apply", "sid", agent, "new/model")]
    finally:
        server._sessions.pop("sid", None)

def test_config_set_model_uses_live_switch_path(monkeypatch):
    server._sessions["sid"] = _session()
    seen = {}

    def _fake_apply(sid, session, raw, **_kwargs):
        seen["args"] = (sid, session["session_key"], raw)
        return {"value": "new/model", "warning": "catalog unreachable"}

    monkeypatch.setattr(server, "_apply_model_switch", _fake_apply)
    resp = server.handle_request(
        {
            "id": "1",
            "method": "config.set",
            "params": {"session_id": "sid", "key": "model", "value": "new/model"},
        }
    )

    assert resp["result"]["value"] == "new/model"
    assert resp["result"]["warning"] == "catalog unreachable"
    assert seen["args"] == ("sid", "session-key", "new/model")


def test_config_set_model_requires_confirmation_for_expensive_model(monkeypatch):
    class _Agent:
        provider = "openrouter"
        model = "old/model"
        base_url = ""
        api_key = "sk-or"
        switched = False

        def switch_model(self, **_kwargs):
            self.switched = True

    result = types.SimpleNamespace(
        success=True,
        new_model="openai/gpt-5.5-pro",
        target_provider="openrouter",
        api_key="sk-or",
        base_url="https://openrouter.ai/api/v1",
        api_mode="chat_completions",
        warning_message="",
        model_info=types.SimpleNamespace(
            has_cost_data=lambda: True,
            cost_input=25.0,
            cost_output=125.0,
        ),
    )

    agent = _Agent()
    server._sessions["sid"] = _session(agent=agent)
    monkeypatch.setattr(
        "hermes_cli.model_switch.switch_model", lambda **_kwargs: result
    )
    monkeypatch.setattr(server, "_restart_slash_worker", lambda sid, session: None)
    monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None)

    resp = server.handle_request(
        {
            "id": "1",
            "method": "config.set",
            "params": {
                "session_id": "sid",
                "key": "model",
                "value": "openai/gpt-5.5-pro --provider openrouter",
            },
        }
    )

    assert resp["result"]["confirm_required"] is True
    assert "did you mean to select openai/gpt-5.5?" in resp["result"]["confirm_message"]
    assert agent.switched is False

    confirmed = server.handle_request(
        {
            "id": "2",
            "method": "config.set",
            "params": {
                "session_id": "sid",
                "key": "model",
                "value": "openai/gpt-5.5-pro --provider openrouter",
                "confirm_expensive_model": True,
            },
        }
    )

    assert confirmed["result"]["confirm_required"] is False
    assert confirmed["result"]["value"] == "openai/gpt-5.5-pro"
    assert agent.switched is True


def test_config_set_model_global_persists(monkeypatch):
    class _Agent:
        provider = "openrouter"
        model = "old/model"
        base_url = ""
        api_key = "sk-old"

        def switch_model(self, **kwargs):
            return None

    result = types.SimpleNamespace(
        success=True,
        new_model="anthropic/claude-sonnet-4.6",
        target_provider="anthropic",
        api_key="sk-new",
        base_url="https://api.anthropic.com",
        api_mode="anthropic_messages",
        warning_message="",
    )
    seen = {}
    saved_values = {}

    def _switch_model(**kwargs):
        seen.update(kwargs)
        return result

    server._sessions["sid"] = _session(agent=_Agent())
    monkeypatch.setattr("hermes_cli.model_switch.switch_model", _switch_model)
    monkeypatch.setattr(server, "_restart_slash_worker", lambda sid, session: None)
    monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None)
    # _persist_model_switch uses targeted save_config_value writes (#48305) so it
    # preserves sibling model.* keys instead of rewriting the whole block.
    monkeypatch.setattr("cli.save_config_value", lambda key, value: saved_values.__setitem__(key, value) or True)

    resp = server.handle_request(
        {
            "id": "1",
            "method": "config.set",
            "params": {
                "session_id": "sid",
                "key": "model",
                "value": "anthropic/claude-sonnet-4.6 --global",
            },
        }
    )

    assert resp["result"]["value"] == "anthropic/claude-sonnet-4.6"
    assert seen["is_global"] is True
    assert saved_values["model.default"] == "anthropic/claude-sonnet-4.6"
    assert saved_values["model.provider"] == "anthropic"
    assert saved_values["model.base_url"] == "https://api.anthropic.com"


def test_config_set_model_explicit_provider_skips_broken_default_init(monkeypatch):
    seen = {"build": 0, "wait": 0, "requested": []}
    session = _session()
    session["agent"] = None
    server._sessions["sid"] = session
    monkeypatch.setattr(server, "_load_cfg", lambda: {"model": {"default": "broken/model", "provider": "openrouter"}})
    monkeypatch.setattr(server, "_start_agent_build", lambda *_args: seen.__setitem__("build", seen["build"] + 1))
    monkeypatch.setattr(server, "_wait_agent", lambda *_args: seen.__setitem__("wait", seen["wait"] + 1))
    monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None)
    monkeypatch.setattr(server, "_restart_slash_worker", lambda *args, **kwargs: None)

    def fake_runtime_provider(*, requested=None, target_model=None, **_kwargs):
        seen["requested"].append((requested, target_model))
        if requested is None:
            raise RuntimeError("broken default provider should not be initialized")
        if requested == "anthropic":
            return {
                "api_key": "sk-anthropic",
                "api_mode": "anthropic_messages",
                "base_url": "https://api.anthropic.com",
            }
        raise RuntimeError(f"unexpected provider {requested}")

    monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", fake_runtime_provider)

    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "config.set",
                "params": {
                    "session_id": "sid",
                    "key": "model",
                    "value": "claude-sonnet-4.6 --provider anthropic",
                },
            }
        )

        assert resp["result"]["value"] == "claude-sonnet-4-6"
        assert seen["build"] == 0
        assert seen["wait"] == 0
        assert seen["requested"] == [("anthropic", "claude-sonnet-4.6")]
        assert session["model_override"]["provider"] == "anthropic"
        assert session["model_override"]["model"] == "claude-sonnet-4-6"
    finally:
        server._sessions.pop("sid", None)


def test_config_set_model_explicit_provider_surfaces_selected_provider_errors(monkeypatch):
    seen = {"build": 0, "wait": 0}
    session = _session()
    session["agent"] = None
    server._sessions["sid"] = session
    monkeypatch.setattr(server, "_load_cfg", lambda: {"model": {"default": "broken/model", "provider": "openrouter"}})
    monkeypatch.setattr(server, "_start_agent_build", lambda *_args: seen.__setitem__("build", seen["build"] + 1))
    monkeypatch.setattr(server, "_wait_agent", lambda *_args: seen.__setitem__("wait", seen["wait"] + 1))

    def fake_runtime_provider(*, requested=None, **_kwargs):
        if requested is None:
            raise RuntimeError("broken default provider should not be initialized")
        if requested == "anthropic":
            raise RuntimeError("missing anthropic API key")
        raise RuntimeError(f"unexpected provider {requested}")

    monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", fake_runtime_provider)

    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "config.set",
                "params": {
                    "session_id": "sid",
                    "key": "model",
                    "value": "claude-sonnet-4.6 --provider anthropic",
                },
            }
        )

        assert resp["error"]["code"] == 5001
        assert "anthropic" in resp["error"]["message"].lower()
        assert "missing anthropic api key" in resp["error"]["message"].lower()
        assert seen["build"] == 0
        assert seen["wait"] == 0
    finally:
        server._sessions.pop("sid", None)


def test_config_set_model_does_not_leak_inference_provider_env(monkeypatch):
    """A /model switch must NOT mutate process-global env vars. The desktop /
    dashboard tui_gateway backend hosts every same-profile session in one
    process; writing HERMES_INFERENCE_PROVIDER on a switch leaked the new
    provider into every other live session's next agent rebuild. The switch
    must instead record a per-session override and leave shared env untouched.

    (Was test_config_set_model_syncs_inference_provider_env, which asserted the
    leaky env-sync contract that caused the cross-session contamination bug.)
    """

    class _Agent:
        provider = "openrouter"
        model = "old/model"
        base_url = ""
        api_key = "sk-or"

        def switch_model(self, **_kwargs):
            return None

    result = types.SimpleNamespace(
        success=True,
        new_model="claude-sonnet-4.6",
        target_provider="anthropic",
        api_key="sk-ant",
        base_url="https://api.anthropic.com",
        api_mode="anthropic_messages",
        warning_message="",
    )

    session = _session(agent=_Agent())
    server._sessions["sid"] = session
    monkeypatch.setenv("HERMES_INFERENCE_PROVIDER", "openrouter")
    monkeypatch.setattr(
        "hermes_cli.model_switch.switch_model", lambda **_kwargs: result
    )
    monkeypatch.setattr(server, "_restart_slash_worker", lambda sid, session: None)
    monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None)

    try:
        server.handle_request(
            {
                "id": "1",
                "method": "config.set",
                "params": {
                    "session_id": "sid",
                    "key": "model",
                    "value": "claude-sonnet-4.6 --provider anthropic",
                },
            }
        )

        # Shared process env is UNCHANGED (the contamination vector is gone).
        assert os.environ["HERMES_INFERENCE_PROVIDER"] == "openrouter"
        # The switch was recorded as a per-session override instead.
        assert session["model_override"]["provider"] == "anthropic"
        assert session["model_override"]["model"] == "claude-sonnet-4.6"
    finally:
        server._sessions.clear()


def test_config_set_model_records_per_session_override_not_env(monkeypatch):
    """Regression for #16857 via the per-session override (not env vars):
    /model must record the user's explicit provider on the session so a later
    /new (which rebuilds via _make_agent honoring model_override) honours that
    choice — WITHOUT writing process-global env vars that would leak into
    sibling sessions.

    (Was test_config_set_model_syncs_tui_provider_unconditionally.)
    """

    class _Agent:
        provider = "openrouter"
        model = "old/model"
        base_url = ""
        api_key = "sk-or"

        def switch_model(self, **_kwargs):
            return None

    result = types.SimpleNamespace(
        success=True,
        new_model="deepseek-v4-pro",
        target_provider="custom:xuanji",
        api_key="sk-xuanji",
        base_url="https://xuanji.example/v1",
        api_mode="chat_completions",
        warning_message="",
    )

    session = _session(agent=_Agent())
    server._sessions["sid"] = session
    monkeypatch.delenv("HERMES_TUI_PROVIDER", raising=False)
    monkeypatch.delenv("HERMES_INFERENCE_PROVIDER", raising=False)
    monkeypatch.setattr(
        "hermes_cli.model_switch.switch_model", lambda **_kwargs: result
    )
    monkeypatch.setattr(server, "_restart_slash_worker", lambda sid, session: None)
    monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None)

    try:
        server.handle_request(
            {
                "id": "1",
                "method": "config.set",
                "params": {
                    "session_id": "sid",
                    "key": "model",
                    "value": "deepseek-v4-pro --provider custom:xuanji",
                },
            }
        )

        # No process-global env mutation.
        assert "HERMES_TUI_PROVIDER" not in os.environ
        assert "HERMES_INFERENCE_PROVIDER" not in os.environ
        # The user's explicit provider + resolved endpoint live on the session,
        # carried into the next /new rebuild by _make_agent.
        override = session["model_override"]
        assert override["provider"] == "custom:xuanji"
        assert override["model"] == "deepseek-v4-pro"
        assert override["base_url"] == "https://xuanji.example/v1"
        assert override["api_key"] == "sk-xuanji"
        assert override["api_mode"] == "chat_completions"
    finally:
        server._sessions.clear()


def test_config_set_model_switches_agent_without_touching_env(monkeypatch):
    """A /model switch mutates the target session's agent in place and records
    a per-session override; it does NOT write HERMES_MODEL / HERMES_TUI_PROVIDER
    etc. into the shared process environment.

    (Was test_config_set_model_syncs_tui_provider_env.)
    """

    class Agent:
        model = "gpt-5.3-codex"
        provider = "openai-codex"
        base_url = ""
        api_key = ""
        session_id = "sid"
        _cached_system_prompt = "Model: gpt-5.3-codex\nProvider: openai-codex"

        def switch_model(self, **kwargs):
            self.model = kwargs["new_model"]
            self.provider = kwargs["new_provider"]

        def _build_system_prompt(self, _system_message=None):
            return f"Model: {self.model}\nProvider: {self.provider}"

    class SessionDB:
        def __init__(self):
            self.model_config = None
            self.system_prompt = None
            self.messages = []

        def get_session(self, _session_id):
            return {"model_config": self.model_config}

        def update_session_meta(self, _session_id, model_config_json, _model=None):
            self.model_config = model_config_json

        def update_system_prompt(self, _session_id, system_prompt):
            self.system_prompt = system_prompt

        def append_message(self, session_id, role, content=None, **_kwargs):
            self.messages.append(
                {"session_id": session_id, "role": role, "content": content}
            )

    agent = Agent()
    db = SessionDB()
    agent._session_db = db
    session = _session(agent=agent)
    server._sessions["sid"] = session
    monkeypatch.setenv("HERMES_TUI_PROVIDER", "openai-codex")
    monkeypatch.delenv("HERMES_MODEL", raising=False)
    monkeypatch.delenv("HERMES_INFERENCE_MODEL", raising=False)
    monkeypatch.setattr(server, "_restart_slash_worker", lambda sid, session: None)
    monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None)

    def fake_switch_model(**kwargs):
        return types.SimpleNamespace(
            success=True,
            new_model="anthropic/claude-sonnet-4.6",
            target_provider="anthropic",
            api_key="key",
            base_url="https://api.anthropic.com",
            api_mode="anthropic_messages",
            warning_message="",
        )

    monkeypatch.setattr("hermes_cli.model_switch.switch_model", fake_switch_model)

    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "config.set",
                "params": {
                    "session_id": "sid",
                    "key": "model",
                    "value": "anthropic/claude-sonnet-4.6 --provider anthropic",
                },
            }
        )

        assert resp["result"]["value"] == "anthropic/claude-sonnet-4.6"
        # Agent switched in place...
        assert agent.model == "anthropic/claude-sonnet-4.6"
        assert agent.provider == "anthropic"
        # ...override recorded on the session...
        assert session["model_override"]["model"] == "anthropic/claude-sonnet-4.6"
        assert session["model_override"]["provider"] == "anthropic"
        # ...the persisted prompt snapshot tracks the new runtime identity too.
        # Without this, the next turn restored the old system prompt from the DB:
        # API calls went to the new model, but "what model are you?" still read
        # "Model: old/model" from the stored prompt.
        assert db.system_prompt == (
            "Model: anthropic/claude-sonnet-4.6\nProvider: anthropic"
        )
        assert agent._cached_system_prompt == db.system_prompt
        assert session["history"][-1]["role"] == "user"
        assert "changed to anthropic/claude-sonnet-4.6" in session["history"][-1]["content"]
        assert db.messages[-1] == {
            "session_id": "session-key",
            "role": "user",
            "content": session["history"][-1]["content"],
        }
        # ...and the shared process env was NOT touched.
        assert os.environ["HERMES_TUI_PROVIDER"] == "openai-codex"
        assert "HERMES_MODEL" not in os.environ
        assert "HERMES_INFERENCE_MODEL" not in os.environ
    finally:
        server._sessions.clear()


def test_config_set_model_once_keeps_env_and_records_restore(monkeypatch):
    class Agent:
        model = "old/model"
        provider = "openrouter"
        base_url = "https://openrouter.ai/api/v1"
        api_key = "sk-old"
        api_mode = "chat_completions"

        def switch_model(self, **kwargs):
            self.model = kwargs["new_model"]
            self.provider = kwargs["new_provider"]
            self.api_key = kwargs["api_key"]
            self.base_url = kwargs["base_url"]
            self.api_mode = kwargs["api_mode"]

    result = types.SimpleNamespace(
        success=True,
        new_model="claude-sonnet-4.6",
        target_provider="anthropic",
        api_key="sk-ant",
        base_url="https://api.anthropic.com",
        api_mode="anthropic_messages",
        warning_message="",
    )
    seen = {}
    agent = Agent()
    session = _session(agent=agent)
    server._sessions["sid"] = session
    monkeypatch.setenv("HERMES_INFERENCE_PROVIDER", "openrouter")
    monkeypatch.setenv("HERMES_MODEL", "old/model")
    monkeypatch.setattr(
        "hermes_cli.model_switch.switch_model",
        lambda **kwargs: seen.update(kwargs) or result,
    )
    monkeypatch.setattr(server, "_restart_slash_worker", lambda *args, **kwargs: None)
    monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None)

    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "config.set",
                "params": {
                    "session_id": "sid",
                    "key": "model",
                    "value": "claude-sonnet-4.6 --provider anthropic --once",
                },
            }
        )

        assert resp["result"]["scope"] == "once"
        assert seen["is_global"] is False
        assert agent.model == "claude-sonnet-4.6"
        assert session["one_turn_model_restore"]["model"] == "old/model"
        assert os.environ["HERMES_INFERENCE_PROVIDER"] == "openrouter"
        assert os.environ["HERMES_MODEL"] == "old/model"
    finally:
        server._sessions.clear()


def test_config_set_model_once_requires_live_session(monkeypatch):
    monkeypatch.setattr(
        "hermes_cli.model_switch.switch_model",
        lambda **_: (_ for _ in ()).throw(AssertionError("switch should not run")),
    )

    resp = server.handle_request(
        {
            "id": "1",
            "method": "config.set",
            "params": {
                "key": "model",
                "value": "claude-sonnet-4.6 --provider anthropic --once",
            },
        }
    )

    assert resp["error"]["code"] == 5001
    assert "/model --once requires a live session" in resp["error"]["message"]


def test_config_set_model_session_switch_clears_pending_once_restore(monkeypatch):
    class Agent:
        model = "temp/model"
        provider = "anthropic"
        base_url = "https://api.anthropic.com"
        api_key = "sk-temp"
        api_mode = "anthropic_messages"

        def switch_model(self, **kwargs):
            self.model = kwargs["new_model"]
            self.provider = kwargs["new_provider"]
            self.api_key = kwargs["api_key"]
            self.base_url = kwargs["base_url"]
            self.api_mode = kwargs["api_mode"]

    result = types.SimpleNamespace(
        success=True,
        new_model="new/model",
        target_provider="openrouter",
        api_key="sk-new",
        base_url="https://openrouter.ai/api/v1",
        api_mode="chat_completions",
        warning_message="",
    )
    session = _session(agent=Agent())
    session["one_turn_model_restore"] = {"model": "old/model"}
    server._sessions["sid"] = session
    monkeypatch.setattr("hermes_cli.model_switch.switch_model", lambda **_kwargs: result)
    monkeypatch.setattr(server, "_restart_slash_worker", lambda *args, **kwargs: None)
    monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None)

    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "config.set",
                "params": {
                    "session_id": "sid",
                    "key": "model",
                    "value": "new/model --provider openrouter --session",
                },
            }
        )

        assert resp["result"]["scope"] == "session"
        assert "one_turn_model_restore" not in session
    finally:
        server._sessions.clear()


def test_restore_agent_model_runtime_falls_back_to_switch_model():
    class Agent:
        model = "temp/model"
        provider = "anthropic"
        base_url = "https://api.anthropic.com"
        api_key = "sk-temp"
        api_mode = "anthropic_messages"

        def switch_model(self, **kwargs):
            self.model = kwargs["new_model"]
            self.provider = kwargs["new_provider"]
            self.api_key = kwargs["api_key"]
            self.base_url = kwargs["base_url"]
            self.api_mode = kwargs["api_mode"]

    agent = Agent()

    server._restore_agent_model_runtime(
        agent,
        {
            "model": "old/model",
            "provider": "openrouter",
            "api_key": "sk-old",
            "base_url": "https://openrouter.ai/api/v1",
            "api_mode": "chat_completions",
        },
    )

    assert agent.model == "old/model"
    assert agent.provider == "openrouter"
    assert agent.base_url == "https://openrouter.ai/api/v1"


def test_config_set_personality_rejects_unknown_name(monkeypatch):
    monkeypatch.setattr(
        server,
        "_available_personalities",
        lambda cfg=None: {"helpful": "You are helpful."},
    )
    resp = server.handle_request(
        {
            "id": "1",
            "method": "config.set",
            "params": {"key": "personality", "value": "bogus"},
        }
    )

    assert "error" in resp
    assert "Unknown personality" in resp["error"]["message"]


def test_config_set_personality_preserves_history_and_returns_info(monkeypatch):
    agent = types.SimpleNamespace(
        ephemeral_system_prompt=None, _cached_system_prompt="old"
    )
    session = _session(
        agent=agent,
        history=[{"role": "user", "text": "hi"}],
        history_version=4,
    )
    emits = []

    server._sessions["sid"] = session
    monkeypatch.setattr(
        server,
        "_available_personalities",
        lambda cfg=None: {"helpful": "You are helpful."},
    )
    monkeypatch.setattr(
        server, "_session_info", lambda agent, *a: {"model": getattr(agent, "model", "?")}
    )
    monkeypatch.setattr(server, "_emit", lambda *args: emits.append(args))
    monkeypatch.setattr(server, "_write_config_key", lambda path, value: None)

    resp = server.handle_request(
        {
            "id": "1",
            "method": "config.set",
            "params": {"session_id": "sid", "key": "personality", "value": "helpful"},
        }
    )

    assert resp["result"]["history_reset"] is False
    assert resp["result"]["info"] == {"model": "?"}
    # History is preserved with a pivot marker appended
    assert len(session["history"]) == 2
    assert session["history"][0] == {"role": "user", "text": "hi"}
    assert session["history"][1]["role"] == "user"
    assert "personality" in session["history"][1]["content"].lower()
    assert "You are helpful." in session["history"][1]["content"]
    assert session["history_version"] == 5
    # Agent's system prompt was updated in-place; cached prompt untouched
    assert agent.ephemeral_system_prompt == "You are helpful."
    assert agent._cached_system_prompt == "old"
    assert ("session.info", "sid", {"model": "?"}) in emits


def test_compress_session_history_passes_force():
    """_compress_session_history is manual-only (session.compress RPC, slash
    compress/compact, slash-worker mirror) — it must bypass the
    summary-failure cooldown via force=True, matching the CLI and gateway
    manual-compress handlers."""
    from unittest.mock import MagicMock

    agent = MagicMock()
    agent.context_compressor = None  # keep _get_usage on the simple path
    compressed = [{"role": "user", "content": "summary"}]
    agent._compress_context.return_value = (compressed, "")
    # Explicit non-lock-skip: MagicMock getattr would return a truthy mock.
    agent._compression_skipped_due_to_lock = False
    session = _session(
        agent=agent,
        history=[
            {"role": "user", "content": "one"},
            {"role": "assistant", "content": "two"},
            {"role": "user", "content": "three"},
            {"role": "assistant", "content": "four"},
        ],
    )

    removed, _usage = server._compress_session_history(session)

    assert removed == 3
    assert session["history"] == compressed
    assert agent._compress_context.call_args.kwargs.get("force") is True


def test_compress_session_history_works_when_auto_compaction_disabled():
    """compression.enabled: false disables *automatic* compaction only —
    manual /compress must still work on every TUI route (session.compress
    RPC, slash compress/compact, slash-worker mirror), all of which converge
    on _compress_session_history. Pin that the helper never gates on
    agent.compression_enabled (#64438)."""
    from unittest.mock import MagicMock

    agent = MagicMock()
    agent.compression_enabled = False
    agent.context_compressor = None  # keep _get_usage on the simple path
    compressed = [{"role": "user", "content": "summary"}]
    agent._compress_context.return_value = (compressed, "")
    # Explicit non-lock-skip: MagicMock getattr would return a truthy mock.
    agent._compression_skipped_due_to_lock = False
    session = _session(
        agent=agent,
        history=[
            {"role": "user", "content": "one"},
            {"role": "assistant", "content": "two"},
            {"role": "user", "content": "three"},
            {"role": "assistant", "content": "four"},
        ],
    )

    removed, _usage = server._compress_session_history(session)

    assert removed == 3
    assert session["history"] == compressed
    agent._compress_context.assert_called_once()
    assert agent._compress_context.call_args.kwargs.get("force") is True


def test_session_compress_uses_compress_helper(monkeypatch):
    agent = types.SimpleNamespace()
    server._sessions["sid"] = _session(agent=agent)

    monkeypatch.setattr(
        server,
        "_compress_session_history",
        lambda session, focus_topic=None, **_kw: (2, {"total": 42}),
    )
    monkeypatch.setattr(server, "_session_info", lambda _agent, *a: {"model": "x"})

    with patch("tui_gateway.server._emit") as emit:
        resp = server.handle_request(
            {"id": "1", "method": "session.compress", "params": {"session_id": "sid"}}
        )

    assert resp["result"]["removed"] == 2
    assert resp["result"]["usage"]["total"] == 42
    emit.assert_any_call("session.info", "sid", {"model": "x"})
    # Final status.update clears the pinned "compressing" indicator so the
    # status bar can revert to the neutral state when compaction finishes.
    emit.assert_any_call("status.update", "sid", {"kind": "status", "text": "ready"})


def test_session_compress_normalizes_messages_for_desktop_transcript(monkeypatch):
    history = [
        {
            "role": "assistant",
            "content": "",
            "tool_calls": [
                {
                    "id": "call-1",
                    "function": {"name": "read_file", "arguments": '{"path":"secret.txt"}'},
                }
            ],
        },
        {"role": "tool", "tool_call_id": "call-1", "content": "very sensitive tool output"},
    ]
    agent = types.SimpleNamespace()
    server._sessions["sid"] = _session(agent=agent, history=history)
    monkeypatch.setattr(server, "_compress_session_history", lambda *_args, **_kwargs: (0, {}))
    monkeypatch.setattr(server, "_session_info", lambda *_args: {})

    try:
        response = server.handle_request(
            {"id": "1", "method": "session.compress", "params": {"session_id": "sid"}}
        )
    finally:
        server._sessions.pop("sid", None)

    assert response["result"]["messages"] == server._history_to_messages(history)
    assert "very sensitive tool output" not in str(response["result"]["messages"])


def test_session_compress_returns_compute_host_history(monkeypatch):
    session = _session(agent=None, _compute_host_active=True)
    server._sessions["sid"] = session
    ack = {
        "type": "control.ack",
        "output": "Compressed 4 → 2 messages",
        "messages": [{"role": "user", "content": "compressed context"}],
        "session_info": {"usage": {"total": 42}},
    }
    monkeypatch.setattr(server, "_session_uses_compute_host", lambda _session: True)
    monkeypatch.setattr(server, "_send_compute_host_control", lambda *args, **kwargs: ack)

    try:
        resp = server.handle_request(
            {"id": "1", "method": "session.compress", "params": {"session_id": "sid"}}
        )
    finally:
        server._sessions.pop("sid", None)

    assert resp["result"] == {
        "status": "compressed",
        "turn_isolation": True,
        "host_ack": {key: value for key, value in ack.items() if key != "messages"},
        "info": {"usage": {"total": 42}},
        "messages": [{"role": "user", "text": "compressed context"}],
        "usage": {"total": 42},
    }


def test_session_compress_forwards_120_second_budget_to_compute_host(monkeypatch):
    session = _session(agent=None, _compute_host_active=True)
    server._sessions["sid"] = session
    calls = []

    def send_control(*args, **kwargs):
        calls.append((args, kwargs))
        return {
            "type": "control.ack",
            "result": {
                "status": "compressed",
                "messages": [],
                "removed": 0,
                "summary": {"headline": "Already compressed", "noop": True},
            },
        }

    monkeypatch.setattr(server, "_session_uses_compute_host", lambda _session: True)
    monkeypatch.setattr(server, "_send_compute_host_control", send_control)

    try:
        resp = server.handle_request(
            {"id": "1", "method": "session.compress", "params": {"session_id": "sid"}}
        )
    finally:
        server._sessions.pop("sid", None)

    assert resp["result"]["status"] == "compressed"
    assert calls == [
        (
            ("sid",),
            {
                "route_name": "session.compress",
                "command": "/compress",
                "wait": True,
                "timeout": 120.0,
            },
        )
    ]


def test_session_compress_preserves_compute_host_aborted_summary(monkeypatch):
    session = _session(agent=None, _compute_host_active=True)
    server._sessions["sid"] = session
    result = {
        "status": "aborted",
        "messages": [{"role": "user", "content": "preserved context"}],
        "removed": 0,
        "summary": {
            "aborted": True,
            "headline": "Compression aborted: 6 messages preserved",
            "note": "No compression provider is configured.",
        },
    }
    monkeypatch.setattr(server, "_session_uses_compute_host", lambda _session: True)
    monkeypatch.setattr(
        server,
        "_send_compute_host_control",
        lambda *args, **kwargs: {
            "type": "control.ack",
            "result": result,
            "session_key": "rotated-host-key",
            "history_version": 7,
            "message_count": 1,
            "session_info": {"model": "host-model"},
        },
    )

    try:
        resp = server.handle_request(
            {"id": "1", "method": "session.compress", "params": {"session_id": "sid"}}
        )
    finally:
        server._sessions.pop("sid", None)

    assert resp["result"] == {**result, "turn_isolation": True}
    assert session["session_key"] == "rotated-host-key"
    assert session["history_version"] == 7
    assert session["_metadata_message_count"] == 1
    assert session["_metadata_mirror"]["model"] == "host-model"


def test_session_compress_reports_aborted_summary_without_success(monkeypatch):
    compression_state = types.SimpleNamespace(
        _last_compress_aborted=True,
        _last_summary_fallback_used=False,
        _last_summary_error=(
            "Provider 'opencode-zen' is set in config.yaml but no API key was found."
        ),
    )
    agent = types.SimpleNamespace(
        context_compressor=compression_state,
        _cached_system_prompt="",
        tools=None,
    )
    history = [{"role": "user", "content": f"m{i}"} for i in range(6)]
    server._sessions["sid"] = _session(agent=agent, history=history)

    monkeypatch.setattr(
        server,
        "_compress_session_history",
        lambda session, focus_topic=None, **_kw: (0, {"total": 42}),
    )
    monkeypatch.setattr(server, "_session_info", lambda _agent, *a: {"model": "x"})

    try:
        with patch("tui_gateway.server._emit"):
            resp = server.handle_request(
                {
                    "id": "1",
                    "method": "session.compress",
                    "params": {"session_id": "sid"},
                }
            )

        result = resp["result"]
        assert result["status"] == "aborted"
        assert result["removed"] == 0
        assert result["summary"]["aborted"] is True
        assert result["summary"]["headline"] == (
            "Compression aborted: 6 messages preserved"
        )
        assert "no API key was found" in result["summary"]["note"]
        assert "Compressed:" not in result["summary"]["headline"]
    finally:
        server._sessions.pop("sid", None)


def test_session_compress_syncs_session_key_after_rotation(monkeypatch):
    """LCM notification follows the TUI's final session-key transition."""
    from agent.conversation_compression import (
        _queue_context_engine_compression_notification,
    )

    events = []
    agent = types.SimpleNamespace(
        session_id="rotated-id",
        context_compressor=types.SimpleNamespace(
            on_session_start=lambda *_args, **_kwargs: events.append("notify")
        ),
    )
    server._sessions["sid"] = _session(agent=agent)
    server._sessions["sid"]["session_key"] = "old-key"
    server._sessions["sid"]["pending_title"] = "stale title"

    def _compress(session, focus_topic=None, **_kw):
        _queue_context_engine_compression_notification(
            session["agent"],
            new_session_id="rotated-id",
            old_session_id="old-key",
        )
        return 2, {"total": 42}

    monkeypatch.setattr(server, "_compress_session_history", _compress)
    monkeypatch.setattr(server, "_session_info", lambda _agent, *a: {"model": "x"})
    restart_calls = []
    monkeypatch.setattr(
        server,
        "_restart_slash_worker",
        lambda sid, s: (restart_calls.append(s), events.append("sync")),
    )

    try:
        with patch("tui_gateway.server._emit"):
            server.handle_request(
                {
                    "id": "1",
                    "method": "session.compress",
                    "params": {"session_id": "sid"},
                }
            )

        assert server._sessions["sid"]["session_key"] == "rotated-id"
        assert server._sessions["sid"]["pending_title"] is None
        assert len(restart_calls) == 1
        assert events == ["sync", "notify"]
    finally:
        server._sessions.pop("sid", None)


def test_session_compress_sync_failure_discards_lcm_notification(monkeypatch):
    from agent.conversation_compression import (
        _queue_context_engine_compression_notification,
    )

    events = []
    agent = types.SimpleNamespace(
        session_id="rotated-id",
        context_compressor=types.SimpleNamespace(
            on_session_start=lambda *_args, **_kwargs: events.append("notify")
        ),
    )
    server._sessions["sid"] = _session(agent=agent)
    server._sessions["sid"]["session_key"] = "old-key"

    def _compress(session, focus_topic=None, **_kw):
        _queue_context_engine_compression_notification(
            session["agent"],
            new_session_id="rotated-id",
            old_session_id="old-key",
        )
        return 2, {"total": 42}

    monkeypatch.setattr(server, "_compress_session_history", _compress)
    monkeypatch.setattr(
        server,
        "_session_info",
        lambda *_args: (_ for _ in ()).throw(RuntimeError("finalization failed")),
    )

    try:
        with patch("tui_gateway.server._emit"):
            resp = server.handle_request(
                {
                    "id": "1",
                    "method": "session.compress",
                    "params": {"session_id": "sid"},
                }
            )
        assert resp["error"]["code"] == 5005
        assert events == []
    finally:
        server._sessions.pop("sid", None)


def test_slash_exec_r7_read_commands_use_metadata_mirror_flag_on(monkeypatch):
    class _ExplodingWorker:
        def __init__(self, *args, **kwargs):
            raise AssertionError("slash worker should not run for isolated read commands")

    history_from_db = [
        {"role": "user", "content": "live question from state db"},
        {"role": "assistant", "content": "live answer from state db"},
    ]

    class _DB:
        def get_session(self, key):
            assert key == "session-key"
            return {
                "title": "Live title",
                "started_at": 1_700_000_000,
                "updated_at": 1_700_000_060,
                "pinned": True,
            }

        def get_resume_conversations(self, session_id):
            return (
                self.get_messages_as_conversation(session_id, repair_alternation=True),
                self.get_messages_as_conversation(session_id, include_ancestors=True),
            )

        def get_ancestor_display_prefix(self, _sid):
            return []

        def get_messages_as_conversation(self, key, include_ancestors=True, repair_alternation=False, **_kwargs):
            assert key == "session-key"
            assert include_ancestors is True
            return list(history_from_db)

    server._sessions["sid"] = _session(
        agent=None,
        history=[{"role": "user", "content": "stale parent mirror"}],
        _compute_host_active=True,
        _metadata_mirror={
            "model": "host-model",
            "provider": "host-provider",
            "system_prompt": "host system prompt",
            "tools": {"core": ["terminal", "read_file"]},
            "usage": {
                "model": "host-model",
                "input": 100,
                "output": 20,
                "reasoning": 5,
                "prompt": 120,
                "completion": 20,
                "total": 140,
                "calls": 2,
                "context_used": 80,
                "context_max": 1000,
                "context_percent": 8,
                "compressions": 1,
            },
        },
        _metadata_message_count=2,
    )
    monkeypatch.setattr(server, "_SlashWorker", _ExplodingWorker)
    monkeypatch.setattr(server, "_get_db", lambda: _DB())
    monkeypatch.setattr(server, "_load_cfg", lambda: {"dashboard": {"turn_isolation": True}})

    cases = {
        "usage": "Total tokens:                 140",
        "history": "live question from state db",
        "prompt": "host system prompt",
        "status": "Tokens: 140",
        "context": "Context usage: ~80 / 1,000 tokens",
        "tools": "terminal",
        "help": "/status",
    }

    try:
        for command, expected in cases.items():
            resp = server.handle_request(
                {
                    "id": command,
                    "method": "slash.exec",
                    "params": {"command": command, "session_id": "sid"},
                }
            )
            assert "result" in resp, (command, resp)
            assert expected in resp["result"]["output"]
            assert "stale parent mirror" not in resp["result"]["output"]
            assert "(._.)" not in resp["result"]["output"]
    finally:
        server._sessions.pop("sid", None)


def test_prompt_submit_sets_approval_session_key(monkeypatch):
    from tools.approval import get_current_session_key

    captured = {}

    class _Agent:
        def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
            captured["session_key"] = get_current_session_key(default="")
            return {
                "final_response": "ok",
                "messages": [{"role": "assistant", "content": "ok"}],
            }

    class _ImmediateThread:
        def __init__(self, target=None, daemon=None):
            self._target = target

        def start(self):
            self._target()

    server._sessions["sid"] = _session(agent=_Agent())
    monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
    monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None)
    monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None)
    monkeypatch.setattr(server, "render_message", lambda raw, cols: None)

    resp = server.handle_request(
        {
            "id": "1",
            "method": "prompt.submit",
            "params": {"session_id": "sid", "text": "ping"},
        }
    )

    assert resp["result"]["status"] == "streaming"
    assert captured["session_key"] == "session-key"


def test_prompt_submit_expands_context_refs(monkeypatch):
    captured = {}

    class _Agent:
        model = "test/model"
        base_url = ""
        api_key = ""

        def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
            captured["prompt"] = prompt
            return {
                "final_response": "ok",
                "messages": [{"role": "assistant", "content": "ok"}],
            }

    class _ImmediateThread:
        def __init__(self, target=None, daemon=None):
            self._target = target

        def start(self):
            self._target()

    fake_ctx = types.ModuleType("agent.context_references")
    fake_ctx.preprocess_context_references = (
        lambda message, **kwargs: types.SimpleNamespace(
            blocked=False,
            message="expanded prompt",
            warnings=[],
            references=[],
            injected_tokens=0,
        )
    )
    fake_meta = types.ModuleType("agent.model_metadata")
    fake_meta.get_model_context_length = lambda *args, **kwargs: 100000

    server._sessions["sid"] = _session(agent=_Agent())
    monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
    monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None)
    monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None)
    monkeypatch.setattr(server, "render_message", lambda raw, cols: None)
    monkeypatch.setitem(sys.modules, "agent.context_references", fake_ctx)
    monkeypatch.setitem(sys.modules, "agent.model_metadata", fake_meta)

    server.handle_request(
        {
            "id": "1",
            "method": "prompt.submit",
            "params": {"session_id": "sid", "text": "@diff"},
        }
    )

    assert captured["prompt"] == "expanded prompt"


def test_image_attach_appends_local_image(monkeypatch):
    fake_cli = types.ModuleType("cli")
    fake_cli._IMAGE_EXTENSIONS = {".png"}
    fake_cli._detect_file_drop = lambda raw: {
        "path": Path("/tmp/cat.png"),
        "is_image": True,
        "remainder": "",
    }
    fake_cli._split_path_input = lambda raw: (raw, "")
    fake_cli._resolve_attachment_path = lambda raw: Path("/tmp/cat.png")

    server._sessions["sid"] = _session()
    monkeypatch.setitem(sys.modules, "cli", fake_cli)

    resp = server.handle_request(
        {
            "id": "1",
            "method": "image.attach",
            "params": {"session_id": "sid", "path": "/tmp/cat.png"},
        }
    )

    assert resp["result"]["attached"] is True
    assert resp["result"]["name"] == "cat.png"
    assert len(server._sessions["sid"]["attached_images"]) == 1


def test_image_attach_accepts_unquoted_screenshot_path_with_spaces(monkeypatch):
    screenshot = Path("/tmp/Screenshot 2026-04-21 at 1.04.43 PM.png")
    fake_cli = types.ModuleType("cli")
    fake_cli._IMAGE_EXTENSIONS = {".png"}
    fake_cli._detect_file_drop = lambda raw: {
        "path": screenshot,
        "is_image": True,
        "remainder": "",
    }
    fake_cli._split_path_input = lambda raw: (
        "/tmp/Screenshot",
        "2026-04-21 at 1.04.43 PM.png",
    )
    fake_cli._resolve_attachment_path = lambda raw: None

    server._sessions["sid"] = _session()
    monkeypatch.setitem(sys.modules, "cli", fake_cli)

    resp = server.handle_request(
        {
            "id": "1",
            "method": "image.attach",
            "params": {"session_id": "sid", "path": str(screenshot)},
        }
    )

    assert resp["result"]["attached"] is True
    assert resp["result"]["path"] == str(screenshot)
    assert resp["result"]["remainder"] == ""
    assert len(server._sessions["sid"]["attached_images"]) == 1


def test_file_attach_uploads_remote_file_into_session_workspace(monkeypatch, tmp_path):
    """Remote case: client path doesn't exist on gateway → decode data_url bytes."""
    workspace = tmp_path / "workspace"
    workspace.mkdir()
    fake_cli = types.ModuleType("cli")
    fake_cli._detect_file_drop = lambda raw: None
    fake_cli._split_path_input = lambda raw: (raw, "")
    fake_cli._resolve_attachment_path = lambda raw: None

    server._sessions["sid"] = _session(cwd=str(workspace))
    monkeypatch.setitem(sys.modules, "cli", fake_cli)

    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "file.attach",
                "params": {
                    "session_id": "sid",
                    "path": "/Users/alice/Downloads/report.txt",
                    "name": "report.txt",
                    "data_url": "data:text/plain;base64,aGVsbG8gd29ybGQ=",
                },
            }
        )

        stored = workspace / ".hermes" / "desktop-attachments" / "report.txt"
        assert resp["result"]["attached"] is True
        assert resp["result"]["uploaded"] is True
        assert resp["result"]["path"] == str(stored)
        assert resp["result"]["ref_text"] == "@file:.hermes/desktop-attachments/report.txt"
        assert stored.read_text(encoding="utf-8") == "hello world"
    finally:
        server._sessions.pop("sid", None)


def test_file_attach_copies_gateway_visible_file_outside_workspace(monkeypatch, tmp_path):
    """Local case: gateway can see the file but it's outside the workspace → copy in."""
    workspace = tmp_path / "workspace"
    workspace.mkdir()
    source = tmp_path / "outside.txt"
    source.write_text("outside workspace", encoding="utf-8")
    fake_cli = types.ModuleType("cli")
    fake_cli._detect_file_drop = lambda raw: None
    fake_cli._split_path_input = lambda raw: (raw, "")
    fake_cli._resolve_attachment_path = lambda raw: source

    server._sessions["sid"] = _session(cwd=str(workspace))
    monkeypatch.setitem(sys.modules, "cli", fake_cli)

    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "file.attach",
                "params": {"session_id": "sid", "path": str(source)},
            }
        )

        stored = workspace / ".hermes" / "desktop-attachments" / "outside.txt"
        assert resp["result"]["attached"] is True
        assert resp["result"]["uploaded"] is True
        assert resp["result"]["ref_text"] == "@file:.hermes/desktop-attachments/outside.txt"
        assert stored.read_text(encoding="utf-8") == "outside workspace"
    finally:
        server._sessions.pop("sid", None)


def test_file_attach_uses_in_workspace_file_without_copying(monkeypatch, tmp_path):
    """Local case: file already inside the workspace → ref it directly, no copy."""
    workspace = tmp_path / "workspace"
    (workspace / "data").mkdir(parents=True)
    source = workspace / "data" / "exam.csv"
    source.write_text("a,b,c\n1,2,3\n", encoding="utf-8")
    fake_cli = types.ModuleType("cli")
    fake_cli._detect_file_drop = lambda raw: None
    fake_cli._split_path_input = lambda raw: (raw, "")
    fake_cli._resolve_attachment_path = lambda raw: source

    server._sessions["sid"] = _session(cwd=str(workspace))
    monkeypatch.setitem(sys.modules, "cli", fake_cli)

    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "file.attach",
                "params": {"session_id": "sid", "path": str(source)},
            }
        )

        assert resp["result"]["attached"] is True
        assert resp["result"]["uploaded"] is False
        assert resp["result"]["ref_text"] == "@file:data/exam.csv"
        # No copy: nothing staged under desktop-attachments.
        assert not (workspace / ".hermes" / "desktop-attachments").exists()
    finally:
        server._sessions.pop("sid", None)


def test_file_attach_errors_when_unresolvable_and_no_bytes(monkeypatch, tmp_path):
    """Remote path not on gateway and no data_url → actionable error, not a stage."""
    workspace = tmp_path / "workspace"
    workspace.mkdir()
    fake_cli = types.ModuleType("cli")
    fake_cli._detect_file_drop = lambda raw: None
    fake_cli._split_path_input = lambda raw: (raw, "")
    fake_cli._resolve_attachment_path = lambda raw: None

    server._sessions["sid"] = _session(cwd=str(workspace))
    monkeypatch.setitem(sys.modules, "cli", fake_cli)

    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "file.attach",
                "params": {"session_id": "sid", "path": "/Users/alice/missing.txt"},
            }
        )

        assert "error" in resp
        assert "no data_url" in resp["error"]["message"]
    finally:
        server._sessions.pop("sid", None)


def test_file_attach_quotes_ref_with_spaces(monkeypatch, tmp_path):
    """Staged names with spaces must be backtick-quoted so the @file: ref parses."""
    workspace = tmp_path / "workspace"
    workspace.mkdir()
    fake_cli = types.ModuleType("cli")
    fake_cli._detect_file_drop = lambda raw: None
    fake_cli._split_path_input = lambda raw: (raw, "")
    fake_cli._resolve_attachment_path = lambda raw: None

    server._sessions["sid"] = _session(cwd=str(workspace))
    monkeypatch.setitem(sys.modules, "cli", fake_cli)

    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "file.attach",
                "params": {
                    "session_id": "sid",
                    "name": "my exam schedule.csv",
                    "data_url": "data:text/csv;base64,YSxiCg==",
                },
            }
        )

        assert resp["result"]["attached"] is True
        assert resp["result"]["ref_text"] == "@file:`.hermes/desktop-attachments/my exam schedule.csv`"
    finally:
        server._sessions.pop("sid", None)


def test_commands_catalog_surfaces_quick_commands(monkeypatch):
    monkeypatch.setattr(
        server,
        "_load_cfg",
        lambda: {
            "quick_commands": {
                "build": {"type": "exec", "command": "npm run build"},
                "git": {"type": "alias", "target": "/shell git"},
                "notes": {
                    "type": "exec",
                    "command": "cat NOTES.md",
                    "description": "Open design notes",
                },
            }
        },
    )

    resp = server.handle_request(
        {"id": "1", "method": "commands.catalog", "params": {}}
    )

    pairs = dict(resp["result"]["pairs"])
    assert "npm run build" in pairs["/build"]
    assert pairs["/git"].startswith("alias →")
    assert pairs["/notes"] == "Open design notes"

    user_cat = next(
        c for c in resp["result"]["categories"] if c["name"] == "User commands"
    )
    user_pairs = dict(user_cat["pairs"])
    assert set(user_pairs) == {"/build", "/git", "/notes"}

    assert resp["result"]["canon"]["/build"] == "/build"
    assert resp["result"]["canon"]["/notes"] == "/notes"


def test_commands_catalog_ranks_skill_commands_by_recorded_usage(monkeypatch):
    """Skill entries carry the usage + origin the `/` menu ranks on.

    Without it the menu is alphabetical, so a bundled skill the user has never
    opened outranks the one they invoke daily.
    """
    monkeypatch.setattr(
        server,
        "_skill_usage_lookup",
        lambda: (
            lambda name: {"research": 60, "work": 172}.get(name, 0),
            lambda name: "bundled" if name == "research-paper-writing" else "local",
        ),
    )
    monkeypatch.setattr(
        "agent.skill_commands.scan_skill_commands",
        lambda: {
            "/research": {"name": "research", "description": "Look it up"},
            "/research-paper-writing": {
                "name": "research-paper-writing",
                "description": "Write a paper",
            },
            "/work": {"name": "work", "description": "Fresh worktree"},
        },
    )

    resp = server.handle_request(
        {"id": "1", "method": "commands.catalog", "params": {}}
    )

    skills = resp["result"]["skills"]
    assert skills["/work"] == {"usage": 172, "origin": "local"}
    assert skills["/research"] == {"usage": 60, "origin": "local"}
    assert skills["/research-paper-writing"] == {"usage": 0, "origin": "bundled"}

    # Every advertised skill command is rankable — a missing entry silently
    # sorts that skill to the bottom of the menu.
    advertised = {name for name, _ in resp["result"]["pairs"]}
    assert set(skills) <= advertised
    assert resp["result"]["skill_count"] == len(skills)


def test_commands_catalog_survives_an_unreadable_usage_sidecar(monkeypatch):
    """A broken/absent .usage.json degrades to no ranking, never a broken menu."""
    monkeypatch.setattr(
        "tools.skill_usage.load_usage",
        lambda: (_ for _ in ()).throw(OSError("sidecar is gone")),
    )

    resp = server.handle_request(
        {"id": "1", "method": "commands.catalog", "params": {}}
    )

    assert "error" not in resp
    assert all(
        entry == {"usage": 0, "origin": "local"}
        for entry in resp["result"]["skills"].values()
    )


def test_commands_catalog_includes_tui_mouse_command():
    resp = server.handle_request(
        {"id": "1", "method": "commands.catalog", "params": {}}
    )

    pairs = dict(resp["result"]["pairs"])
    tui_cat = next(c for c in resp["result"]["categories"] if c["name"] == "TUI")
    tui_pairs = dict(tui_cat["pairs"])

    assert "/mouse" in pairs
    assert "/mouse" in tui_pairs


def test_commands_catalog_has_no_duplicate_or_alias_colliding_names():
    """No command may be advertised twice, and no advertised command may
    shadow an alias of a different command (e.g. the historical /compact
    collision where the registry aliased compact -> compress while the TUI
    also registered its own /compact display toggle; see #57133)."""
    resp = server.handle_request(
        {"id": "1", "method": "commands.catalog", "params": {}}
    )

    names = [name for name, _ in resp["result"]["pairs"]]
    dupes = {n for n in names if names.count(n) > 1}
    assert not dupes, f"duplicate commands advertised in catalog: {sorted(dupes)}"

    canon = resp["result"]["canon"]
    colliding = {
        name
        for name in names
        if canon.get(name.lower(), name) != name
    }
    assert not colliding, (
        f"catalog commands shadow aliases of other commands: {sorted(colliding)}"
    )


def test_commands_catalog_filters_gateway_only_commands_and_keeps_status_visible():
    resp = server.handle_request(
        {"id": "1", "method": "commands.catalog", "params": {}}
    )

    pairs = dict(resp["result"]["pairs"])
    canon = resp["result"]["canon"]

    assert "/status" in pairs
    assert canon["/status"] == "/status"
    assert "/approvals" in pairs
    assert resp["result"]["sub"]["/approvals"] == ["manual", "smart", "off"]

    assert "/topic" not in pairs
    assert "/approve" not in pairs
    assert "/deny" not in pairs
    assert "/sethome" not in pairs

    assert "/update" in pairs
    assert canon["/update"] == "/update"

    assert "/topic" not in canon
    assert "/approve" not in canon
    assert "/deny" not in canon
    assert "/set-home" not in canon


def test_session_status_reads_live_gateway_agent(monkeypatch):
    agent = types.SimpleNamespace(
        model="live-model",
        provider="live-provider",
        session_total_tokens=1234,
    )
    server._sessions["sid"] = _session(agent=agent, running=True)

    class _DB:
        def get_session(self, key):
            assert key == "session-key"
            return {
                "title": "Live TUI",
                "started_at": 1_700_000_000,
                "updated_at": 1_700_000_060,
            }

    monkeypatch.setattr(server, "_get_db", lambda: _DB())
    try:
        resp = server.handle_request(
            {"id": "1", "method": "session.status", "params": {"session_id": "sid"}}
        )
    finally:
        server._sessions.pop("sid", None)

    out = resp["result"]["output"]
    assert "Hermes TUI Status" in out
    assert "Session ID: session-key" in out
    assert "Title: Live TUI" in out
    assert "Model: live-model (live-provider)" in out
    assert "Tokens: 1,234" in out
    assert "Agent Running: Yes" in out


def test_skills_reload_runs_in_gateway_process(monkeypatch):
    import agent.skill_commands as skill_commands

    called = {}
    monkeypatch.setattr(
        skill_commands,
        "reload_skills",
        lambda: called.setdefault(
            "result",
            {
                "added": [{"name": "new-skill", "description": "demo"}],
                "removed": [],
                "total": 42,
            },
        ),
    )

    resp = server.handle_request({"id": "1", "method": "skills.reload", "params": {}})

    assert called["result"]["total"] == 42
    assert "new-skill" in resp["result"]["output"]
    assert "42 skill(s) available" in resp["result"]["output"]


def test_snapshot_restore_is_blocked_from_tui_worker():
    server._sessions["sid"] = _session()
    try:
        worker_resp = server.handle_request(
            {
                "id": "1",
                "method": "slash.exec",
                "params": {"command": "snapshot restore latest", "session_id": "sid"},
            }
        )
        dispatch_resp = server.handle_request(
            {
                "id": "2",
                "method": "command.dispatch",
                "params": {
                    "arg": "restore latest",
                    "name": "snapshot",
                    "session_id": "sid",
                },
            }
        )
    finally:
        server._sessions.pop("sid", None)

    assert worker_resp["error"]["code"] == 4018
    assert (
        "snapshot restore mutates live config/state" in worker_resp["error"]["message"]
    )
    assert dispatch_resp["result"]["type"] == "exec"
    assert (
        "/snapshot restore is blocked in the TUI" in dispatch_resp["result"]["output"]
    )


def test_command_dispatch_exec_nonzero_surfaces_error(monkeypatch):
    monkeypatch.setattr(
        server,
        "_load_cfg",
        lambda: {"quick_commands": {"boom": {"type": "exec", "command": "boom"}}},
    )
    monkeypatch.setattr(
        server.subprocess,
        "run",
        lambda *args, **kwargs: types.SimpleNamespace(
            returncode=1, stdout="", stderr="failed"
        ),
    )

    resp = server.handle_request(
        {"id": "1", "method": "command.dispatch", "params": {"name": "boom"}}
    )

    assert "error" in resp
    assert "failed" in resp["error"]["message"]


def test_plugins_list_surfaces_loader_error(monkeypatch):
    with patch("hermes_cli.plugins.get_plugin_manager", side_effect=Exception("boom")):
        resp = server.handle_request(
            {"id": "1", "method": "plugins.list", "params": {}}
        )

    assert "error" in resp
    assert "boom" in resp["error"]["message"]


def test_complete_slash_surfaces_completer_error(monkeypatch):
    with patch(
        "hermes_cli.commands.SlashCommandCompleter",
        side_effect=Exception("no completer"),
    ):
        resp = server.handle_request(
            {"id": "1", "method": "complete.slash", "params": {"text": "/mo"}}
        )

    assert "error" in resp
    assert "no completer" in resp["error"]["message"]


def test_input_detect_drop_attaches_image(monkeypatch):
    fake_cli = types.ModuleType("cli")
    fake_cli._detect_file_drop = lambda raw: {
        "path": Path("/tmp/cat.png"),
        "is_image": True,
        "remainder": "",
    }

    server._sessions["sid"] = _session()
    monkeypatch.setitem(sys.modules, "cli", fake_cli)

    resp = server.handle_request(
        {
            "id": "1",
            "method": "input.detect_drop",
            "params": {"session_id": "sid", "text": "/tmp/cat.png"},
        }
    )

    assert resp["result"]["matched"] is True
    assert resp["result"]["is_image"] is True
    assert resp["result"]["text"] == "[User attached image: cat.png]"


def test_input_detect_drop_path_with_spaces(tmp_path):
    """input.detect_drop correctly handles image paths containing spaces."""
    # Create a minimal PNG file with a space in its name
    img = tmp_path / "screenshot with spaces.png"
    img.write_bytes(b"\x89PNG\r\n\x1a\n")  # valid PNG header

    server._sessions["sid"] = _session()

    resp = server.handle_request(
        {
            "id": "2",
            "method": "input.detect_drop",
            "params": {"session_id": "sid", "text": str(img)},
        }
    )

    assert resp["result"]["matched"] is True
    assert resp["result"]["is_image"] is True
    assert resp["result"]["path"] == str(img)
    assert resp["result"]["text"] == f"[User attached image: {img.name}]"
    # Verify attachment was recorded in the session
    assert len(server._sessions["sid"]["attached_images"]) == 1
    assert server._sessions["sid"]["attached_images"][0] == str(img)


def test_input_detect_drop_path_with_spaces_and_remainder(tmp_path):
    """input.detect_drop splits remainder when path contains spaces."""
    img = tmp_path / "photo with space.jpg"
    img.write_bytes(b"\xff\xd8\xff" + b"fakejpeg")  # minimal-ish JPEG header

    server._sessions["sid"] = _session()

    user_input = f"{img} describe this image"
    resp = server.handle_request(
        {
            "id": "3",
            "method": "input.detect_drop",
            "params": {"session_id": "sid", "text": user_input},
        }
    )

    assert resp["result"]["matched"] is True
    assert resp["result"]["is_image"] is True
    assert resp["result"]["path"] == str(img)
    # Remainder becomes the text sent to the model
    assert resp["result"]["text"] == "describe this image"
    assert server._sessions["sid"]["attached_images"][0] == str(img)


def test_rollback_restore_resolves_number_and_file_path():
    calls = {}

    class _Mgr:
        enabled = True

        def list_checkpoints(self, cwd):
            return [{"hash": "aaa111"}, {"hash": "bbb222"}]

        def restore(self, cwd, target, file_path=None):
            calls["args"] = (cwd, target, file_path)
            return {"success": True, "message": "done"}

    server._sessions["sid"] = _session(
        agent=types.SimpleNamespace(_checkpoint_mgr=_Mgr()), history=[]
    )
    resp = server.handle_request(
        {
            "id": "1",
            "method": "rollback.restore",
            "params": {"session_id": "sid", "hash": "2", "file_path": "src/app.tsx"},
        }
    )

    assert resp["result"]["success"] is True
    assert calls["args"][1] == "bbb222"
    assert calls["args"][2] == "src/app.tsx"


def test_rollback_restore_truncates_from_real_user_turn_not_marker(monkeypatch):
    """rollback.restore must truncate from the last *real* user turn,
    not a display_kind timeline marker (same bug class as /undo).
    """
    from pathlib import Path as _Path

    class _Mgr:
        enabled = True

        def list_checkpoints(self, cwd):
            return [{"hash": "abc123"}]

        def restore(self, cwd, target, file_path=None):
            return {"success": True, "message": "restored"}

    history = [
        {"role": "user", "content": "first question"},
        {"role": "assistant", "content": "first answer"},
        {"role": "user", "content": "second question"},
        {"role": "assistant", "content": "second answer"},
        {
            "role": "user",
            "content": "background agent finished",
            "display_kind": "async_delegation_complete",
        },
    ]
    server._sessions["sid"] = _session(
        agent=types.SimpleNamespace(_checkpoint_mgr=_Mgr()),
        history=list(history),
    )
    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "rollback.restore",
                "params": {"session_id": "sid", "hash": "abc123"},
            }
        )

        assert resp["result"]["success"] is True
        assert resp["result"]["history_removed"] == 3  # q2 + a2 + marker
        # Only first exchange remains
        remaining = server._sessions["sid"]["history"]
        assert [m["content"] for m in remaining] == ["first question", "first answer"]
    finally:
        server._sessions.pop("sid", None)


def test_rollback_restore_skips_legacy_compaction_handoff(monkeypatch):
    """rollback.restore must not truncate from a legacy standalone compaction
    handoff — a durable role=user row persisted pre-#80622 with NO
    display_kind. Same bug class as the display_kind marker above, caught
    only by the is_user_originated_turn predicate.
    """
    from agent.context_compressor import (
        COMPRESSED_SUMMARY_METADATA_KEY,
        HISTORICAL_TASK_HEADING,
        SUMMARY_PREFIX,
        _SUMMARY_END_MARKER,
    )

    class _Mgr:
        enabled = True

        def list_checkpoints(self, cwd):
            return [{"hash": "abc123"}]

        def restore(self, cwd, target, file_path=None):
            return {"success": True, "message": "restored"}

    handoff = {
        "role": "user",
        "content": (
            f"{SUMMARY_PREFIX}\n{HISTORICAL_TASK_HEADING}\n"
            f"User asked: 'old task'\n\n{_SUMMARY_END_MARKER}"
        ),
        COMPRESSED_SUMMARY_METADATA_KEY: True,
        # NOTE: no display_kind — the legacy-persistence shape (#80622).
    }
    history = [
        {"role": "user", "content": "first question"},
        {"role": "assistant", "content": "first answer"},
        {"role": "user", "content": "second question"},
        {"role": "assistant", "content": "second answer"},
        handoff,
    ]
    server._sessions["sid"] = _session(
        agent=types.SimpleNamespace(_checkpoint_mgr=_Mgr()),
        history=list(history),
    )
    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "rollback.restore",
                "params": {"session_id": "sid", "hash": "abc123"},
            }
        )

        assert resp["result"]["success"] is True
        # Truncation lands on "second question", not the handoff row.
        assert resp["result"]["history_removed"] == 3  # q2 + a2 + handoff
        remaining = server._sessions["sid"]["history"]
        assert [m["content"] for m in remaining] == ["first question", "first answer"]
    finally:
        server._sessions.pop("sid", None)


# ── session.steer ────────────────────────────────────────────────────


def test_session_steer_calls_agent_steer_when_agent_supports_it():
    """The TUI RPC method must call agent.steer(text) and return a
    queued status without touching interrupt state.
    """
    calls = {}

    class _Agent:
        def steer(self, text):
            calls["steer_text"] = text
            return True

        def interrupt(self, *args, **kwargs):
            calls["interrupt_called"] = True

    server._sessions["sid"] = _session(agent=_Agent())
    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "session.steer",
                "params": {"session_id": "sid", "text": "also check auth.log"},
            }
        )
    finally:
        server._sessions.pop("sid", None)

    assert "result" in resp, resp
    assert resp["result"]["status"] == "queued"
    assert resp["result"]["text"] == "also check auth.log"
    assert calls["steer_text"] == "also check auth.log"
    assert "interrupt_called" not in calls  # must NOT interrupt


def test_session_steer_rejects_empty_text():
    server._sessions["sid"] = _session(
        agent=types.SimpleNamespace(steer=lambda t: True)
    )
    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "session.steer",
                "params": {"session_id": "sid", "text": "   "},
            }
        )
    finally:
        server._sessions.pop("sid", None)

    assert "error" in resp, resp
    assert resp["error"]["code"] == 4002


def test_session_steer_errors_when_agent_has_no_steer_method():
    server._sessions["sid"] = _session(agent=types.SimpleNamespace())  # no steer()
    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "session.steer",
                "params": {"session_id": "sid", "text": "hi"},
            }
        )
    finally:
        server._sessions.pop("sid", None)

    assert "error" in resp, resp
    assert resp["error"]["code"] == 4010


def test_session_redirect_calls_capable_core_agent(monkeypatch):
    calls = []
    agent = types.SimpleNamespace(
        _supports_active_turn_redirect=True,
        redirect=lambda text: calls.append(text) or True,
    )
    session = _session(agent=agent)
    session["inflight_turn"] = {"user": "original request", "assistant": "partial reply"}
    server._sessions["sid"] = session
    try:
        before = session.get("last_active")
        resp = server.handle_request(
            {
                "id": "1",
                "method": "session.redirect",
                "params": {"session_id": "sid", "text": "use Postgres"},
            }
        )
    finally:
        server._sessions.pop("sid", None)

    assert resp["result"] == {
        "status": "redirected",
        "text": "use Postgres",
    }
    assert calls == ["use Postgres"]
    # The correction is recorded alongside the prompt that started the turn,
    # never over it — resume must be able to rebuild both bubbles.
    assert session["inflight_turn"]["user"] == "original request"
    assert session["inflight_turn"]["corrections"] == ["use Postgres"]
    assert session.get("last_active") is not None
    assert before is None or session["last_active"] >= before


def test_session_redirect_records_correction_without_erasing_prompt():
    """A redirect must not overwrite the turn's original user text.

    The inflight snapshot is the only thing session.resume can replay, so
    overwriting ``user`` erased the prompt that started the turn and the
    client repainted the thread with the user's message missing.
    """
    session = {}
    server._start_inflight_turn(session, "remove the session counts")
    server._append_inflight_delta(session, "Moving.")
    server._record_inflight_correction(session, "hurry up")
    server._record_inflight_correction(session, "and the worktree ones")

    snapshot = server._inflight_snapshot(session)
    assert snapshot is not None

    assert snapshot["user"] == "remove the session counts"
    assert snapshot["corrections"] == ["hurry up", "and the worktree ones"]


def test_inflight_snapshot_omits_corrections_when_none_recorded():
    session = {}
    server._start_inflight_turn(session, "just the prompt")

    snapshot = server._inflight_snapshot(session)
    assert snapshot is not None
    assert "corrections" not in snapshot


def test_new_turn_does_not_inherit_prior_turn_corrections():
    session = {}
    server._start_inflight_turn(session, "first prompt")
    server._record_inflight_correction(session, "first correction")
    server._start_inflight_turn(session, "second prompt")

    snapshot = server._inflight_snapshot(session)
    assert snapshot is not None

    assert snapshot["user"] == "second prompt"
    assert "corrections" not in snapshot


def test_session_redirect_queues_during_agent_build_window(monkeypatch):
    # A fresh turn flips running=True and builds the agent asynchronously, so
    # session["agent"] is briefly None. A correction landing here must queue
    # (lossless, reaches the model next turn), not hard-reject as unsupported.
    session = _session(running=True)
    session["agent"] = None
    server._sessions["sid"] = session
    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "session.redirect",
                "params": {"session_id": "sid", "text": "wait, use SQLite"},
            }
        )
    finally:
        server._sessions.pop("sid", None)

    assert resp["result"] == {"status": "queued", "text": "wait, use SQLite"}
    assert session["queued_prompt"]["text"] == "wait, use SQLite"


def test_session_redirect_rejects_when_idle_without_agent(monkeypatch):
    # No live turn and no agent: nothing to redirect, and we must not queue a
    # phantom turn — keep the explicit unsupported rejection.
    session = _session(running=False)
    session["agent"] = None
    server._sessions["sid"] = session
    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "session.redirect",
                "params": {"session_id": "sid", "text": "hi"},
            }
        )
    finally:
        server._sessions.pop("sid", None)

    assert resp["error"]["code"] == 4010
    assert session.get("queued_prompt") is None


def test_session_info_includes_mcp_servers(monkeypatch):
    fake_status = [
        {"name": "github", "transport": "http", "tools": 12, "connected": True},
        {"name": "filesystem", "transport": "stdio", "tools": 4, "connected": True},
        {"name": "broken", "transport": "stdio", "tools": 0, "connected": False},
    ]
    fake_mod = types.ModuleType("tools.mcp_tool")
    fake_mod.get_mcp_status = lambda: fake_status
    monkeypatch.setitem(sys.modules, "tools.mcp_tool", fake_mod)

    info = server._session_info(types.SimpleNamespace(tools=[], model="", provider="openai-codex"))

    assert info["provider"] == "openai-codex"
    assert info["mcp_servers"] == fake_status


def test_session_info_includes_session_title(monkeypatch):
    class _FakeDB:
        def get_session_title(self, key):
            assert key == "session-key"
            return "Dashboard title"

    monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())

    info = server._session_info(
        types.SimpleNamespace(tools=[], model="test/model", provider="openai-codex"),
        {"session_key": "session-key", "history": []},
    )

    assert info["title"] == "Dashboard title"


def test_session_info_reports_pending_model_switch(monkeypatch):
    """A model queued mid-turn shows as the session's model in session.info, so
    the end-of-turn settle doesn't blip the UI back to the still-live old model
    before the switch applies at the next turn start."""
    agent = types.SimpleNamespace(tools=[], model="old/model", provider="openai")
    session = {
        "session_key": "",
        "history": [],
        "pending_model_switch": {
            "raw": "new/model --provider anthropic",
            "display_model": "new/model",
            "display_provider": "anthropic",
        },
    }

    info = server._session_info(agent, session)
    assert info["model"] == "new/model"
    assert info["provider"] == "anthropic"

    # With nothing queued the live agent model wins, as before.
    session.pop("pending_model_switch")
    assert server._session_info(agent, session)["model"] == "old/model"


# ---------------------------------------------------------------------------
# History-mutating commands must reject while session.running is True.
# Without these guards, prompt.submit's post-run history write either
# clobbers the mutation (version matches) or silently drops the agent's
# output (version mismatch) — both produce UI<->backend state desync.
# ---------------------------------------------------------------------------


def test_session_undo_rejects_while_running():
    """Fix for TUI silent-drop #1: /undo must not mutate history
    while the agent is mid-turn — would either clobber the undo or
    cause prompt.submit to silently drop the agent's response."""
    server._sessions["sid"] = _session(
        running=True,
        history=[
            {"role": "user", "content": "hi"},
            {"role": "assistant", "content": "hello"},
        ],
    )
    try:
        resp = server.handle_request(
            {"id": "1", "method": "session.undo", "params": {"session_id": "sid"}}
        )
        assert resp.get("error"), "session.undo should reject while running"
        assert resp["error"]["code"] == 4009
        assert "session busy" in resp["error"]["message"]
        # History must be unchanged
        assert len(server._sessions["sid"]["history"]) == 2
    finally:
        server._sessions.pop("sid", None)


def test_session_undo_allowed_when_idle():
    """Regression guard: when not running, /undo still works."""
    server._sessions["sid"] = _session(
        running=False,
        history=[
            {"role": "user", "content": "hi"},
            {"role": "assistant", "content": "hello"},
        ],
    )
    try:
        resp = server.handle_request(
            {"id": "1", "method": "session.undo", "params": {"session_id": "sid"}}
        )
        assert resp.get("result"), f"got error: {resp.get('error')}"
        assert resp["result"]["removed"] == 2
        assert server._sessions["sid"]["history"] == []
    finally:
        server._sessions.pop("sid", None)


def test_session_compress_rejects_while_running(monkeypatch):
    server._sessions["sid"] = _session(running=True)
    try:
        resp = server.handle_request(
            {"id": "1", "method": "session.compress", "params": {"session_id": "sid"}}
        )
        assert resp.get("error")
        assert resp["error"]["code"] == 4009
    finally:
        server._sessions.pop("sid", None)


def test_rollback_restore_rejects_full_history_while_running(monkeypatch):
    """Full-history rollback must reject; file-scoped rollback still allowed."""
    server._sessions["sid"] = _session(running=True)
    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "rollback.restore",
                "params": {"session_id": "sid", "hash": "abc"},
            }
        )
        assert resp.get("error"), "full-history rollback should reject while running"
        assert resp["error"]["code"] == 4009
    finally:
        server._sessions.pop("sid", None)


def test_prompt_submit_history_version_mismatch_surfaces_warning(monkeypatch):
    """Fix for TUI silent-drop #2: the defensive backstop at prompt.submit
    must attach a 'warning' to message.complete when history was
    mutated externally during the turn (instead of silently dropping
    the agent's output)."""
    # Agent bumps history_version itself mid-run to simulate an external
    # mutation slipping past the guards.
    session_ref = {"s": None}

    class _RacyAgent:
        def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
            # Simulate: something external bumped history_version
            # while we were running.
            with session_ref["s"]["history_lock"]:
                session_ref["s"]["history_version"] += 1
            return {
                "final_response": "agent reply",
                "messages": [{"role": "assistant", "content": "agent reply"}],
            }

    class _ImmediateThread:
        def __init__(self, target=None, daemon=None):
            self._target = target

        def start(self):
            self._target()

    server._sessions["sid"] = _session(agent=_RacyAgent())
    session_ref["s"] = server._sessions["sid"]
    emits: list[tuple] = []
    try:
        monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
        monkeypatch.setattr(server, "_get_usage", lambda _a: {})
        monkeypatch.setattr(server, "render_message", lambda _t, _c: "")
        monkeypatch.setattr(server, "_emit", lambda *a: emits.append(a))

        resp = server.handle_request(
            {
                "id": "1",
                "method": "prompt.submit",
                "params": {"session_id": "sid", "text": "hi"},
            }
        )
        assert resp.get("result"), f"got error: {resp.get('error')}"

        # History should NOT contain the agent's output (version mismatch)
        assert server._sessions["sid"]["history"] == []

        # message.complete must carry a 'warning' so the UI / operator
        # knows the output was not persisted.
        complete_calls = [a for a in emits if a[0] == "message.complete"]
        assert len(complete_calls) == 1
        _, _, payload = complete_calls[0]
        assert "warning" in payload, (
            "message.complete must include a 'warning' field on "
            "history_version mismatch — otherwise the UI silently "
            "shows output that was never persisted"
        )
        assert (
            "not saved" in payload["warning"].lower()
            or "changed" in payload["warning"].lower()
        )
    finally:
        server._sessions.pop("sid", None)


def test_prompt_submit_merges_on_model_switch_marker(monkeypatch):
    """#76870: when a model-switch marker is the only history mutation during
    a turn, the agent's output must be merged into the current history (which
    now contains the marker) instead of being discarded.

    This test covers BOTH cases:
    - No prior marker in turn-start history (first switch in a session)
    - Prior marker existed (every subsequent switch — the original PR #77274
      fix was dead code here because _append_model_switch_marker strips the
      old marker before appending the new one, producing a net-zero length
      delta that the positional slice missed).
    """
    from tui_gateway.server import _MODEL_SWITCH_MARKER_PREFIX

    session_ref = {"s": None}

    def _make_marker(model: str) -> dict:
        return {
            "role": "user",
            "content": f"{_MODEL_SWITCH_MARKER_PREFIX}{model}.]",
            "display_kind": "model_switch",
        }

    class _MarkerAgent:
        def __init__(self, new_history_state: list):
            self._new_history_state = new_history_state

        def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
            # Simulate _append_model_switch_marker: strip prior markers, append new one.
            with session_ref["s"]["history_lock"]:
                hist = session_ref["s"]["history"]
                hist[:] = [h for h in hist if not _is_marker(h)]
                hist.append(_make_marker("new-model"))
                session_ref["s"]["history_version"] += 1
            # result["messages"] = conversation_history + user msg + assistant reply
            return {
                "final_response": "agent reply",
                "messages": list(conversation_history) + [
                    {"role": "user", "content": "hi"},
                    {"role": "assistant", "content": "agent reply"},
                ],
            }

    def _is_marker(entry) -> bool:
        from tui_gateway.server import _is_model_switch_marker
        return _is_model_switch_marker(entry)

    class _ImmediateThread:
        def __init__(self, target=None, daemon=None):
            self._target = target

        def start(self):
            self._target()

    # Test both: no prior marker, and prior marker present
    for label, prior_history in [
        ("no prior marker", [{"role": "user", "content": "hello"}]),
        ("with prior marker", [
            {"role": "user", "content": "hello"},
            _make_marker("old-model"),
            {"role": "assistant", "content": "hi there"},
        ]),
    ]:
        server._sessions["sid"] = _session(
            agent=_MarkerAgent([]),
            history=list(prior_history),
        )
        session_ref["s"] = server._sessions["sid"]
        emits: list[tuple] = []
        try:
            monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
            monkeypatch.setattr(server, "_get_usage", lambda _a: {})
            monkeypatch.setattr(server, "render_message", lambda _t, _c: "")
            monkeypatch.setattr(server, "_emit", lambda *a: emits.append(a))

            resp = server.handle_request(
                {
                    "id": "1",
                    "method": "prompt.submit",
                    "params": {"session_id": "sid", "text": "hi"},
                }
            )
            assert resp.get("result"), f"[{label}] got error: {resp.get('error')}"

            final_history = server._sessions["sid"]["history"]

            # The agent's new messages must be present in the persisted history.
            assistant_msgs = [
                e for e in final_history
                if isinstance(e, dict) and e.get("role") == "assistant"
                and e.get("content") == "agent reply"
            ]
            assert len(assistant_msgs) == 1, (
                f"[{label}] agent output was not merged into history "
                f"(got {len(assistant_msgs)} assistant 'agent reply' messages)"
            )

            # The model-switch marker must be present.
            markers = [e for e in final_history if _is_marker(e)]
            assert len(markers) == 1, (
                f"[{label}] expected exactly 1 model-switch marker, got {len(markers)}"
            )
            assert "new-model" in markers[0]["content"]

            # No warning should be surfaced — the merge succeeded.
            complete_calls = [a for a in emits if a[0] == "message.complete"]
            assert len(complete_calls) == 1
            _, _, payload = complete_calls[0]
            assert "warning" not in payload, (
                f"[{label}] merge path should not surface a warning"
            )
        finally:
            server._sessions.pop("sid", None)


def test_prompt_submit_sanitizes_bracketed_paste_before_agent(monkeypatch):
    """prompt.submit must sanitize corrupted user text before run_conversation."""
    captured: dict[str, str] = {}

    class _Agent:
        def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
            captured["prompt"] = prompt
            return {
                "final_response": "ok",
                "messages": [{"role": "assistant", "content": "ok"}],
            }

    class _ImmediateThread:
        def __init__(self, target=None, daemon=None, **kw):
            self._target = target

        def start(self):
            self._target()

    corrupted = "hello[" + "~[[e" * 8
    server._sessions["sid"] = _session(agent=_Agent())
    try:
        monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
        monkeypatch.setattr(server, "_get_usage", lambda _a: {})
        monkeypatch.setattr(server, "render_message", lambda _t, _c: "")
        monkeypatch.setattr(server, "_emit", lambda *a: None)
        monkeypatch.setattr(server, "_start_agent_build", lambda *a, **k: None)
        monkeypatch.setattr(server, "_ensure_session_db_row", lambda *a, **k: None)
        monkeypatch.setattr(server, "_persist_branch_seed", lambda *a, **k: None)

        resp = server.handle_request(
            {
                "id": "1",
                "method": "prompt.submit",
                "params": {"session_id": "sid", "text": corrupted},
            }
        )
        assert resp.get("result"), f"got error: {resp.get('error')}"
        assert captured["prompt"] == "hello"
    finally:
        server._sessions.pop("sid", None)


def test_prompt_submit_history_version_match_persists_normally(monkeypatch):
    """Regression guard: the backstop does not affect the happy path."""

    class _Agent:
        def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
            return {
                "final_response": "reply",
                "messages": [{"role": "assistant", "content": "reply"}],
            }

    class _ImmediateThread:
        def __init__(self, target=None, daemon=None):
            self._target = target

        def start(self):
            self._target()

    server._sessions["sid"] = _session(agent=_Agent())
    emits: list[tuple] = []
    try:
        monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
        monkeypatch.setattr(server, "_get_usage", lambda _a: {})
        monkeypatch.setattr(server, "render_message", lambda _t, _c: "")
        monkeypatch.setattr(server, "_emit", lambda *a: emits.append(a))

        resp = server.handle_request(
            {
                "id": "1",
                "method": "prompt.submit",
                "params": {"session_id": "sid", "text": "hi"},
            }
        )
        assert resp.get("result")

        # History was written
        assert server._sessions["sid"]["history"] == [
            {"role": "assistant", "content": "reply"}
        ]
        assert server._sessions["sid"]["history_version"] == 1

        # No warning should be attached
        complete_calls = [a for a in emits if a[0] == "message.complete"]
        assert len(complete_calls) == 1
        _, _, payload = complete_calls[0]
        assert "warning" not in payload
    finally:
        server._sessions.pop("sid", None)


def test_prompt_submit_snapshots_history_after_pending_model_switch(monkeypatch):
    marker = {"role": "user", "content": "[model switched]"}
    seen = {}

    class _Agent:
        def run_conversation(self, prompt, conversation_history=None, **_kwargs):
            seen["history"] = conversation_history
            return {
                "final_response": "reply",
                "messages": [
                    *(conversation_history or []),
                    {"role": "user", "content": prompt},
                    {"role": "assistant", "content": "reply"},
                ],
            }

    class _ImmediateThread:
        def __init__(self, target=None, **_kwargs):
            self._target = target

        def start(self):
            self._target()

    def _apply_pending(_sid, session):
        with session["history_lock"]:
            session["history"].append(marker)
            session["history_version"] += 1

    server._sessions["sid"] = _session(agent=_Agent())
    server._sessions["sid"]["pending_model_switch"] = {"raw": "new-model"}
    emits = []
    try:
        monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
        monkeypatch.setattr(server, "_apply_pending_model_switch", _apply_pending)
        monkeypatch.setattr(server, "_sync_agent_model_with_config", lambda *_a: None)
        monkeypatch.setattr(server, "_get_usage", lambda _a: {})
        monkeypatch.setattr(server, "render_message", lambda *_a: "")
        monkeypatch.setattr(server, "_emit", lambda *a: emits.append(a))

        server.handle_request(
            {"id": "1", "method": "prompt.submit", "params": {"session_id": "sid", "text": "hi"}}
        )

        assert seen["history"] == [marker]
        assert server._sessions["sid"]["history"][-1] == {
            "role": "assistant", "content": "reply"
        }
        complete = [a for a in emits if a[0] == "message.complete"]
        assert "warning" not in complete[0][2]
    finally:
        server._sessions.pop("sid", None)


def test_prompt_submit_can_truncate_before_user_ordinal(monkeypatch):
    """Desktop user-message edits should restart the turn from the edited user."""

    seen = {}

    class _Agent:
        def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
            seen["prompt"] = prompt
            seen["history"] = conversation_history
            return {
                "final_response": "edited reply",
                "messages": [
                    *(conversation_history or []),
                    {"role": "user", "content": prompt},
                    {"role": "assistant", "content": "edited reply"},
                ],
            }

    class _ImmediateThread:
        def __init__(self, target=None, daemon=None):
            self._target = target

        def start(self):
            self._target()

    original_history = [
        {"role": "user", "content": "first"},
        {"role": "assistant", "content": "first reply"},
        {"role": "user", "content": "second"},
        {"role": "assistant", "content": "second reply"},
    ]
    server._sessions["sid"] = _session(agent=_Agent(), history=original_history)

    class _StubDb:
        def __init__(self):
            self.replaced = []

        def replace_messages(self, session_id, messages, active_only=False):
            self.replaced.append((session_id, list(messages)))

    stub_db = _StubDb()

    try:
        monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
        monkeypatch.setattr(server, "_get_usage", lambda _a: {})
        monkeypatch.setattr(server, "render_message", lambda _t, _c: "")
        monkeypatch.setattr(server, "_emit", lambda *a: None)
        monkeypatch.setattr(server, "_get_db", lambda: stub_db)

        resp = server.handle_request(
            {
                "id": "1",
                "method": "prompt.submit",
                "params": {
                    "session_id": "sid",
                    "text": "edited second",
                    "truncate_before_user_ordinal": 1,
                    "confirm_truncate": True,
                },
            }
        )
        assert resp.get("result"), f"got error: {resp.get('error')}"

        assert seen["prompt"] == "edited second"
        assert seen["history"] == original_history[:2]
        assert server._sessions["sid"]["history"] == [
            *original_history[:2],
            {"role": "user", "content": "edited second"},
            {"role": "assistant", "content": "edited reply"},
        ]
        assert server._sessions["sid"]["history_version"] == 2
        assert stub_db.replaced == [("session-key", original_history[:2])]
    finally:
        server._sessions.pop("sid", None)


def test_prompt_submit_refuses_turn_when_truncate_persist_fails(monkeypatch):
    """If replace_messages fails during edit/regenerate truncate, do not run the turn.

    Memory-first + fail-open left session['history'] short while state.db kept
    the old tail. The agent flush then appends the new exchange on top of the
    'undone' turns — durable zombie history. Write first; on failure leave
    memory and DB unchanged and return 5008.
    """
    original_history = [
        {"role": "user", "content": "first"},
        {"role": "assistant", "content": "first reply"},
        {"role": "user", "content": "second"},
        {"role": "assistant", "content": "second reply"},
    ]
    sess = _session(history=list(original_history))
    server._sessions["trunc-fail-sid"] = sess

    class _FailDb:
        def replace_messages(self, session_id, messages, active_only=False):
            raise OSError("disk full")

    monkeypatch.setattr(server, "_get_db", lambda: _FailDb())
    monkeypatch.setattr(
        server, "_start_inflight_turn", lambda *a, **k: pytest.fail("must not start a turn")
    )
    monkeypatch.setattr(
        server, "_start_agent_build", lambda *a, **k: pytest.fail("must not start a turn")
    )

    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "prompt.submit",
                "params": {
                    "session_id": "trunc-fail-sid",
                    "text": "edited second",
                    "truncate_before_user_ordinal": 1,
                    "confirm_truncate": True,
                },
            }
        )
        assert "error" in resp
        assert resp["error"]["code"] == 5008
        assert "truncat" in resp["error"]["message"].lower() or "persist" in resp["error"]["message"].lower()
        # Memory left intact — same list contents as before the refused cut.
        assert sess["history"] == original_history
        assert sess["history_version"] == 0
        assert sess.get("running") is not True
    finally:
        server._sessions.pop("trunc-fail-sid", None)


# ---------------------------------------------------------------------------
# session.interrupt must only cancel pending prompts owned by the calling
# session — it must not blast-resolve clarify/sudo/secret prompts on
# unrelated sessions sharing the same tui_gateway process.  Without
# session scoping the other sessions' prompts silently resolve to empty
# strings, unblocking their agent threads as if the user cancelled.
# ---------------------------------------------------------------------------


def test_prompt_submit_truncate_ordinal_skips_display_kind_rows(monkeypatch):
    """truncate_before_user_ordinal must count only real user turns.

    display_kind timeline rows (model_switch, async_delegation_complete, …)
    are role=user but no client counts them as user turns. Without the
    filter, a trailing marker shifts the ordinal so the wrong message is
    targeted for truncation.
    """

    seen = {}

    class _Agent:
        def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
            seen["prompt"] = prompt
            seen["history"] = conversation_history
            return {
                "final_response": "reply",
                "messages": [
                    *(conversation_history or []),
                    {"role": "user", "content": prompt},
                    {"role": "assistant", "content": "reply"},
                ],
            }

    class _ImmediateThread:
        def __init__(self, target=None, daemon=None):
            self._target = target

        def start(self):
            self._target()

    original_history = [
        {"role": "user", "content": "first"},
        {"role": "assistant", "content": "first reply"},
        {"role": "user", "content": "second"},
        {"role": "assistant", "content": "second reply"},
        {
            "role": "user",
            "content": "background agent finished",
            "display_kind": "async_delegation_complete",
        },
    ]
    server._sessions["sid"] = _session(agent=_Agent(), history=original_history)

    class _StubDb:
        def __init__(self):
            self.replaced = []

        def replace_messages(self, session_id, messages, active_only=False):
            self.replaced.append((session_id, list(messages)))

    stub_db = _StubDb()

    try:
        monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
        monkeypatch.setattr(server, "_get_usage", lambda _a: {})
        monkeypatch.setattr(server, "render_message", lambda _t, _c: "")
        monkeypatch.setattr(server, "_emit", lambda *a: None)
        monkeypatch.setattr(server, "_get_db", lambda: stub_db)

        # ordinal=1 means "truncate before the 2nd-from-last real user turn"
        # which is "first". The display_kind marker must NOT shift the ordinal.
        resp = server.handle_request(
            {
                "id": "1",
                "method": "prompt.submit",
                "params": {
                    "session_id": "sid",
                    "text": "edited first",
                    "truncate_before_user_ordinal": 1,
                    "confirm_truncate": True,
                },
            }
        )
        assert resp.get("result"), f"got error: {resp.get('error')}"

        # With display_kind filter: user_indices = [0, 2] (indices of "first" and "second").
        # ordinal=1 → user_indices[1] = 2, truncated = history[:2] = [first, first reply].
        # Without the filter: user_indices = [0, 2, 4] (includes the marker),
        # ordinal=1 → user_indices[1] = 2, same result by luck — but ordinal=0
        # would truncate to history[:0] vs history[:0], and higher ordinals shift.
        assert seen["history"] == original_history[:2], (
            f"Expected truncation to first 2 messages, got {seen['history']}"
        )
        assert stub_db.replaced == [("session-key", original_history[:2])], (
            f"Expected DB replace with first 2 messages, got {stub_db.replaced}"
        )
    finally:
        server._sessions.pop("sid", None)


# ---------------------------------------------------------------------------
# session.interrupt must only cancel pending prompts owned by the calling
# session — it must not blast-resolve clarify/sudo/secret prompts on
# unrelated sessions sharing the same tui_gateway process.  Without
# session scoping the other sessions' prompts silently resolve to empty
# strings, unblocking their agent threads as if the user cancelled.
# ---------------------------------------------------------------------------


def test_interrupt_only_clears_own_session_pending():
    """session.interrupt on session A must NOT release pending prompts
    that belong to session B."""
    import types

    session_a = _session()
    session_a["agent"] = types.SimpleNamespace(interrupt=lambda: None)
    session_b = _session()
    session_b["agent"] = types.SimpleNamespace(interrupt=lambda: None)
    server._sessions["sid_a"] = session_a
    server._sessions["sid_b"] = session_b

    try:
        # Simulate pending prompts on both sessions (what _block creates
        # while a clarify/sudo/secret request is outstanding).
        ev_a = threading.Event()
        ev_b = threading.Event()
        server._pending["rid-a"] = ("sid_a", ev_a)
        server._pending["rid-b"] = ("sid_b", ev_b)
        server._answers.clear()

        # Interrupt session A.
        resp = server.handle_request(
            {
                "id": "1",
                "method": "session.interrupt",
                "params": {"session_id": "sid_a"},
            }
        )
        assert resp.get("result"), f"got error: {resp.get('error')}"

        # Session A's pending must be released to empty.
        assert ev_a.is_set(), "sid_a pending Event should be set after interrupt"
        assert server._answers.get("rid-a") == ""

        # Session B's pending MUST remain untouched — no cross-session blast.
        assert not ev_b.is_set(), (
            "CRITICAL: session.interrupt on sid_a released a pending prompt "
            "belonging to sid_b — other sessions' clarify/sudo/secret "
            "prompts are being silently cancelled"
        )
        assert "rid-b" not in server._answers
    finally:
        server._sessions.pop("sid_a", None)
        server._sessions.pop("sid_b", None)
        server._pending.pop("rid-a", None)
        server._pending.pop("rid-b", None)
        server._answers.pop("rid-a", None)
        server._answers.pop("rid-b", None)


def test_interrupt_clears_multiple_own_pending():
    """When a single session has multiple pending prompts (uncommon but
    possible via nested tool calls), interrupt must release all of them."""
    import types

    sess = _session()
    sess["agent"] = types.SimpleNamespace(interrupt=lambda: None)
    server._sessions["sid"] = sess

    try:
        ev1, ev2 = threading.Event(), threading.Event()
        server._pending["r1"] = ("sid", ev1)
        server._pending["r2"] = ("sid", ev2)

        resp = server.handle_request(
            {"id": "1", "method": "session.interrupt", "params": {"session_id": "sid"}}
        )
        assert resp.get("result")
        assert ev1.is_set() and ev2.is_set()
        assert server._answers.get("r1") == "" and server._answers.get("r2") == ""
    finally:
        server._sessions.pop("sid", None)
        for key in ("r1", "r2"):
            server._pending.pop(key, None)
            server._answers.pop(key, None)


def test_run_prompt_submit_registers_turn_thread_for_interrupt(monkeypatch):
    """_run_prompt_submit must expose the actual turn thread to session.interrupt.

    prompt.submit's outer wrapper only waits for agent initialization, then
    _run_prompt_submit starts the real conversation thread. If the session keeps
    the wrapper thread handle, stop/esc sees a dead thread and never calls
    agent.interrupt() on the live turn.
    """
    calls = {"interrupted": False, "started": False}

    class _FakeThread:
        def __init__(self, target=None, daemon=None):
            self.target = target

        def start(self):
            calls["started"] = True

        def is_alive(self):
            return True

    agent = types.SimpleNamespace(
        interrupt=lambda: calls.__setitem__("interrupted", True),
        run_conversation=lambda *args, **kwargs: {},
    )
    session = _session(agent=agent, running=True)
    server._sessions["sid"] = session

    try:
        monkeypatch.setattr(server.threading, "Thread", _FakeThread)
        monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None)

        server._run_prompt_submit("1", "sid", session, "hello")

        assert session.get("_run_thread") is not None
        resp = server.handle_request(
            {"id": "2", "method": "session.interrupt", "params": {"session_id": "sid"}}
        )

        assert resp.get("result"), f"got error: {resp.get('error')}"
        assert calls["interrupted"] is True
    finally:
        server._sessions.pop("sid", None)


def test_interrupt_drops_queued_prompt_for_session():
    """Explicit stop cancels a queued next turn instead of auto-draining it."""
    calls = {"interrupted": False}

    class _LiveThread:
        def is_alive(self):
            return True

    session = _session(
        agent=types.SimpleNamespace(
            interrupt=lambda: calls.__setitem__("interrupted", True)
        ),
        running=True,
        queued_prompt={"text": "next prompt", "transport": None},
        queued_prompts=[{"text": "later prompt", "image_paths": ["/tmp/later.png"], "transport": None}],
        _run_thread=_LiveThread(),
    )
    server._sessions["sid"] = session

    try:
        resp = server.handle_request(
            {"id": "1", "method": "session.interrupt", "params": {"session_id": "sid"}}
        )

        assert resp.get("result"), f"got error: {resp.get('error')}"
        assert calls["interrupted"] is True
        assert session.get("queued_prompt") is None
        assert session.get("queued_prompts") is None
    finally:
        server._sessions.pop("sid", None)


def test_interrupt_before_agent_ready_prevents_late_turn_start(monkeypatch):
    """Stop during lazy agent startup must not start the turn after init finishes."""
    threads = []
    calls = {"run_prompt": 0}

    class _FakeThread:
        def __init__(self, target=None, daemon=None):
            self.target = target
            threads.append(self)

        def start(self):
            return None

        def is_alive(self):
            return True

    session = _session()
    session["agent"] = None
    server._sessions["sid"] = session

    try:
        monkeypatch.setattr(server.threading, "Thread", _FakeThread)
        monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None)
        monkeypatch.setattr(server, "_ensure_session_db_row", lambda session: None)
        monkeypatch.setattr(server, "_persist_branch_seed", lambda session: None)
        monkeypatch.setattr(server, "_start_agent_build", lambda sid, session: None)
        monkeypatch.setattr(server, "_wait_agent", lambda session, rid: None)
        monkeypatch.setattr(
            server,
            "_run_prompt_submit",
            lambda *args, **kwargs: calls.__setitem__(
                "run_prompt", calls["run_prompt"] + 1
            ),
        )

        submit = server.handle_request(
            {
                "id": "1",
                "method": "prompt.submit",
                "params": {"session_id": "sid", "text": "hello"},
            }
        )
        assert submit.get("result"), f"got error: {submit.get('error')}"
        assert session["running"] is True
        assert len(threads) == 1

        stop = server.handle_request(
            {"id": "2", "method": "session.interrupt", "params": {"session_id": "sid"}}
        )
        assert stop.get("result"), f"got error: {stop.get('error')}"

        threads[0].target()

        assert calls["run_prompt"] == 0
        assert session["running"] is False
        assert session.get("inflight_turn") is None
    finally:
        server._sessions.pop("sid", None)


def test_cancelled_turn_before_agent_ready_emits_error_event(monkeypatch):
    """A turn cancelled during lazy agent startup must surface an error event.

    Sibling of test_interrupt_before_agent_ready_prevents_late_turn_start: that
    test only asserts `_run_prompt_submit` is skipped, mocking `_emit` to a
    no-op so it cannot catch a silent drop. This test captures `_emit` and
    asserts the client receives an `error` event with a human-readable message,
    so the Desktop composer can show feedback instead of hanging on a
    `{"status":"streaming"}` reply that never produces a turn (issue #63078
    server-side half).
    """
    threads = []
    emitted = []
    calls = {"run_prompt": 0}

    class _FakeThread:
        def __init__(self, target=None, daemon=None):
            self.target = target
            threads.append(self)

        def start(self):
            return None

        def is_alive(self):
            return True

    session = _session()
    session["agent"] = None
    server._sessions["sid"] = session

    try:
        monkeypatch.setattr(server.threading, "Thread", _FakeThread)
        monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: emitted.append(args))
        monkeypatch.setattr(server, "_ensure_session_db_row", lambda session: None)
        monkeypatch.setattr(server, "_persist_branch_seed", lambda session: None)
        monkeypatch.setattr(server, "_start_agent_build", lambda sid, session: None)
        monkeypatch.setattr(server, "_wait_agent", lambda session, rid: None)
        monkeypatch.setattr(
            server,
            "_run_prompt_submit",
            lambda *args, **kwargs: calls.__setitem__(
                "run_prompt", calls["run_prompt"] + 1
            ),
        )

        submit = server.handle_request(
            {
                "id": "1",
                "method": "prompt.submit",
                "params": {"session_id": "sid", "text": "hello"},
            }
        )
        assert submit.get("result"), f"got error: {submit.get('error')}"
        assert session["running"] is True

        # User hits Stop while the agent is still building.
        stop = server.handle_request(
            {"id": "2", "method": "session.interrupt", "params": {"session_id": "sid"}}
        )
        assert stop.get("result"), f"got error: {stop.get('error')}"
        assert session.get("_turn_cancel_requested") is True

        # The deferred run thread now wakes up; without the emit it would bail
        # silently and the Desktop would never learn the turn was dropped.
        threads[0].target()

        assert calls["run_prompt"] == 0
        assert session["running"] is False
        assert session.get("inflight_turn") is None
        # Exactly one error event addressed to this session.
        error_events = [e for e in emitted if e and len(e) >= 2 and e[0] == "error" and e[1] == "sid"]
        assert len(error_events) == 1, f"expected one error event, got: {emitted}"
        msg = error_events[0][2].get("message", "")
        assert "cancelled" in msg.lower(), f"unexpected message: {msg}"
    finally:
        server._sessions.pop("sid", None)


def test_session_not_running_before_agent_ready_emits_error_event(monkeypatch):
    """When `running` is cleared by something other than an explicit interrupt
    (e.g. a concurrent session.create race that resets the flag), the deferred
    run thread must still emit an error event rather than disappearing silently.
    """
    threads = []
    emitted = []
    calls = {"run_prompt": 0}

    class _FakeThread:
        def __init__(self, target=None, daemon=None):
            self.target = target
            threads.append(self)

        def start(self):
            return None

        def is_alive(self):
            return True

    session = _session()
    session["agent"] = None
    server._sessions["sid"] = session

    try:
        monkeypatch.setattr(server.threading, "Thread", _FakeThread)
        monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: emitted.append(args))
        monkeypatch.setattr(server, "_ensure_session_db_row", lambda session: None)
        monkeypatch.setattr(server, "_persist_branch_seed", lambda session: None)
        monkeypatch.setattr(server, "_start_agent_build", lambda sid, session: None)
        monkeypatch.setattr(server, "_wait_agent", lambda session, rid: None)
        monkeypatch.setattr(
            server,
            "_run_prompt_submit",
            lambda *args, **kwargs: calls.__setitem__(
                "run_prompt", calls["run_prompt"] + 1
            ),
        )

        submit = server.handle_request(
            {
                "id": "1",
                "method": "prompt.submit",
                "params": {"session_id": "sid", "text": "hello"},
            }
        )
        assert submit.get("result"), f"got error: {submit.get('error')}"
        assert session["running"] is True

        # Simulate a concurrent path clearing `running` without setting the
        # cancel flag (the other branch of the guard).
        with session["history_lock"]:
            session["running"] = False

        threads[0].target()

        assert calls["run_prompt"] == 0
        assert session.get("inflight_turn") is None
        error_events = [e for e in emitted if e and len(e) >= 2 and e[0] == "error" and e[1] == "sid"]
        assert len(error_events) == 1, f"expected one error event, got: {emitted}"
        msg = error_events[0][2].get("message", "")
        assert "no longer running" in msg.lower(), f"unexpected message: {msg}"
    finally:
        server._sessions.pop("sid", None)


def test_slow_agent_build_delivers_prompt_instead_of_timing_out(monkeypatch):
    """#63078 server-side half: a deferred build slower than the old 30s
    ``_wait_agent`` cliff must NOT eat the first message. The patient wait
    keeps the pending prompt attached and delivers it as soon as the
    still-running build completes."""
    threads = []
    emitted = []
    calls = {"run_prompt": 0}

    class _FakeThread:
        def __init__(self, target=None, daemon=None):
            self.target = target
            threads.append(self)

        def start(self):
            return None

        def is_alive(self):
            return True

    ready = threading.Event()
    session = _session(agent_ready=ready)
    session["agent"] = None
    server._sessions["sid"] = session

    # The build "completes" only after the wait loop has already gone through
    # several empty slices — i.e. well past what a single fixed-timeout wait
    # slice would tolerate.
    slices = {"n": 0}

    class _SlowReady:
        def wait(self, timeout=None):
            slices["n"] += 1
            if slices["n"] >= 3:
                ready.set()
                session["agent"] = types.SimpleNamespace()
                return True
            return False

        def is_set(self):
            return ready.is_set()

    session["agent_ready"] = _SlowReady()

    try:
        monkeypatch.setattr(server.threading, "Thread", _FakeThread)
        monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: emitted.append(args))
        monkeypatch.setattr(server, "_ensure_session_db_row", lambda session: None)
        monkeypatch.setattr(server, "_persist_branch_seed", lambda session: None)
        monkeypatch.setattr(server, "_start_agent_build", lambda sid, session: None)
        monkeypatch.setattr(
            server,
            "_run_prompt_submit",
            lambda *args, **kwargs: calls.__setitem__(
                "run_prompt", calls["run_prompt"] + 1
            ),
        )

        submit = server.handle_request(
            {
                "id": "1",
                "method": "prompt.submit",
                "params": {"session_id": "sid", "text": "first message"},
            }
        )
        assert submit.get("result"), f"got error: {submit.get('error')}"

        threads[0].target()

        # The message was DELIVERED, not dropped, and no error event fired.
        assert calls["run_prompt"] == 1
        error_events = [e for e in emitted if e and e[0] == "error"]
        assert not error_events, f"unexpected error events: {error_events}"
    finally:
        server._sessions.pop("sid", None)


def test_slow_agent_build_emits_keyed_progress_notice(monkeypatch):
    """Past the slow threshold the patient wait must tell the user once
    (keyed notification.show) and clear the notice when the build lands —
    a long wait is acceptable, a silent one is not."""
    threads = []
    emitted = []
    calls = {"run_prompt": 0}

    class _FakeThread:
        def __init__(self, target=None, daemon=None):
            self.target = target
            threads.append(self)

        def start(self):
            return None

        def is_alive(self):
            return True

    ready = threading.Event()
    session = _session(agent_ready=ready)
    session["agent"] = None
    server._sessions["sid"] = session

    slices = {"n": 0}

    class _SlowReady:
        def wait(self, timeout=None):
            slices["n"] += 1
            if slices["n"] >= 3:
                ready.set()
                session["agent"] = types.SimpleNamespace()
                return True
            return False

        def is_set(self):
            return ready.is_set()

    session["agent_ready"] = _SlowReady()

    try:
        monkeypatch.setattr(server.threading, "Thread", _FakeThread)
        # Every wait slice lands past the slow threshold.
        monkeypatch.setattr(server, "_AGENT_BUILD_SLOW_NOTICE_AFTER", 0.0)
        monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: emitted.append(args))
        monkeypatch.setattr(server, "_ensure_session_db_row", lambda session: None)
        monkeypatch.setattr(server, "_persist_branch_seed", lambda session: None)
        monkeypatch.setattr(server, "_start_agent_build", lambda sid, session: None)
        monkeypatch.setattr(
            server,
            "_run_prompt_submit",
            lambda *args, **kwargs: calls.__setitem__(
                "run_prompt", calls["run_prompt"] + 1
            ),
        )

        submit = server.handle_request(
            {
                "id": "1",
                "method": "prompt.submit",
                "params": {"session_id": "sid", "text": "first message"},
            }
        )
        assert submit.get("result"), f"got error: {submit.get('error')}"

        threads[0].target()

        assert calls["run_prompt"] == 1
        shows = [e for e in emitted if e and e[0] == "notification.show" and e[1] == "sid"]
        clears = [e for e in emitted if e and e[0] == "notification.clear" and e[1] == "sid"]
        # Exactly one keyed notice, replaced-in-place semantics, then cleared.
        assert len(shows) == 1, f"expected one slow-build notice, got: {shows}"
        assert shows[0][2].get("key") == server._AGENT_BUILD_SLOW_NOTICE_KEY
        assert len(clears) == 1 and clears[0][2].get("key") == server._AGENT_BUILD_SLOW_NOTICE_KEY
    finally:
        server._sessions.pop("sid", None)


def test_agent_build_failure_surfaces_error_and_drops_turn(monkeypatch):
    """When the build itself FAILS (agent_error set when ready fires), the
    prompt must not run and the failure must reach the client as a visible
    error event — never a silent drop."""
    threads = []
    emitted = []
    calls = {"run_prompt": 0}

    class _FakeThread:
        def __init__(self, target=None, daemon=None):
            self.target = target
            threads.append(self)

        def start(self):
            return None

        def is_alive(self):
            return True

    ready = threading.Event()
    ready.set()  # build finished...
    session = _session(agent_ready=ready)
    session["agent"] = None
    session["agent_error"] = "No LLM provider configured"  # ...but failed
    server._sessions["sid"] = session

    try:
        monkeypatch.setattr(server.threading, "Thread", _FakeThread)
        monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: emitted.append(args))
        monkeypatch.setattr(server, "_ensure_session_db_row", lambda session: None)
        monkeypatch.setattr(server, "_persist_branch_seed", lambda session: None)
        monkeypatch.setattr(server, "_start_agent_build", lambda sid, session: None)
        monkeypatch.setattr(
            server,
            "_run_prompt_submit",
            lambda *args, **kwargs: calls.__setitem__(
                "run_prompt", calls["run_prompt"] + 1
            ),
        )

        submit = server.handle_request(
            {
                "id": "1",
                "method": "prompt.submit",
                "params": {"session_id": "sid", "text": "first message"},
            }
        )
        assert submit.get("result"), f"got error: {submit.get('error')}"

        threads[0].target()

        assert calls["run_prompt"] == 0
        assert session["running"] is False
        # #71184 upgraded failure delivery from a bare "error" event to a
        # terminal message.complete frame (status=error, recoverable) so
        # failed turns are retained as replayable inflight snapshots. The
        # contract this test pins is unchanged: the build failure must reach
        # the client VISIBLY — never a silent drop.
        failure_frames = [
            e
            for e in emitted
            if e
            and e[0] in ("error", "message.complete")
            and e[1] == "sid"
            and (
                "No LLM provider configured" in str(e[2].get("message", ""))
                or "No LLM provider configured" in str(e[2].get("error", ""))
                or "No LLM provider configured" in str(e[2].get("text", ""))
            )
        ]
        assert len(failure_frames) == 1, f"expected one visible failure frame, got: {emitted}"
        frame = failure_frames[0]
        if frame[0] == "message.complete":
            assert frame[2].get("status") == "error"
    finally:
        server._sessions.pop("sid", None)


def test_dead_build_thread_fails_fast_not_full_cap(monkeypatch):
    """A build thread that died without setting agent_ready means the build
    died hard — the waiter must fail promptly with a visible error instead of
    sitting out the full wait cap on a corpse."""
    emitted = []

    class _DeadThread:
        def is_alive(self):
            return False

    ready = threading.Event()  # never set
    session = _session(agent_ready=ready)
    session["agent"] = None
    session["running"] = True
    session["_agent_build_thread"] = _DeadThread()
    session["agent_error"] = "agent init failed: boom"
    server._sessions["sid"] = session

    try:
        monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: emitted.append(args))
        # Short slices so the test is fast; the dead-thread check fires on the
        # first empty slice, far below the cap.
        monkeypatch.setattr(server, "_AGENT_BUILD_WAIT_SLICE", 0.01)

        start = time.monotonic()
        err = server._wait_agent_for_prompt(session, "rid-1", "sid")
        elapsed = time.monotonic() - start

        assert err is not None
        assert "boom" in (err.get("error") or {}).get("message", "")
        assert elapsed < 5.0, f"dead-thread detection took {elapsed:.1f}s"
    finally:
        server._sessions.pop("sid", None)


def test_wait_agent_for_prompt_honors_cancel_mid_wait(monkeypatch):
    """A cancel arriving during the patient wait must end it promptly and
    return None (the caller's cancel branch owns the user-visible event)."""
    ready = threading.Event()  # never set
    session = _session(agent_ready=ready)
    session["agent"] = None
    session["running"] = True
    server._sessions["sid"] = session

    try:
        monkeypatch.setattr(server, "_AGENT_BUILD_WAIT_SLICE", 0.01)

        def cancel_soon():
            time.sleep(0.05)
            with session["history_lock"]:
                session["_turn_cancel_requested"] = True

        canceller = threading.Thread(target=cancel_soon)
        canceller.start()
        start = time.monotonic()
        err = server._wait_agent_for_prompt(session, "rid-1", "sid")
        elapsed = time.monotonic() - start
        canceller.join()

        assert err is None
        assert elapsed < 5.0, f"cancel honored only after {elapsed:.1f}s"
    finally:
        server._sessions.pop("sid", None)


def test_agent_build_wait_cap_config_override(monkeypatch):
    """agent.build_wait_timeout in config.yaml overrides the default cap;
    invalid/absent values fall back to 600s."""
    monkeypatch.setattr(server, "_load_cfg", lambda: {"agent": {"build_wait_timeout": 90}})
    assert server._agent_build_wait_cap() == 90.0

    monkeypatch.setattr(server, "_load_cfg", lambda: {"agent": {}})
    assert server._agent_build_wait_cap() == 600.0

    monkeypatch.setattr(server, "_load_cfg", lambda: {"agent": {"build_wait_timeout": 0}})
    assert server._agent_build_wait_cap() == 600.0

    monkeypatch.setattr(server, "_load_cfg", lambda: {"agent": {"build_wait_timeout": "nonsense"}})
    assert server._agent_build_wait_cap() == 600.0


def test_wait_agent_for_prompt_expires_at_cap(monkeypatch):
    """A genuinely hung build (thread alive, never ready) still fails at the
    bounded cap with a message that tells the user their text was not sent."""
    class _AliveThread:
        def is_alive(self):
            return True

    ready = threading.Event()  # never set
    session = _session(agent_ready=ready)
    session["agent"] = None
    session["running"] = True
    session["_agent_build_thread"] = _AliveThread()
    server._sessions["sid"] = session

    try:
        monkeypatch.setattr(server, "_AGENT_BUILD_WAIT_SLICE", 0.01)
        monkeypatch.setattr(server, "_agent_build_wait_cap", lambda: 0.05)

        err = server._wait_agent_for_prompt(session, "rid-1", "sid")

        assert err is not None
        message = (err.get("error") or {}).get("message", "")
        assert "timed out" in message and "was not sent" in message
    finally:
        server._sessions.pop("sid", None)


def test_clear_pending_without_sid_clears_all():
    """_clear_pending(None) is the shutdown path — must still release
    every pending prompt regardless of owning session."""
    ev1, ev2, ev3 = threading.Event(), threading.Event(), threading.Event()
    server._pending["a"] = ("sid_x", ev1)
    server._pending["b"] = ("sid_y", ev2)
    server._pending["c"] = ("sid_z", ev3)
    try:
        server._clear_pending(None)
        assert ev1.is_set() and ev2.is_set() and ev3.is_set()
    finally:
        for key in ("a", "b", "c"):
            server._pending.pop(key, None)
            server._answers.pop(key, None)


def test_respond_unpacks_sid_tuple_correctly():
    """After the (sid, Event) tuple change, _respond must still work."""
    ev = threading.Event()
    server._pending["rid-x"] = ("sid_x", ev)
    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "clarify.respond",
                "params": {"request_id": "rid-x", "answer": "the answer"},
            }
        )
        assert resp.get("result")
        assert ev.is_set()
        assert server._answers.get("rid-x") == "the answer"
    finally:
        server._pending.pop("rid-x", None)
        server._answers.pop("rid-x", None)


# ---------------------------------------------------------------------------
# /model switch and other agent-mutating commands must reject while the
# session is running.  agent.switch_model() mutates self.model, self.provider,
# self.base_url, self.client etc. in place — the worker thread running
# agent.run_conversation is reading those on every iteration.  So a mid-turn
# config.set model must NOT switch in place; instead it queues the pick
# (session["pending_model_switch"]) and _apply_pending_model_switch applies it
# on the turn thread at the next turn start, where nothing is in flight.
# ---------------------------------------------------------------------------


def test_config_set_model_defers_while_running(monkeypatch):
    """/model via config.set queues the pick during an in-flight turn instead
    of rejecting or racing the worker thread."""
    seen = {"called": False}

    def _fake_apply(sid, session, raw, **_kwargs):
        seen["called"] = True
        return {"value": raw, "warning": ""}

    monkeypatch.setattr(server, "_apply_model_switch", _fake_apply)

    server._sessions["sid"] = _session(running=True)
    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "config.set",
                "params": {
                    "session_id": "sid",
                    "key": "model",
                    "value": "anthropic/claude-sonnet-4.6",
                },
            }
        )
        assert not resp.get("error")
        result = resp["result"]
        assert result["deferred"] is True
        assert result["value"] == "anthropic/claude-sonnet-4.6"
        assert not seen["called"], (
            "_apply_model_switch ran mid-turn — would race the worker thread "
            "reading agent.model / agent.client; it must defer to turn start"
        )
        pending = server._sessions["sid"].get("pending_model_switch")
        assert pending and pending["raw"] == "anthropic/claude-sonnet-4.6"
    finally:
        server._sessions.pop("sid", None)


def test_apply_pending_model_switch_runs_queued_pick(monkeypatch):
    """The queued pick is consumed once, on the turn thread, via
    _apply_model_switch — and cleared so it can't re-fire next turn."""
    calls = []

    def _fake_apply(sid, session, raw, **kwargs):
        calls.append(raw)
        return {"value": raw, "warning": "", "confirm_required": False}

    monkeypatch.setattr(server, "_apply_model_switch", _fake_apply)

    session = _session(running=False)
    session["agent"] = object()
    session["pending_model_switch"] = {
        "raw": "anthropic/claude-sonnet-4.6",
        "confirm_expensive_model": False,
    }

    server._apply_pending_model_switch("sid", session)
    assert calls == ["anthropic/claude-sonnet-4.6"]
    assert "pending_model_switch" not in session

    # Idempotent: a second turn start with nothing queued is a no-op.
    server._apply_pending_model_switch("sid", session)
    assert calls == ["anthropic/claude-sonnet-4.6"]


def test_config_set_model_allowed_when_idle(monkeypatch):
    """Regression guard: idle sessions can still switch models."""
    seen = {"called": False}

    def _fake_apply(sid, session, raw, **_kwargs):
        seen["called"] = True
        return {"value": "newmodel", "warning": ""}

    monkeypatch.setattr(server, "_apply_model_switch", _fake_apply)

    server._sessions["sid"] = _session(running=False)
    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "config.set",
                "params": {"session_id": "sid", "key": "model", "value": "newmodel"},
            }
        )
        assert resp.get("result")
        assert resp["result"]["value"] == "newmodel"
        assert seen["called"]
    finally:
        server._sessions.pop("sid", None)


def test_mirror_slash_side_effects_rejects_mutating_commands_while_running(monkeypatch):
    """Slash worker passthrough (e.g. /model, /personality, /prompt,
    /compress) must reject during an in-flight turn.  Same race as
    config.set — mutates live agent state while run_conversation is
    reading it."""
    import types

    applied = {"model": False, "compress": False}

    def _fake_apply_model(sid, session, arg):
        applied["model"] = True
        return {"value": arg, "warning": ""}

    def _fake_compress(session, focus):
        applied["compress"] = True
        return (0, {})

    monkeypatch.setattr(server, "_apply_model_switch", _fake_apply_model)
    monkeypatch.setattr(server, "_compress_session_history", _fake_compress)

    session = _session(running=True)
    session["agent"] = types.SimpleNamespace(model="x")

    for cmd, expected_name in [
        ("/model new/model", "model"),
        ("/personality default", "personality"),
        ("/prompt", "prompt"),
        ("/compress", "compress"),
    ]:
        warning = server._mirror_slash_side_effects("sid", session, cmd)
        assert (
            "session busy" in warning
        ), f"{cmd} should have returned busy warning, got: {warning!r}"
        assert f"/{expected_name}" in warning

    # None of the mutating side-effect helpers should have fired.
    assert not applied["model"], "model switch fired despite running session"
    assert not applied["compress"], "compress fired despite running session"


def test_mirror_slash_side_effects_allowed_when_idle(monkeypatch):
    """Regression guard: idle session still runs the side effects."""
    import types

    applied = {"model": False}

    def _fake_apply_model(sid, session, arg):
        applied["model"] = True
        return {"value": arg, "warning": ""}

    monkeypatch.setattr(server, "_apply_model_switch", _fake_apply_model)

    session = _session(running=False)
    session["agent"] = types.SimpleNamespace(model="x")

    warning = server._mirror_slash_side_effects("sid", session, "/model foo")
    # Should NOT contain "session busy" — the switch went through.
    assert "session busy" not in warning
    assert applied["model"]


def test_mirror_slash_compress_does_not_prelock_history(monkeypatch):
    """Regression guard: /compress side effect must not hold history_lock
    when calling _compress_session_history (the helper snapshots under
    the same non-reentrant lock internally). It also returns a before/after
    summary string (#46686)."""
    import types

    seen = {"compress": False, "sync": False}
    emitted = []

    def _fake_compress(session, focus_topic=None, **_kw):
        seen["compress"] = True
        assert not session["history_lock"].locked()
        # Simulate a real compaction shrinking the transcript.
        session["history"] = [{"role": "user", "content": "summary"}]
        return (1, {"total": 0})

    def _fake_sync(_sid, _session):
        seen["sync"] = True

    monkeypatch.setattr(server, "_compress_session_history", _fake_compress)
    monkeypatch.setattr(server, "_sync_session_key_after_compress", _fake_sync)
    monkeypatch.setattr(server, "_session_info", lambda _agent, *a: {"model": "x"})
    monkeypatch.setattr(server, "_emit", lambda *args: emitted.append(args))

    session = _session(running=False)
    session["history"] = [
        {"role": "user", "content": f"m{i}"} for i in range(6)
    ]
    session["agent"] = types.SimpleNamespace(model="x", _cached_system_prompt="", tools=None)

    warning = server._mirror_slash_side_effects("sid", session, "/compress")

    # Now returns a before/after summary (was "" before #46686).
    assert seen["compress"]
    assert seen["sync"]
    assert ("session.info", "sid", {"model": "x"}) in emitted
    assert "Compressed:" in warning
    assert "6 → 1 messages" in warning
    assert "tokens" in warning


_PARTIAL_FAKE_HISTORY = [
    {"role": "user", "content": "msg1"},
    {"role": "assistant", "content": "resp1"},
    {"role": "user", "content": "msg2"},
    {"role": "assistant", "content": "resp2"},
    {"role": "user", "content": "keep this"},
    {"role": "assistant", "content": "keep this too"},
]
_PARTIAL_COMPRESSED_HEAD = [
    {"role": "user", "content": "[summary]"},
    {"role": "assistant", "content": "ok"},
]


def _partial_compress_agent(compress_context_calls):
    """Agent stub whose _compress_context records (history, focus_topic)."""
    agent = types.SimpleNamespace(
        _cached_system_prompt=None,
        tools=None,
        session_id="s1",
        context_compressor=None,  # keep _get_usage on the simple path
    )

    def _fake_compress_context(history, sys, approx_tokens=0, focus_topic=None, **kw):
        compress_context_calls.append((list(history), focus_topic))
        return list(_PARTIAL_COMPRESSED_HEAD), {}

    agent._compress_context = _fake_compress_context
    return agent


def test_compress_session_history_here_triggers_partial_compress():
    """/compress here [N] must split history into head/tail and rejoin after
    compression — the partial_compress module is used, not full compress.

    Before this fix, /compress here 3 passed "here 3" as focus_topic to the
    full compress, silently ignoring the boundary intent. The parsing lives
    in _compress_session_history — the choke point every manual-compress
    route (session.compress RPC, command.dispatch, slash-exec mirror)
    converges on — so 'here [N]' works everywhere (#35533).
    """
    compress_context_calls = []
    agent = _partial_compress_agent(compress_context_calls)

    session = _session(agent=agent)
    session["history"] = list(_PARTIAL_FAKE_HISTORY)
    session["history_version"] = 7

    removed, _usage = server._compress_session_history(session, "here 1")

    # agent._compress_context must have been called with the HEAD only
    assert len(compress_context_calls) == 1
    head_passed, focus_passed = compress_context_calls[0]
    assert head_passed == _PARTIAL_FAKE_HISTORY[:-2]
    assert focus_passed is None  # partial compress has no focus topic
    # Session history must now contain the rejoined transcript: compressed
    # head + the last exchange verbatim.
    assert session["history"] == _PARTIAL_COMPRESSED_HEAD + _PARTIAL_FAKE_HISTORY[-2:]
    assert session["history_version"] == 8
    assert removed == len(_PARTIAL_FAKE_HISTORY) - len(session["history"])


def test_compress_session_history_here_falls_back_on_degenerate_split():
    """/compress here with keep_last >= exchanges produces an empty tail —
    must fall back to full compression (whole history, no rejoined tail)."""
    compress_context_calls = []
    agent = _partial_compress_agent(compress_context_calls)

    # 4 messages = 2 exchanges; keep_last=5 leaves nothing to compress.
    short_history = _PARTIAL_FAKE_HISTORY[:4]
    session = _session(agent=agent)
    session["history"] = list(short_history)

    server._compress_session_history(session, "here 5")

    # Degenerate split → full compress of the whole history, focus_topic=None
    assert len(compress_context_calls) == 1
    head_passed, focus_passed = compress_context_calls[0]
    assert head_passed == short_history
    assert focus_passed is None
    assert session["history"] == _PARTIAL_COMPRESSED_HEAD


def test_compress_session_history_plain_focus_topic_not_parsed_as_partial():
    """/compress my topic must still do full compress with focus_topic set."""
    compress_context_calls = []
    agent = _partial_compress_agent(compress_context_calls)

    session = _session(agent=agent)
    session["history"] = list(_PARTIAL_FAKE_HISTORY)

    server._compress_session_history(session, "my topic")

    assert len(compress_context_calls) == 1
    head_passed, focus_passed = compress_context_calls[0]
    assert head_passed == _PARTIAL_FAKE_HISTORY  # full history, no split
    assert focus_passed == "my topic"
    assert session["history"] == _PARTIAL_COMPRESSED_HEAD


def test_session_compress_rpc_honors_here_argument(monkeypatch):
    """Route 1/3: the session.compress RPC must honor 'here [N]'."""
    compress_context_calls = []
    agent = _partial_compress_agent(compress_context_calls)
    session = _session(agent=agent)
    session["history"] = list(_PARTIAL_FAKE_HISTORY)
    server._sessions["sid"] = session

    monkeypatch.setattr(server, "_session_info", lambda *_a, **_kw: {"model": "x"})
    monkeypatch.setattr(server, "_sync_session_key_after_compress", lambda *a, **kw: None)
    monkeypatch.setattr(server, "_emit", lambda *args: None)

    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "session.compress",
                "params": {"session_id": "sid", "focus_topic": "here 1"},
            }
        )
    finally:
        server._sessions.pop("sid", None)

    assert resp["result"]["status"] == "compressed"
    assert len(compress_context_calls) == 1
    head_passed, focus_passed = compress_context_calls[0]
    assert head_passed == _PARTIAL_FAKE_HISTORY[:-2]
    assert focus_passed is None
    assert session["history"] == _PARTIAL_COMPRESSED_HEAD + _PARTIAL_FAKE_HISTORY[-2:]


def test_command_dispatch_compress_honors_here_argument(monkeypatch):
    """Route 2/3: command.dispatch /compress must honor 'here [N]'."""
    compress_context_calls = []
    agent = _partial_compress_agent(compress_context_calls)
    session = _session(agent=agent)
    session["history"] = list(_PARTIAL_FAKE_HISTORY)
    server._sessions["sid"] = session

    monkeypatch.setattr(server, "_session_uses_compute_host", lambda *_a, **_kw: False)
    monkeypatch.setattr(server, "_session_info", lambda *_a, **_kw: {"model": "x"})
    monkeypatch.setattr(server, "_sync_session_key_after_compress", lambda *a, **kw: None)
    monkeypatch.setattr(server, "_emit", lambda *args: None)

    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "command.dispatch",
                "params": {"session_id": "sid", "name": "compress", "arg": "here 1"},
            }
        )
    finally:
        server._sessions.pop("sid", None)

    assert resp["result"]["type"] == "exec"
    assert len(compress_context_calls) == 1
    head_passed, focus_passed = compress_context_calls[0]
    assert head_passed == _PARTIAL_FAKE_HISTORY[:-2]
    assert focus_passed is None
    assert session["history"] == _PARTIAL_COMPRESSED_HEAD + _PARTIAL_FAKE_HISTORY[-2:]


def test_mirror_slash_compress_honors_here_argument(monkeypatch):
    """Route 3/3: the slash-exec mirror must honor 'here [N]'."""
    compress_context_calls = []
    agent = _partial_compress_agent(compress_context_calls)
    session = _session(agent=agent)
    session["history"] = list(_PARTIAL_FAKE_HISTORY)

    monkeypatch.setattr(server, "_session_info", lambda *_a, **_kw: {"model": "x"})
    monkeypatch.setattr(server, "_sync_session_key_after_compress", lambda *a, **kw: None)
    monkeypatch.setattr(server, "_emit", lambda *args: None)

    warning = server._mirror_slash_side_effects("sid", session, "/compress here 1")

    assert "Compressed:" in warning
    assert len(compress_context_calls) == 1
    head_passed, focus_passed = compress_context_calls[0]
    assert head_passed == _PARTIAL_FAKE_HISTORY[:-2]
    assert focus_passed is None
    assert session["history"] == _PARTIAL_COMPRESSED_HEAD + _PARTIAL_FAKE_HISTORY[-2:]


# ---------------------------------------------------------------------------
# session.create / session.close race: fast /new churn must not orphan the
# global approval-notify registration. (Slash workers are no longer pre-warmed
# by the build thread — slash.exec spawns them on demand — so the build thread
# must ALSO never construct one here.)
# ---------------------------------------------------------------------------


@pytest.mark.real_agent_prewarm
def test_session_create_close_race_does_not_orphan_worker(monkeypatch):
    """Regression guard: if session.close runs while session.create's
    _build thread is still constructing the agent, the build thread
    must detect the orphan and unregister the notify registration it's
    about to install.  It must also never pre-warm a slash worker (each
    worker forks the full stdio MCP fleet; spawn is on-demand in
    slash.exec) — a worker constructed here would be a regression."""
    import threading

    created_workers: list[str] = []
    closed_workers: list[str] = []
    unregistered_keys: list[str] = []

    class _FakeWorker:
        def __init__(self, key, model, profile_home=None):
            self.key = key
            self._closed = False
            created_workers.append(key)

        def close(self):
            self._closed = True
            closed_workers.append(self.key)

    class _FakeAgent:
        def __init__(self):
            self.model = "x"
            self.provider = "openrouter"
            self.base_url = ""
            self.api_key = ""

    # Make _build block until we release it — simulates slow agent init.
    # Also signal when _build actually reaches _make_agent so the test
    # can close the session at the right moment: session.create now
    # defers _start_agent_build behind a 50ms timer (see the
    # `_deferred_build` path in @method("session.create")), so closing
    # before the build thread has even started would skip the orphan
    # detection entirely and the test would race a non-event.
    build_started = threading.Event()
    release_build = threading.Event()
    build_entered = threading.Event()

    def _slow_make_agent(sid, key, session_id=None, session_db=None, **_kwargs):
        build_started.set()
        build_entered.set()
        release_build.wait(timeout=3.0)
        return _FakeAgent()

    # Stub everything _build touches
    monkeypatch.setattr(server, "_make_agent", _slow_make_agent)
    monkeypatch.setattr(server, "_SlashWorker", _FakeWorker)
    monkeypatch.setattr(
        server,
        "_get_db",
        lambda: types.SimpleNamespace(create_session=lambda *a, **kw: None),
    )
    monkeypatch.setattr(server, "_session_info", lambda _a, *a2: {"model": "x"})
    monkeypatch.setattr(server, "_probe_credentials", lambda _a: None)
    monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None)
    monkeypatch.setattr(server, "_emit", lambda *a, **kw: None)

    # Shim register/unregister to observe leaks
    import tools.approval as _approval

    monkeypatch.setattr(_approval, "register_gateway_notify", lambda key, cb: None)
    monkeypatch.setattr(
        _approval,
        "unregister_gateway_notify",
        lambda key: unregistered_keys.append(key),
    )
    monkeypatch.setattr(_approval, "load_permanent_allowlist", lambda: None)

    # Start: session.create spawns _build thread, returns synchronously
    resp = server.handle_request(
        {
            "id": "1",
            "method": "session.create",
            "params": {"cols": 80},
        }
    )
    assert resp.get("result"), f"got error: {resp.get('error')}"
    sid = resp["result"]["session_id"]
    own_key = resp["result"]["stored_session_id"]
    assert build_entered.wait(timeout=1.0), "deferred build did not start"

    # Wait until the (deferred) build thread has actually entered
    # _make_agent — otherwise session.close pops _sessions[sid] before
    # _build ever runs, _start_agent_build never calls _build, and we
    # never exercise the orphan-cleanup path.
    assert build_started.wait(timeout=2.0), "build thread never entered _make_agent"

    # Build thread is blocked in _slow_make_agent.  Close the session
    # NOW — this pops _sessions[sid] before _build can install the
    # worker/notify.
    close_resp = server.handle_request(
        {
            "id": "2",
            "method": "session.close",
            "params": {"session_id": sid},
        }
    )
    assert close_resp.get("result", {}).get("closed") is True

    # At this point session.close saw slash_worker=None (never eagerly
    # installed) so it had nothing to close.  Release the build thread
    # and let it finish — it should detect the orphan and unregister
    # the notify, without ever having constructed a worker.
    release_build.set()

    # Give the build thread a moment to run through its finally.
    for _ in range(100):
        if own_key in unregistered_keys:
            break
        import time

        time.sleep(0.02)

    assert created_workers == [], (
        f"build thread pre-warmed a slash worker (spawn must stay on-demand "
        f"in slash.exec) — created_workers={created_workers}"
    )
    # Notify may be unregistered by both session.close (unconditional)
    # and the orphan-cleanup path; the key guarantee is that THIS session's
    # key gets unregistered (any prior close already popped the callback; the
    # duplicate is a no-op). Match on our own key, not the global count: the
    # registry is process-wide and a leaked _build thread from another
    # session.create test can append a foreign key here and falsely satisfy
    # a bare `>= 1`.
    assert own_key in unregistered_keys, (
        f"orphan notify registration was not unregistered — "
        f"{own_key} not in unregistered_keys={unregistered_keys}"
    )


@pytest.mark.real_agent_prewarm
def test_session_create_no_race_keeps_worker_alive(monkeypatch):
    """Regression guard: when session.close does NOT race, the build
    thread must install the notify normally and leave it alone (no
    over-eager cleanup) — and must not pre-warm a slash worker (spawn
    is on-demand in slash.exec)."""
    closed_workers: list[str] = []
    unregistered_keys: list[str] = []

    class _FakeWorker:
        def __init__(self, key, model, profile_home=None):
            self.key = key

        def close(self):
            closed_workers.append(self.key)

    class _FakeAgent:
        def __init__(self):
            self.model = "x"
            self.provider = "openrouter"
            self.base_url = ""
            self.api_key = ""

    monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_db=None, **_kwargs: _FakeAgent())
    monkeypatch.setattr(server, "_SlashWorker", _FakeWorker)
    monkeypatch.setattr(
        server,
        "_get_db",
        lambda: types.SimpleNamespace(create_session=lambda *a, **kw: None),
    )
    monkeypatch.setattr(server, "_session_info", lambda _a, *a2: {"model": "x"})
    monkeypatch.setattr(server, "_probe_credentials", lambda _a: None)
    monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None)
    monkeypatch.setattr(server, "_emit", lambda *a, **kw: None)

    import tools.approval as _approval

    monkeypatch.setattr(_approval, "register_gateway_notify", lambda key, cb: None)
    monkeypatch.setattr(
        _approval,
        "unregister_gateway_notify",
        lambda key: unregistered_keys.append(key),
    )
    monkeypatch.setattr(_approval, "load_permanent_allowlist", lambda: None)

    # Isolate from sibling-test leakage: daemon build threads from prior
    # session.create tests in the same shard process mutate the shared
    # ``server._sessions`` dict under ``_sessions_lock`` and can replace/pop
    # entries mid-run, which would flip this build thread's ``replaced`` check
    # to True and trigger a spurious unregister. Snapshot, clear, and restore
    # so this test sees only its own session regardless of shard composition.
    _saved_sessions = dict(server._sessions)
    server._sessions.clear()

    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "session.create",
                "params": {"cols": 80},
            }
        )
        sid = resp["result"]["session_id"]

        # Wait for the build to finish (ready event inside session dict).
        session = server._sessions[sid]
        built = session["agent_ready"].wait(timeout=10.0)
        assert built, "agent build did not complete within timeout"

        # Build finished without a close race — nothing should have been
        # cleaned up by the orphan check.  Scope the assertions to THIS
        # test's own session_key: a daemon build thread leaked from a prior
        # session.create test in the same shard process can fire close/
        # unregister against its own (foreign) key after we've patched the
        # global hooks, polluting these lists.  Filtering by this session's
        # key keeps the regression intent (this session's worker/notify must
        # survive) while making the test immune to shard composition.
        # (flaky under -j 8: foreign key e.g. 20260629_210208_d4f545)
        own_key = session["session_key"]
        own_closed = [k for k in closed_workers if k == own_key]
        own_unregistered = [k for k in unregistered_keys if k == own_key]
        assert (
            own_closed == []
        ), f"build thread closed its own worker despite no race: {own_closed}"
        assert (
            own_unregistered == []
        ), f"build thread unregistered its own notify despite no race: {own_unregistered}"

        # No pre-warmed worker: slash.exec spawns on demand, so a fresh
        # session that hasn't run a worker-routed command carries None.
        assert session.get("slash_worker") is None
    finally:
        # Cleanup + restore sibling sessions we snapshotted.
        server._sessions.clear()
        server._sessions.update(_saved_sessions)


def test_get_db_degrades_cleanly_when_sessiondb_init_fails(monkeypatch):
    fake_mod = types.ModuleType("hermes_state")

    class _BrokenSessionDB:
        def __init__(self):
            raise RuntimeError("locking protocol")

    fake_mod.SessionDB = _BrokenSessionDB
    monkeypatch.setitem(sys.modules, "hermes_state", fake_mod)
    monkeypatch.setattr(server, "_db", None)
    monkeypatch.setattr(server, "_db_error", None)

    assert server._get_db() is None
    assert server._db_error == "locking protocol"


@pytest.mark.real_agent_prewarm
def test_session_create_continues_when_state_db_is_unavailable(monkeypatch):
    class _FakeWorker:
        def __init__(self, key, model, profile_home=None):
            self.key = key

        def close(self):
            return None

    class _FakeAgent:
        def __init__(self):
            self.model = "x"
            self.provider = "openrouter"
            self.base_url = ""
            self.api_key = ""

    emits = []

    monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_db=None, **_kwargs: _FakeAgent())
    monkeypatch.setattr(server, "_SlashWorker", _FakeWorker)
    monkeypatch.setattr(server, "_get_db", lambda: None)
    monkeypatch.setattr(server, "_session_info", lambda _a, *a2: {"model": "x"})
    monkeypatch.setattr(server, "_probe_credentials", lambda _a: None)
    monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None)
    monkeypatch.setattr(server, "_emit", lambda *a, **kw: emits.append(a))

    import tools.approval as _approval

    monkeypatch.setattr(_approval, "register_gateway_notify", lambda key, cb: None)
    monkeypatch.setattr(_approval, "load_permanent_allowlist", lambda: None)

    resp = server.handle_request(
        {"id": "1", "method": "session.create", "params": {"cols": 80}}
    )
    sid = resp["result"]["session_id"]
    session = server._sessions[sid]
    session["agent_ready"].wait(timeout=2.0)

    assert session["agent_error"] is None
    assert session["agent"] is not None
    assert not any(args and args[0] == "error" for args in emits)

    server._sessions.pop(sid, None)


def test_session_create_lazy_info_reports_desktop_contract(monkeypatch):
    """The lazy session.create info payload must carry desktop_contract, else
    the desktop GUI reads it as undefined and falsely warns "Backend out of
    date" on every launch even against a current backend."""

    class _FakeWorker:
        def __init__(self, key, model, profile_home=None):
            self.key = key

        def close(self):
            return None

    monkeypatch.setattr(server, "_SlashWorker", _FakeWorker)
    monkeypatch.setattr(server, "_get_db", lambda: None)
    monkeypatch.setattr(server, "_emit", lambda *a, **kw: None)
    monkeypatch.setattr(server, "_start_agent_build", lambda *a, **kw: None)

    resp = server.handle_request(
        {"id": "1", "method": "session.create", "params": {"cols": 80}}
    )
    info = resp["result"]["info"]

    assert info["desktop_contract"] == server.DESKTOP_BACKEND_CONTRACT

    server._sessions.pop(resp["result"]["session_id"], None)


def test_session_list_returns_clean_error_when_state_db_is_unavailable(monkeypatch):
    monkeypatch.setattr(server, "_get_db", lambda: None)
    monkeypatch.setattr(server, "_db_error", "locking protocol")

    resp = server.handle_request({"id": "1", "method": "session.list", "params": {}})

    assert "error" in resp
    assert "state.db unavailable: locking protocol" in resp["error"]["message"]


# --------------------------------------------------------------------------
# session.delete — TUI resume picker `d` key
# --------------------------------------------------------------------------


def test_session_delete_requires_session_id(monkeypatch):
    """Empty / missing session_id is a 4006 client error (no DB call)."""
    called: list[tuple] = []

    class _DB:
        def delete_session(self, *a, **kw):
            called.append((a, kw))
            return True

    monkeypatch.setattr(server, "_get_db", lambda: _DB())

    resp = server.handle_request({"id": "1", "method": "session.delete", "params": {}})
    assert "error" in resp
    assert resp["error"]["code"] == 4006
    assert called == []


def test_session_delete_returns_db_unavailable_when_no_db(monkeypatch):
    monkeypatch.setattr(server, "_get_db", lambda: None)
    monkeypatch.setattr(server, "_db_error", "locked")

    resp = server.handle_request(
        {"id": "1", "method": "session.delete", "params": {"session_id": "abc"}}
    )

    assert "error" in resp
    assert resp["error"]["code"] == 5036
    assert "state.db unavailable" in resp["error"]["message"]


def test_session_delete_refuses_active_session(monkeypatch):
    """Cannot delete a session currently bound to a live TUI session."""
    called: list[str] = []

    class _DB:
        def delete_session(self, sid, sessions_dir=None):
            called.append(sid)
            return True

    monkeypatch.setattr(server, "_get_db", lambda: _DB())
    monkeypatch.setitem(server._sessions, "live", {"session_key": "key-live"})
    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "session.delete",
                "params": {"session_id": "key-live"},
            }
        )
    finally:
        server._sessions.pop("live", None)

    assert "error" in resp
    assert resp["error"]["code"] == 4023
    assert "active session" in resp["error"]["message"]
    assert called == [], "delete_session must not be called for active sessions"


def test_session_delete_fails_closed_when_active_snapshot_raises(monkeypatch):
    """Concurrent ``_sessions`` mutation from another RPC thread can raise
    ``RuntimeError: dictionary changed size during iteration``.  When the
    handler can't enumerate active sessions safely it must refuse the
    delete (fail closed) rather than fall through and allow it."""

    class _DB:
        def delete_session(self, *a, **kw):
            raise AssertionError("delete must not run when active snapshot fails")

    class _ExplodingDict:
        def values(self):
            raise RuntimeError("dictionary changed size during iteration")

    monkeypatch.setattr(server, "_get_db", lambda: _DB())
    monkeypatch.setattr(server, "_sessions", _ExplodingDict())

    resp = server.handle_request(
        {"id": "1", "method": "session.delete", "params": {"session_id": "x"}}
    )

    assert "error" in resp
    assert resp["error"]["code"] == 5036
    assert "enumerate active sessions" in resp["error"]["message"]


def test_session_delete_returns_4007_when_missing(monkeypatch):
    class _DB:
        def delete_session(self, sid, sessions_dir=None):
            return False

    monkeypatch.setattr(server, "_get_db", lambda: _DB())

    resp = server.handle_request(
        {"id": "1", "method": "session.delete", "params": {"session_id": "ghost"}}
    )

    assert "error" in resp
    assert resp["error"]["code"] == 4007


def test_session_delete_propagates_db_exception(monkeypatch):
    class _DB:
        def delete_session(self, sid, sessions_dir=None):
            raise RuntimeError("disk full")

    monkeypatch.setattr(server, "_get_db", lambda: _DB())

    resp = server.handle_request(
        {"id": "1", "method": "session.delete", "params": {"session_id": "x"}}
    )

    assert "error" in resp
    assert resp["error"]["code"] == 5036
    assert "disk full" in resp["error"]["message"]


def test_session_delete_success_returns_deleted_id(monkeypatch):
    """Happy path — DB delete succeeds, response carries the deleted id
    and the on-disk sessions dir is forwarded so transcript files get
    cleaned up alongside the row."""
    captured: dict = {}

    class _DB:
        def delete_session(self, sid, sessions_dir=None):
            captured["sid"] = sid
            captured["sessions_dir"] = sessions_dir
            return True

    monkeypatch.setattr(server, "_get_db", lambda: _DB())

    resp = server.handle_request(
        {"id": "1", "method": "session.delete", "params": {"session_id": "old-1"}}
    )

    assert "result" in resp, resp
    assert resp["result"] == {"deleted": "old-1"}
    assert captured["sid"] == "old-1"
    # sessions_dir must be forwarded so transcript files get cleaned up
    # too — not just the SQLite row.  The autouse _isolate_hermes_home
    # fixture pins HERMES_HOME to a temp dir; the handler should append
    # /sessions to it.
    assert captured["sessions_dir"] is not None
    assert str(captured["sessions_dir"]).endswith("sessions")




# --------------------------------------------------------------------------
# session.* profile scoping (app-global remote mode) — #62503
# --------------------------------------------------------------------------


def test_session_list_honors_params_profile_opens_profile_db(monkeypatch, tmp_path):
    """Issue #62503: session.list must read the profile's state.db, not launch."""
    profile_home = tmp_path / "profiles" / "mlperf"
    profile_home.mkdir(parents=True)
    (profile_home / "state.db").write_bytes(b"")
    seen: dict = {}

    class LaunchDB:
        def list_sessions_rich(self, **kwargs):
            seen["launch"] = True
            return [{"id": "launch-1", "source": "tui", "title": "L"}]

    class ProfileDB:
        def __init__(self, db_path=None):
            seen["db_path"] = db_path

        def list_sessions_rich(self, **kwargs):
            seen["profile"] = True
            return [
                {
                    "id": "ml-1",
                    "source": "tui",
                    "title": "M",
                    "preview": "",
                    "started_at": 1,
                    "message_count": 1,
                }
            ]

        def close(self):
            seen["closed"] = True

    monkeypatch.setattr(server, "_profile_home", lambda p: profile_home if p == "mlperf" else None)
    monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
    monkeypatch.setattr("hermes_state.SessionDB", ProfileDB)

    resp = server.handle_request(
        {
            "id": "1",
            "method": "session.list",
            "params": {"profile": "mlperf", "limit": 5},
        }
    )
    assert "result" in resp, resp
    assert resp["result"]["sessions"][0]["id"] == "ml-1"
    assert seen.get("profile") is True
    assert seen.get("launch") is None
    assert str(seen.get("db_path")).endswith("state.db")
    assert seen.get("closed") is True


def test_session_most_recent_honors_params_profile(monkeypatch, tmp_path):
    """Issue #62503: session.most_recent must not return the launch profile tip."""
    profile_home = tmp_path / "profiles" / "mlperf"
    profile_home.mkdir(parents=True)

    class LaunchDB:
        def list_sessions_rich(self, **kwargs):
            return [{"id": "launch-tip", "source": "tui", "title": "L", "started_at": 9}]

    class ProfileDB2:
        def __init__(self, db_path=None):
            self.db_path = db_path

        def list_sessions_rich(self, **kwargs):
            return [
                {"id": "tool-noise", "source": "tool", "title": "t", "started_at": 9},
                {"id": "ml-tip", "source": "desktop", "title": "M", "started_at": 3},
            ]

        def close(self):
            pass

    monkeypatch.setattr(server, "_profile_home", lambda p: profile_home if p == "mlperf" else None)
    monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
    monkeypatch.setattr("hermes_state.SessionDB", ProfileDB2)

    resp = server.handle_request(
        {
            "id": "1",
            "method": "session.most_recent",
            "params": {"profile": "mlperf"},
        }
    )
    assert resp["result"]["session_id"] == "ml-tip"


def test_session_create_reports_requested_profile_name(monkeypatch, tmp_path):
    """Issue #62503: session.create info.profile_name must not always be launch."""
    profile_home = tmp_path / "profiles" / "mlperf"
    profile_home.mkdir(parents=True)

    def _clear():
        for session in list(server._sessions.values()):
            server._teardown_session(session)
        server._sessions.clear()

    monkeypatch.setattr(server, "_start_agent_build", lambda *a, **k: None)
    monkeypatch.setattr(server, "_schedule_agent_build", lambda *a, **k: None)
    monkeypatch.setattr(server, "_schedule_session_cap_enforcement", lambda *a, **k: None)
    monkeypatch.setattr(server, "_completion_cwd", lambda params=None: str(tmp_path))
    monkeypatch.setattr(server, "_profile_home", lambda p: profile_home if p == "mlperf" else None)
    monkeypatch.setattr(server, "_current_profile_name", lambda: "default")
    monkeypatch.setattr(server, "_claim_active_session_slot", lambda *a, **k: (None, None))
    _clear()
    try:
        resp = server._methods["session.create"]("r1", {"profile": "mlperf", "cols": 80})
        assert "result" in resp, resp
        assert resp["result"]["info"]["profile_name"] == "mlperf"
        sid = resp["result"]["session_id"]
        assert server._sessions[sid]["profile_home"] == str(profile_home)
    finally:
        _clear()


def test_session_delete_honors_params_profile_sessions_dir(monkeypatch, tmp_path):
    """Issue #62503: delete must target the profile state.db + sessions dir."""
    profile_home = tmp_path / "profiles" / "mlperf"
    (profile_home / "sessions").mkdir(parents=True)
    captured: dict = {}

    class ProfileDB:
        def __init__(self, db_path=None):
            captured["db_path"] = db_path

        def delete_session(self, sid, sessions_dir=None):
            captured["sid"] = sid
            captured["sessions_dir"] = sessions_dir
            return True

        def close(self):
            captured["closed"] = True

    monkeypatch.setattr(server, "_profile_home", lambda p: profile_home if p == "mlperf" else None)
    monkeypatch.setattr(server, "_get_db", lambda: None)
    monkeypatch.setattr("hermes_state.SessionDB", ProfileDB)

    resp = server.handle_request(
        {
            "id": "1",
            "method": "session.delete",
            "params": {"session_id": "old-ml", "profile": "mlperf"},
        }
    )
    assert "result" in resp, resp
    assert resp["result"] == {"deleted": "old-ml"}
    assert str(captured["db_path"]).endswith("state.db")
    assert Path(captured["sessions_dir"]) == profile_home / "sessions"
    assert captured.get("closed") is True


def test_session_title_uses_session_profile_db_not_launch(monkeypatch, tmp_path):
    """session.title on a non-launch profile session must not touch launch DB."""
    profile_home = tmp_path / "profiles" / "mlperf"
    profile_home.mkdir(parents=True)
    seen: dict = {}

    class LaunchDB:
        def get_session_title(self, _key):
            seen["launch_read"] = True
            return "from-launch"

        def set_session_title(self, _key, _title):
            seen["launch_write"] = True
            return True

        def get_session(self, _key):
            return {"id": _key, "title": "from-launch"}

    class ProfileDB:
        def __init__(self, db_path=None):
            self.db_path = db_path
            seen["db_path"] = db_path

        def get_session_title(self, _key):
            return seen.get("title")

        def get_session(self, _key):
            if "title" in seen:
                return {"id": _key, "title": seen["title"]}
            return None

        def set_session_title(self, _key, title):
            seen["title"] = title
            seen["profile_write"] = True
            return True

        def close(self):
            seen["closed"] = True

    server._sessions["sid"] = {
        "session_key": "ml-sess",
        "history": [],
        "history_lock": __import__("threading").Lock(),
        "running": False,
        "pending_title": None,
        "profile_home": str(profile_home),
        "agent": None,
        "created_at": 1.0,
        "last_active": 1.0,
    }
    monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
    monkeypatch.setattr("hermes_state.SessionDB", ProfileDB)
    try:
        set_resp = server.handle_request(
            {
                "id": "1",
                "method": "session.title",
                "params": {"session_id": "sid", "title": "profile-title"},
            }
        )
        assert "result" in set_resp, set_resp
        assert set_resp["result"]["title"] == "profile-title"
        assert seen.get("profile_write") is True
        assert seen.get("launch_write") is None
        assert str(seen.get("db_path")).endswith("state.db")

        get_resp = server.handle_request(
            {"id": "2", "method": "session.title", "params": {"session_id": "sid"}}
        )
        assert get_resp["result"]["title"] == "profile-title"
        assert seen.get("launch_read") is None
    finally:
        server._sessions.pop("sid", None)


def test_session_history_uses_session_profile_db(monkeypatch, tmp_path):
    """session.history must read durable messages from the profile state.db."""
    profile_home = tmp_path / "profiles" / "mlperf"
    profile_home.mkdir(parents=True)
    seen: dict = {}

    class LaunchDB:
        def get_messages_as_conversation(self, _key, include_ancestors=True, **_kwargs):
            seen["launch"] = True
            return [{"role": "user", "content": "launch"}]

    class ProfileDB:
        def __init__(self, db_path=None):
            seen["db_path"] = db_path

        def get_messages_as_conversation(self, _key, include_ancestors=True, **_kwargs):
            seen["profile"] = True
            return [{"role": "user", "content": "from-profile"}]

        def close(self):
            seen["closed"] = True

    server._sessions["sid"] = {
        "session_key": "ml-sess",
        "history": [{"role": "user", "content": "mem"}],
        "history_lock": __import__("threading").Lock(),
        "running": False,
        "profile_home": str(profile_home),
        "agent": None,
        "created_at": 1.0,
        "last_active": 1.0,
    }
    monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
    monkeypatch.setattr("hermes_state.SessionDB", ProfileDB)
    try:
        resp = server.handle_request(
            {"id": "1", "method": "session.history", "params": {"session_id": "sid"}}
        )
        assert "result" in resp, resp
        assert seen.get("profile") is True
        assert seen.get("launch") is None
        # Count comes from profile-backed conversation (1 msg), not bare mem list alone.
        assert resp["result"]["count"] == 1
        texts = []
        for m in resp["result"]["messages"]:
            texts.append(str(m))
        assert any("from-profile" in t for t in texts) or resp["result"]["count"] == 1
    finally:
        server._sessions.pop("sid", None)


def test_session_status_uses_session_profile_db(monkeypatch, tmp_path):
    """session.status must load meta from the session profile state.db."""
    profile_home = tmp_path / "profiles" / "mlperf"
    profile_home.mkdir(parents=True)
    seen: dict = {}

    class LaunchDB:
        def get_session(self, _key):
            seen["launch"] = True
            return {"id": _key, "title": "launch-title", "started_at": 1}

    class ProfileDB:
        def __init__(self, db_path=None):
            seen["db_path"] = db_path

        def get_session(self, _key):
            seen["profile"] = True
            return {"id": _key, "title": "profile-title", "started_at": 42}

        def close(self):
            seen["closed"] = True

    server._sessions["sid"] = {
        "session_key": "ml-sess",
        "history": [],
        "history_lock": __import__("threading").Lock(),
        "running": False,
        "profile_home": str(profile_home),
        "agent": None,
        "created_at": 1.0,
        "last_active": 1.0,
    }
    monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
    monkeypatch.setattr("hermes_state.SessionDB", ProfileDB)
    try:
        resp = server.handle_request(
            {"id": "1", "method": "session.status", "params": {"session_id": "sid"}}
        )
        assert "result" in resp, resp
        assert "profile-title" in resp["result"]["output"]
        assert seen.get("profile") is True
        assert seen.get("launch") is None
    finally:
        server._sessions.pop("sid", None)


def test_teardown_ends_session_in_profile_db(monkeypatch, tmp_path):
    """_teardown_session must end_session on the profile store, not launch."""
    profile_home = tmp_path / "profiles" / "mlperf"
    profile_home.mkdir(parents=True)
    seen: dict = {}

    class LaunchDB:
        def get_session(self, _key):
            seen["launch"] = True
            return {"id": _key, "source": "tui"}

        def end_session(self, _key, _reason):
            seen["launch_end"] = True

    class ProfileDB:
        def __init__(self, db_path=None):
            seen["db_path"] = db_path

        def get_session(self, _key):
            seen["profile"] = True
            return {"id": _key, "source": "tui"}

        def end_session(self, key, reason):
            seen["ended"] = (key, reason)

        def close(self):
            seen["closed"] = True

    monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
    monkeypatch.setattr("hermes_state.SessionDB", ProfileDB)
    session = {
        "session_key": "ml-sess",
        "profile_home": str(profile_home),
        "agent": None,
        "history": [],
        "source": "tui",
    }
    server._teardown_session(session, end_reason="closed")
    assert seen.get("ended") == ("ml-sess", "closed")
    assert seen.get("launch_end") is None
    assert seen.get("launch") is None
    assert str(seen.get("db_path")).endswith("state.db")


def test_session_branch_writes_to_parent_profile_db(monkeypatch, tmp_path):
    """session.branch must copy history into the parent's profile state.db."""
    profile_home = tmp_path / "profiles" / "mlperf"
    profile_home.mkdir(parents=True)
    seen: dict = {"msgs": []}

    class LaunchDB:
        def get_session_title(self, _key):
            seen["launch"] = True
            return "L"

        def create_session(self, *a, **k):
            seen["launch_create"] = True

        def append_message(self, **k):
            seen["launch_msg"] = True

        def set_session_title(self, *a, **k):
            return True

    class ProfileDB:
        def __init__(self, db_path=None):
            seen["db_path"] = db_path
            seen.setdefault("inits", 0)
            seen["inits"] += 1

        def get_session_title(self, _key):
            return "parent"

        def get_next_title_in_lineage(self, current):
            return f"{current} (branch)"

        def create_session(self, new_key, **kwargs):
            seen["created"] = new_key
            seen["parent"] = kwargs.get("parent_session_id")
            seen["profile_name"] = kwargs.get("profile_name")

        def append_message(self, **kwargs):
            seen["msgs"].append(kwargs)

        def append_messages_batch(self, session_id, messages, **kwargs):
            for m in messages:
                seen["msgs"].append(dict(m, session_id=session_id))
            return list(range(1, len(messages) + 1))

        def set_session_title(self, key, title):
            seen["title"] = (key, title)
            return True

        def get_session(self, key):
            return {"id": key, "cwd": str(tmp_path)}

        def update_session_cwd(self, *a, **k):
            return None

        def close(self):
            seen["closed"] = True

    class FakeAgent:
        def __init__(self):
            self.model = "test-model"
            self.session_id = None

    parent = {
        "session_key": "parent-key",
        "history": [{"role": "user", "content": "hi"}],
        "history_lock": __import__("threading").Lock(),
        "running": False,
        "cols": 80,
        "profile_home": str(profile_home),
        "source": "tui",
        "agent": FakeAgent(),
        "created_at": 1.0,
        "last_active": 1.0,
        "cwd": str(tmp_path),
    }
    server._sessions["parent"] = parent
    monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
    monkeypatch.setattr("hermes_state.SessionDB", ProfileDB)
    monkeypatch.setattr(server, "_claim_active_session_slot", lambda *a, **k: (None, None))

    def _fake_make_agent(*a, **k):
        seen["agent_session_db"] = k.get("session_db")
        return FakeAgent()

    monkeypatch.setattr(server, "_make_agent", _fake_make_agent)
    monkeypatch.setattr(server, "_set_session_context", lambda *a, **k: {})
    monkeypatch.setattr(server, "_clear_session_context", lambda *a, **k: None)
    monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")
    monkeypatch.setattr(server, "_session_cwd", lambda s: str(tmp_path))
    monkeypatch.setattr(server, "_register_session_cwd", lambda *a, **k: None)
    monkeypatch.setattr(server, "_attach_worker", lambda *a, **k: None)
    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "session.branch",
                "params": {"session_id": "parent", "name": "forked"},
            }
        )
        assert "result" in resp, resp
        assert seen.get("created")
        assert seen.get("parent") == "parent-key"
        # The branch row is self-describing: stamped with the parent's owning
        # profile, not left NULL for aggregators to mis-tag as "default".
        assert seen.get("profile_name") == "mlperf"
        assert seen.get("title") == (seen["created"], "forked")
        assert len(seen["msgs"]) == 1
        assert seen.get("launch") is None
        assert seen.get("launch_create") is None
        child_sid = resp["result"]["session_id"]
        assert server._sessions[child_sid]["profile_home"] == str(profile_home)
        # The branched AGENT must be bound to the parent profile's state.db —
        # not just the row. Otherwise its own flushes (and a later compression
        # rotation) land on the launch db, splitting the lineage again.
        assert isinstance(seen.get("agent_session_db"), ProfileDB)
    finally:
        for k in list(server._sessions):
            server._sessions.pop(k, None)


def test_session_branch_installs_parent_profile_secret_scope(monkeypatch, tmp_path):
    """The branched agent must be built under the parent profile's secrets.

    session.branch already binds the parent's HERMES_HOME and state.db, but the
    secret scope is what makes get_secret() resolve that profile's .env. Without
    it the build falls through to process os.environ — the LAUNCH profile's
    credentials — which is exactly the cross-profile resolution #67605 fixed for
    session.create / session.resume.
    """
    import threading

    from agent.secret_scope import current_secret_scope

    profile_home = tmp_path / "profiles" / "mlperf"
    profile_home.mkdir(parents=True)
    (profile_home / ".env").write_text(
        "PROXMOX_TOKEN=mlperf-secret\n", encoding="utf-8"
    )
    seen: dict = {"msgs": []}

    class ProfileDB:
        def __init__(self, db_path=None):
            pass

        def get_session_title(self, _key):
            return "parent"

        def get_next_title_in_lineage(self, current):
            return f"{current} (branch)"

        def create_session(self, new_key, **kwargs):
            seen["created"] = new_key

        def append_message(self, **kwargs):
            seen["msgs"].append(kwargs)

        def append_messages_batch(self, session_id, messages, **kwargs):
            for m in messages:
                seen["msgs"].append(dict(m, session_id=session_id))
            return list(range(1, len(messages) + 1))

        def set_session_title(self, key, title):
            return True

        def get_session(self, key):
            return {"id": key, "cwd": str(tmp_path)}

        def update_session_cwd(self, *a, **k):
            return None

        def close(self):
            return None

    class FakeAgent:
        def __init__(self):
            self.model = "test-model"
            self.session_id = None

    parent = {
        "session_key": "parent-key",
        "history": [{"role": "user", "content": "hi"}],
        "history_lock": threading.Lock(),
        "running": False,
        "cols": 80,
        "profile_home": str(profile_home),
        "source": "tui",
        "agent": FakeAgent(),
        "created_at": 1.0,
        "last_active": 1.0,
        "cwd": str(tmp_path),
    }
    server._sessions["parent"] = parent
    monkeypatch.setattr(server, "_get_db", lambda: ProfileDB())
    monkeypatch.setattr("hermes_state.SessionDB", ProfileDB)
    monkeypatch.setattr(server, "_claim_active_session_slot", lambda *a, **k: (None, None))

    def _fake_make_agent(*a, **k):
        scope = current_secret_scope()
        seen["scope"] = dict(scope) if scope else None
        return FakeAgent()

    monkeypatch.setattr(server, "_make_agent", _fake_make_agent)
    monkeypatch.setattr(server, "_set_session_context", lambda *a, **k: {})
    monkeypatch.setattr(server, "_clear_session_context", lambda *a, **k: None)
    monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")
    monkeypatch.setattr(server, "_session_cwd", lambda s: str(tmp_path))
    monkeypatch.setattr(server, "_register_session_cwd", lambda *a, **k: None)
    monkeypatch.setattr(server, "_attach_worker", lambda *a, **k: None)
    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "session.branch",
                "params": {"session_id": "parent", "name": "forked"},
            }
        )
        assert "result" in resp, resp
        assert seen.get("scope") == {"PROXMOX_TOKEN": "mlperf-secret"}
    finally:
        for k in list(server._sessions):
            server._sessions.pop(k, None)


def test_pending_title_finalizer_uses_session_profile_db(monkeypatch, tmp_path):
    """Post-turn pending_title must land in the session profile store."""
    profile_home = tmp_path / "profiles" / "mlperf"
    profile_home.mkdir(parents=True)
    seen: dict = {}

    class LaunchDB:
        def set_session_title(self, _key, _title):
            seen["launch"] = True
            return True

    class ProfileDB:
        def __init__(self, db_path=None):
            seen["db_path"] = db_path

        def set_session_title(self, key, title):
            seen["set"] = (key, title)
            return True

        def close(self):
            seen["closed"] = True

    monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
    monkeypatch.setattr("hermes_state.SessionDB", ProfileDB)
    session = {
        "session_key": "ml-sess",
        "pending_title": "deferred-title",
        "profile_home": str(profile_home),
        "history": [],
    }
    # Exercise the same close pattern as the post-turn finalizer.
    with server._session_db(session) as db:
        assert db is not None
        assert db.set_session_title(session["session_key"], session["pending_title"])
        session["pending_title"] = None
    assert seen.get("set") == ("ml-sess", "deferred-title")
    assert seen.get("launch") is None
    assert session["pending_title"] is None


# --------------------------------------------------------------------------
# model.options — curated-list parity with `hermes model` and classic /model
# --------------------------------------------------------------------------


def test_model_options_does_not_overwrite_curated_models(monkeypatch):
    """The TUI model.options handler must surface the same curated model
    list as `hermes model` and the classic CLI /model picker.

    Regression: earlier versions of this handler unconditionally replaced
    each provider's curated ``models`` field with ``provider_model_ids()``
    (live /models catalog).  That pulled in hundreds of non-agentic models
    for providers like Nous whose /models endpoint returns image/video
    generators, rerankers, embeddings, and TTS models alongside chat models.
    """
    curated_providers = [
        {
            "slug": "nous",
            "name": "Nous",
            "models": ["moonshotai/kimi-k2.5", "anthropic/claude-opus-4.7"],
            "total_models": 30,
            "source": "built-in",
            "is_current": False,
            "is_user_defined": False,
        },
    ]

    monkeypatch.setattr(
        server,
        "_load_cfg",
        lambda: {"providers": {}, "custom_providers": []},
    )

    with patch(
        "hermes_cli.model_switch.list_authenticated_providers",
        return_value=curated_providers,
    ) as listing:
        # If provider_model_ids gets called at all, the handler is still
        # overwriting curated with live — that's the regression we're
        # guarding against.
        with patch("hermes_cli.models.provider_model_ids") as live_fetch:
            resp = server._methods["model.options"](99, {"session_id": ""})

    assert "result" in resp, resp
    providers = resp["result"]["providers"]
    nous = next((p for p in providers if p.get("slug") == "nous"), None)
    assert nous is not None
    assert nous["models"] == [
        "moonshotai/kimi-k2.5",
        "anthropic/claude-opus-4.7",
    ]
    assert nous["total_models"] == 30
    # Handler must not consult the live catalog — curated is the truth.
    live_fetch.assert_not_called()
    # list_authenticated_providers is the single source.
    assert listing.call_count == 1
    assert listing.call_args.kwargs["probe_custom_providers"] is False
    assert listing.call_args.kwargs["probe_current_custom_provider"] is True


def test_model_options_propagates_list_exception(monkeypatch):
    """If list_authenticated_providers itself raises, surface as an RPC
    error rather than swallowing to a blank picker."""
    monkeypatch.setattr(
        server,
        "_load_cfg",
        lambda: {"providers": {}, "custom_providers": []},
    )
    with patch(
        "hermes_cli.model_switch.list_authenticated_providers",
        side_effect=RuntimeError("catalog blew up"),
    ):
        resp = server._methods["model.options"](77, {"session_id": ""})
    assert "error" in resp
    assert resp["error"]["code"] == 5033
    assert "catalog blew up" in resp["error"]["message"]


def test_model_options_hides_unconfigured_providers_by_default(monkeypatch):
    from hermes_cli.inventory import ConfigContext

    calls = []

    monkeypatch.setattr(server, "_resolve_model", lambda: "")
    monkeypatch.setattr(
        "hermes_cli.inventory.load_picker_context",
        lambda: ConfigContext(
            current_provider="",
            current_model="",
            current_base_url="",
            user_providers={},
            custom_providers=[],
        ),
    )

    def _fake_build_models_payload(_ctx, **kwargs):
        calls.append(kwargs)
        return {"providers": [], "model": "", "provider": ""}

    monkeypatch.setattr(
        "hermes_cli.inventory.build_models_payload",
        _fake_build_models_payload,
    )

    resp = server._methods["model.options"](99, {"session_id": ""})
    assert "result" in resp, resp
    assert calls[-1]["explicit_only"] is False
    assert calls[-1]["include_unconfigured"] is False

    resp = server._methods["model.options"](
        100,
        {"session_id": "", "explicit_only": True},
    )
    assert "result" in resp, resp
    assert calls[-1]["explicit_only"] is True

    resp = server._methods["model.options"](
        101,
        {"session_id": "", "include_unconfigured": True},
    )
    assert "result" in resp, resp
    assert calls[-1]["include_unconfigured"] is True


def test_model_options_preserves_canonical_custom_row_after_agent_init(monkeypatch):
    from hermes_cli.inventory import ConfigContext

    class _Agent:
        provider = "custom"
        model = "qwen3.6:35b-65k"
        base_url = "http://127.0.0.1:11434/v1"

    server._sessions["custom-session"] = _session(agent=_Agent())
    monkeypatch.setattr(server, "_resolve_model", lambda: "")
    monkeypatch.setattr(
        "hermes_cli.inventory.load_picker_context",
        lambda: ConfigContext(
            current_provider="custom:local-ollama",
            current_model="qwen3.6:35b-65k",
            current_base_url="http://127.0.0.1:11434/v1",
            user_providers={},
            custom_providers=[],
        ),
    )
    canonical = Mock(return_value="custom:local-ollama")
    monkeypatch.setattr(
        "hermes_cli.runtime_provider.canonical_custom_identity",
        canonical,
    )
    monkeypatch.setattr(
        "hermes_cli.model_switch.list_authenticated_providers",
        lambda **_kwargs: [
            {
                "slug": "custom:local-ollama",
                "name": "Local Ollama",
                "is_current": True,
                "is_user_defined": True,
                "models": ["qwen3.6:35b-65k"],
                "total_models": 1,
            },
            {
                "slug": "anthropic",
                "name": "Anthropic",
                "is_current": False,
                "is_user_defined": False,
                "models": ["claude-sonnet-4.6"],
                "total_models": 1,
            },
        ],
    )
    monkeypatch.setattr(
        "hermes_cli.auth.is_provider_explicitly_configured",
        lambda _slug: False,
    )
    monkeypatch.setattr("hermes_cli.inventory._apply_pricing", lambda *_args, **_kwargs: None)
    monkeypatch.setattr("hermes_cli.inventory._apply_capabilities", lambda *_args, **_kwargs: None)

    resp = server._methods["model.options"](
        102,
        {"session_id": "custom-session", "explicit_only": True},
    )

    assert "result" in resp, resp
    assert resp["result"]["provider"] == "custom:local-ollama"
    assert [row["slug"] for row in resp["result"]["providers"]] == [
        "custom:local-ollama"
    ]
    canonical.assert_called_once_with(
        base_url="http://127.0.0.1:11434/v1",
        config_provider="custom:local-ollama",
        model="qwen3.6:35b-65k",
    )


def test_model_save_key_uses_credential_lifecycle_and_picker_context(monkeypatch):
    env_var = "TEST_PROVIDER_API_KEY"
    agent = object()
    picker_ctx = object()
    provider = {
        "slug": "test-provider",
        "name": "Test Provider",
        "models": ["test-model"],
        "total_models": 1,
    }
    server._sessions["save-key-session"] = _session(agent=agent)
    monkeypatch.setattr(
        "hermes_cli.auth.PROVIDER_REGISTRY",
        {
            "test-provider": types.SimpleNamespace(
                name="Test Provider",
                auth_type="api_key",
                api_key_env_vars=(env_var,),
            )
        },
    )
    monkeypatch.setattr("hermes_cli.config.is_managed", lambda: False)
    save_credential = Mock()
    monkeypatch.setattr(
        "hermes_cli.credential_lifecycle.save_provider_env_credential",
        save_credential,
    )
    picker_context = Mock(return_value=picker_ctx)
    monkeypatch.setattr(server, "_model_picker_context", picker_context)
    build_payload = Mock(return_value={"providers": [provider]})
    monkeypatch.setattr(
        "hermes_cli.inventory.build_models_payload",
        build_payload,
    )
    monkeypatch.setenv(env_var, "previous-value")
    fake_key = "replacement-" + "value"

    resp = server._methods["model.save_key"](
        103,
        {
            "slug": "test-provider",
            "api_key": fake_key,
            "session_id": "save-key-session",
        },
    )

    assert "result" in resp, resp
    assert resp["result"]["provider"] == {**provider, "authenticated": True}
    save_credential.assert_called_once_with(env_var, fake_key)
    picker_context.assert_called_once_with(agent)
    build_payload.assert_called_once_with(
        picker_ctx,
        picker_hints=True,
        max_models=50,
    )


# ---------------------------------------------------------------------------
# prompt.submit — auto-title
# ---------------------------------------------------------------------------


def test_model_options_refresh_allows_custom_provider_probes(monkeypatch):
    monkeypatch.setattr(
        server,
        "_load_cfg",
        lambda: {"providers": {}, "custom_providers": []},
    )
    with patch(
        "hermes_cli.model_switch.list_authenticated_providers",
        return_value=[],
    ) as listing:
        resp = server._methods["model.options"](78, {"session_id": "", "refresh": True})

    assert "result" in resp, resp
    assert listing.call_args.kwargs["probe_custom_providers"] is True
    assert listing.call_args.kwargs["probe_current_custom_provider"] is False


class _ImmediateThread:
    """Runs the target callable synchronously so assertions can follow."""

    def __init__(self, target=None, daemon=None):
        self._target = target

    def start(self):
        self._target()


def test_prompt_submit_auto_titles_session_on_complete(monkeypatch):
    """maybe_auto_title is called after a successful (complete) prompt."""

    class _Agent:
        model = "gpt-5.6-sol"
        provider = "openai-codex"
        base_url = "https://chatgpt.example.test/backend-api/codex"
        api_key = object()
        api_mode = "codex_responses"

        def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
            return {
                "final_response": "Rome was founded in 753 BC.",
                "messages": [
                    {"role": "user", "content": "Tell me about Rome"},
                    {"role": "assistant", "content": "Rome was founded in 753 BC."},
                ],
            }

    server._sessions["sid"] = _session(agent=_Agent())
    monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
    monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None)
    monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None)
    monkeypatch.setattr(server, "render_message", lambda raw, cols: None)
    monkeypatch.setattr(server, "_get_db", lambda: None)

    with patch("agent.title_generator.maybe_auto_title") as mock_title:
        server.handle_request(
            {
                "id": "1",
                "method": "prompt.submit",
                "params": {"session_id": "sid", "text": "Tell me about Rome"},
            }
        )

    mock_title.assert_called_once()
    args = mock_title.call_args.args
    assert args[1] == "session-key"
    assert args[2] == "Tell me about Rome"
    assert args[3] == "Rome was founded in 753 BC."
    assert mock_title.call_args.kwargs["main_runtime"] == {
        "model": "gpt-5.6-sol",
        "provider": "openai-codex",
        "base_url": "https://chatgpt.example.test/backend-api/codex",
        "api_key": _Agent.api_key,
        "api_mode": "codex_responses",
    }


def test_prompt_submit_skips_auto_title_when_interrupted(monkeypatch):
    """maybe_auto_title must NOT be called when the agent was interrupted."""

    class _Agent:
        def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
            return {
                "final_response": "partial answer",
                "interrupted": True,
                "messages": [],
            }

    server._sessions["sid"] = _session(agent=_Agent())
    monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
    monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None)
    monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None)
    monkeypatch.setattr(server, "render_message", lambda raw, cols: None)
    monkeypatch.setattr(server, "_get_db", lambda: None)

    with patch("agent.title_generator.maybe_auto_title") as mock_title:
        server.handle_request(
            {
                "id": "1",
                "method": "prompt.submit",
                "params": {"session_id": "sid", "text": "Tell me about Rome"},
            }
        )

    mock_title.assert_not_called()


def test_prompt_submit_skips_auto_title_when_response_empty(monkeypatch):
    """maybe_auto_title must NOT be called when the agent returns an empty reply."""

    class _Agent:
        def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
            return {
                "final_response": "",
                "messages": [],
            }

    server._sessions["sid"] = _session(agent=_Agent())
    monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
    monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None)
    monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None)
    monkeypatch.setattr(server, "render_message", lambda raw, cols: None)
    monkeypatch.setattr(server, "_get_db", lambda: None)

    with patch("agent.title_generator.maybe_auto_title") as mock_title:
        server.handle_request(
            {
                "id": "1",
                "method": "prompt.submit",
                "params": {"session_id": "sid", "text": "Tell me about Rome"},
            }
        )

    mock_title.assert_not_called()


def test_prompt_submit_surfaces_backend_error_as_visible_text(monkeypatch):
    """When the backend fails with no visible response (e.g. invalid model slug
    → provider 4xx), the TUI must surface result['error'] as visible text
    instead of emitting a blank message.complete turn."""

    class _Agent:
        def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
            return {
                "final_response": None,
                "messages": [],
                "api_calls": 0,
                "completed": False,
                "failed": True,
                "error": "HTTP 400: invalid model id 'kimi-k2.6'",
            }

    server._sessions["sid"] = _session(agent=_Agent())
    monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)

    emitted: list[tuple[str, str, dict]] = []
    monkeypatch.setattr(
        server,
        "_emit",
        lambda event, sid, payload=None: emitted.append((event, sid, payload or {})),
    )
    monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None)
    monkeypatch.setattr(server, "render_message", lambda raw, cols: None)
    monkeypatch.setattr(server, "_get_db", lambda: None)

    server.handle_request(
        {
            "id": "1",
            "method": "prompt.submit",
            "params": {"session_id": "sid", "text": "hello"},
        }
    )

    complete_events = [e for e in emitted if e[0] == "message.complete"]
    assert complete_events, "expected message.complete to be emitted"
    payload = complete_events[-1][2]
    assert payload.get("status") == "error"
    assert payload.get("text", "").startswith("Error:")
    assert "kimi-k2.6" in payload.get("text", "")


def test_prompt_submit_preserves_empty_response_without_error(monkeypatch):
    """An empty final_response with NO backend error must stay empty — do not
    synthesize an error string. Preserves the existing None/empty-sentinel
    semantics owned by downstream handlers."""

    class _Agent:
        def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
            return {
                "final_response": None,
                "messages": [],
                "api_calls": 1,
                "completed": True,
            }

    server._sessions["sid"] = _session(agent=_Agent())
    monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)

    emitted: list[tuple[str, str, dict]] = []
    monkeypatch.setattr(
        server,
        "_emit",
        lambda event, sid, payload=None: emitted.append((event, sid, payload or {})),
    )
    monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None)
    monkeypatch.setattr(server, "render_message", lambda raw, cols: None)
    monkeypatch.setattr(server, "_get_db", lambda: None)

    server.handle_request(
        {
            "id": "1",
            "method": "prompt.submit",
            "params": {"session_id": "sid", "text": "hello"},
        }
    )

    complete_events = [e for e in emitted if e[0] == "message.complete"]
    assert complete_events, "expected message.complete to be emitted"
    payload = complete_events[-1][2]
    # Status stays "complete" because no error flag was set
    assert payload.get("status") == "complete"
    # Text stays empty — we did NOT fabricate an "Error:" string
    text = payload.get("text", "")
    assert text in {"", None}, f"expected empty text, got {text!r}"


# ── active live TUI sessions ─────────────────────────────────────────


def test_session_active_list_reports_live_sessions(monkeypatch):
    class _DB:
        def get_session_title(self, key):
            return {"key-a": "Research", "key-b": "Implement"}.get(key, "")

    previous_sessions = dict(server._sessions)
    server._sessions.clear()
    monkeypatch.setattr(server, "_get_db", lambda: _DB())
    server._sessions["sid-a"] = _session(
        agent=types.SimpleNamespace(model="model-a"),
        history=[{"role": "user", "content": "find docs"}],
        session_key="key-a",
        created_at=10.0,
        last_active=20.0,
    )
    server._sessions["sid-b"] = _session(
        agent=types.SimpleNamespace(model="model-b"),
        history=[{"role": "assistant", "content": "writing code"}],
        running=True,
        session_key="key-b",
        created_at=11.0,
        last_active=30.0,
    )
    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "session.active_list",
                "params": {"current_session_id": "sid-b"},
            }
        )
    finally:
        server._sessions.clear()
        server._sessions.update(previous_sessions)

    session_rows = resp["result"]["sessions"]
    assert [row["id"] for row in session_rows] == ["sid-a", "sid-b"]

    rows = {row["id"]: row for row in session_rows}
    assert rows["sid-a"] == {
        "current": False,
        "id": "sid-a",
        "last_active": 20.0,
        "message_count": 1,
        "model": "model-a",
        "preview": "find docs",
        "session_key": "key-a",
        "started_at": 10.0,
        "status": "idle",
        "title": "Research",
    }
    assert rows["sid-b"]["current"] is True
    assert rows["sid-b"]["status"] == "working"
    assert rows["sid-b"]["title"] == "Implement"
    assert rows["sid-b"]["preview"] == "writing code"


def test_session_active_list_excludes_finalized_sessions(monkeypatch):
    """#38950: a finalized-but-not-yet-popped session must not inflate the count.

    The WS grace-reap and idle reaper set ``_finalized`` inside
    ``_teardown_session`` before popping the entry from ``_sessions``. During
    that window ``session.active_list`` would otherwise still report the dead
    session, which is exactly the footer "N sessions" count that only ever grew
    until a gateway restart. A live session on the real stdio transport (the
    standalone ``hermes --tui`` case) must still be reported.
    """
    class _DB:
        def get_session_title(self, key):
            return {"key-live": "Live", "key-dead": "Dead"}.get(key, "")

    previous_sessions = dict(server._sessions)
    server._sessions.clear()
    monkeypatch.setattr(server, "_get_db", lambda: _DB())
    server._sessions["sid-live"] = _session(
        agent=types.SimpleNamespace(model="model-live"),
        history=[{"role": "user", "content": "still here"}],
        session_key="key-live",
        created_at=10.0,
        last_active=20.0,
    )
    dead = _session(
        agent=types.SimpleNamespace(model="model-dead"),
        history=[{"role": "user", "content": "gone"}],
        session_key="key-dead",
        created_at=11.0,
        last_active=21.0,
    )
    dead["_finalized"] = True
    server._sessions["sid-dead"] = dead
    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "session.active_list",
                "params": {},
            }
        )
    finally:
        server._sessions.clear()
        server._sessions.update(previous_sessions)

    session_rows = resp["result"]["sessions"]
    assert [row["id"] for row in session_rows] == ["sid-live"]



def test_session_activate_returns_inflight_stream_before_completion(monkeypatch):
    """Switching into a still-running live session must hydrate partial output.

    The committed session history is only updated after run_conversation returns,
    so session.activate needs an explicit in-flight payload sourced from the
    backend stream callback.
    """
    started = threading.Event()
    release = threading.Event()
    done = threading.Event()

    class _Agent:
        model = "model-live"

        def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
            assert prompt == "write a long answer"
            assert conversation_history == []
            stream_callback("partial ")
            stream_callback("answer")
            started.set()
            assert release.wait(2), "test timed out waiting to finish fake model turn"
            return {
                "final_response": "partial answer complete",
                "messages": [
                    {"role": "user", "content": "write a long answer"},
                    {"role": "assistant", "content": "partial answer complete"},
                ],
            }

    server._sessions["sid-live"] = _session(agent=_Agent())
    monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None)
    monkeypatch.setattr(server, "render_message", lambda raw, cols: None)
    monkeypatch.setattr(server, "_get_db", lambda: None)
    monkeypatch.setattr(server, "_session_info", lambda agent: {"model": agent.model})

    def _emit(event, sid, payload=None):
        if event == "message.complete":
            done.set()

    monkeypatch.setattr(server, "_emit", _emit)

    try:
        submit = server.handle_request(
            {
                "id": "submit",
                "method": "prompt.submit",
                "params": {"session_id": "sid-live", "text": "write a long answer"},
            }
        )
        assert submit["result"]["status"] == "streaming"
        assert started.wait(2), "fake model did not stream before activation"

        resp = server.handle_request(
            {
                "id": "activate",
                "method": "session.activate",
                "params": {"session_id": "sid-live"},
            }
        )

        inflight = resp["result"].get("inflight")
        assert inflight == {
            "assistant": "partial answer",
            "streaming": True,
            "user": "write a long answer",
        }
        assert resp["result"]["messages"] == []

        release.set()
        assert done.wait(2), "fake model turn did not complete"
        completed = server.handle_request(
            {
                "id": "activate-done",
                "method": "session.activate",
                "params": {"session_id": "sid-live"},
            }
        )
        assert completed["result"].get("inflight") is None
        assert completed["result"]["messages"] == [
            {"role": "user", "text": "write a long answer"},
            {"role": "assistant", "text": "partial answer complete"},
        ]
    finally:
        release.set()
        done.wait(2)
        server._sessions.pop("sid-live", None)


def test_session_activate_returns_prompt_queued_during_busy_turn(monkeypatch):
    """A full client restart must recover an accepted next-turn prompt.

    Busy prompts are intentionally not durable until they drain. Their only
    authoritative copy is ``queued_prompt``, so the live projection must expose
    that copy without leaking the transport object.
    """
    monkeypatch.setattr(server, "_load_busy_input_mode", lambda: "queue")
    monkeypatch.setattr(server, "_session_info", lambda agent: {"model": agent.model})
    agent = types.SimpleNamespace(model="model-live")
    session = _session(
        agent=agent,
        running=True,
        inflight_turn={
            "assistant": "partial answer",
            "streaming": True,
            "user": "current prompt",
        },
    )
    server._sessions["sid-live"] = session
    try:
        queued = server._handle_busy_submit(
            "submit", "sid-live", session, "newest prompt", object()
        )
        assert queued["result"]["status"] == "queued"

        activated = server.handle_request(
            {
                "id": "activate",
                "method": "session.activate",
                "params": {"session_id": "sid-live"},
            }
        )

        assert activated["result"]["queued"] == {"user": "newest prompt"}
        assert "transport" not in activated["result"]["queued"]
    finally:
        server._sessions.pop("sid-live", None)


def test_session_activate_switches_live_session_without_closing_siblings(monkeypatch):
    monkeypatch.setattr(server, "_session_info", lambda agent: {"model": agent.model})
    server._sessions["sid-a"] = _session(
        agent=types.SimpleNamespace(model="model-a"),
        history=[{"role": "user", "content": "old"}],
        session_key="key-a",
    )
    server._sessions["sid-b"] = _session(
        agent=types.SimpleNamespace(model="model-b"),
        history=[
            {"role": "user", "content": "new prompt"},
            {"role": "assistant", "content": "new answer"},
        ],
        running=True,
        session_key="key-b",
    )
    try:
        resp = server.handle_request(
            {"id": "1", "method": "session.activate", "params": {"session_id": "sid-b"}}
        )

        assert "sid-a" in server._sessions
        assert "sid-b" in server._sessions
        assert resp["result"]["session_id"] == "sid-b"
        assert resp["result"]["session_key"] == "key-b"
        assert resp["result"]["running"] is True
        assert resp["result"]["status"] == "working"
        assert resp["result"]["info"] == {"model": "model-b"}
        assert resp["result"]["messages"] == [
            {"role": "user", "text": "new prompt"},
            {"role": "assistant", "text": "new answer"},
        ]
    finally:
        server._sessions.pop("sid-a", None)
        server._sessions.pop("sid-b", None)


def test_session_activate_can_omit_duplicate_desktop_transcript(monkeypatch):
    monkeypatch.setattr(server, "_session_info", lambda agent: {"model": agent.model})
    server._sessions["sid-large"] = _session(
        agent=types.SimpleNamespace(model="model-large"),
        history=[
            {"role": "user", "content": "large prompt"},
            {"role": "assistant", "content": "large answer"},
        ],
        session_key="key-large",
    )
    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "session.activate",
                "params": {"session_id": "sid-large", "omit_messages": True},
            }
        )

        assert resp["result"]["messages"] == []
        assert resp["result"]["message_count"] == 2
        assert resp["result"]["messages_omitted"] is True
        assert resp["result"]["session_key"] == "key-large"
    finally:
        server._sessions.pop("sid-large", None)


# ── session.most_recent ──────────────────────────────────────────────


def test_session_most_recent_returns_first_non_denied(monkeypatch):
    """Drops `tool` rows like session.list does, returns the first hit."""

    class _DB:
        def list_sessions_rich(self, *, source=None, limit=200, order_by_last_active=False, compact_rows=False):
            return [
                {"id": "tool-1", "source": "tool", "title": "noise", "started_at": 100},
                {"id": "tui-1", "source": "tui", "title": "real", "started_at": 99},
            ]

    monkeypatch.setattr(server, "_get_db", lambda: _DB())

    resp = server.handle_request(
        {"id": "1", "method": "session.most_recent", "params": {}}
    )

    assert resp["result"]["session_id"] == "tui-1"
    assert resp["result"]["title"] == "real"
    assert resp["result"]["source"] == "tui"


def test_session_most_recent_returns_null_when_only_tool_rows(monkeypatch):
    class _DB:
        def list_sessions_rich(self, *, source=None, limit=200, order_by_last_active=False, compact_rows=False):
            return [{"id": "tool-1", "source": "tool", "started_at": 1}]

    monkeypatch.setattr(server, "_get_db", lambda: _DB())

    resp = server.handle_request(
        {"id": "1", "method": "session.most_recent", "params": {}}
    )

    assert resp["result"]["session_id"] is None


def test_session_most_recent_folds_db_exception_into_null_result(monkeypatch):
    """Per contract, errors are folded into the null-result shape so
    callers don't have to special-case JSON-RPC error envelopes for
    'no answer' (Copilot review on #17130)."""

    class _BrokenDB:
        def list_sessions_rich(self, *, source=None, limit=200, order_by_last_active=False, compact_rows=False):
            raise RuntimeError("db locked")

    monkeypatch.setattr(server, "_get_db", lambda: _BrokenDB())

    resp = server.handle_request(
        {"id": "1", "method": "session.most_recent", "params": {}}
    )

    assert "error" not in resp
    assert resp["result"]["session_id"] is None


def test_session_most_recent_handles_db_unavailable(monkeypatch):
    monkeypatch.setattr(server, "_get_db", lambda: None)

    resp = server.handle_request(
        {"id": "1", "method": "session.most_recent", "params": {}}
    )

    assert resp["result"]["session_id"] is None


# ── verification.status ──────────────────────────────────────────────


def test_verification_status_returns_recorded_evidence(tmp_path, monkeypatch):
    profile_home = tmp_path / "profiles" / "verify"
    profile_home.mkdir(parents=True)
    monkeypatch.setattr(server, "_profile_home", lambda p: profile_home if p == "verify" else None)
    token = set_hermes_home_override(profile_home)
    project = tmp_path / "project"
    project.mkdir()
    (project / ".git").mkdir()
    (project / "package.json").write_text(
        json.dumps({"scripts": {"test": "vitest"}}),
        encoding="utf-8",
    )
    (project / "pnpm-lock.yaml").write_text("", encoding="utf-8")
    try:
        from agent.verification_evidence import record_terminal_result

        record_terminal_result(
            command="pnpm run test",
            cwd=project,
            session_id="sid",
            exit_code=0,
            output="green",
        )

        resp = server.handle_request(
            {
                "id": "1",
                "method": "verification.status",
                "params": {"cwd": str(project), "session_id": "sid", "profile": "verify"},
            }
        )
    finally:
        reset_hermes_home_override(token)

    verification = resp["result"]["verification"]
    assert verification["status"] == "passed"
    assert verification["evidence"]["canonical_command"] == "pnpm run test"
    assert verification["evidence"]["scope"] == "full"


def test_verification_status_outside_workspace_is_not_applicable(monkeypatch, tmp_path):
    # A cwd with no project facts (outside any code workspace) must report
    # not_applicable. Force the "no facts" precondition rather than relying on
    # tmp_path's ancestors being pristine — a stray marker file in a shared
    # tmp-root ancestor (e.g. /tmp/package.json left by another tool) would
    # otherwise make _marker_root() resolve tmp_path as a workspace and flip
    # the status to "unverified".
    import agent.coding_context as coding_context

    monkeypatch.setattr(coding_context, "project_facts_for", lambda _cwd=None: None)

    home = tmp_path / ".hermes"
    home.mkdir()
    token = set_hermes_home_override(home)
    try:
        resp = server.handle_request(
            {
                "id": "1",
                "method": "verification.status",
                "params": {"cwd": str(tmp_path), "session_id": "sid"},
            }
        )
    finally:
        reset_hermes_home_override(token)

    assert resp["result"]["verification"]["status"] == "not_applicable"


# ── browser.manage ───────────────────────────────────────────────────


def _stub_urlopen(monkeypatch, *, ok: bool):
    """Patch urllib.request.urlopen used by browser.manage to short-circuit probes."""

    class _Resp:
        status = 200 if ok else 503

        def __enter__(self):
            return self

        def __exit__(self, *_):
            return False

    def _opener(_url, timeout=2.0):  # noqa: ARG001 — match urllib signature
        if not ok:
            raise OSError("probe failed")
        return _Resp()

    import urllib.request

    monkeypatch.setattr(urllib.request, "urlopen", _opener)


def _stub_urlopen_capture(monkeypatch, *, ok: bool):
    urls: list[str] = []

    class _Resp:
        status = 200

        def __enter__(self):
            return self

        def __exit__(self, *_):
            return False

    def _opener(url, timeout=2.0):  # noqa: ARG001 — match urllib signature
        urls.append(url)
        if not ok:
            raise OSError("probe failed")
        return _Resp()

    import urllib.request

    monkeypatch.setattr(urllib.request, "urlopen", _opener)
    return urls


def test_browser_manage_status_reads_env_var(monkeypatch):
    """Status returns the env var verbatim (no network I/O)."""
    monkeypatch.setenv("BROWSER_CDP_URL", "http://127.0.0.1:9222")

    resp = server.handle_request(
        {"id": "1", "method": "browser.manage", "params": {"action": "status"}}
    )

    assert resp["result"]["connected"] is True
    assert resp["result"]["url"] == "http://127.0.0.1:9222"


def test_browser_manage_status_falls_back_to_config_cdp_url(monkeypatch):
    """When env is unset, status surfaces ``browser.cdp_url`` from
    config.yaml so users see what the next tool call will read."""
    monkeypatch.delenv("BROWSER_CDP_URL", raising=False)

    fake_cfg = types.SimpleNamespace(
        read_raw_config=lambda: {"browser": {"cdp_url": "http://lan:9222"}}
    )
    with patch.dict(sys.modules, {"hermes_cli.config": fake_cfg}):
        resp = server.handle_request(
            {"id": "1", "method": "browser.manage", "params": {"action": "status"}}
        )

    assert resp["result"] == {"connected": True, "url": "http://lan:9222"}


def test_browser_manage_status_does_not_call_get_cdp_override(monkeypatch):
    """Regression guard for Copilot's "status must not block" review:
    status must NOT route through `_get_cdp_override`, which performs a
    `/json/version` HTTP probe with a multi-second timeout."""
    monkeypatch.setenv("BROWSER_CDP_URL", "http://127.0.0.1:9222")

    fake = types.SimpleNamespace(
        _get_cdp_override=lambda: pytest.fail(  # noqa: PT015 — fail loudly if called
            "_get_cdp_override must not run on /browser status (network I/O)"
        )
    )
    with patch.dict(sys.modules, {"tools.browser_tool": fake}):
        resp = server.handle_request(
            {"id": "1", "method": "browser.manage", "params": {"action": "status"}}
        )

    assert resp["result"]["connected"] is True


def test_browser_manage_connect_sets_env_and_cleans_twice(monkeypatch):
    """`/browser connect` must reach the live process: set env, reap browser
    sessions before AND after publishing the new URL.  The double-cleanup
    closes the supervisor swap window where ``_ensure_cdp_supervisor``
    could re-attach to the *old* CDP endpoint between steps."""
    monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
    cleanup_calls: list[str] = []

    def _cleanup_all():
        cleanup_calls.append(os.environ.get("BROWSER_CDP_URL", ""))

    fake = types.SimpleNamespace(
        cleanup_all_browsers=_cleanup_all,
        _get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
    )
    with patch.dict(sys.modules, {"tools.browser_tool": fake}):
        _stub_urlopen(monkeypatch, ok=True)
        resp = server.handle_request(
            {
                "id": "1",
                "method": "browser.manage",
                "params": {"action": "connect", "url": "http://127.0.0.1:9222"},
            }
        )

    assert resp["result"]["connected"] is True
    assert resp["result"]["url"] == "http://127.0.0.1:9222"
    assert resp["result"]["messages"] == [
        "Chromium-family browser is already listening at http://127.0.0.1:9222"
    ]
    assert os.environ.get("BROWSER_CDP_URL") == "http://127.0.0.1:9222"
    # First cleanup runs against the OLD env (none here), second against the NEW.
    assert cleanup_calls == ["", "http://127.0.0.1:9222"]


def test_browser_manage_connect_defaults_to_loopback(monkeypatch):
    monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
    fake = types.SimpleNamespace(
        cleanup_all_browsers=lambda: None,
        _get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
    )
    with patch.dict(sys.modules, {"tools.browser_tool": fake}):
        urls = _stub_urlopen_capture(monkeypatch, ok=True)
        resp = server.handle_request(
            {"id": "1", "method": "browser.manage", "params": {"action": "connect"}}
        )

    assert resp["result"]["connected"] is True
    assert resp["result"]["url"] == "http://127.0.0.1:9222"
    assert resp["result"]["messages"] == [
        "Chromium-family browser is already listening at http://127.0.0.1:9222"
    ]
    assert urls[0] == "http://127.0.0.1:9222/json/version"


def test_browser_manage_connect_default_local_reports_launch_hint(monkeypatch):
    monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
    monkeypatch.setattr("platform.system", lambda: "Linux")
    emitted: list[tuple[str, dict]] = []
    monkeypatch.setattr(
        server,
        "_emit",
        lambda evt, sid, payload=None: emitted.append((evt, payload or {})),
    )
    fake = types.SimpleNamespace(
        cleanup_all_browsers=lambda: None,
        _get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
    )
    with patch.dict(sys.modules, {"tools.browser_tool": fake}):
        _stub_urlopen(monkeypatch, ok=False)
        with (
            patch(
                "hermes_cli.browser_connect.launch_chrome_debug",
                return_value=ChromeDebugLaunch(),
            ),
            patch("hermes_cli.browser_connect.local_port_in_use", return_value=False),
            patch("hermes_cli.browser_connect.manual_chrome_debug_command", return_value=None),
            patch(
                "hermes_cli.browser_connect.get_chrome_debug_candidates",
                return_value=[],
            ),
        ):
            resp = server.handle_request(
                {
                    "id": "1",
                    "method": "browser.manage",
                    "params": {
                        "action": "connect",
                        "session_id": "sess-1",
                        "url": "http://localhost:9222",
                    },
                }
            )

    assert resp["result"]["connected"] is False
    assert resp["result"]["url"] == "http://127.0.0.1:9222"
    assert (
        resp["result"]["messages"][0]
        == "Chromium-family browser isn't running with remote debugging — attempting to launch..."
    )
    assert any(
        "No supported Chromium-family browser executable was found" in line
        for line in resp["result"]["messages"]
    )
    assert any(
        "--remote-debugging-port=9222" in line for line in resp["result"]["messages"]
    )
    assert "BROWSER_CDP_URL" not in os.environ
    progress = [p["message"] for evt, p in emitted if evt == "browser.progress"]
    assert progress == resp["result"]["messages"]


def test_browser_manage_connect_no_session_skips_progress_events(monkeypatch):
    """Without a session_id the TUI prints messages from the response;
    emitting ``browser.progress`` events would double-render. Gate the
    emit so callers without a session see the bundled list only."""
    monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
    emitted: list[tuple[str, dict]] = []
    monkeypatch.setattr(
        server,
        "_emit",
        lambda evt, sid, payload=None: emitted.append((evt, payload or {})),
    )
    fake = types.SimpleNamespace(
        cleanup_all_browsers=lambda: None,
        _get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
    )
    with patch.dict(sys.modules, {"tools.browser_tool": fake}):
        _stub_urlopen(monkeypatch, ok=False)
        with (
            patch(
                "hermes_cli.browser_connect.launch_chrome_debug",
                return_value=ChromeDebugLaunch(),
            ),
            patch("hermes_cli.browser_connect.manual_chrome_debug_command", return_value=None),
            patch(
                "hermes_cli.browser_connect.get_chrome_debug_candidates",
                return_value=[],
            ),
        ):
            resp = server.handle_request(
                {
                    "id": "1",
                    "method": "browser.manage",
                    "params": {"action": "connect", "url": "http://localhost:9222"},
                }
            )

    assert resp["result"]["connected"] is False
    assert resp["result"]["messages"]  # bundled list still populated
    assert [evt for evt, _ in emitted if evt == "browser.progress"] == []


def test_browser_manage_connect_handles_null_url(monkeypatch):
    """Explicit ``{"url": null}`` (or empty string) must fall back to the
    default loopback URL instead of raising a TypeError that gets swallowed
    by the outer 5031 catch."""
    monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
    fake = types.SimpleNamespace(
        cleanup_all_browsers=lambda: None,
        _get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
    )
    with patch.dict(sys.modules, {"tools.browser_tool": fake}):
        _stub_urlopen(monkeypatch, ok=True)
        resp = server.handle_request(
            {
                "id": "1",
                "method": "browser.manage",
                "params": {"action": "connect", "url": None},
            }
        )

    assert resp["result"]["connected"] is True
    assert resp["result"]["url"] == "http://127.0.0.1:9222"


def test_browser_manage_connect_rejects_non_string_url(monkeypatch):
    monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
    resp = server.handle_request(
        {
            "id": "1",
            "method": "browser.manage",
            "params": {"action": "connect", "url": 9222},
        }
    )

    assert resp["error"]["code"] == 4015
    assert "must be a string" in resp["error"]["message"]
    assert "BROWSER_CDP_URL" not in os.environ


def test_browser_manage_connect_default_local_retries_after_launch(monkeypatch):
    monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
    monkeypatch.setattr(server.time, "sleep", lambda _seconds: None)
    fake = types.SimpleNamespace(
        cleanup_all_browsers=lambda: None,
        _get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
    )

    class _Resp:
        status = 200

        def __enter__(self):
            return self

        def __exit__(self, *_):
            return False

    # IPv4 answers only from the 3rd probe onwards (browser still starting);
    # the IPv6 loopback never answers.
    attempts = {"n": 0}

    def _opener(url, timeout=2.0):  # noqa: ARG001 — match urllib signature
        if "[::1]" in url:
            raise OSError("no IPv6 listener")
        attempts["n"] += 1
        if attempts["n"] < 3:
            raise OSError("not ready")
        return _Resp()

    import urllib.request

    monkeypatch.setattr(urllib.request, "urlopen", _opener)
    launched = ChromeDebugLaunch(launched=True)
    with patch.dict(sys.modules, {"tools.browser_tool": fake}):
        with (
            patch(
                "hermes_cli.browser_connect.launch_chrome_debug",
                return_value=launched,
            ),
            patch("hermes_cli.browser_connect.local_port_in_use", return_value=False),
        ):
            resp = server.handle_request(
                {"id": "1", "method": "browser.manage", "params": {"action": "connect"}}
            )

    assert resp["result"]["connected"] is True
    assert resp["result"]["url"] == "http://127.0.0.1:9222"
    assert resp["result"]["messages"] == [
        "Chromium-family browser isn't running with remote debugging — attempting to launch...",
        "Chromium-family browser launched and listening on port 9222",
    ]
    assert os.environ["BROWSER_CDP_URL"] == "http://127.0.0.1:9222"


def test_browser_manage_connect_finds_ipv6_only_browser(monkeypatch):
    """Regression: an IDE debugger squatting 127.0.0.1:9222 pushes the debug
    browser onto [::1]:9222. Connect must discover and adopt the IPv6
    endpoint instead of timing out against the squatter."""
    monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
    fake = types.SimpleNamespace(
        cleanup_all_browsers=lambda: None,
        _get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
    )

    class _Resp:
        status = 200

        def __enter__(self):
            return self

        def __exit__(self, *_):
            return False

    def _opener(url, timeout=2.0):  # noqa: ARG001 — match urllib signature
        if "[::1]" in url:
            return _Resp()
        raise OSError("IPv4 loopback held by a non-CDP squatter")

    import urllib.request

    monkeypatch.setattr(urllib.request, "urlopen", _opener)
    with patch.dict(sys.modules, {"tools.browser_tool": fake}):
        resp = server.handle_request(
            {"id": "1", "method": "browser.manage", "params": {"action": "connect"}}
        )

    assert resp["result"]["connected"] is True
    assert resp["result"]["url"] == "http://[::1]:9222"
    assert os.environ["BROWSER_CDP_URL"] == "http://[::1]:9222"


def test_browser_manage_connect_squatted_port_launches_on_alternate(monkeypatch):
    """When neither loopback speaks CDP but the port is held by another
    application, connect must pick an alternate port for the launch and
    say so — never fight the squatter for 9222."""
    monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
    monkeypatch.setattr(server.time, "sleep", lambda _seconds: None)
    fake = types.SimpleNamespace(
        cleanup_all_browsers=lambda: None,
        _get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
    )

    class _Resp:
        status = 200

        def __enter__(self):
            return self

        def __exit__(self, *_):
            return False

    def _opener(url, timeout=2.0):  # noqa: ARG001 — match urllib signature
        if ":9223" in url and "127.0.0.1" in url:
            return _Resp()  # relaunched browser comes up on the alternate port
        raise OSError("9222 squatted / nothing else listening")

    import urllib.request

    monkeypatch.setattr(urllib.request, "urlopen", _opener)
    launch_ports: list[int] = []

    def _launch(port, _system):
        launch_ports.append(port)
        return ChromeDebugLaunch(launched=True)

    with patch.dict(sys.modules, {"tools.browser_tool": fake}):
        with (
            patch("hermes_cli.browser_connect.launch_chrome_debug", side_effect=_launch),
            patch("hermes_cli.browser_connect.local_port_in_use", return_value=True),
            patch("hermes_cli.browser_connect.find_free_debug_port", return_value=9223),
        ):
            resp = server.handle_request(
                {"id": "1", "method": "browser.manage", "params": {"action": "connect"}}
            )

    assert launch_ports == [9223]
    assert resp["result"]["connected"] is True
    assert resp["result"]["url"] == "http://127.0.0.1:9223"
    assert os.environ["BROWSER_CDP_URL"] == "http://127.0.0.1:9223"
    assert any("occupied by another application" in m for m in resp["result"]["messages"])


def test_browser_manage_connect_rejects_unreachable_endpoint(monkeypatch):
    """An unreachable endpoint must NOT mutate the env or reap sessions."""
    monkeypatch.setenv("BROWSER_CDP_URL", "http://existing:9222")
    cleanup_calls: list[str] = []
    fake = types.SimpleNamespace(
        cleanup_all_browsers=lambda: cleanup_calls.append(
            os.environ.get("BROWSER_CDP_URL", "")
        ),
        _get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
    )
    with patch.dict(sys.modules, {"tools.browser_tool": fake}):
        _stub_urlopen(monkeypatch, ok=False)
        resp = server.handle_request(
            {
                "id": "1",
                "method": "browser.manage",
                "params": {"action": "connect", "url": "http://unreachable:9222"},
            }
        )

    assert "error" in resp
    # Env preserved; nothing reaped.
    assert os.environ["BROWSER_CDP_URL"] == "http://existing:9222"
    assert cleanup_calls == []


def test_browser_manage_connect_normalizes_bare_host_port(monkeypatch):
    """Persist a parsed `scheme://host:port` URL so `_get_cdp_override`
    can normalize it; storing a bare host:port would break subsequent
    tool calls (Copilot review on #17120)."""
    monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
    fake = types.SimpleNamespace(
        cleanup_all_browsers=lambda: None,
        _get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
    )
    with patch.dict(sys.modules, {"tools.browser_tool": fake}):
        _stub_urlopen(monkeypatch, ok=True)
        resp = server.handle_request(
            {
                "id": "1",
                "method": "browser.manage",
                "params": {"action": "connect", "url": "127.0.0.1:9222"},
            }
        )

    assert resp["result"]["connected"] is True
    # Bare host:port got promoted to a full URL with explicit scheme.
    assert resp["result"]["url"].startswith("http://")
    assert os.environ["BROWSER_CDP_URL"].startswith("http://")


def test_browser_manage_connect_strips_discovery_path(monkeypatch):
    """User-supplied discovery paths like `/json` or `/json/version`
    must collapse to bare `scheme://host:port`; otherwise
    ``_resolve_cdp_override`` will append ``/json/version`` again and
    produce a duplicate path (Copilot review round-2 on #17120)."""
    monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
    fake = types.SimpleNamespace(
        cleanup_all_browsers=lambda: None,
        _get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
    )
    with patch.dict(sys.modules, {"tools.browser_tool": fake}):
        _stub_urlopen(monkeypatch, ok=True)
        resp = server.handle_request(
            {
                "id": "1",
                "method": "browser.manage",
                "params": {"action": "connect", "url": "http://127.0.0.1:9222/json"},
            }
        )

    assert resp["result"]["connected"] is True
    assert resp["result"]["url"] == "http://127.0.0.1:9222"
    assert os.environ["BROWSER_CDP_URL"] == "http://127.0.0.1:9222"


def test_browser_manage_connect_preserves_devtools_browser_endpoint(monkeypatch):
    """Concrete devtools websocket endpoints (e.g. Browserbase) must
    survive verbatim — we only collapse discovery-style paths."""
    monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
    fake = types.SimpleNamespace(
        cleanup_all_browsers=lambda: None,
        _get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
    )
    concrete = "ws://browserbase.example/devtools/browser/abc123"

    class _OkSocket:
        def __enter__(self):
            return self

        def __exit__(self, *a):
            return False

    with patch.dict(sys.modules, {"tools.browser_tool": fake}):
        # If urlopen is reached for a concrete ws endpoint, the test
        # would still pass because _stub_urlopen returned ok=True before;
        # patch it to assert-fail so we prove the HTTP probe is skipped.
        with patch(
            "urllib.request.urlopen", side_effect=AssertionError("urlopen called")
        ):
            with patch("socket.create_connection", return_value=_OkSocket()):
                resp = server.handle_request(
                    {
                        "id": "1",
                        "method": "browser.manage",
                        "params": {"action": "connect", "url": concrete},
                    }
                )

    assert resp["result"]["connected"] is True
    assert resp["result"]["url"] == concrete
    assert os.environ["BROWSER_CDP_URL"] == concrete


def test_browser_manage_connect_local_devtools_ws_preserves_path(monkeypatch):
    """Regression: ``ws://127.0.0.1:9222/devtools/browser/<id>`` is a real
    connectable endpoint; default-local normalization must not strip the
    ``/devtools/browser/...`` path or it breaks valid local CDP connects."""
    monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
    fake = types.SimpleNamespace(
        cleanup_all_browsers=lambda: None,
        _get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
    )
    concrete = "ws://127.0.0.1:9222/devtools/browser/abc123"

    class _OkSocket:
        def __enter__(self):
            return self

        def __exit__(self, *a):
            return False

    with patch.dict(sys.modules, {"tools.browser_tool": fake}):
        with patch("socket.create_connection", return_value=_OkSocket()):
            resp = server.handle_request(
                {
                    "id": "1",
                    "method": "browser.manage",
                    "params": {"action": "connect", "url": concrete},
                }
            )

    assert resp["result"]["connected"] is True
    assert resp["result"]["url"] == concrete
    assert os.environ["BROWSER_CDP_URL"] == concrete


def test_browser_manage_connect_rejects_invalid_port(monkeypatch):
    monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
    resp = server.handle_request(
        {
            "id": "1",
            "method": "browser.manage",
            "params": {"action": "connect", "url": "http://localhost:abc"},
        }
    )

    assert resp["error"]["code"] == 4015
    assert "invalid port" in resp["error"]["message"]
    assert "BROWSER_CDP_URL" not in os.environ


def test_browser_manage_connect_rejects_missing_host(monkeypatch):
    monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
    resp = server.handle_request(
        {
            "id": "1",
            "method": "browser.manage",
            "params": {"action": "connect", "url": "http://:9222"},
        }
    )

    assert resp["error"]["code"] == 4015
    assert "missing host" in resp["error"]["message"]
    assert "BROWSER_CDP_URL" not in os.environ


def test_browser_manage_connect_concrete_ws_skips_http_probe(monkeypatch):
    """Regression for round-2 Copilot review: a hosted CDP endpoint
    (no HTTP discovery) must connect via TCP-only reachability check.
    The HTTP probe used to reject these even though they're valid."""
    monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
    fake = types.SimpleNamespace(
        cleanup_all_browsers=lambda: None,
        _get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
    )
    concrete = "wss://chrome.browserless.io/devtools/browser/sess-1"

    seen_targets: list[tuple[str, int]] = []

    class _OkSocket:
        def __enter__(self):
            return self

        def __exit__(self, *a):
            return False

    def _fake_create_connection(addr, timeout=None):
        seen_targets.append(addr)
        return _OkSocket()

    with patch.dict(sys.modules, {"tools.browser_tool": fake}):
        # urlopen would 404/ECONNREFUSED on a real hosted CDP endpoint;
        # asserting it's never called proves the probe was skipped.
        with patch(
            "urllib.request.urlopen", side_effect=AssertionError("urlopen called")
        ):
            with patch("socket.create_connection", side_effect=_fake_create_connection):
                resp = server.handle_request(
                    {
                        "id": "1",
                        "method": "browser.manage",
                        "params": {"action": "connect", "url": concrete},
                    }
                )

    assert resp["result"] == {"connected": True, "url": concrete}
    # wss → port 443, host preserved verbatim.
    assert seen_targets == [("chrome.browserless.io", 443)]


def test_browser_manage_connect_concrete_ws_tcp_unreachable(monkeypatch):
    """If the TCP reachability check fails for a concrete ws endpoint,
    return a clear 5031 error — no fallback to the HTTP probe (which
    can never succeed for these URLs anyway)."""
    monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
    fake = types.SimpleNamespace(
        cleanup_all_browsers=lambda: None,
        _get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
    )
    concrete = "ws://offline.example/devtools/browser/missing"

    with patch.dict(sys.modules, {"tools.browser_tool": fake}):
        with patch("socket.create_connection", side_effect=OSError("ECONNREFUSED")):
            resp = server.handle_request(
                {
                    "id": "1",
                    "method": "browser.manage",
                    "params": {"action": "connect", "url": concrete},
                }
            )

    assert "error" in resp
    assert resp["error"]["code"] == 5031


def test_browser_manage_disconnect_drops_env_and_cleans(monkeypatch):
    monkeypatch.setenv("BROWSER_CDP_URL", "http://127.0.0.1:9222")
    cleanup_count = {"n": 0}
    fake = types.SimpleNamespace(
        cleanup_all_browsers=lambda: cleanup_count.__setitem__(
            "n", cleanup_count["n"] + 1
        ),
        _get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
    )
    with patch.dict(sys.modules, {"tools.browser_tool": fake}):
        resp = server.handle_request(
            {"id": "1", "method": "browser.manage", "params": {"action": "disconnect"}}
        )

    assert resp["result"] == {"connected": False}
    assert "BROWSER_CDP_URL" not in os.environ
    # Two cleanups: once before env removal, once after, matching connect.
    assert cleanup_count["n"] == 2


# ── config.get indicator normalization ───────────────────────────────


def test_config_get_indicator_returns_known_value_verbatim(monkeypatch):
    monkeypatch.setattr(
        server, "_load_cfg", lambda: {"display": {"tui_status_indicator": "emoji"}}
    )
    resp = server.handle_request(
        {"id": "1", "method": "config.get", "params": {"key": "indicator"}}
    )
    assert resp["result"] == {"value": "emoji"}


def test_config_get_indicator_normalizes_casing_and_whitespace(monkeypatch):
    """Hand-edited config.yaml stays consistent with what the TUI shows.

    Frontend's `normalizeIndicatorStyle` lowercases + trims, so config.get
    must do the same — otherwise `/indicator` prints 'EMOJI ' while the
    UI is actually rendering the kaomoji default."""
    monkeypatch.setattr(
        server, "_load_cfg", lambda: {"display": {"tui_status_indicator": " EMOJI "}}
    )
    resp = server.handle_request(
        {"id": "1", "method": "config.get", "params": {"key": "indicator"}}
    )
    assert resp["result"] == {"value": "emoji"}


def test_config_get_indicator_falls_back_to_default_for_unknown(monkeypatch):
    """An unknown value in config.yaml falls back to the same default
    the frontend uses (`_INDICATOR_DEFAULT`)."""
    monkeypatch.setattr(
        server, "_load_cfg", lambda: {"display": {"tui_status_indicator": "rainbow"}}
    )
    resp = server.handle_request(
        {"id": "1", "method": "config.get", "params": {"key": "indicator"}}
    )
    assert resp["result"] == {"value": "kaomoji"}


def test_config_get_indicator_falls_back_when_unset(monkeypatch):
    monkeypatch.setattr(server, "_load_cfg", lambda: {"display": {}})
    resp = server.handle_request(
        {"id": "1", "method": "config.get", "params": {"key": "indicator"}}
    )
    assert resp["result"] == {"value": "kaomoji"}


# ── config.set indicator validation ──────────────────────────────────


def test_config_set_indicator_accepts_known_value(monkeypatch):
    written: dict = {}
    monkeypatch.setattr(
        server,
        "_write_config_key",
        lambda k, v: written.update({k: v}),
    )
    resp = server.handle_request(
        {
            "id": "1",
            "method": "config.set",
            "params": {"key": "indicator", "value": "EMOJI"},
        }
    )
    assert resp["result"] == {"key": "indicator", "value": "emoji"}
    assert written == {"display.tui_status_indicator": "emoji"}


def test_config_set_indicator_falsy_non_string_surfaces_in_error(monkeypatch):
    """`0` / `False` / `[]` are not valid styles, but the error message
    must still tell the user what they sent — `value or ""` would have
    erased them to a blank string."""
    monkeypatch.setattr(server, "_write_config_key", lambda *a, **k: None)

    for bad in (0, False, []):
        resp = server.handle_request(
            {
                "id": "1",
                "method": "config.set",
                "params": {"key": "indicator", "value": bad},
            }
        )
        assert "error" in resp
        msg = resp["error"]["message"]
        assert "unknown indicator" in msg
        # The exact repr varies; `0`/`False` stringify with content,
        # `[]` becomes an empty list — what matters is the diagnostic
        # is no longer just `unknown indicator: ` with nothing after.
        assert msg.split("; ")[0] != "unknown indicator: ''"


def test_config_set_indicator_none_keeps_blank_repr(monkeypatch):
    """`None` is the genuine 'no value' case — empty raw is acceptable."""
    monkeypatch.setattr(server, "_write_config_key", lambda *a, **k: None)
    resp = server.handle_request(
        {
            "id": "1",
            "method": "config.set",
            "params": {"key": "indicator", "value": None},
        }
    )
    assert "error" in resp
    assert "unknown indicator: ''" in resp["error"]["message"]


# ── reload.env ───────────────────────────────────────────────────────


def test_reload_env_rpc_calls_hermes_cli_reload_env(monkeypatch):
    """reload.env mirrors classic CLI's `/reload` — re-reads ~/.hermes/.env
    into the gateway process and reports the count of vars updated."""
    calls = {"n": 0}

    def _fake_reload():
        calls["n"] += 1
        return 7

    fake = types.SimpleNamespace(reload_env=_fake_reload)
    with patch.dict(sys.modules, {"hermes_cli.config": fake}):
        resp = server.handle_request({"id": "1", "method": "reload.env", "params": {}})

    assert resp["result"] == {"updated": 7}
    assert calls["n"] == 1


def test_reload_env_rpc_surfaces_errors(monkeypatch):
    def _broken():
        raise RuntimeError("env path locked")

    fake = types.SimpleNamespace(reload_env=_broken)
    with patch.dict(sys.modules, {"hermes_cli.config": fake}):
        resp = server.handle_request({"id": "1", "method": "reload.env", "params": {}})

    assert "error" in resp
    assert "env path locked" in resp["error"]["message"]


# ── max_iterations config reading ─────────────────────────────────────


def _setup_make_agent_mocks(monkeypatch, cfg):
    monkeypatch.setattr(server, "_load_cfg", lambda: cfg)
    monkeypatch.setattr(
        server, "_resolve_startup_runtime", lambda: ("test-model", None)
    )
    monkeypatch.setattr(
        "hermes_cli.runtime_provider.resolve_runtime_provider",
        lambda requested=None, target_model=None: {
            "provider": None,
            "base_url": None,
            "api_key": None,
            "api_mode": None,
            "command": None,
            "args": None,
            "credential_pool": None,
        },
    )
    monkeypatch.setattr(server, "_load_tool_progress_mode", lambda: "off")
    monkeypatch.setattr(server, "_load_reasoning_config", lambda model="": None)
    monkeypatch.setattr(server, "_load_service_tier", lambda: None)
    monkeypatch.setattr(server, "_load_enabled_toolsets", lambda *_a, **_kw: None)
    monkeypatch.setattr(server, "_get_db", lambda: None)
    monkeypatch.setattr(server, "_agent_cbs", lambda sid: {})


def test_make_agent_reads_nested_max_turns(monkeypatch):
    _setup_make_agent_mocks(monkeypatch, {"agent": {"max_turns": 200}})

    with patch("run_agent.AIAgent") as mock_agent:
        server._make_agent("sid1", "key1")

    assert mock_agent.call_args.kwargs["max_iterations"] == 200


def test_make_agent_waits_for_shared_mcp_discovery(monkeypatch):
    _setup_make_agent_mocks(monkeypatch, {})
    waited = []

    from hermes_cli import mcp_startup

    monkeypatch.setattr(
        mcp_startup,
        "wait_for_mcp_discovery",
        lambda timeout=0.75: waited.append(timeout),
    )

    with patch("run_agent.AIAgent"):
        server._make_agent("sid1", "key1")

    assert waited == [0.75]


def test_make_agent_nested_max_turns_takes_priority(monkeypatch):
    _setup_make_agent_mocks(
        monkeypatch, {"agent": {"max_turns": 400}, "max_turns": 100}
    )

    with patch("run_agent.AIAgent") as mock_agent:
        server._make_agent("sid1", "key1")

    assert mock_agent.call_args.kwargs["max_iterations"] == 400


def test_make_agent_defaults_to_500(monkeypatch):
    _setup_make_agent_mocks(monkeypatch, {})

    with patch("run_agent.AIAgent") as mock_agent:
        server._make_agent("sid1", "key1")

    assert mock_agent.call_args.kwargs["max_iterations"] == 500


def test_make_agent_uses_session_runtime_overrides(monkeypatch):
    _setup_make_agent_mocks(monkeypatch, {})
    resolved = {}

    def fake_resolve_runtime_provider(requested=None, target_model=None):
        resolved["requested"] = requested
        resolved["target_model"] = target_model
        return {
            "provider": requested,
            "base_url": None,
            "api_key": None,
            "api_mode": None,
            "command": None,
            "args": None,
            "credential_pool": None,
        }

    monkeypatch.setattr(
        "hermes_cli.runtime_provider.resolve_runtime_provider",
        fake_resolve_runtime_provider,
    )

    with patch("run_agent.AIAgent") as mock_agent:
        server._make_agent(
            "sid1",
            "key1",
            model_override="gpt-5.4",
            provider_override="openai-codex",
            reasoning_config_override={"enabled": True, "effort": "high"},
            service_tier_override="priority",
        )

    assert resolved == {"requested": "openai-codex", "target_model": "gpt-5.4"}
    assert mock_agent.call_args.kwargs["model"] == "gpt-5.4"
    assert mock_agent.call_args.kwargs["provider"] == "openai-codex"
    assert mock_agent.call_args.kwargs["reasoning_config"] == {"enabled": True, "effort": "high"}
    assert mock_agent.call_args.kwargs["service_tier"] == "priority"


def test_make_agent_handles_null_agent_config(monkeypatch):
    _setup_make_agent_mocks(monkeypatch, {"agent": None, "max_turns": 80})

    with patch("run_agent.AIAgent") as mock_agent:
        server._make_agent("sid1", "key1")

    assert mock_agent.call_args.kwargs["max_iterations"] == 80


class _FakeAgentForBackground:
    base_url = None
    api_key = None
    provider = None
    api_mode = None
    acp_command = None
    acp_args = None
    model = "test-model"
    enabled_toolsets = None
    ephemeral_system_prompt = None
    providers_allowed = None
    providers_ignored = None
    providers_order = None
    provider_sort = None
    provider_require_parameters = False
    provider_data_collection = None
    reasoning_config = None
    service_tier = None
    request_overrides = {}
    _fallback_model = None


def test_background_agent_kwargs_reads_nested_max_turns(monkeypatch):
    monkeypatch.setattr(server, "_load_cfg", lambda: {"agent": {"max_turns": 300}})

    kwargs = server._background_agent_kwargs(_FakeAgentForBackground(), "task_1")

    assert kwargs["max_iterations"] == 300


def test_background_agent_kwargs_falls_back_to_root_max_turns(monkeypatch):
    monkeypatch.setattr(server, "_load_cfg", lambda: {"max_turns": 50})

    kwargs = server._background_agent_kwargs(_FakeAgentForBackground(), "task_1")

    assert kwargs["max_iterations"] == 50


def test_background_agent_kwargs_defaults_to_25(monkeypatch):
    monkeypatch.setattr(server, "_load_cfg", lambda: {})

    kwargs = server._background_agent_kwargs(_FakeAgentForBackground(), "task_1")

    assert kwargs["max_iterations"] == 25


def test_background_agent_kwargs_handles_null_agent_config(monkeypatch):
    monkeypatch.setattr(server, "_load_cfg", lambda: {"agent": None, "max_turns": 40})

    kwargs = server._background_agent_kwargs(_FakeAgentForBackground(), "task_1")

    assert kwargs["max_iterations"] == 40


def test_config_show_displays_nested_max_turns(monkeypatch):
    monkeypatch.setattr(
        server,
        "_load_cfg",
        lambda: {"agent": {"max_turns": 120}, "enabled_toolsets": [], "verbose": False},
    )
    monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")

    resp = server.handle_request({"id": "1", "method": "config.show", "params": {}})
    sections = resp["result"]["sections"]
    agent_rows = next(
        section["rows"] for section in sections if section["title"] == "Agent"
    )

    assert ["Max Turns", "120"] in agent_rows


def test_notification_poller_delivers_completion(monkeypatch):
    """Poller picks up completion events and triggers agent turns."""
    import queue as _queue_mod

    from tools.process_registry import process_registry

    turns = []
    emitted = []

    class _Agent:
        def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
            turns.append(prompt)
            return {
                "final_response": "ok",
                "messages": [{"role": "assistant", "content": "ok"}],
            }

    class _ImmediateThread:
        def __init__(self, target=None, daemon=None):
            self._target = target
        def start(self):
            self._target()

    sess = _session(agent=_Agent())
    server._sessions["sid_poll"] = sess
    monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
    monkeypatch.setattr(server, "_emit", lambda *a, **kw: emitted.append(a))
    monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None)
    monkeypatch.setattr(server, "render_message", lambda raw, cols: None)

    # Isolate the completion queue for the duration of this test. The poller
    # reads process_registry.completion_queue by attribute at runtime; the
    # event below carries no session_key, so any *other* poller (a leaked
    # daemon thread from another test, or a concurrent one in the same xdist
    # worker) is allowed to dequeue and dispatch it to its own session — whose
    # agent may be a fixture double without run_conversation. A fresh Queue
    # here fully isolates this test; monkeypatch restores the original on
    # teardown. (Same pattern as test_notification_poller_requeues_when_busy.)
    isolated_queue: _queue_mod.Queue = _queue_mod.Queue()
    monkeypatch.setattr(process_registry, "completion_queue", isolated_queue)
    process_registry._completion_consumed.discard("proc_poller_test")

    stop = threading.Event()

    # Put event on queue, then immediately signal stop so the poller
    # runs exactly one iteration.
    isolated_queue.put({
        "type": "completion",
        "session_id": "proc_poller_test",
        "command": "echo hello",
        "exit_code": 0,
        "output": "hello",
    })
    stop.set()

    try:
        server._notification_poller_loop(stop, "sid_poll", sess)

        # Should have emitted a status.update with kind=process
        status_calls = [a for a in emitted if a[0] == "status.update"]
        assert len(status_calls) >= 1
        assert status_calls[0][2]["kind"] == "process"

        # Should have triggered an agent turn
        assert len(turns) == 1
        assert "[IMPORTANT: Background process proc_poller_test completed normally" in turns[0]
    finally:
        server._sessions.pop("sid_poll", None)
        while not process_registry.completion_queue.empty():
            process_registry.completion_queue.get_nowait()


def test_notification_poller_skips_consumed(monkeypatch):
    """Already-consumed completions are not dispatched by the poller."""
    import queue as _queue_mod

    from tools.process_registry import process_registry

    turns = []

    class _Agent:
        def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
            turns.append(prompt)
            return {"final_response": "ok", "messages": []}

    class _ImmediateThread:
        def __init__(self, target=None, daemon=None):
            self._target = target
        def start(self):
            self._target()

    sess = _session(agent=_Agent())
    server._sessions["sid_skip"] = sess
    monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
    monkeypatch.setattr(server, "_emit", lambda *a, **kw: None)
    monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None)
    monkeypatch.setattr(server, "render_message", lambda raw, cols: None)

    # Isolate the completion queue so a concurrent/leaked poller in the same
    # xdist worker can't dequeue this session_key-less event before our poller
    # does. monkeypatch restores the shared singleton on teardown. (Same
    # pattern as test_notification_poller_requeues_when_busy.)
    isolated_queue: _queue_mod.Queue = _queue_mod.Queue()
    monkeypatch.setattr(process_registry, "completion_queue", isolated_queue)

    process_registry._completion_consumed.add("proc_already_done")
    isolated_queue.put({
        "type": "completion",
        "session_id": "proc_already_done",
        "command": "echo x",
        "exit_code": 0,
        "output": "x",
    })

    stop = threading.Event()
    stop.set()

    try:
        server._notification_poller_loop(stop, "sid_skip", sess)
        assert len(turns) == 0
    finally:
        server._sessions.pop("sid_skip", None)
        process_registry._completion_consumed.discard("proc_already_done")
        while not process_registry.completion_queue.empty():
            process_registry.completion_queue.get_nowait()


def test_notification_poller_requeues_when_busy(monkeypatch):
    """When the agent is busy, the poller requeues the event."""
    import queue as _queue_mod

    from tools.process_registry import process_registry

    emitted = []

    sess = _session(running=True)  # agent is busy
    server._sessions["sid_busy"] = sess
    monkeypatch.setattr(server, "_emit", lambda *a, **kw: emitted.append(a))

    # Isolate the completion queue for the duration of this test. The poller
    # reads process_registry.completion_queue by attribute at runtime, so a
    # fresh Queue here means no concurrently-running test in the same xdist
    # worker can put/get on the shared singleton mid-run and drain the event
    # we expect to be requeued. monkeypatch restores the original on teardown.
    isolated_queue: _queue_mod.Queue = _queue_mod.Queue()
    monkeypatch.setattr(process_registry, "completion_queue", isolated_queue)
    process_registry._completion_consumed.discard("proc_busy_test")

    evt = {
        "type": "completion",
        "session_id": "proc_busy_test",
        "command": "make build",
        "exit_code": 0,
        "output": "ok",
    }
    isolated_queue.put(evt)

    stop = threading.Event()
    stop.set()

    try:
        server._notification_poller_loop(stop, "sid_busy", sess)

        # Status update was emitted (user sees it)
        status_calls = [a for a in emitted if a[0] == "status.update"]
        assert len(status_calls) == 1

        # Event was requeued (agent was busy, no turn triggered)
        assert not isolated_queue.empty()
        requeued = isolated_queue.get_nowait()
        assert requeued["session_id"] == "proc_busy_test"
    finally:
        server._sessions.pop("sid_busy", None)
        while not process_registry.completion_queue.empty():
            process_registry.completion_queue.get_nowait()


def test_session_save_writes_under_hermes_home_with_system_prompt(monkeypatch, tmp_path):
    """TUI /save (session.save RPC) must snapshot under the Hermes profile
    home — not the project/workspace CWD — and include the system prompt,
    mirroring the classic CLI /save and the dashboard save export.

    Regression: the gateway handler wrote ``hermes_conversation_*.json`` to
    ``os.path.abspath(...)`` (the workspace CWD) and only exported ``model``
    and ``messages``, so ``system_prompt`` was missing.
    """
    home = tmp_path / ".hermes"
    home.mkdir()
    monkeypatch.setenv("HERMES_HOME", str(home))

    # Run from a different CWD to prove the snapshot does NOT leak there.
    work = tmp_path / "workspace"
    work.mkdir()
    monkeypatch.chdir(work)

    sid = "save-sid"
    agent = types.SimpleNamespace(
        model="hermes-test",
        session_id="20260101_120000_abc123",
        session_start=datetime(2026, 1, 1, 12, 0, 0),
        _cached_system_prompt="You are Hermes.",
    )
    history = [
        {"role": "user", "content": "hi"},
        {"role": "assistant", "content": "hello"},
    ]
    server._sessions[sid] = {
        "agent": agent,
        "session_key": "save-key",
        "history": history,
        "history_lock": threading.Lock(),
        "created_at": 1735732800.0,
    }
    try:
        resp = server._methods["session.save"]("1", {"session_id": sid})
    finally:
        server._sessions.pop(sid, None)

    assert "result" in resp, resp
    saved_file = Path(resp["result"]["file"])

    # Must NOT leak into the workspace/project CWD.
    assert not list(work.glob("hermes_conversation_*.json"))

    saved_dir = home / "sessions" / "saved"
    assert saved_file.parent == saved_dir
    assert saved_file.exists()

    payload = json.loads(saved_file.read_text())
    assert payload["model"] == "hermes-test"
    assert payload["session_id"] == "20260101_120000_abc123"
    assert payload["session_start"] == "2026-01-01T12:00:00"
    assert payload["system_prompt"] == "You are Hermes."
    assert payload["messages"] == history


def test_session_save_proxies_to_compute_host_history(monkeypatch):
    """Isolated turns own history in the host; /save must not export the stale parent mirror."""
    sid = "save-host-sid"
    server._sessions[sid] = _session(agent=None, _compute_host_active=True)
    calls = []

    def send_control(control_sid, **kwargs):
        calls.append((control_sid, kwargs))
        return {"type": "control.ack", "result": {"file": "/tmp/host-save.json"}}

    monkeypatch.setattr(server, "_session_uses_compute_host", lambda _session: True)
    monkeypatch.setattr(server, "_send_compute_host_control", send_control)
    try:
        resp = server._methods["session.save"]("1", {"session_id": sid})
    finally:
        server._sessions.pop(sid, None)

    assert resp["result"] == {"file": "/tmp/host-save.json"}
    assert calls == [(sid, {"route_name": "session.save", "wait": True})]


def test_notification_event_dedup_key_preserves_distinct_watch_matches():
    """Watch-match identity includes match content, not just session/type."""
    base = {
        "type": "watch_match",
        "session_id": "proc_watch",
        "command": "tail -f app.log",
        "pattern": "READY",
        "output": "READY on port 8000",
        "suppressed": 0,
    }

    identical = dict(base)
    distinct_output = {**base, "output": "READY on port 9000"}
    distinct_pattern = {**base, "pattern": "MIGRATION_DONE"}

    base_key = server._notification_event_dedup_key(base)
    assert server._notification_event_dedup_key(identical) == base_key
    assert server._notification_event_dedup_key(distinct_output) != base_key
    assert server._notification_event_dedup_key(distinct_pattern) != base_key


def test_notification_poller_emits_distinct_watch_matches_once(monkeypatch):
    """Distinct watch matches from one process emit; exact replay is deduped."""
    import queue as _queue_mod

    from tools.process_registry import process_registry

    turns = []
    emitted = []

    def _fake_run_prompt_submit(rid, sid, session, text):
        turns.append(text)
        with session["history_lock"]:
            session["running"] = False

    sess = _session()
    server._sessions["sid_watch_dedup"] = sess
    monkeypatch.setattr(server, "_emit", lambda *a, **kw: emitted.append(a))
    monkeypatch.setattr(server, "_run_prompt_submit", _fake_run_prompt_submit)

    isolated_queue: _queue_mod.Queue = _queue_mod.Queue()
    monkeypatch.setattr(process_registry, "completion_queue", isolated_queue)

    base = {
        "type": "watch_match",
        "session_id": "proc_watch_dedup",
        "command": "tail -f app.log",
        "pattern": "READY",
        "output": "READY on port 8000",
        "suppressed": 0,
    }
    isolated_queue.put(base)
    isolated_queue.put({**base, "output": "READY on port 9000"})
    isolated_queue.put(dict(base))

    stop = threading.Event()
    stop.set()

    try:
        server._notification_poller_loop(stop, "sid_watch_dedup", sess)
        status_calls = [a for a in emitted if a[0] == "status.update"]
        assert len(status_calls) == 2
        status_text = "\n".join(call[2]["text"] for call in status_calls)
        assert "READY on port 8000" in status_text
        assert "READY on port 9000" in status_text
        assert len(turns) == 3
    finally:
        server._sessions.pop("sid_watch_dedup", None)
        while not process_registry.completion_queue.empty():
            process_registry.completion_queue.get_nowait()


def test_notification_event_dedup_key_keeps_completions_one_shot():
    """Completion identity remains process-session scoped to avoid floods."""
    first = {
        "type": "completion",
        "session_id": "proc_done",
        "command": "make build",
        "exit_code": 0,
        "output": "first output",
    }
    replay = {
        "type": "completion",
        "session_id": "proc_done",
        "command": "make build --again",
        "exit_code": 1,
        "output": "different output should not change completion key",
    }

    assert server._notification_event_dedup_key(first) == server._notification_event_dedup_key(
        replay
    )


# --- image.attach_bytes / pdf.attach (remote-client byte upload) -------------

# Smallest valid 1x1 PNG, base64-encoded.
_PNG_1X1_B64 = (
    "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk"
    "+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
)


def _attach_bytes_cli(monkeypatch):
    fake_cli = types.ModuleType("cli")
    fake_cli._IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
    monkeypatch.setitem(sys.modules, "cli", fake_cli)


def test_image_attach_bytes_writes_to_gateway_dir(monkeypatch, tmp_path):
    """Remote client uploads base64 bytes; gateway writes them to its own disk."""
    _attach_bytes_cli(monkeypatch)
    monkeypatch.setattr(server, "_hermes_home", tmp_path)
    server._sessions["abx"] = _session()

    resp = server.handle_request(
        {
            "id": "1",
            "method": "image.attach_bytes",
            "params": {
                "session_id": "abx",
                "content_base64": _PNG_1X1_B64,
                "filename": "shot.png",
            },
        }
    )

    res = resp["result"]
    assert res["attached"] is True
    written = Path(res["path"])
    assert written.is_file()
    assert written.parent == tmp_path / "images"
    assert written.read_bytes().startswith(b"\x89PNG")
    assert len(server._sessions["abx"]["attached_images"]) == 1
    assert res["bytes"] > 0


def test_image_attach_bytes_accepts_data_url_prefix(monkeypatch, tmp_path):
    _attach_bytes_cli(monkeypatch)
    monkeypatch.setattr(server, "_hermes_home", tmp_path)
    server._sessions["abx2"] = _session()

    resp = server.handle_request(
        {
            "id": "1",
            "method": "image.attach_bytes",
            "params": {
                "session_id": "abx2",
                "content_base64": f"data:image/png;base64,{_PNG_1X1_B64}",
            },
        }
    )
    assert resp["result"]["attached"] is True


def test_image_attach_bytes_data_alias_and_magic_sniff(monkeypatch, tmp_path):
    """Older desktop builds send `data` (not content_base64); ext sniffed from bytes."""
    _attach_bytes_cli(monkeypatch)
    monkeypatch.setattr(server, "_hermes_home", tmp_path)
    server._sessions["abx3"] = _session()

    resp = server.handle_request(
        {
            "id": "1",
            "method": "image.attach_bytes",
            "params": {"session_id": "abx3", "data": _PNG_1X1_B64},
        }
    )
    res = resp["result"]
    assert res["attached"] is True
    assert Path(res["path"]).suffix == ".png"  # sniffed from magic bytes


def test_image_attach_bytes_rejects_invalid_base64(monkeypatch, tmp_path):
    _attach_bytes_cli(monkeypatch)
    monkeypatch.setattr(server, "_hermes_home", tmp_path)
    server._sessions["abx4"] = _session()

    resp = server.handle_request(
        {
            "id": "1",
            "method": "image.attach_bytes",
            "params": {"session_id": "abx4", "content_base64": "!!!not base64!!!"},
        }
    )
    assert "error" in resp
    assert resp["error"]["code"] == 4017


def test_image_attach_bytes_rejects_oversize(monkeypatch, tmp_path):
    import base64 as _b64

    _attach_bytes_cli(monkeypatch)
    monkeypatch.setattr(server, "_hermes_home", tmp_path)
    monkeypatch.setattr(server, "_ATTACH_BYTES_MAX_BYTES", 10)
    server._sessions["abx5"] = _session()

    big = _b64.b64encode(b"\x89PNG\r\n\x1a\n" + b"0" * 100).decode("ascii")
    resp = server.handle_request(
        {
            "id": "1",
            "method": "image.attach_bytes",
            "params": {"session_id": "abx5", "content_base64": big},
        }
    )
    assert "error" in resp
    assert resp["error"]["code"] == 4018


def test_image_attach_bytes_rejects_unsupported_extension(monkeypatch, tmp_path):
    _attach_bytes_cli(monkeypatch)
    monkeypatch.setattr(server, "_hermes_home", tmp_path)
    server._sessions["abx6"] = _session()

    # filename hint forces a non-image extension; magic sniff is bypassed by hint
    resp = server.handle_request(
        {
            "id": "1",
            "method": "image.attach_bytes",
            "params": {
                "session_id": "abx6",
                "content_base64": _PNG_1X1_B64,
                "filename": "evil.exe",
            },
        }
    )
    assert "error" in resp
    assert resp["error"]["code"] == 4016


def test_pdf_attach_requires_poppler(monkeypatch, tmp_path):
    """Without pdftoppm on PATH, pdf.attach returns a clear 5028."""
    _attach_bytes_cli(monkeypatch)
    monkeypatch.setattr(server, "_hermes_home", tmp_path)
    monkeypatch.setattr("shutil.which", lambda _name: None)
    server._sessions["pdf1"] = _session()

    resp = server.handle_request(
        {
            "id": "1",
            "method": "pdf.attach",
            "params": {"session_id": "pdf1", "content_base64": "JVBERi0xLjQK"},
        }
    )
    assert "error" in resp
    assert resp["error"]["code"] == 5028


def test_pdf_attach_rejects_non_pdf_bytes(monkeypatch, tmp_path):
    import base64 as _b64

    _attach_bytes_cli(monkeypatch)
    monkeypatch.setattr(server, "_hermes_home", tmp_path)
    monkeypatch.setattr("shutil.which", lambda _name: "/usr/bin/pdftoppm")
    server._sessions["pdf2"] = _session()

    not_pdf = _b64.b64encode(b"this is not a pdf").decode("ascii")
    resp = server.handle_request(
        {
            "id": "1",
            "method": "pdf.attach",
            "params": {"session_id": "pdf2", "content_base64": not_pdf},
        }
    )
    assert "error" in resp
    assert resp["error"]["code"] == 4017


def test_pdf_attach_requires_path_or_bytes(monkeypatch, tmp_path):
    _attach_bytes_cli(monkeypatch)
    monkeypatch.setattr(server, "_hermes_home", tmp_path)
    monkeypatch.setattr("shutil.which", lambda _name: "/usr/bin/pdftoppm")
    server._sessions["pdf3"] = _session()

    resp = server.handle_request(
        {"id": "1", "method": "pdf.attach", "params": {"session_id": "pdf3"}}
    )
    assert "error" in resp
    assert resp["error"]["code"] == 4015


def test_decode_attach_base64_helper():
    import base64 as _b64

    raw = _b64.b64encode(b"hello").decode("ascii")
    assert server._decode_attach_base64(raw, mime_prefix="image/") == b"hello"
    assert (
        server._decode_attach_base64(f"data:image/png;base64,{raw}", mime_prefix="image/")
        == b"hello"
    )
    # whitespace inside payload is tolerated
    assert server._decode_attach_base64(raw[:4] + "\n" + raw[4:], mime_prefix="image/") == b"hello"
    assert server._decode_attach_base64("@@@", mime_prefix="image/") is None


def test_sniff_image_ext_magic_and_filename():
    assert server._sniff_image_ext(b"\x89PNG\r\n\x1a\n") == ".png"
    assert server._sniff_image_ext(b"\xff\xd8\xff\xe0") == ".jpg"
    assert server._sniff_image_ext(b"GIF89a....") == ".gif"
    assert server._sniff_image_ext(b"RIFF1234WEBPxxxx") == ".webp"
    assert server._sniff_image_ext(b"BM......") == ".bmp"
    assert server._sniff_image_ext(b"unknown") == ".png"  # fallback
    # filename hint wins over magic bytes
    assert server._sniff_image_ext(b"\x89PNG", "photo.jpeg") == ".jpeg"


def test_slash_worker_close_reaps_zombie_and_closes_fds():
    """A hung worker is SIGKILLed, the zombie reaped, all pipes closed — once."""
    calls = {k: 0 for k in ("terminate", "kill", "wait", "stdin", "stdout", "stderr")}

    class FakeStream:
        def __init__(self, name):
            self.name = name

        def close(self):
            calls[self.name] += 1

    class FakeProc:
        stdin, stdout, stderr = (FakeStream(n) for n in ("stdin", "stdout", "stderr"))

        def poll(self):
            return None  # always alive -> forces terminate then kill

        def terminate(self):
            calls["terminate"] += 1

        def kill(self):
            calls["kill"] += 1

        def wait(self, timeout=None):
            calls["wait"] += 1
            raise subprocess.TimeoutExpired(cmd="x", timeout=timeout)

    worker = object.__new__(server._SlashWorker)
    worker.proc = FakeProc()

    worker.close()
    worker.close()  # idempotent

    assert calls["terminate"] == 1
    assert calls["kill"] == 1
    assert calls["wait"] >= 2  # reaped after both terminate and kill
    assert calls["stdin"] == calls["stdout"] == calls["stderr"] == 1


def test_close_session_by_id_is_idempotent_and_full(monkeypatch):
    """One call tears the session down fully; a second is a no-op."""
    calls = {"worker": 0, "agent": 0, "unreg": 0, "finalize": 0}

    class W:
        def close(self):
            calls["worker"] += 1

    class A:
        def close(self):
            calls["agent"] += 1

    def _fake_finalize(s, end_reason="tui_close"):
        # Real _finalize_session is the single chokepoint that closes the
        # slash-worker; mirror that here so the test exercises the actual
        # teardown contract (worker close lives in finalize, not the caller).
        calls["finalize"] += 1
        w = s.get("slash_worker")
        if w:
            w.close()

    monkeypatch.setattr(server, "_finalize_session", _fake_finalize)
    monkeypatch.setattr(
        "tools.approval.unregister_gateway_notify",
        lambda key: calls.__setitem__("unreg", calls["unreg"] + 1), raising=False,
    )
    server._sessions["sid-1"] = {"session_key": "k1", "agent": A(), "slash_worker": W()}

    assert server._close_session_by_id("sid-1", end_reason="ws_disconnect") is True
    assert server._close_session_by_id("sid-1", end_reason="ws_disconnect") is False
    assert calls == {"worker": 1, "agent": 1, "unreg": 1, "finalize": 1}
    assert "sid-1" not in server._sessions


def test_attach_worker_closes_orphan_when_session_already_torn_down():
    """A worker built after its session was reaped must be closed, not orphaned."""
    closed = []

    class W:
        def close(self):
            closed.append(True)

    server._sessions.pop("gone", None)
    detached = {"session_key": "k"}  # not in _sessions -> already torn down
    server._attach_worker("gone", detached, W())

    assert closed == [True]
    assert "slash_worker" not in detached
    assert "gone" not in server._sessions


def test_attach_worker_stores_worker_on_live_session():
    class W:
        def close(self):
            raise AssertionError("must not close a worker for a live session")

    live = {"session_key": "k"}
    server._sessions["live"] = live
    worker = W()
    try:
        server._attach_worker("live", live, worker)
        assert live["slash_worker"] is worker
    finally:
        server._sessions.pop("live", None)


def test_restart_slash_worker_closes_orphan_when_session_reaped(monkeypatch):
    """Post-turn restart of a session reaped mid-flight (e.g. close_on_disconnect
    fired while `running` flipped false) must close both the stale worker and
    the fresh replacement, not orphan either."""
    closed = []

    class _FakeWorker:
        def __init__(self, *a, **k):
            pass

        def close(self):
            closed.append(True)

    monkeypatch.setattr(server, "_SlashWorker", _FakeWorker)
    server._sessions.pop("reaped", None)
    # not in _sessions -> torn down concurrently; carries a live worker so the
    # restart path actually runs (a workerless session is a restart no-op now)
    reaped = {"session_key": "k", "slash_worker": _FakeWorker()}
    server._restart_slash_worker("reaped", reaped)

    # stale worker closed by the restart, fresh worker closed by _attach_worker
    # (sid no longer maps to this session)
    assert closed == [True, True]
    assert "reaped" not in server._sessions


def test_restart_slash_worker_stores_on_live_session(monkeypatch):
    class _FakeWorker:
        def __init__(self, *a, **k):
            pass

        def close(self):
            pass

    monkeypatch.setattr(server, "_SlashWorker", _FakeWorker)
    old_worker = _FakeWorker()
    live = {"session_key": "k", "slash_worker": old_worker}
    server._sessions["live-restart"] = live
    try:
        server._restart_slash_worker("live-restart", live)
        assert isinstance(live["slash_worker"], _FakeWorker)
        assert live["slash_worker"] is not old_worker
    finally:
        server._sessions.pop("live-restart", None)


def test_restart_slash_worker_noop_without_worker(monkeypatch):
    """A session that never spawned a worker (slash.exec not used yet) must
    stay workerless across a restart — spawning here would fork the per-worker
    stdio MCP fleet for sessions that never run worker-routed commands."""
    spawned = []

    class _FakeWorker:
        def __init__(self, *a, **k):
            spawned.append(True)

        def close(self):
            pass

    monkeypatch.setattr(server, "_SlashWorker", _FakeWorker)
    live = {"session_key": "k", "slash_worker": None}
    server._sessions["lazy-noop"] = live
    try:
        server._restart_slash_worker("lazy-noop", live)
        assert spawned == []
        assert live["slash_worker"] is None
    finally:
        server._sessions.pop("lazy-noop", None)


def test_slash_exec_concurrent_first_use_spawns_single_worker(monkeypatch):
    """With eager pre-warm removed, slash.exec is the only spawn path — two
    concurrent worker-routed commands on a fresh session must not each fork a
    full MCP-fleet worker. The per-session spawn lock serializes first use."""
    import time as _time

    spawned = []
    barrier = threading.Barrier(2, timeout=5)

    class _SlowWorker:
        def __init__(self, *a, **k):
            spawned.append(self)
            _time.sleep(0.05)  # widen the None-observation window

        def run(self, cmd):
            return f"ran {cmd}"

        def close(self):
            pass

    monkeypatch.setattr(server, "_SlashWorker", _SlowWorker)
    monkeypatch.setattr(server, "_mirror_slash_side_effects", lambda *a, **k: None)
    session = _session(slash_worker=None)
    server._sessions["race-spawn"] = session

    results = []

    def _exec(n):
        barrier.wait()
        resp = server.handle_request(
            {
                "id": str(n),
                "method": "slash.exec",
                "params": {"command": "/context", "session_id": "race-spawn"},
            }
        )
        results.append(resp)

    try:
        threads = [threading.Thread(target=_exec, args=(i,)) for i in range(2)]
        for t in threads:
            t.start()
        for t in threads:
            t.join(timeout=10)
        assert len(spawned) == 1, (
            f"concurrent slash.exec spawned {len(spawned)} workers — first-use "
            f"spawn must be serialized per session"
        )
        assert session["slash_worker"] is spawned[0]
        assert all("result" in r for r in results), results
    finally:
        server._sessions.pop("race-spawn", None)


def test_session_close_rpc_claims_then_tears_down(monkeypatch):
    seen = []
    claimed = {"session_key": "k"}
    monkeypatch.setattr(server, "_pop_session_by_id", lambda sid: seen.append(sid) or claimed)
    monkeypatch.setattr(
        server,
        "_teardown_popped_session",
        lambda session, *, end_reason: seen.append((session, end_reason)) or True,
    )
    resp = server.handle_request(
        {"id": "1", "method": "session.close", "params": {"session_id": "s9"}}
    )
    assert resp["result"] == {"closed": True}
    assert seen == ["s9", (claimed, "tui_close")]


def test_close_sessions_for_transport_closes_flagged_repoints_rest(monkeypatch):
    seen = []
    monkeypatch.setattr(
        server, "_close_session_by_id",
        lambda sid, *, end_reason: bool(seen.append((sid, end_reason))) or True,
    )
    # Detached session "b" would schedule a real grace-reap threading.Timer that
    # outlives the test; grace=0 short-circuits it so no thread lingers.
    monkeypatch.setattr(server, "_WS_ORPHAN_REAP_GRACE_S", 0)
    transport = object()  # the disconnecting transport
    server._sessions.clear()
    server._sessions["a"] = {"transport": transport, "close_on_disconnect": True}
    server._sessions["b"] = {"transport": transport, "close_on_disconnect": False}
    try:
        server._close_sessions_for_transport(transport, end_reason="ws_disconnect")
        assert seen == [("a", "ws_disconnect")]  # only the flagged one closed
        assert server._sessions["b"]["transport"] is server._detached_ws_transport  # re-pointed
    finally:
        server._sessions.clear()


def test_session_create_records_close_on_disconnect_flag(monkeypatch):
    monkeypatch.setattr(server, "_start_agent_build", lambda sid, session: None)
    server._sessions.clear()
    try:
        on = server.handle_request(
            {"id": "1", "method": "session.create", "params": {"close_on_disconnect": True}}
        )["result"]["session_id"]
        off = server.handle_request(
            {"id": "2", "method": "session.create", "params": {}}
        )["result"]["session_id"]
        assert server._sessions[on]["close_on_disconnect"]
        assert not server._sessions[off]["close_on_disconnect"]
    finally:
        server._sessions.clear()


def test_session_create_records_source(monkeypatch):
    monkeypatch.setattr(server, "_start_agent_build", lambda sid, session: None)
    server._sessions.clear()
    try:
        sid = server.handle_request(
            {"id": "1", "method": "session.create", "params": {"source": "tool"}}
        )["result"]["session_id"]
        assert server._sessions[sid]["source"] == "tool"
    finally:
        server._sessions.clear()


def test_shutdown_sessions_closes_every_session_via_helper(monkeypatch):
    seen = []
    monkeypatch.setattr(
        server, "_close_session_by_id",
        lambda sid, *, end_reason: seen.append((sid, end_reason)),
    )
    server._sessions.clear()
    server._sessions["a"] = {}
    server._sessions["b"] = {}
    try:
        server._shutdown_sessions()
        assert sorted(sid for sid, _ in seen) == ["a", "b"]
        assert {reason for _, reason in seen} == {"tui_shutdown"}
    finally:
        server._sessions.clear()


def _idle_evictable_session(now):
    """A session that satisfies every eviction precondition."""
    ready = threading.Event()
    ready.set()
    old = now - 10 * 3600  # well past the 6h TTL
    return {
        "running": False,
        "agent_ready": ready,
        "transport": server._detached_ws_transport,  # dead/detached
        "last_active": old,
        "created_at": old,
    }


def test_session_is_evictable_when_idle_dead_and_quiescent(monkeypatch):
    monkeypatch.setattr(server, "_session_pending_kind", lambda sid: "")
    now = time.time()
    assert server._session_is_evictable("s", _idle_evictable_session(now), now) is True


def test_session_not_evictable_violating_each_exemption(monkeypatch):
    monkeypatch.setattr(server, "_session_pending_kind", lambda sid: "")
    now = time.time()
    live_transport = type("T", (), {"_closed": False})()

    running = _idle_evictable_session(now) | {"running": True}
    assert server._session_is_evictable("s", running, now) is False

    starting = _idle_evictable_session(now)
    starting["agent_ready"] = threading.Event()  # not set -> still starting
    assert server._session_is_evictable("s", starting, now) is False

    on_socket = _idle_evictable_session(now) | {"transport": live_transport}
    assert server._session_is_evictable("s", on_socket, now) is False

    recent = _idle_evictable_session(now) | {"last_active": now}
    assert server._session_is_evictable("s", recent, now) is False

    young = _idle_evictable_session(now) | {"created_at": now}
    assert server._session_is_evictable("s", young, now) is False

    # Pending input request, even when everything else looks idle.
    monkeypatch.setattr(server, "_session_pending_kind", lambda sid: "input")
    assert server._session_is_evictable("s", _idle_evictable_session(now), now) is False


def test_reap_idle_sessions_closes_only_evictable(monkeypatch):
    closed = []
    monkeypatch.setattr(server, "_session_pending_kind", lambda sid: "")
    monkeypatch.setattr(
        server, "_close_session_by_id",
        lambda sid, *, end_reason, predicate=None: closed.append((sid, end_reason)),
    )
    now = time.time()
    server._sessions.clear()
    server._sessions["stale"] = _idle_evictable_session(now)
    server._sessions["fresh"] = _idle_evictable_session(now) | {"last_active": now}
    try:
        server._reap_idle_sessions()
        assert closed == [("stale", "idle_timeout")]
    finally:
        server._sessions.clear()


def test_reap_idle_sessions_calls_periodic_trim(monkeypatch):
    """The idle reaper must call trim_memory every scan, even with no victims."""
    trim_calls = []
    monkeypatch.setattr(server, "_session_pending_kind", lambda sid: "")
    monkeypatch.setattr(server, "_close_session_by_id", lambda *a, **k: None)
    monkeypatch.setattr(server, "_enforce_session_cap", lambda: None)
    monkeypatch.setattr(server, "_reclaim_orphaned_leases", lambda: None)

    # Patch the delayed import path: the function does
    # `from hermes_cli.mem_trim import trim_memory` at call time.
    import hermes_cli.mem_trim as mem_trim

    monkeypatch.setattr(
        mem_trim, "trim_memory",
        lambda **kw: trim_calls.append(kw.get("reason", "")) or True,
    )

    server._sessions.clear()
    try:
        server._reap_idle_sessions()
        assert len(trim_calls) == 1
        assert trim_calls[0] == "idle reaper periodic trim"
    finally:
        server._sessions.clear()


def test_reap_idle_sessions_logs_trim_failure(monkeypatch, caplog):
    monkeypatch.setattr(server, "_session_pending_kind", lambda sid: "")
    monkeypatch.setattr(server, "_close_session_by_id", lambda *a, **k: None)
    monkeypatch.setattr(server, "_enforce_session_cap", lambda: None)
    monkeypatch.setattr(server, "_reclaim_orphaned_leases", lambda: None)
    import hermes_cli.mem_trim as mem_trim

    monkeypatch.setattr(mem_trim, "trim_memory", lambda **_kw: (_ for _ in ()).throw(RuntimeError("boom")))
    server._sessions.clear()
    try:
        with caplog.at_level("DEBUG", logger="tui_gateway.server"):
            server._reap_idle_sessions()
        assert "idle reaper memory trim failed: RuntimeError: boom" in caplog.text
    finally:
        server._sessions.clear()


def test_ttl_reaper_spares_session_with_active_delegation(monkeypatch):
    from tools import async_delegation

    closed = []
    delegation_id = "deleg_ttl_reaper_test"
    monkeypatch.setattr(server, "_session_pending_kind", lambda sid: "")
    monkeypatch.setattr(server, "_enforce_session_cap", lambda: None)
    monkeypatch.setattr(server, "_reclaim_orphaned_leases", lambda: None)
    monkeypatch.setattr(
        server,
        "_close_session_by_id",
        lambda sid, *, end_reason, predicate=None: closed.append((sid, end_reason)),
    )
    now = time.time()
    server._sessions.clear()
    server._sessions["delegating-ttl"] = _idle_evictable_session(now)
    with async_delegation._records_lock:
        async_delegation._records[delegation_id] = {
            "status": "running",
            "origin_ui_session_id": "delegating-ttl",
        }

    try:
        server._reap_idle_sessions()
        assert closed == []
    finally:
        server._sessions.clear()
        with async_delegation._records_lock:
            async_delegation._records.pop(delegation_id, None)


def test_lru_reaper_spares_active_delegation_and_evicts_idle_peer(monkeypatch):
    from tools import async_delegation

    closed = []
    delegation_id = "deleg_lru_reaper_test"
    monkeypatch.setattr(server, "_session_pending_kind", lambda sid: "")
    monkeypatch.setattr(server, "_max_live_sessions", lambda: 1)
    monkeypatch.setattr(
        server,
        "_close_session_by_id",
        lambda sid, *, end_reason, predicate=None: closed.append((sid, end_reason)),
    )
    now = time.time()
    server._sessions.clear()
    server._sessions["delegating-lru"] = _idle_evictable_session(now) | {
        "last_active": now - 20 * 3600
    }
    server._sessions["idle-peer"] = _idle_evictable_session(now)
    with async_delegation._records_lock:
        async_delegation._records[delegation_id] = {
            "status": "running",
            "origin_ui_session_id": "delegating-lru",
        }

    try:
        server._enforce_session_cap()
        assert closed == [("idle-peer", "lru_evict")]
    finally:
        server._sessions.clear()
        with async_delegation._records_lock:
            async_delegation._records.pop(delegation_id, None)


def test_ttl_reaper_revalidates_session_before_teardown(monkeypatch):
    closed = []
    calls = {"count": 0}
    live_transport = type("T", (), {"_closed": False})()
    original_is_evictable = server._session_is_evictable
    monkeypatch.setattr(server, "_session_pending_kind", lambda sid: "")
    monkeypatch.setattr(server, "_enforce_session_cap", lambda: None)
    monkeypatch.setattr(server, "_reclaim_orphaned_leases", lambda: None)
    monkeypatch.setattr(
        server,
        "_teardown_session",
        lambda session, *, end_reason: closed.append((session, end_reason)),
    )

    def _reattach_after_scan(sid, session, now):
        calls["count"] += 1
        evictable = original_is_evictable(sid, session, now)
        if calls["count"] == 1:
            session["transport"] = live_transport
        return evictable

    monkeypatch.setattr(server, "_session_is_evictable", _reattach_after_scan)
    now = time.time()
    server._sessions.clear()
    server._sessions["ttl-race"] = _idle_evictable_session(now)

    try:
        server._reap_idle_sessions()
        assert server._sessions["ttl-race"]["transport"] is live_transport
        assert calls["count"] == 2
        assert closed == []
    finally:
        server._sessions.clear()


def test_lru_reaper_revalidates_and_tries_next_candidate(monkeypatch):
    closed = []
    live_transport = type("T", (), {"_closed": False})()
    original_is_evictable = server._session_is_lru_evictable
    monkeypatch.setattr(server, "_session_pending_kind", lambda sid: "")
    monkeypatch.setattr(server, "_max_live_sessions", lambda: 1)
    monkeypatch.setattr(
        server,
        "_teardown_session",
        lambda session, *, end_reason: closed.append((session, end_reason)),
    )

    def _reattach_oldest_after_scan(sid, session):
        evictable = original_is_evictable(sid, session)
        if sid == "lru-race" and session.get("transport") is server._detached_ws_transport:
            session["transport"] = live_transport
        return evictable

    monkeypatch.setattr(server, "_session_is_lru_evictable", _reattach_oldest_after_scan)
    now = time.time()
    server._sessions.clear()
    server._sessions["lru-race"] = _idle_evictable_session(now) | {
        "last_active": now - 20 * 3600
    }
    server._sessions["idle-peer"] = _idle_evictable_session(now)

    try:
        server._enforce_session_cap()
        assert server._sessions["lru-race"]["transport"] is live_transport
        assert "idle-peer" not in server._sessions
        assert [reason for _session, reason in closed] == ["lru_evict"]
    finally:
        server._sessions.clear()


def test_session_create_records_ui_model_as_session_override(monkeypatch):
    """The desktop composer owns its model as plain UI state and ships it on
    session.create. The gateway must record it as a PER-SESSION override (built
    into the agent), never a global config write — picking a model for a new chat
    must not mutate the profile default.
    """
    monkeypatch.setattr(server, "_enable_gateway_prompts", lambda: None)
    # Don't run the real deferred build in this storage-focused test.
    monkeypatch.setattr(server, "_start_agent_build", lambda *a, **k: None)
    try:
        resp = server._methods["session.create"](
            "r1",
            {
                "cols": 80,
                "model": "claude-sonnet-4.6",
                "provider": "anthropic",
                "reasoning_effort": "high",
                "fast": True,
            },
        )
        sid = resp["result"]["session_id"]
        sess = server._sessions[sid]
        assert sess["model_override"] == {"model": "claude-sonnet-4.6", "provider": "anthropic"}
        assert sess["create_reasoning_override"] is not None
        assert sess["create_service_tier_override"] == "priority"
        # The immediate response reflects the override (not the global default) so
        # the client never clobbers its sticky pick before the build lands.
        assert resp["result"]["info"]["model"] == "claude-sonnet-4.6"
        assert resp["result"]["info"]["provider"] == "anthropic"

        # Explicit false is not the same as omission: it must suppress a Fast
        # profile default for this session's first request.
        normal = server._methods["session.create"](
            "r2", {"cols": 80, "fast": False}
        )
        normal_sess = server._sessions[normal["result"]["session_id"]]
        assert normal_sess["create_service_tier_override"] == ""

        # No knobs → no overrides; the session builds from the profile default.
        plain = server._methods["session.create"]("r3", {"cols": 80})
        plain_sess = server._sessions[plain["result"]["session_id"]]
        assert plain_sess["model_override"] is None
        assert plain_sess["create_reasoning_override"] is None
        assert plain_sess["create_service_tier_override"] is None
    finally:
        server._sessions.clear()


@pytest.mark.parametrize("service_tier_override", ["priority", ""])
def test_start_agent_build_passes_session_model_override(
    monkeypatch, service_tier_override
):
    """A model staged on the session (e.g. by session.create from the desktop
    composer) must reach _make_agent so the first build runs on it directly —
    no global config, no build-then-switch.
    """
    captured = {}

    class FakeWorker:
        def __init__(self, *_a, **_k):
            pass

        def close(self):
            pass

    def fake_make_agent(sid, key, session_id=None, session_db=None, **kwargs):
        captured.update(kwargs)
        return types.SimpleNamespace(model="claude-sonnet-4.6")

    monkeypatch.setattr(server, "_set_session_context", lambda target: [])
    monkeypatch.setattr(server, "_clear_session_context", lambda tokens: None)
    monkeypatch.setattr(server, "_make_agent", fake_make_agent)
    monkeypatch.setattr(server, "_SlashWorker", FakeWorker)
    monkeypatch.setattr(server, "_attach_worker", lambda *a, **k: None)
    monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None)
    monkeypatch.setattr(server, "_emit", lambda *a, **k: None)
    monkeypatch.setattr(server, "_session_info", lambda *a, **k: {})
    monkeypatch.setattr(server, "_start_notification_poller", lambda *a, **k: None)
    monkeypatch.setattr(server, "_notify_session_boundary", lambda *a, **k: None)
    monkeypatch.setattr(server, "_probe_config_health", lambda *_a: None)

    sid = "build-sid"
    override = {"model": "claude-sonnet-4.6", "provider": "anthropic"}
    reasoning = {"enabled": True, "effort": "high"}
    session = {
        "agent": None,
        "agent_ready": threading.Event(),
        "session_key": "k1",
        "profile_home": None,
        "model_override": override,
        "create_reasoning_override": reasoning,
        "create_service_tier_override": service_tier_override,
    }
    server._sessions[sid] = session
    try:
        server._start_agent_build(sid, session)
        assert session["agent_ready"].wait(timeout=3), "agent build did not finish"
        assert captured.get("model_override") == override
        assert captured.get("reasoning_config_override") == reasoning
        assert captured.get("service_tier_override") == service_tier_override
        assert session["agent"].model == "claude-sonnet-4.6"
    finally:
        server._sessions.clear()


# ── billing/subscription state + error serialization ─────────────────


def test_reset_session_agent_clears_session_overrides(monkeypatch):
    """/new is a full conversation boundary: session-scoped /model, /reasoning,
    and /fast overrides do NOT carry into the fresh agent — it re-derives
    everything from config.yaml (#48055, #23131)."""
    captured = {}
    new_agent = types.SimpleNamespace(model="openai/gpt-5.4", service_tier="")
    session = _session(
        agent=types.SimpleNamespace(
            model="openai/gpt-5.4",
            reasoning_config={"enabled": True, "effort": "high"},
            service_tier="",
        ),
        model_override={"model": "openai/gpt-5.4"},
        create_reasoning_override={"enabled": True, "effort": "high"},
        create_service_tier_override="",
    )

    def make_agent(*_args, **kwargs):
        captured.update(kwargs)
        return new_agent

    monkeypatch.setattr(server, "_set_session_context", lambda _key: [])
    monkeypatch.setattr(server, "_clear_session_context", lambda _tokens: None)
    monkeypatch.setattr(server, "_make_agent", make_agent)
    monkeypatch.setattr(server, "_config_model_target", lambda: ("", ""))
    monkeypatch.setattr(server, "_load_show_reasoning", lambda: True)
    monkeypatch.setattr(server, "_load_tool_progress_mode", lambda: "all")
    monkeypatch.setattr(server, "_session_info", lambda *_args: {})
    monkeypatch.setattr(server, "_emit", lambda *_args: None)
    monkeypatch.setattr(server, "_restart_slash_worker", lambda *_args: None)

    server._reset_session_agent("sid", session)

    # No session overrides forwarded — fresh agent builds from config.
    assert "model_override" not in captured
    assert "reasoning_config_override" not in captured
    assert "service_tier_override" not in captured
    # And the session pins are gone so a later rebuild can't resurrect them.
    assert "model_override" not in session
    assert "create_reasoning_override" not in session
    assert "create_service_tier_override" not in session
    assert session["agent"] is new_agent


@pytest.mark.parametrize(
    "card,expected",
    [
        ("canonical", {"kind": "canonical"}),
        (
            "distinct",
            {
                "kind": "distinct",
                "payment_method_id": "pm_auto",
                "brand": None,
                "last4": None,
            },
        ),
        ("none", {"kind": "none"}),
    ],
)
def test_billing_state_serializes_auto_reload_card_union(monkeypatch, card, expected):
    from agent.billing_view import AutoReload, AutoReloadCard, BillingState

    monkeypatch.setattr(server, "_usage_payload", lambda state: {"available": False})
    auto_reload_card = AutoReloadCard(
        kind=card,
        payment_method_id="pm_auto" if card == "distinct" else None,
    )
    state = BillingState(
        logged_in=True,
        auto_reload=AutoReload(enabled=True, card=auto_reload_card),
    )

    result = server._serialize_billing_state(state)

    assert result["auto_reload"]["card"] == expected


def test_billing_state_serializes_server_plan_capability(monkeypatch):
    from agent.billing_view import BillingState

    monkeypatch.setattr(server, "_usage_payload", lambda state: {"available": False})
    state = BillingState(
        logged_in=True,
        role="MEMBER",
        can_change_plan_raw=True,
    )

    result = server._serialize_billing_state(state)

    assert result["is_admin"] is False
    assert result["can_change_plan"] is True


class _BillingHeaders:
    def __init__(self, values):
        self._values = values

    def get(self, key):
        return self._values.get(key)


@pytest.mark.parametrize(
    "status,error,retry_after",
    [
        (503, "stripe_unavailable", 75),
        (429, "upgrade_cap_exceeded", None),
        (429, "rate_limited", None),
    ],
)
def test_billing_error_serialization_preserves_server_code(
    status, error, retry_after
):
    import hermes_cli.nous_billing as nb

    headers = _BillingHeaders({"Retry-After": str(retry_after)}) if retry_after else None
    with pytest.raises(nb.BillingTransient) as ei:
        nb._raise_for_error(status, {"error": error}, headers)

    result = server._serialize_billing_error(ei.value)

    assert result["error"] == error
    assert ei.value.error == error
    assert result["retry_after"] == retry_after


def test_billing_rate_limit_without_error_defaults_wire_code():
    import hermes_cli.nous_billing as nb

    exc = nb.BillingRateLimited("slow down", status=429, retry_after=10)

    result = server._serialize_billing_error(exc)

    assert result["error"] == "rate_limited"


# ── subscription change RPCs (V3): preview + pending-change + upgrade ──


def _sub_rpc(method, params):
    # These RPCs are in _LONG_HANDLERS (pool-routed → dispatch returns None and the
    # worker writes via the transport), so drive the inline handler directly.
    return server.handle_request({"id": "1", "method": method, "params": params})["result"]


def test_subscription_preview_serializes_quote(monkeypatch):
    import hermes_cli.nous_billing as nb

    monkeypatch.setattr(
        nb,
        "post_subscription_preview",
        lambda subscription_type_id: {
            "effect": "charge_now",
            "reason": None,
            "currentTierId": "plus",
            "currentTierName": "Plus",
            "targetTierId": "ultra",
            "targetTierName": "Ultra",
            "monthlyCreditsDelta": "6000",
            "amountDueNowCents": 1234,
            "effectiveAt": None,
        },
    )
    res = _sub_rpc("subscription.preview", {"subscription_type_id": "ultra"})
    assert res["ok"] is True
    assert res["effect"] == "charge_now"
    assert res["amount_due_now_cents"] == 1234
    assert res["target_tier_name"] == "Ultra"
    assert res["monthly_credits_delta"] == "6000"


def test_subscription_preview_requires_tier():
    res = _sub_rpc("subscription.preview", {})
    assert res["ok"] is False
    assert res["error"] == "invalid_request"


def test_subscription_preview_scope_error_maps_to_step_up(monkeypatch):
    import hermes_cli.nous_billing as nb

    def _raise(subscription_type_id):
        raise nb.BillingScopeRequired("billing:manage required")

    monkeypatch.setattr(nb, "post_subscription_preview", _raise)
    res = _sub_rpc("subscription.preview", {"subscription_type_id": "ultra"})
    assert res["ok"] is False
    assert res["error"] == "insufficient_scope"


def test_subscription_change_cancellation(monkeypatch):
    import hermes_cli.nous_billing as nb

    seen = {}

    def _put(*, subscription_type_id=None, cancel=False):
        seen["tier"] = subscription_type_id
        seen["cancel"] = cancel
        return {"rail": "stripe", "cancelAtPeriodEnd": True, "message": "Scheduled to cancel."}

    monkeypatch.setattr(nb, "put_subscription_pending_change", _put)
    res = _sub_rpc("subscription.change", {"cancel": True})
    assert res["ok"] is True
    assert seen == {"tier": None, "cancel": True}
    assert res["message"] == "Scheduled to cancel."


def test_subscription_change_tier_downgrade(monkeypatch):
    import hermes_cli.nous_billing as nb

    seen = {}

    def _put(*, subscription_type_id=None, cancel=False):
        seen["tier"] = subscription_type_id
        seen["cancel"] = cancel
        return {"rail": "stripe", "changeType": "downgrade", "targetTierName": "Plus", "message": "Scheduled."}

    monkeypatch.setattr(nb, "put_subscription_pending_change", _put)
    res = _sub_rpc("subscription.change", {"subscription_type_id": "plus"})
    assert res["ok"] is True
    assert seen == {"tier": "plus", "cancel": False}


def test_subscription_change_requires_tier_or_cancel():
    res = _sub_rpc("subscription.change", {})
    assert res["ok"] is False
    assert res["error"] == "invalid_request"


def test_subscription_resume(monkeypatch):
    import hermes_cli.nous_billing as nb

    monkeypatch.setattr(
        nb,
        "delete_subscription_pending_change",
        lambda: {"rail": "stripe", "cancelAtPeriodEnd": False, "message": "Resumed."},
    )
    res = _sub_rpc("subscription.resume", {})
    assert res["ok"] is True
    assert res["message"] == "Resumed."


def test_subscription_upgrade_echoes_status_and_idempotency(monkeypatch):
    import hermes_cli.nous_billing as nb

    seen = {}

    def _upgrade(*, subscription_type_id, idempotency_key):
        seen["key"] = idempotency_key
        return {"status": "upgraded", "targetTierId": "ultra", "targetTierName": "Ultra"}

    monkeypatch.setattr(nb, "post_subscription_upgrade", _upgrade)
    res = _sub_rpc("subscription.upgrade", {"subscription_type_id": "ultra", "idempotency_key": "k-1"})
    assert res["ok"] is True
    assert res["status"] == "upgraded"
    assert res["target_tier_name"] == "Ultra"
    assert res["idempotency_key"] == "k-1"
    assert seen["key"] == "k-1"


def test_subscription_upgrade_requires_action_surfaces_recovery(monkeypatch):
    import hermes_cli.nous_billing as nb

    monkeypatch.setattr(
        nb,
        "post_subscription_upgrade",
        lambda *, subscription_type_id, idempotency_key: {
            "status": "requires_action",
            "reason": "authentication_required",
            "recoveryUrl": "https://portal.example/subscription?org_id=o",
        },
    )
    res = _sub_rpc("subscription.upgrade", {"subscription_type_id": "ultra"})
    # The RPC succeeds; the CHARGE needs 3DS → status + recovery_url for the portal.
    assert res["ok"] is True
    assert res["status"] == "requires_action"
    assert res["recovery_url"].startswith("https://portal.example")
    assert res["idempotency_key"]  # minted when the caller omits one
# ── _get_usage active_subagents (TUI status-bar ⛓ indicator) ──────────────
# Mirrors the classic CLI status bar: _get_usage embeds a live count of
# background/async subagents from tools.async_delegation.active_count() so the
# Ink status bar can render ⛓ N. Source of truth is the same registry the CLI
# reads; the field rides the existing per-update `usage` payload.


class _BareAgent:
    """Agent stub with no compressor — exercises the active_subagents path
    independent of the `if comp:` context-percent block."""

    model = "x"


def test_get_usage_includes_active_subagents(monkeypatch):
    import tools.async_delegation as ad_mod
    monkeypatch.setattr(ad_mod, "active_count", lambda: 4)
    usage = server._get_usage(_BareAgent())
    assert usage["active_subagents"] == 4


def test_get_usage_active_subagents_zero(monkeypatch):
    import tools.async_delegation as ad_mod
    monkeypatch.setattr(ad_mod, "active_count", lambda: 0)
    usage = server._get_usage(_BareAgent())
    assert usage["active_subagents"] == 0


def test_get_usage_safe_when_active_count_raises(monkeypatch):
    """A raising active_count() must not break the usage payload."""
    import tools.async_delegation as ad_mod

    def _boom():
        raise RuntimeError("boom")

    monkeypatch.setattr(ad_mod, "active_count", _boom)
    usage = server._get_usage(_BareAgent())
    # Field omitted, but the rest of the payload is intact.
    assert "active_subagents" not in usage
    assert usage["model"] == "x"


def test_persist_model_switch_preserves_sibling_model_keys(tmp_path, monkeypatch):
    """#48305: switching models from the TUI must NOT destroy sibling keys under
    `model:` (model_slots, model_fallback, etc.). _persist_model_switch now uses
    targeted save_config_value writes instead of rewriting the whole block."""
    import types
    import yaml
    import cli

    cfg_path = tmp_path / "config.yaml"
    cfg_path.write_text(
        "model:\n"
        "  default: old-model\n"
        "  provider: openai\n"
        "  model_slots:\n"
        "    fast: gpt-5-mini\n"
        "  model_fallback:\n"
        "    - claude-haiku\n"
        "agent:\n"
        "  system_prompt: keepme\n"
    )
    # save_config_value() resolves the config path from get_hermes_home() (live
    # env var), always targeting HERMES_HOME/config.yaml — point it at tmp_path.
    monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    monkeypatch.setattr(cli, "_hermes_home", tmp_path)

    result = types.SimpleNamespace(
        new_model="new-model", target_provider="anthropic", base_url=None
    )
    server._persist_model_switch(result)
    saved = yaml.safe_load(cfg_path.read_text())

    # The switched fields updated...
    assert saved["model"]["default"] == "new-model"
    assert saved["model"]["provider"] == "anthropic"
    # ...and the sibling keys SURVIVED (the bug was that they got wiped).
    assert saved["model"]["model_slots"] == {"fast": "gpt-5-mini"}
    assert saved["model"]["model_fallback"] == ["claude-haiku"]
    assert saved["agent"]["system_prompt"] == "keepme"


def test_persist_model_switch_clears_stale_base_url(tmp_path, monkeypatch):
    """#48305: switching from a custom endpoint (which set model.base_url) to a
    provider with no base_url must CLEAR the stale base_url, not leave it
    pointing at the old host."""
    import types
    import yaml
    import cli

    cfg_path = tmp_path / "config.yaml"
    cfg_path.write_text(
        "model:\n"
        "  default: local-model\n"
        "  provider: custom:mylocal\n"
        "  base_url: http://localhost:1234/v1\n"
    )
    monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    monkeypatch.setattr(cli, "_hermes_home", tmp_path)

    # Switch to a native provider with no base_url.
    result = types.SimpleNamespace(
        new_model="claude-haiku", target_provider="anthropic", base_url=None
    )
    server._persist_model_switch(result)
    saved = yaml.safe_load(cfg_path.read_text())

    assert saved["model"]["default"] == "claude-haiku"
    assert saved["model"]["provider"] == "anthropic"
    # Stale custom base_url must be cleared (null coalesces to absent on read).
    assert not saved["model"].get("base_url"), saved["model"].get("base_url")


# ---------------------------------------------------------------------------
# _resolve_runtime_with_fallback — init-time provider fallback
# ---------------------------------------------------------------------------

class TestResolveRuntimeWithFallback:
    """Tests for _resolve_runtime_with_fallback(): init-time provider
    fallback when the primary provider raises AuthError."""

    def test_primary_success_returns_runtime(self, monkeypatch):
        """When primary resolve succeeds, return its result directly."""
        expected = {"provider": "openai", "api_key": "tok"}
        monkeypatch.setattr(
            "hermes_cli.runtime_provider.resolve_runtime_provider",
            lambda **kw: expected,
        )
        resolution = server._resolve_runtime_with_fallback(
            {"requested": "openai"}
        )
        assert resolution.runtime == expected
        assert resolution.selected_model is None
        assert resolution.used_fallback is False

    def test_auth_error_tries_fallback_chain(self, monkeypatch):
        """On AuthError from primary, walk fallback_providers chain."""
        from hermes_cli.auth import AuthError

        fallback_runtime = {"provider": "deepseek", "api_key": "fb-tok"}

        def fake_resolve(**kwargs):
            if kwargs.get("requested") == "openai-codex":
                raise AuthError("No Codex credentials stored")
            return fallback_runtime

        monkeypatch.setattr(
            "hermes_cli.runtime_provider.resolve_runtime_provider",
            fake_resolve,
        )
        monkeypatch.setattr(
            server,
            "_load_fallback_model",
            lambda: [{"provider": "deepseek", "model": "deepseek-v4-pro"}],
        )
        resolution = server._resolve_runtime_with_fallback(
            {"requested": "openai-codex"},
        )
        assert resolution.runtime == fallback_runtime
        assert resolution.selected_model == "deepseek-v4-pro"
        assert resolution.used_fallback is True

    def test_auth_error_skips_provider_only_fallback(self, monkeypatch):
        """Auth fallback requires one complete provider/model pair."""
        from hermes_cli.auth import AuthError

        requested = []
        fallback_runtime = {"provider": "openrouter", "api_key": "fb-tok"}

        def fake_resolve(**kwargs):
            requested.append(kwargs.get("requested"))
            if kwargs.get("requested") == "openai-codex":
                raise AuthError("No Codex credentials stored")
            return fallback_runtime

        monkeypatch.setattr(
            "hermes_cli.runtime_provider.resolve_runtime_provider",
            fake_resolve,
        )
        monkeypatch.setattr(
            server,
            "_load_fallback_model",
            lambda: [
                {"provider": "anthropic"},
                {"provider": "openrouter", "model": "z-ai/glm-5.2"},
            ],
        )

        resolution = server._resolve_runtime_with_fallback(
            {"requested": "openai-codex"}
        )

        assert requested == ["openai-codex", "openrouter"]
        assert resolution.runtime == fallback_runtime
        assert resolution.selected_model == "z-ai/glm-5.2"
        assert resolution.used_fallback is True

    def test_fallback_entry_key_env_resolves_api_key(self, monkeypatch):
        """A fallback entry naming its key via key_env passes the resolved
        env value as explicit_api_key (#43861, @VrtxOmega)."""
        from hermes_cli.auth import AuthError

        monkeypatch.setenv("FB_TEST_KEY", "env-resolved-key")
        captured = {}
        fallback_runtime = {"provider": "openrouter", "api_key": "x"}

        def fake_resolve(**kwargs):
            if kwargs.get("requested") == "openai-codex":
                raise AuthError("No Codex credentials stored")
            captured.update(kwargs)
            return fallback_runtime

        monkeypatch.setattr(
            "hermes_cli.runtime_provider.resolve_runtime_provider",
            fake_resolve,
        )
        monkeypatch.setattr(
            server,
            "_load_fallback_model",
            lambda: [
                {
                    "provider": "openrouter",
                    "model": "z-ai/glm-5.2",
                    "key_env": "FB_TEST_KEY",
                }
            ],
        )
        resolution = server._resolve_runtime_with_fallback(
            {"requested": "openai-codex"}
        )
        assert resolution.used_fallback is True
        assert captured.get("explicit_api_key") == "env-resolved-key"

    def test_auth_error_all_fallbacks_fail_raises(self, monkeypatch):
        """When all fallbacks also fail, re-raise the original AuthError."""
        from hermes_cli.auth import AuthError

        def fake_resolve(**kwargs):
            raise AuthError("No credentials for " + str(kwargs.get("requested")))

        monkeypatch.setattr(
            "hermes_cli.runtime_provider.resolve_runtime_provider",
            fake_resolve,
        )
        monkeypatch.setattr(
            server,
            "_load_fallback_model",
            lambda: [{"provider": "deepseek", "model": "deepseek-v4-pro"}],
        )
        import pytest

        with pytest.raises(AuthError, match="No credentials for openai-codex"):
            server._resolve_runtime_with_fallback(
                {"requested": "openai-codex"},
            )

    def test_auth_error_skips_non_dict_entries(self, monkeypatch):
        """Fallback chain entries that are not dicts are skipped."""
        from hermes_cli.auth import AuthError

        fallback_runtime = {"provider": "anthropic", "api_key": "ant-tok"}

        def fake_resolve(**kwargs):
            if kwargs.get("requested") == "openai-codex":
                raise AuthError("No Codex credentials stored")
            return fallback_runtime

        monkeypatch.setattr(
            "hermes_cli.runtime_provider.resolve_runtime_provider",
            fake_resolve,
        )
        monkeypatch.setattr(
            server,
            "_load_fallback_model",
            lambda: [
                "invalid-string-entry",
                {"provider": "anthropic", "model": "claude-sonnet-4-6"},
            ],
        )
        resolution = server._resolve_runtime_with_fallback(
            {"requested": "openai-codex"},
        )
        assert resolution.runtime == fallback_runtime
        assert resolution.selected_model == "claude-sonnet-4-6"
        assert resolution.used_fallback is True

    def test_make_agent_uses_fallback_on_auth_error(self, monkeypatch):
        """Integration: _make_agent falls back to configured fallback
        provider when the primary provider raises AuthError."""
        import types

        from hermes_cli.auth import AuthError

        captured = {}
        fallback_runtime = {
            "provider": "deepseek",
            "api_key": "fb-tok",
            "base_url": "https://fallback.invalid/v1",
        }

        def fake_resolve(**kwargs):
            if kwargs.get("requested") in (None, "openai-codex"):
                raise AuthError("No Codex credentials stored")
            return fallback_runtime

        def fake_agent(**kwargs):
            captured.update(kwargs)
            return types.SimpleNamespace(model=kwargs.get("model"))

        monkeypatch.delenv("HERMES_MODEL", raising=False)
        monkeypatch.delenv("HERMES_INFERENCE_MODEL", raising=False)
        monkeypatch.delenv("HERMES_TUI_PROVIDER", raising=False)
        monkeypatch.setattr(
            server,
            "_load_cfg",
            lambda: {
                "model": {"default": "gpt-5.5", "provider": "openai-codex"},
                "fallback_providers": [
                    {"provider": "deepseek", "model": "deepseek-v4-pro"},
                ],
            },
        )
        monkeypatch.setattr(
            "hermes_cli.runtime_provider.resolve_runtime_provider",
            fake_resolve,
        )
        monkeypatch.setattr("run_agent.AIAgent", fake_agent)
        monkeypatch.setattr(server, "_load_enabled_toolsets", lambda *_a, **_kw: ["file"])
        monkeypatch.setattr(server, "_get_db", lambda: None)

        agent = server._make_agent(
            "sid",
            "session-key",
            model_override={
                "model": "gpt-5.5",
                "provider": "openai-codex",
                "base_url": "https://chatgpt.com/backend-api/codex",
                "api_key": "stale-codex-token",
            },
        )

        assert agent.model == "deepseek-v4-pro"
        assert captured["provider"] == "deepseek"
        assert captured["base_url"] == "https://fallback.invalid/v1"
        assert captured["api_key"] == "fb-tok"


def test_get_usage_does_not_substitute_cumulative_total_for_context_used():
    """An external context engine that does not report last_prompt_tokens must
    not have the cumulative lifetime session_total_tokens shown as its current
    context occupancy — that substitution produced impossible 1.9m/120k (100%)
    status-bar readings (#50421). With no real current occupancy known,
    context_used/percent stay unset rather than wrong."""
    agent = types.SimpleNamespace(
        model="test-model",
        session_total_tokens=1_900_000,
        context_compressor=types.SimpleNamespace(
            last_prompt_tokens=0,
            context_length=120_000,
            compression_count=0,
        ),
    )
    usage = server._get_usage(agent)
    assert usage.get("context_used") != 1_900_000
    assert "context_used" not in usage
    assert "context_percent" not in usage


def test_get_usage_reports_real_current_occupancy():
    """When the compressor reports a real current prompt size, context_used is
    that value (not the cumulative total) and the percent is sane."""
    agent = types.SimpleNamespace(
        model="test-model",
        session_total_tokens=1_900_000,
        context_compressor=types.SimpleNamespace(
            last_prompt_tokens=60_000,
            context_length=120_000,
            compression_count=2,
        ),
    )
    usage = server._get_usage(agent)
    assert usage["context_used"] == 60_000
    assert usage["context_max"] == 120_000
    assert usage["context_percent"] == 50


def test_get_usage_clamps_post_compression_sentinel():
    """Right after a compression, last_prompt_tokens is the -1 sentinel
    (conversation_compression sets it until the next real usage report). It is
    truthy, so `or 0` doesn't neutralize it — the guard must clamp <0 to 0 so
    the transitional turn emits no gauge instead of leaking context_used=-1."""
    agent = types.SimpleNamespace(
        model="test-model",
        session_total_tokens=4_000_000,
        context_compressor=types.SimpleNamespace(
            last_prompt_tokens=-1,
            context_length=1_048_576,
            compression_count=6,
        ),
    )
    usage = server._get_usage(agent)
    assert "context_used" not in usage
    assert "context_percent" not in usage


# ---------------------------------------------------------------------------
# Streaming TTS — per-turn pipeline + barge-in
# ---------------------------------------------------------------------------

def _fake_tts_modules(monkeypatch, *, requirements=True, playback_stops=None, listen=None, transcribe=None):
    """Install lightweight tools.tts_tool / tools.voice_mode fakes."""
    started = {}

    def fake_stream(text_queue, stop, done, **_kw):
        started["queue"] = text_queue
        stop.wait(5)
        done.set()

    def default_listen(should_stop, capture=False, on_trigger=None, **_kw):
        return None if capture else False

    def default_fd_listen(should_stop, is_playing=None, on_trigger=None, **_kw):
        return None

    monkeypatch.setitem(
        sys.modules,
        "tools.tts_tool",
        types.SimpleNamespace(
            check_tts_requirements=lambda: requirements,
            stream_tts_to_speaker=fake_stream,
            _get_provider=lambda cfg: "edge",
            _load_tts_config=lambda: {},
            get_env_value=lambda key, default="": default,
        ),
    )
    monkeypatch.setitem(
        sys.modules,
        "tools.voice_mode",
        types.SimpleNamespace(
            stop_playback=lambda: (playback_stops.append(True) if playback_stops is not None else None),
            listen_for_speech=listen or default_listen,
            full_duplex_listen=listen or default_fd_listen,
            is_audio_output_active=lambda: False,
            transcribe_recording=transcribe or (lambda path, model=None: {"success": True, "transcript": ""}),
        ),
    )
    # Fresh listener slot per test — the arm is idempotent per process.
    monkeypatch.setattr(server, "_fd_listener_active", False)
    return started


def test_tts_stream_begin_requires_voice_tts(monkeypatch):
    monkeypatch.setenv("HERMES_VOICE_TTS", "0")
    assert server._tts_stream_begin() is None


def test_tts_stream_begin_requires_working_provider(monkeypatch):
    monkeypatch.setenv("HERMES_VOICE_TTS", "1")
    _fake_tts_modules(monkeypatch, requirements=False)
    assert server._tts_stream_begin() is None


def test_tts_stream_begin_and_stop_lifecycle(monkeypatch):
    """begin() spawns the consumer; stop() cuts it and clears the slot."""
    monkeypatch.setenv("HERMES_VOICE_TTS", "1")
    monkeypatch.setenv("HERMES_VOICE", "0")  # no barge-in monitor (no mic)
    playback_stops: list = []
    started = _fake_tts_modules(monkeypatch, playback_stops=playback_stops)

    text_queue = server._tts_stream_begin()
    assert text_queue is not None
    assert started["queue"] is text_queue

    with server._tts_stream_lock:
        state = server._tts_stream_state
    assert state is not None and not state["stop"].is_set()

    server._tts_stream_stop()
    assert state["stop"].is_set()
    assert playback_stops == [True]
    with server._tts_stream_lock:
        assert server._tts_stream_state is None


def test_tts_stream_begin_barges_in_on_previous_pipeline(monkeypatch):
    """A new turn's pipeline stops the previous turn's speech (one speaker)."""
    monkeypatch.setenv("HERMES_VOICE_TTS", "1")
    monkeypatch.setenv("HERMES_VOICE", "0")
    _fake_tts_modules(monkeypatch)

    server._tts_stream_begin()
    with server._tts_stream_lock:
        first = server._tts_stream_state
    server._tts_stream_begin()
    assert first is not None and first["stop"].is_set()
    server._tts_stream_stop()


def test_tts_stream_stop_latches_interruption_for_next_turn(monkeypatch):
    """Cutting live speech (interrupt / typing barge) marks the latch the next
    turn's model note consumes; a mode change (user_barge=False) does not."""
    import tools.tts_streaming as ts

    ts._interrupted_at = None
    monkeypatch.setenv("HERMES_VOICE_TTS", "1")
    monkeypatch.setenv("HERMES_VOICE", "0")
    _fake_tts_modules(monkeypatch)

    server._tts_stream_begin()
    server._tts_stream_stop()  # default: user barge
    assert ts.take_speech_interrupted() is True

    server._tts_stream_begin()
    server._tts_stream_stop(user_barge=False)  # /voice off
    assert ts.take_speech_interrupted() is False


def test_tts_stream_stop_after_natural_finish_does_not_latch(monkeypatch):
    """Speech that already finished (done set) isn't an interruption."""
    import tools.tts_streaming as ts

    ts._interrupted_at = None
    monkeypatch.setenv("HERMES_VOICE_TTS", "1")
    monkeypatch.setenv("HERMES_VOICE", "0")
    _fake_tts_modules(monkeypatch)

    server._tts_stream_begin()
    with server._tts_stream_lock:
        server._tts_stream_state["done"].set()
    server._tts_stream_stop()
    assert ts.take_speech_interrupted() is False


def test_tts_stream_vad_barge_in_cuts_pipeline_and_submits_capture(monkeypatch, tmp_path):
    """User speech during playback cuts TTS at the moment of detection
    (voice.interrupted), then the captured interruption is transcribed and
    emitted as voice.transcript so the TUI submits it — complete from its
    first syllable, no re-record round trip. The cut also latches the
    speech-interrupted note for the next turn."""
    import tools.tts_streaming as ts

    ts._interrupted_at = None
    monkeypatch.setenv("HERMES_VOICE_TTS", "1")
    monkeypatch.setenv("HERMES_VOICE", "1")
    monkeypatch.setattr(server, "_load_cfg", lambda: {"voice": {"barge_in": True}})
    events: list = []
    monkeypatch.setattr(
        server, "_voice_emit", lambda event, payload=None: events.append((event, payload))
    )

    wav = tmp_path / "barge.wav"
    wav.write_bytes(b"RIFF")

    def fake_listen(should_stop, is_playing=None, on_trigger=None, **_kw):
        on_trigger("playback")  # playback cut happens at detection
        return str(wav)

    _fake_tts_modules(
        monkeypatch,
        listen=fake_listen,
        transcribe=lambda path, model=None: {"success": True, "transcript": "stop, actually—"},
    )

    server._tts_stream_begin()
    deadline = time.monotonic() + 5.0
    while time.monotonic() < deadline and wav.exists():
        time.sleep(0.01)  # unlink (finally) runs after the transcript emit
    assert ("voice.interrupted", None) in events
    assert ("voice.transcript", {"text": "stop, actually—"}) in events
    assert not wav.exists()  # capture temp file cleaned up
    assert ts.take_speech_interrupted() is True  # VAD cut latches the model note
    server._tts_stream_stop()


def test_full_duplex_generation_phase_interrupts_running_turn(monkeypatch, tmp_path):
    """Speech DURING LLM generation (no TTS audio yet) must interrupt the
    in-flight agent turn via the same seam session.interrupt uses, and the
    captured interjection is emitted as voice.transcript. This is the
    half-duplex gap: previously no listener existed until playback started."""
    import tools.tts_streaming as ts

    ts._interrupted_at = None
    monkeypatch.setenv("HERMES_VOICE", "1")
    monkeypatch.setenv("HERMES_VOICE_TTS", "0")
    monkeypatch.setattr(server, "_load_cfg", lambda: {"voice": {"barge_in": True}})
    events: list = []
    monkeypatch.setattr(
        server, "_voice_emit", lambda event, payload=None: events.append((event, payload))
    )

    wav = tmp_path / "interject.wav"
    wav.write_bytes(b"RIFF")

    interrupted = threading.Event()
    fake_agent = types.SimpleNamespace(interrupt=lambda: interrupted.set())
    fake_session = {"running": True, "agent": fake_agent}
    monkeypatch.setattr(server, "_sessions", {"sid-fd": fake_session})

    def fake_listen(should_stop, is_playing=None, on_trigger=None, **_kw):
        assert is_playing is not None and is_playing() is False  # generation phase
        on_trigger("generation")
        return str(wav)

    _fake_tts_modules(
        monkeypatch,
        listen=fake_listen,
        transcribe=lambda path, model=None: {"success": True, "transcript": "wait, try another way"},
    )

    server._arm_full_duplex_listener()

    deadline = time.monotonic() + 5.0
    while time.monotonic() < deadline and wav.exists():
        time.sleep(0.01)
    assert interrupted.is_set()  # the running turn was interrupted
    assert ("voice.interrupted", None) in events
    assert ("voice.transcript", {"text": "wait, try another way"}) in events
    assert not wav.exists()


def test_full_duplex_stop_phrase_mid_generation_ends_voice_chat(monkeypatch, tmp_path):
    """Bare 'stop' during generation = interrupt the turn AND end the voice
    chat ('stop everything'), emitted as the explicit stop_phrase signal."""
    monkeypatch.setenv("HERMES_VOICE", "1")
    monkeypatch.setattr(server, "_load_cfg", lambda: {"voice": {"barge_in": True}})
    events: list = []
    monkeypatch.setattr(
        server, "_voice_emit", lambda event, payload=None: events.append((event, payload))
    )

    wav = tmp_path / "stop.wav"
    wav.write_bytes(b"RIFF")

    interrupted = threading.Event()
    fake_agent = types.SimpleNamespace(interrupt=lambda: interrupted.set())
    monkeypatch.setattr(
        server, "_sessions", {"sid-fd": {"running": True, "agent": fake_agent}}
    )

    def fake_listen(should_stop, is_playing=None, on_trigger=None, **_kw):
        on_trigger("generation")
        return str(wav)

    _fake_tts_modules(
        monkeypatch,
        listen=fake_listen,
        transcribe=lambda path, model=None: {"success": True, "transcript": "stop"},
    )
    # is_voice_stop_phrase lives in the faked tools.voice_mode namespace.
    sys.modules["tools.voice_mode"].is_voice_stop_phrase = (
        lambda text: text.strip().lower() == "stop"
    )
    monkeypatch.setitem(
        sys.modules,
        "hermes_cli.voice",
        types.SimpleNamespace(stop_continuous=lambda **_kw: None, speak_text=lambda *a, **k: None),
    )

    server._arm_full_duplex_listener()

    deadline = time.monotonic() + 5.0
    while time.monotonic() < deadline and wav.exists():
        time.sleep(0.01)
    assert interrupted.is_set()
    assert ("voice.transcript", {"stop_phrase": True, "text": "stop"}) in events
    assert os.environ.get("HERMES_VOICE") == "0"  # voice chat ended


def test_speak_text_with_barge_arms_monitor_and_cuts_playback(monkeypatch, tmp_path):
    """The fallback whole-reply speak path (streaming pipeline couldn't
    start) and the voice.tts RPC must be barge-able too: speaking over the
    reply cuts playback and the captured interruption is emitted as
    voice.transcript — previously these paths called speak_text bare and
    were uninterruptible by voice."""
    import tools.tts_streaming as ts

    ts._interrupted_at = None
    monkeypatch.setenv("HERMES_VOICE", "1")
    monkeypatch.setenv("HERMES_VOICE_TTS", "1")
    monkeypatch.setattr(
        server,
        "_load_cfg",
        lambda: {"voice": {"barge_in": True, "barge_in_grace_seconds": 0}},
    )
    events: list = []
    monkeypatch.setattr(
        server, "_voice_emit", lambda event, payload=None: events.append((event, payload))
    )

    wav = tmp_path / "barge.wav"
    wav.write_bytes(b"RIFF")

    speak_calls = {}
    speak_started = threading.Event()
    release_speak = threading.Event()

    def fake_speak_text(text, stop_event=None):
        speak_calls["text"] = text
        speak_calls["stop_event"] = stop_event
        speak_started.set()
        release_speak.wait(5)

    monkeypatch.setitem(
        sys.modules,
        "hermes_cli.voice",
        types.SimpleNamespace(speak_text=fake_speak_text),
    )

    def fake_listen(should_stop, is_playing=None, on_trigger=None, **_kw):
        speak_started.wait(5)
        on_trigger("playback")  # user talks over the reply → cut now
        return str(wav)

    _fake_tts_modules(
        monkeypatch,
        listen=fake_listen,
        transcribe=lambda path, model=None: {"success": True, "transcript": "hang on"},
    )

    server._speak_text_with_barge("a long spoken reply")

    deadline = time.monotonic() + 5.0
    while time.monotonic() < deadline and ("voice.transcript", {"text": "hang on"}) not in events:
        time.sleep(0.01)
    release_speak.set()

    assert speak_calls["text"] == "a long spoken reply"
    # The pipeline stop event is shared with speak_text so a streaming
    # dispatch inside it is cut too.
    assert speak_calls["stop_event"] is not None
    assert speak_calls["stop_event"].is_set()
    assert ("voice.interrupted", None) in events
    assert ("voice.transcript", {"text": "hang on"}) in events
    assert ts.take_speech_interrupted() is True


def test_speak_text_with_barge_no_monitor_when_voice_mode_off(monkeypatch):
    """Auto-speak with voice mode off (no mic loop) must not open the mic."""
    monkeypatch.setenv("HERMES_VOICE", "0")
    monkeypatch.setenv("HERMES_VOICE_TTS", "1")
    monkeypatch.setattr(server, "_load_cfg", lambda: {"voice": {"barge_in": True}})

    listened = threading.Event()

    def fake_listen(should_stop, capture=False, on_trigger=None, **_kw):
        listened.set()
        return None

    done_speaking = threading.Event()
    monkeypatch.setitem(
        sys.modules,
        "hermes_cli.voice",
        types.SimpleNamespace(
            speak_text=lambda text, stop_event=None: done_speaking.set()
        ),
    )
    _fake_tts_modules(monkeypatch, listen=fake_listen)

    server._speak_text_with_barge("quiet reply")
    assert done_speaking.wait(5)
    time.sleep(0.1)
    assert not listened.is_set()


def test_clarify_callback_uses_configured_timeout(monkeypatch):
    """The TUI/desktop clarify bridge honors the canonical clarify timeout
    (via _clarify_timeout_seconds) instead of the hardcoded _block default."""
    captured = {}

    monkeypatch.setattr(server, "_clarify_timeout_seconds", lambda: 42)

    def fake_block(event, sid, payload, timeout=300):
        captured.update(event=event, sid=sid, payload=payload, timeout=timeout)
        return "answer"

    monkeypatch.setattr(server, "_block", fake_block)

    result = server._agent_cbs("sid-1")["clarify_callback"]("Pick one", ["a", "b"])

    assert result == "answer"
    assert captured["event"] == "clarify.request"
    assert captured["timeout"] == 42
    assert captured["payload"] == {"question": "Pick one", "choices": ["a", "b"]}


def test_clarify_callback_multi_select_hint(monkeypatch):
    """multi_select=True adds the hint to the payload; the single-select
    payload shape stays byte-identical to the pre-multi-select protocol
    (older renderers must never see the extra field)."""
    captured = {}

    def fake_block(event, sid, payload, timeout=300):
        captured.update(payload=payload)
        return "answer"

    monkeypatch.setattr(server, "_block", fake_block)
    cb = server._agent_cbs("sid-1")["clarify_callback"]

    cb("Pick many", ["a", "b"], multi_select=True)
    assert captured["payload"] == {
        "question": "Pick many",
        "choices": ["a", "b"],
        "multi_select": True,
    }

    cb("Pick one", ["a", "b"], multi_select=False)
    assert captured["payload"] == {"question": "Pick one", "choices": ["a", "b"]}


@pytest.mark.parametrize(
    ("configured", "expected"),
    [(0, None), (-1, None), (42, 42)],
)
def test_clarify_timeout_seconds_maps_non_positive_to_unlimited(monkeypatch, configured, expected):
    """A ``<= 0`` clarify timeout means unlimited and reaches _block as None
    (ev.wait(None) waits forever) rather than an immediate ev.wait(0) skip."""
    monkeypatch.setattr("tools.clarify_gateway.get_clarify_timeout", lambda: configured)

    assert server._clarify_timeout_seconds() == expected


def test_build_persist_message_with_image_refs_without_images_returns_text(monkeypatch):
    """#70720: when no images are attached the persisted message is the raw
    prompt — no @image directive prefix is introduced."""
    assert server._build_persist_message_with_image_refs("what is this?", []) == "what is this?"
    assert server._build_persist_message_with_image_refs("", []) == ""


def test_build_persist_message_with_image_refs_appends_existing_paths(monkeypatch, tmp_path):
    """Attached images that still exist on disk are persisted as trailing
    ``@image:<path>`` directive lines so the desktop renders them after a
    restart (instead of the vision-only enrichment that silently breaks)."""
    img = tmp_path / "cat.png"
    img.write_bytes(b"\x89PNG")

    result = server._build_persist_message_with_image_refs("what is in this photo?", [str(img)])

    assert result == f"what is in this photo?\n@image:{img}"


def test_build_persist_message_keeps_the_caption_on_the_first_line(tmp_path):
    """Session previews are the first 60 characters of the first user message,
    so a leading directive would title the session with a truncated file path
    in the sidebar, switcher, and command palette."""
    img = tmp_path / "cat.png"
    img.write_bytes(b"png")

    result = server._build_persist_message_with_image_refs("what is in this photo?", [str(img)])

    assert result.split("\n", 1)[0] == "what is in this photo?"


def test_build_persist_message_with_image_refs_skips_missing_paths(monkeypatch, tmp_path):
    """Only paths that still exist are persisted; a missing file must not
    inject a dangling @image ref into the transcript."""
    existing = tmp_path / "a.png"
    existing.write_bytes(b"png")
    missing = str(tmp_path / "gone.png")

    result = server._build_persist_message_with_image_refs("compare them", [str(existing), missing])

    assert result == f"compare them\n@image:{existing}"


def test_build_persist_message_with_image_refs_without_text_is_refs_only(monkeypatch, tmp_path):
    """A stand-alone attachment (no caption) persists as just the directive
    line, so a bare image survives in history and is not dropped as empty."""
    img = tmp_path / "only.png"
    img.write_bytes(b"png")

    assert server._build_persist_message_with_image_refs("", [str(img)]) == f"@image:{img}"


def test_build_persist_message_quotes_paths_containing_spaces(tmp_path):
    """The unquoted alternative in the directive pattern is ``\\S+``, so a path
    with a space parses as a truncated ref with the tail left as loose text.
    Desktop composer images live in the app's userData dir, which on macOS is
    ``~/Library/Application Support/...`` — a space every time."""
    img_dir = tmp_path / "Application Support" / "Hermes" / "composer-images"
    img_dir.mkdir(parents=True)
    img = img_dir / "cat.png"
    img.write_bytes(b"png")

    result = server._build_persist_message_with_image_refs("what is this?", [str(img)])

    assert result == f"what is this?\n@image:`{img}`"


def test_persist_user_message_mirrors_the_shape_sent_to_the_model(tmp_path):
    """A native-vision turn sends ``content`` as a parts list, and the session
    store ignores a plain-string override for a list payload. The override must
    mirror the list shape (ref text + the original image parts) or it is
    silently dropped and the attachment never reaches history."""
    img = tmp_path / "cat.png"
    img.write_bytes(b"png")
    image_part = {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}
    native_parts = [{"type": "text", "text": "api-only text"}, image_part]

    override = server._build_persist_user_message("what is this?", [str(img)], native_parts)

    assert override == [{"type": "text", "text": f"what is this?\n@image:{img}"}, image_part]


def test_persist_user_message_stays_a_string_for_text_mode(tmp_path):
    """Text-mode (vision-preprocessed) turns send a string, so the override
    stays a string — the shape the session store rewrites directly."""
    img = tmp_path / "cat.png"
    img.write_bytes(b"png")

    override = server._build_persist_user_message("what is this?", [str(img)], "enriched api-only text")

    assert override == f"what is this?\n@image:{img}"


def test_native_vision_turn_persists_a_renderable_image_ref(tmp_path):
    """End to end through the real session-store flush: whichever image input
    mode the turn used, the durable row carries an ``@image:`` ref the desktop
    can render after a restart."""
    from unittest.mock import MagicMock

    from agent.image_routing import build_native_content_parts
    from run_agent import AIAgent

    img_dir = tmp_path / "Application Support" / "composer-images"
    img_dir.mkdir(parents=True)
    img = img_dir / "cat.png"
    img.write_bytes(
        bytes.fromhex(
            "89504e470d0a1a0a0000000d494844520000000100000001080600000"
            "01f15c4890000000a49444154789c6360000002000100ffff0300000600"
            "0557bfabd40000000049454e44ae426082"
        )
    )
    native_parts, skipped = build_native_content_parts("what is in this photo?", [str(img)])
    assert not skipped

    agent = AIAgent.__new__(AIAgent)
    agent._session_db = MagicMock()
    agent._session_db_created = True
    agent.session_id = "s-1"
    agent._last_flushed_db_idx = 0
    agent._persist_disabled = False
    agent._flushed_db_message_ids = set()
    agent._flushed_db_message_session_id = None
    agent._pending_cli_user_message = None
    agent._persist_user_message_timestamp = None
    agent._persist_user_message_idx = 0
    agent._persist_user_message_override = server._build_persist_user_message(
        "what is in this photo?", [str(img)], native_parts
    )

    agent._flush_messages_to_session_db([{"role": "user", "content": native_parts}], [])

    written = agent._session_db.append_messages_batch.call_args.kwargs["messages"][0]["content"]
    assert f"@image:`{img}`" in written
    assert "what is in this photo?" in written
    # The model keeps the pixels for the rest of the session.
    assert any(part.get("type") == "image_url" for part in agent._persist_user_message_override)


def test_prompt_submit_passes_persist_user_message_to_agent(monkeypatch):
    """#70720: _run_prompt_submit must forward the (image-ref-aware) persisted
    user message to run_conversation via persist_user_message, so the gateway
    stores the UI-recognizable form instead of the vision enrichment."""
    captured = {}

    class _Agent:
        def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
            captured["persist_user_message"] = _kwargs.get("persist_user_message")
            return {
                "final_response": "reply",
                "messages": [{"role": "assistant", "content": "reply"}],
            }

    class _ImmediateThread:
        def __init__(self, target=None, daemon=None):
            self._target = target

        def start(self):
            self._target()

    server._sessions["sid"] = _session(agent=_Agent())
    try:
        monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
        monkeypatch.setattr(server, "_get_usage", lambda _a: {})
        monkeypatch.setattr(server, "render_message", lambda _t, _c: "")
        monkeypatch.setattr(server, "_emit", lambda *a: None)

        resp = server.handle_request(
            {
                "id": "1",
                "method": "prompt.submit",
                "params": {"session_id": "sid", "text": "hi"},
            }
        )
        assert resp.get("result")

        # Without attachments the persist form equals the raw prompt.
        assert captured.get("persist_user_message") == "hi"
    finally:
        server._sessions.pop("sid", None)


def test_prompt_submit_releases_old_history_before_heap_trim(monkeypatch):
    """The trim boundary must not retain the just-pruned history snapshots."""
    observed = {}
    cleanup_order = []

    class _Agent:
        def run_conversation(
            self, prompt, conversation_history=None, stream_callback=None
        ):
            return {
                "final_response": "reply",
                "messages": [{"role": "assistant", "content": "reply"}],
            }

    class _ImmediateThread:
        def __init__(self, target=None, daemon=None):
            self._target = target

        def start(self):
            assert self._target is not None
            self._target()

    def _inspect_trim_frame(**_kwargs):
        import inspect

        cleanup_order.append("trim")
        frame = inspect.currentframe()
        assert frame is not None and frame.f_back is not None
        caller_locals = frame.f_back.f_locals
        # Loud, not vacuous: if the production locals are ever renamed, fail
        # the test instead of silently reading None and "passing".
        assert "history" in caller_locals and "run_kwargs" in caller_locals, (
            "expected locals not found in _run_prompt_submit's finally frame — "
            "renamed? update this test"
        )
        observed["history"] = caller_locals.get("history")
        observed["run_kwargs"] = caller_locals.get("run_kwargs")

    session = _session(agent=_Agent())
    session["profile_home"] = "/tmp/test-profile"
    session["history"] = [
        {"role": "tool", "tool_call_id": "old", "content": "x" * 20_000}
    ]
    server._sessions["sid_trim"] = session
    try:
        monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
        monkeypatch.setattr(server, "_get_usage", lambda _a: {})
        monkeypatch.setattr(server, "render_message", lambda _t, _c: "")
        monkeypatch.setattr(server, "_emit", lambda *a: None)
        monkeypatch.setattr(server, "set_hermes_home_override", lambda _home: object())
        monkeypatch.setattr(
            server,
            "reset_hermes_home_override",
            lambda _token: cleanup_order.append("reset_home"),
        )
        monkeypatch.setattr("hermes_cli.mem_trim.trim_memory", _inspect_trim_frame)

        resp = server.handle_request(
            {
                "id": "1",
                "method": "prompt.submit",
                "params": {"session_id": "sid_trim", "text": "hi"},
            }
        )

        assert resp is not None and resp.get("result")
        assert not observed["history"]
        assert not observed["run_kwargs"]
        assert cleanup_order == ["trim", "reset_home"]
    finally:
        server._sessions.pop("sid_trim", None)


def test_fallback_session_info_reports_session_cwd_not_launch_dir(monkeypatch):
    """A lazily-resumed session must report ITS workspace, not the gateway's.

    ``_fallback_session_info`` used ``_default_session_cwd()`` — the directory the
    gateway process happened to start in — so the desktop Files pane painted the
    wrong project for any session resumed without a built agent (#71254).
    """
    monkeypatch.setattr(server, "_default_session_cwd", lambda: "/gateway/launch/dir")
    monkeypatch.setattr(server, "_git_branch_for_cwd", lambda cwd: "bb/feature")
    monkeypatch.setattr(server, "_project_info_for_cwd", lambda cwd: None)
    monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")

    info = server._fallback_session_info({"cwd": "/projects/session-own-repo"})

    assert info["cwd"] == "/projects/session-own-repo"
    assert info["branch"] == "bb/feature"


def test_fallback_session_info_always_emits_branch(monkeypatch):
    """``branch`` is always present so a client can CLEAR a stale label.

    Omitting the key left the desktop showing the previous conversation's branch
    after switching into a non-git session.
    """
    monkeypatch.setattr(server, "_default_session_cwd", lambda: "/gateway/launch/dir")
    monkeypatch.setattr(server, "_git_branch_for_cwd", lambda cwd: "")
    monkeypatch.setattr(server, "_project_info_for_cwd", lambda cwd: None)
    monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")

    info = server._fallback_session_info({"cwd": "/plain/folder"})

    assert "branch" in info
    assert info["branch"] == ""
