22264 lines
797 KiB
Python
22264 lines
797 KiB
Python
import json
|
|
import logging
|
|
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
|
|
from tui_gateway.transport import bind_transport, reset_transport
|
|
|
|
|
|
def _dispatch_sync(req: dict, transport=None) -> dict | None:
|
|
"""Run one RPC to completion synchronously, regardless of pool routing.
|
|
|
|
Voice RPCs (voice.toggle/record/tts) are in ``_LONG_HANDLERS`` so they run
|
|
on the RPC pool and ``server.dispatch`` returns None — the pool worker
|
|
writes the response via the bound transport. These tests exercise the
|
|
handler's business logic, not the routing, so they drive the handler
|
|
inline while preserving the transport-binding semantics ``dispatch``
|
|
applies around a real request.
|
|
"""
|
|
token = bind_transport(transport)
|
|
try:
|
|
return server.handle_request(req)
|
|
finally:
|
|
reset_transport(token)
|
|
|
|
|
|
@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
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reap_leaked_notification_pollers():
|
|
"""Stop and join notification pollers leaked by each test.
|
|
|
|
session.init/create paths start a per-session poller daemon thread. A
|
|
poller left running by one test steals-and-requeues events off the
|
|
PROCESS-GLOBAL process_registry.completion_queue while a later test is
|
|
asserting on it — the root cause of the flaky
|
|
test_run_prompt_submit_requeues_all_unstarted_notifications_with_real_threading
|
|
(two CI hits on unrelated PRs, Aug 2026). Set every registered poller's
|
|
stop event (the loop wakes at least every 0.5s), then join with ONE
|
|
small shared budget — never per-thread — so teardown stays O(seconds)
|
|
for the whole file even when many tests leaked pollers.
|
|
"""
|
|
yield
|
|
pollers = [
|
|
(stop, thread)
|
|
for stop, thread in list(server._notification_pollers)
|
|
if thread.is_alive()
|
|
]
|
|
for stop, _thread in pollers:
|
|
stop.set()
|
|
deadline = time.time() + 3.0
|
|
for _stop, thread in pollers:
|
|
remaining = deadline - time.time()
|
|
if remaining <= 0:
|
|
break
|
|
thread.join(timeout=remaining)
|
|
server._notification_pollers[:] = [
|
|
(stop, thread)
|
|
for stop, thread in server._notification_pollers
|
|
if thread.is_alive()
|
|
]
|
|
|
|
|
|
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_unknown_session_logs_warning(caplog):
|
|
"""A submit against a reaped runtime id must leave a diagnosable trace.
|
|
|
|
Regression for #90428: messages sent into a session whose in-memory
|
|
runtime was detached on WS disconnect and orphan-reaped vanished
|
|
silently — the 4001 was never logged, so "request arrived and was
|
|
rejected" was indistinguishable from "request never arrived".
|
|
"""
|
|
for session in list(server._sessions.values()):
|
|
server._teardown_session(session)
|
|
server._sessions.clear()
|
|
|
|
with caplog.at_level(logging.WARNING, logger="tui_gateway.server"):
|
|
resp = _dispatch_sync(
|
|
{
|
|
"id": "r1",
|
|
"method": "prompt.submit",
|
|
"params": {"session_id": "gone-sid", "text": "hello"},
|
|
}
|
|
)
|
|
|
|
assert resp == {
|
|
"jsonrpc": "2.0",
|
|
"id": "r1",
|
|
"error": {"code": 4001, "message": "session not found"},
|
|
}
|
|
assert any(
|
|
"session-scoped RPC rejected" in rec.message and "gone-sid" in rec.message
|
|
for rec in caplog.records
|
|
)
|
|
# The method name must be in the line. Without it this warning cannot
|
|
# identify WHICH client call is looping on a stale runtime id — the gap
|
|
# that made a 5s `process.list` poll storm (18,614 rejections against one
|
|
# id) unattributable from the logs alone.
|
|
assert any(
|
|
"method=prompt.submit" in rec.message for rec in caplog.records
|
|
)
|
|
|
|
|
|
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, **_kwargs: 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_info embeds get_update_result(), whose value flips whenever the
|
|
# background update-check thread happens to finish. This test compares two
|
|
# snapshots taken at different times, so pin the value to keep it
|
|
# deterministic regardless of how long the preceding tests ran.
|
|
import hermes_cli.banner as _banner
|
|
|
|
monkeypatch.setattr(_banner, "get_update_result", lambda timeout=0.5: None)
|
|
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_compute_host_clarify_snapshot_replays_and_proxies_batch_answers(monkeypatch):
|
|
"""A host-owned clarify survives activation and receives its UI answers."""
|
|
class _Supervisor:
|
|
def __init__(self):
|
|
self.responses = []
|
|
|
|
def respond(self, sid, params, *, timeout=15.0):
|
|
self.responses.append((sid, dict(params), timeout))
|
|
remaining = ["q1"] if params.get("question_id") == "q0" else []
|
|
return {"type": "respond.ack", "response": {"result": {"status": "ok", "remaining": remaining}}}
|
|
|
|
sid = "host-clarify"
|
|
supervisor = _Supervisor()
|
|
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: supervisor)
|
|
monkeypatch.setattr(server, "write_json", lambda _message: True)
|
|
|
|
try:
|
|
server._relay_compute_host_rpc(
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"method": "event",
|
|
"params": {
|
|
"type": "clarify.request",
|
|
"session_id": sid,
|
|
"payload": {
|
|
"request_id": "host-request",
|
|
"questions": [
|
|
{"qid": "q0", "question": "First?", "choices": ["a"]},
|
|
{"qid": "q1", "question": "Second?", "choices": ["b"]},
|
|
],
|
|
},
|
|
},
|
|
}
|
|
)
|
|
|
|
activated = server._live_session_payload(sid, session)
|
|
assert activated["pending_clarify"]["request_id"] == "host-request"
|
|
|
|
response = server.handle_request(
|
|
{
|
|
"id": "clarify-q0",
|
|
"method": "clarify.respond",
|
|
"params": {"request_id": "host-request", "question_id": "q0", "answer": "a"},
|
|
}
|
|
)
|
|
|
|
assert response["result"] == {"status": "ok", "remaining": ["q1"]}
|
|
assert supervisor.responses == [
|
|
(sid, {"request_id": "host-request", "question_id": "q0", "answer": "a"}, 15.0)
|
|
]
|
|
replayed = server._live_session_payload(sid, session)["pending_clarify"]
|
|
assert replayed["answers"] == {"q0": "a"}
|
|
|
|
final_response = server.handle_request(
|
|
{
|
|
"id": "clarify-q1",
|
|
"method": "clarify.respond",
|
|
"params": {"request_id": "host-request", "question_id": "q1", "answer": "b"},
|
|
}
|
|
)
|
|
|
|
assert final_response["result"] == {"status": "ok", "remaining": []}
|
|
assert "pending_clarify" not in server._live_session_payload(sid, session)
|
|
finally:
|
|
server._sessions.pop(sid, None)
|
|
|
|
|
|
def test_compute_host_interrupt_forwards_when_parent_running_mirror_is_stale(monkeypatch):
|
|
"""The host, not the parent's mirrored running flag, owns interruption."""
|
|
interrupted = []
|
|
|
|
class _Supervisor:
|
|
def interrupt(self, sid, *, request_id=None):
|
|
interrupted.append((sid, request_id))
|
|
|
|
sid = "host-stale-running"
|
|
server._sessions[sid] = _session(
|
|
agent=None,
|
|
agent_ready=threading.Event(),
|
|
_compute_host_active=True,
|
|
running=False,
|
|
)
|
|
monkeypatch.setattr(server, "_load_cfg", lambda: {"dashboard": {"turn_isolation": True}})
|
|
monkeypatch.setattr(server, "_get_compute_host_supervisor", lambda _cfg=None: _Supervisor())
|
|
|
|
try:
|
|
response = server.handle_request(
|
|
{"id": "interrupt", "method": "session.interrupt", "params": {"session_id": sid}}
|
|
)
|
|
assert response["result"] == {"status": "interrupted", "turn_isolation": True}
|
|
assert interrupted == [(sid, "interrupt-interrupt")]
|
|
finally:
|
|
server._sessions.pop(sid, None)
|
|
|
|
|
|
def test_compute_host_interrupt_skips_lazy_session_with_no_hosted_turn(monkeypatch):
|
|
"""A lazy session that never submitted a hosted turn must not spawn a host.
|
|
|
|
``HostSupervisor.interrupt()`` calls ``start()``, so forwarding the
|
|
interrupt unconditionally would launch a compute-host child just to
|
|
deliver an interrupt for a session with no work in it.
|
|
"""
|
|
class _Supervisor:
|
|
def interrupt(self, sid, *, request_id=None): # pragma: no cover - must not run
|
|
raise AssertionError("interrupt must not be forwarded for idle lazy sessions")
|
|
|
|
sid = "lazy-idle"
|
|
session = _session(
|
|
agent_ready=threading.Event(),
|
|
running=False,
|
|
)
|
|
session["agent"] = None # _session() substitutes a namespace for None
|
|
server._sessions[sid] = session
|
|
monkeypatch.setattr(server, "_load_cfg", lambda: {"dashboard": {"turn_isolation": True}})
|
|
monkeypatch.setattr(server, "_get_compute_host_supervisor", lambda _cfg=None: _Supervisor())
|
|
|
|
try:
|
|
response = server.handle_request(
|
|
{"id": "interrupt", "method": "session.interrupt", "params": {"session_id": sid}}
|
|
)
|
|
assert response["result"] == {"status": "interrupted", "turn_isolation": True}
|
|
finally:
|
|
server._sessions.pop(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, on_late_ack=None):
|
|
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
|
|
import uuid
|
|
|
|
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: ("", ""))
|
|
# CI runs this huge file serially under load; a prior session's _build can
|
|
# still be finishing (session.info emit) when the next test starts, so a
|
|
# 2s Event wait flakes. Unique sid + longer bound; still fail closed.
|
|
monkeypatch.setattr(server, "_start_notification_poller", lambda *a, **k: None)
|
|
monkeypatch.setattr(server, "_schedule_mcp_late_refresh", lambda *a, **k: None)
|
|
monkeypatch.setattr(server, "_emit", lambda *a, **k: None)
|
|
|
|
ready = threading.Event()
|
|
sid = f"test-sid-{uuid.uuid4().hex[:8]}"
|
|
session = {
|
|
"agent_ready": ready,
|
|
"session_key": f"test-key-{uuid.uuid4().hex[:8]}",
|
|
"profile_home": str(profile_home),
|
|
}
|
|
|
|
server._sessions[sid] = session
|
|
try:
|
|
server._start_agent_build(sid, session)
|
|
assert built.wait(timeout=15), "agent build thread never called _make_agent"
|
|
assert ready.wait(timeout=5), "agent_ready never set after build"
|
|
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
|
|
import uuid
|
|
|
|
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: ("", ""))
|
|
# Same CI flake class as the MCP profile-home test: bound wait + less work
|
|
# on the build thread (no poller / late MCP refresh / session.info emit).
|
|
monkeypatch.setattr(server, "_start_notification_poller", lambda *a, **k: None)
|
|
monkeypatch.setattr(server, "_schedule_mcp_late_refresh", lambda *a, **k: None)
|
|
monkeypatch.setattr(server, "_emit", lambda *a, **k: None)
|
|
|
|
ready = threading.Event()
|
|
sid = f"test-secret-sid-{uuid.uuid4().hex[:8]}"
|
|
session = {
|
|
"agent_ready": ready,
|
|
"session_key": f"test-secret-key-{uuid.uuid4().hex[:8]}",
|
|
"profile_home": str(profile_home),
|
|
}
|
|
|
|
server._sessions[sid] = session
|
|
try:
|
|
server._start_agent_build(sid, session)
|
|
assert built.wait(timeout=15), "agent build thread never called _make_agent"
|
|
assert ready.wait(timeout=5), "agent_ready never set after build"
|
|
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):
|
|
"""Assert StdioTransport holds _stdout_lock across the full stream.write.
|
|
|
|
The old char-by-char sleep mock made this test take long enough that
|
|
leftover background write_json calls from earlier cases in this file
|
|
could append an extra line (intermittent ``assert 9 == 8`` on CI/main).
|
|
Match the WS concurrent-send check: count in-flight writes, and only
|
|
assert on frames that carry this test's marker payload.
|
|
"""
|
|
marker = "x" * 24
|
|
active = 0
|
|
max_active = 0
|
|
gate = threading.Lock()
|
|
frames: list[str] = []
|
|
|
|
class RecordingStdout:
|
|
def write(self, text: str) -> int:
|
|
nonlocal active, max_active
|
|
with gate:
|
|
active += 1
|
|
max_active = max(max_active, active)
|
|
try:
|
|
# Release the GIL while "in write" so a missing outer lock
|
|
# would let another thread bump max_active above 1.
|
|
time.sleep(0.01)
|
|
frames.append(text)
|
|
finally:
|
|
with gate:
|
|
active -= 1
|
|
return len(text)
|
|
|
|
def flush(self) -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr(server, "_real_stdout", RecordingStdout())
|
|
|
|
barrier = threading.Barrier(8)
|
|
|
|
def _worker(seq: int) -> None:
|
|
barrier.wait(timeout=5)
|
|
server.write_json({"seq": seq, "text": marker})
|
|
|
|
threads = [threading.Thread(target=_worker, args=(i,)) for i in range(8)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join(timeout=10)
|
|
assert not t.is_alive()
|
|
|
|
assert max_active == 1
|
|
|
|
ours = []
|
|
for frame in frames:
|
|
assert frame.endswith("\n"), frame
|
|
obj = json.loads(frame)
|
|
if obj.get("text") == marker and "seq" in obj:
|
|
ours.append(obj)
|
|
|
|
assert {obj["seq"] for obj in ours} == set(range(8))
|
|
assert len(ours) == 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_usage_ticker_emits_wrapped_usage_payload(monkeypatch):
|
|
# The live ticker must nest the snapshot under a "usage" key, matching the
|
|
# message.complete / session.info payloads the desktop & TUI handlers read
|
|
# as payload.usage. Emitting the bare _get_usage() dict (payload.input/total
|
|
# …) silently drops every live tick on the client side.
|
|
events: list[tuple[str, str, dict]] = []
|
|
monkeypatch.setattr(
|
|
server, "_emit", lambda event_type, sid, payload: events.append((event_type, sid, payload))
|
|
)
|
|
snapshot = {"input": 1200, "total": 1280}
|
|
monkeypatch.setattr(server, "_get_usage", lambda agent: dict(snapshot))
|
|
|
|
stop, thread = server._start_usage_ticker("sess-1", object(), interval=0.01)
|
|
# The dedup baseline is sampled synchronously inside _start_usage_ticker,
|
|
# so this mutation is guaranteed to read as the first counter movement.
|
|
snapshot["total"] = 2400
|
|
try:
|
|
deadline = time.time() + 1.0
|
|
while not events and time.time() < deadline:
|
|
time.sleep(0.01)
|
|
finally:
|
|
stop.set()
|
|
thread.join(timeout=2.0)
|
|
|
|
assert events, "ticker never emitted"
|
|
event_type, sid, payload = events[0]
|
|
assert event_type == "session.usage"
|
|
assert sid == "sess-1"
|
|
assert payload == {"usage": {"input": 1200, "total": 2400}}
|
|
|
|
|
|
def test_usage_ticker_skips_unchanged_snapshots(monkeypatch):
|
|
# A single long API call leaves the token counters frozen for many
|
|
# intervals; the ticker must emit nothing at all (the client already has
|
|
# the turn-start values from the previous message.complete / session.info).
|
|
# Only a changed snapshot emits.
|
|
events: list[dict] = []
|
|
monkeypatch.setattr(
|
|
server, "_emit", lambda event_type, sid, payload: events.append(payload)
|
|
)
|
|
snapshot = {"input": 1200, "total": 1280}
|
|
monkeypatch.setattr(server, "_get_usage", lambda agent: dict(snapshot))
|
|
|
|
stop, thread = server._start_usage_ticker("sess-1", object(), interval=0.01)
|
|
try:
|
|
# ~15 ticks with counters frozen at the turn-start baseline: zero frames.
|
|
time.sleep(0.15)
|
|
assert events == []
|
|
|
|
# Counters move → the next tick emits the new snapshot.
|
|
snapshot["total"] = 2400
|
|
deadline = time.time() + 1.0
|
|
while not events and time.time() < deadline:
|
|
time.sleep(0.01)
|
|
finally:
|
|
stop.set()
|
|
thread.join(timeout=2.0)
|
|
|
|
assert events == [{"usage": {"input": 1200, "total": 2400}}]
|
|
|
|
|
|
def test_usage_ticker_baseline_sampled_before_thread_start(monkeypatch):
|
|
"""The dedup baseline must be sampled synchronously in _start_usage_ticker,
|
|
not inside the ticker thread: a late-scheduled thread would otherwise seed
|
|
itself with counters the turn's first API call already bumped, absorbing
|
|
that first growth so it never emits."""
|
|
events: list[dict] = []
|
|
monkeypatch.setattr(
|
|
server, "_emit", lambda event_type, sid, payload: events.append(payload)
|
|
)
|
|
snapshot = {"input": 1200, "total": 1280}
|
|
monkeypatch.setattr(server, "_get_usage", lambda agent: dict(snapshot))
|
|
|
|
class _SlowStartThread(threading.Thread):
|
|
def start(self):
|
|
# Deterministic stand-in for a scheduler delay: the turn's first
|
|
# API call bumps the counters before the ticker thread ever runs.
|
|
snapshot["total"] = 2400
|
|
super().start()
|
|
|
|
monkeypatch.setattr(server, "_RealThread", _SlowStartThread)
|
|
|
|
stop, thread = server._start_usage_ticker("sess-1", object(), interval=0.01)
|
|
try:
|
|
deadline = time.time() + 1.0
|
|
while not events and time.time() < deadline:
|
|
time.sleep(0.01)
|
|
finally:
|
|
stop.set()
|
|
thread.join(timeout=2.0)
|
|
|
|
# An in-thread seed would have read 2400 as the baseline and stayed
|
|
# silent; the synchronous seed (1280) sees it as the first growth.
|
|
assert events == [{"usage": {"input": 1200, "total": 2400}}]
|
|
|
|
|
|
def test_usage_ticker_stop_join_prevents_late_ticks(monkeypatch):
|
|
"""The stop sequence (set + join) must guarantee no session.usage after it
|
|
returns: a tick captured mid-turn but emitted after message.complete would
|
|
roll the client's final usage back to a stale snapshot (clients merge
|
|
payload.usage unconditionally)."""
|
|
events: list[str] = []
|
|
monkeypatch.setattr(
|
|
server, "_emit", lambda event_type, sid, payload: events.append(event_type)
|
|
)
|
|
|
|
in_snapshot = threading.Event()
|
|
release = threading.Event()
|
|
calls = {"n": 0}
|
|
|
|
def _blocking_get_usage(agent):
|
|
calls["n"] += 1
|
|
if calls["n"] == 1:
|
|
return {"total": 0} # dedup seed
|
|
# First real tick: hold it mid-snapshot so the stop lands while the
|
|
# iteration is already past the stop.wait() gate.
|
|
in_snapshot.set()
|
|
release.wait(2.0)
|
|
return {"total": 999}
|
|
|
|
monkeypatch.setattr(server, "_get_usage", _blocking_get_usage)
|
|
|
|
stop, thread = server._start_usage_ticker("sess-1", object(), interval=0.01)
|
|
assert in_snapshot.wait(2.0), "ticker never reached a snapshot"
|
|
|
|
# Turn ends while the tick is mid-snapshot: run the exact stop sequence
|
|
# _run_prompt_submit uses, then emit message.complete.
|
|
stop.set()
|
|
release.set()
|
|
thread.join(timeout=2.0)
|
|
assert not thread.is_alive(), "ticker thread survived the stop sequence"
|
|
server._emit("message.complete", "sess-1", {})
|
|
|
|
# The in-flight tick was dropped (stop re-checked before emit), so nothing
|
|
# can land after — let alone overwrite — the final usage.
|
|
assert "session.usage" not in events
|
|
assert events[-1] == "message.complete"
|
|
|
|
|
|
def test_run_prompt_submit_never_ticks_after_message_complete(monkeypatch):
|
|
"""End-to-end ordering through _run_prompt_submit: live session.usage ticks
|
|
happen strictly before message.complete, never after it."""
|
|
events: list[str] = []
|
|
tick_seen = threading.Event()
|
|
|
|
def _record_emit(event_type, sid, payload=None):
|
|
events.append(event_type)
|
|
if event_type == "session.usage":
|
|
tick_seen.set()
|
|
|
|
monkeypatch.setattr(server, "_emit", _record_emit)
|
|
|
|
counter = {"n": 0}
|
|
|
|
def _moving_usage(agent):
|
|
counter["n"] += 1
|
|
return {"total": counter["n"]} # moves every sample → every tick emits
|
|
|
|
monkeypatch.setattr(server, "_get_usage", _moving_usage)
|
|
real_ticker = server._start_usage_ticker
|
|
monkeypatch.setattr(
|
|
server,
|
|
"_start_usage_ticker",
|
|
lambda sid, agent, interval=1.0: real_ticker(sid, agent, interval=0.01),
|
|
)
|
|
monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
|
|
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)
|
|
|
|
class _Agent:
|
|
def run_conversation(
|
|
self,
|
|
prompt,
|
|
conversation_history=None,
|
|
stream_callback=None,
|
|
persist_user_message=None,
|
|
):
|
|
# Hold the turn open until at least one live tick has fired.
|
|
assert tick_seen.wait(5.0), "no live tick during the turn"
|
|
return {"final_response": "done", "messages": [], "completed": True}
|
|
|
|
server._sessions["sid"] = _session(agent=_Agent())
|
|
try:
|
|
server.handle_request(
|
|
{
|
|
"id": "1",
|
|
"method": "prompt.submit",
|
|
"params": {"session_id": "sid", "text": "hello"},
|
|
}
|
|
)
|
|
finally:
|
|
server._sessions.pop("sid", None)
|
|
|
|
assert "session.usage" in events
|
|
assert "message.complete" in events
|
|
last_tick = max(i for i, e in enumerate(events) if e == "session.usage")
|
|
assert last_tick < events.index("message.complete")
|
|
|
|
|
|
def test_usage_ticker_unbounded_join_waits_out_blocked_emit(monkeypatch):
|
|
"""A tick stalled inside _emit (a transport write can block up to
|
|
_WS_WRITE_TIMEOUT_S = 10s on a stalled event loop) must be waited out by
|
|
the stop sequence, not abandoned: stop.set() + an unbounded join may only
|
|
return after the in-flight emit has fully flushed, so nothing can land
|
|
after message.complete."""
|
|
order: list[str] = []
|
|
in_emit = threading.Event()
|
|
release = threading.Event()
|
|
|
|
def _stalled_emit(event_type, sid, payload):
|
|
in_emit.set()
|
|
release.wait(10.0) # the stalled transport write
|
|
order.append(event_type)
|
|
|
|
monkeypatch.setattr(server, "_emit", _stalled_emit)
|
|
|
|
counter = {"n": 0}
|
|
|
|
def _moving_usage(agent):
|
|
counter["n"] += 1
|
|
return {"total": counter["n"]} # moves every sample → a tick emits
|
|
|
|
monkeypatch.setattr(server, "_get_usage", _moving_usage)
|
|
|
|
stop, thread = server._start_usage_ticker("sess-1", object(), interval=0.01)
|
|
assert in_emit.wait(2.0), "no tick got in flight"
|
|
|
|
# Run the exact stop sequence _run_prompt_submit uses, on a side thread so
|
|
# the test can observe whether it returns while the emit is still stuck.
|
|
stopped = threading.Event()
|
|
|
|
def _stop_sequence():
|
|
stop.set()
|
|
thread.join()
|
|
stopped.set()
|
|
|
|
stopper = threading.Thread(target=_stop_sequence, daemon=True)
|
|
stopper.start()
|
|
|
|
# While the tick is stalled in the transport write, the stop sequence must
|
|
# NOT complete — a timed join returning here is exactly the bug: the
|
|
# caller would proceed to message.complete with the tick still pending.
|
|
assert not stopped.wait(0.2), "stop sequence returned with the tick still in flight"
|
|
|
|
release.set()
|
|
assert stopped.wait(2.0), "stop sequence never completed after the emit flushed"
|
|
stopper.join(timeout=2.0)
|
|
|
|
# The flushed tick strictly precedes anything the caller emits afterwards.
|
|
order.append("message.complete")
|
|
assert order == ["session.usage", "message.complete"]
|
|
|
|
|
|
def test_run_prompt_submit_joins_ticker_without_timeout(monkeypatch):
|
|
"""_run_prompt_submit must join the ticker with NO timeout. A timed join
|
|
can expire while a tick sits in a stalled transport write (up to
|
|
_WS_WRITE_TIMEOUT_S = 10s) and abandon it to land after message.complete;
|
|
the wait is bounded by that same write anyway — message.complete's own
|
|
emit would stall on the same transport."""
|
|
joins: list = []
|
|
real_ticker = server._start_usage_ticker
|
|
|
|
class _JoinSpy:
|
|
def __init__(self, thread):
|
|
self._thread = thread
|
|
|
|
def join(self, timeout=None):
|
|
joins.append(timeout)
|
|
return self._thread.join(timeout)
|
|
|
|
def __getattr__(self, name):
|
|
return getattr(self._thread, name)
|
|
|
|
def _spying_ticker(sid, agent, interval=1.0):
|
|
stop, thread = real_ticker(sid, agent, interval=interval)
|
|
return stop, _JoinSpy(thread)
|
|
|
|
monkeypatch.setattr(server, "_start_usage_ticker", _spying_ticker)
|
|
monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
|
|
monkeypatch.setattr(server, "_emit", lambda event_type, sid, payload=None: 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)
|
|
|
|
class _Agent:
|
|
def run_conversation(
|
|
self, prompt, conversation_history=None, stream_callback=None
|
|
):
|
|
return {"final_response": "done", "messages": [], "completed": True}
|
|
|
|
server._sessions["sid"] = _session(agent=_Agent())
|
|
try:
|
|
server.handle_request(
|
|
{
|
|
"id": "1",
|
|
"method": "prompt.submit",
|
|
"params": {"session_id": "sid", "text": "hello"},
|
|
}
|
|
)
|
|
finally:
|
|
server._sessions.pop("sid", None)
|
|
|
|
assert joins == [None], f"expected one unbounded join, got {joins}"
|
|
|
|
|
|
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 = _dispatch_sync(
|
|
{"id": "voice-on", "method": "voice.toggle", "params": {"action": "on"}}
|
|
)
|
|
status_resp = _dispatch_sync(
|
|
{"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 = _dispatch_sync(
|
|
{"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 = _dispatch_sync(
|
|
{"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 = _dispatch_sync(
|
|
{"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 = _dispatch_sync(
|
|
{
|
|
"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 = _dispatch_sync(
|
|
{
|
|
"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 = _dispatch_sync(
|
|
{
|
|
"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 = _dispatch_sync(
|
|
{
|
|
"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 = _dispatch_sync({
|
|
"id": "wake-1",
|
|
"method": "wake.start",
|
|
"params": {"surface": "gui", "session_id": "first-session"},
|
|
}, transport=first)
|
|
denied = _dispatch_sync({
|
|
"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 = _dispatch_sync({
|
|
"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 = _dispatch_sync({
|
|
"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 = _dispatch_sync({
|
|
"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 = _dispatch_sync({
|
|
"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 = _dispatch_sync({
|
|
"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 = _dispatch_sync({
|
|
"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 = _dispatch_sync(
|
|
{"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 = _dispatch_sync(
|
|
{
|
|
"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 = _dispatch_sync(
|
|
{
|
|
"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 = _dispatch_sync(
|
|
{
|
|
"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 = _dispatch_sync(
|
|
{
|
|
"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 = _dispatch_sync(
|
|
{"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"},
|
|
{
|
|
"args": {"pattern": "resume"},
|
|
"context": "resume",
|
|
"name": "search_files",
|
|
"role": "tool",
|
|
},
|
|
{"role": "assistant", "text": "first answer"},
|
|
{"role": "user", "text": "second prompt"},
|
|
]
|
|
|
|
|
|
def test_history_to_messages_drops_pure_compaction_scaffolding():
|
|
from agent.context_compressor import (
|
|
HISTORICAL_TASK_HEADING,
|
|
SUMMARY_PREFIX,
|
|
_SUMMARY_END_MARKER,
|
|
)
|
|
|
|
summary = (
|
|
f"{SUMMARY_PREFIX}\n\n"
|
|
f"{HISTORICAL_TASK_HEADING}\nold work\n\n"
|
|
f"{_SUMMARY_END_MARKER}"
|
|
)
|
|
|
|
assert server._history_to_messages(
|
|
[
|
|
{"role": "user", "content": summary},
|
|
{"role": "assistant", "content": "real answer"},
|
|
]
|
|
) == [{"role": "assistant", "text": "real answer"}]
|
|
|
|
|
|
def test_history_to_messages_preserves_live_ask_without_compaction_scaffolding():
|
|
from agent.context_compressor import (
|
|
HISTORICAL_TASK_HEADING,
|
|
SUMMARY_PREFIX,
|
|
_SUMMARY_END_MARKER,
|
|
)
|
|
|
|
carrier = (
|
|
f"{SUMMARY_PREFIX}\n\n"
|
|
f"{HISTORICAL_TASK_HEADING}\nold work\n\n"
|
|
f"{_SUMMARY_END_MARKER}\n\n"
|
|
"test the browser controller"
|
|
)
|
|
|
|
assert server._history_to_messages(
|
|
[
|
|
{
|
|
"role": "user",
|
|
"content": carrier,
|
|
"tool_calls": [{"id": "stale"}],
|
|
"reasoning": "internal compaction reasoning",
|
|
}
|
|
]
|
|
) == [{"role": "user", "text": "test the browser controller"}]
|
|
|
|
|
|
def test_history_to_messages_unwraps_merged_assistant_carrier():
|
|
from agent.context_compressor import (
|
|
HISTORICAL_TASK_HEADING,
|
|
SUMMARY_PREFIX,
|
|
_MERGED_PRIOR_CONTEXT_HEADER,
|
|
_MERGED_SUMMARY_DELIMITER,
|
|
_SUMMARY_END_MARKER,
|
|
)
|
|
|
|
carrier = (
|
|
f"{_MERGED_PRIOR_CONTEXT_HEADER}\n"
|
|
"real completed answer\n\n"
|
|
f"{_MERGED_SUMMARY_DELIMITER}\n\n"
|
|
f"{SUMMARY_PREFIX}\n\n"
|
|
f"{HISTORICAL_TASK_HEADING}\nold work\n\n"
|
|
f"{_SUMMARY_END_MARKER}"
|
|
)
|
|
|
|
assert server._history_to_messages(
|
|
[
|
|
{
|
|
"role": "assistant",
|
|
"content": carrier,
|
|
"tool_calls": [{"id": "stale"}],
|
|
"reasoning_details": [{"summary": "internal"}],
|
|
}
|
|
]
|
|
) == [{"role": "assistant", "text": "real completed answer"}]
|
|
|
|
|
|
def test_history_to_messages_ships_full_tool_args():
|
|
# This is the display projection. `context` is an 80-char preview for
|
|
# collapsed row titles. A renderer that shows the full call (the expanded
|
|
# `$` transcript in the desktop) rebuilds it from `args`. When the
|
|
# projection dropped the args, the preview truncation was permanent.
|
|
long_command = "echo " + "x" * 200
|
|
history = [
|
|
{"role": "user", "content": "run it"},
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_1",
|
|
"function": {
|
|
"name": "terminal",
|
|
"arguments": json.dumps({"command": long_command}),
|
|
},
|
|
}
|
|
],
|
|
},
|
|
{"role": "tool", "content": "{}", "tool_call_id": "call_1"},
|
|
]
|
|
|
|
rows = server._history_to_messages(history)
|
|
assert rows[1]["args"] == {"command": long_command}
|
|
# The preview stays alongside for the collapsed title.
|
|
assert rows[1]["context"]
|
|
|
|
# A tool row with no recorded args keeps the old small shape.
|
|
argless = server._history_to_messages(
|
|
[{"role": "tool", "content": "{}", "tool_call_id": "missing"}]
|
|
)
|
|
assert "args" not in argless[0]
|
|
|
|
|
|
def test_tool_start_ships_full_args(monkeypatch):
|
|
# The desktop rebuilds the expanded row's `$` transcript from args. When
|
|
# only the 80-char `context` preview shipped, the expanded command was
|
|
# truncated until tool.complete. tool.complete already ships full args to
|
|
# every client, so tool.start does too. There is no per-client gate.
|
|
events: list[tuple[str, str, dict]] = []
|
|
monkeypatch.setattr(
|
|
server, "_emit", lambda event_type, sid, payload: events.append((event_type, sid, payload))
|
|
)
|
|
long_command = "echo " + "y" * 200
|
|
monkeypatch.setitem(
|
|
server._sessions,
|
|
"args-test",
|
|
{"source": "desktop", "tool_progress_mode": "all", "tool_started_at": {}},
|
|
)
|
|
|
|
server._on_tool_start("args-test", "tool-1", "terminal", {"command": long_command})
|
|
server._on_tool_start("args-test", "tool-2", "terminal", {})
|
|
|
|
assert events[0][2]["args"] == {"command": long_command}
|
|
# Empty args stay omitted. Argless tools get no noise key.
|
|
assert "args" not in events[1][2]
|
|
|
|
|
|
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,
|
|
include_row_ids=False,
|
|
**_kwargs,
|
|
):
|
|
captured.setdefault("history_calls", []).append(
|
|
(target, include_ancestors, include_row_ids)
|
|
)
|
|
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, True)] if omit_messages else [
|
|
(target, False, False),
|
|
(target, True, False),
|
|
]
|
|
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_live_session_payload_reads_profile_db_not_launch_db(monkeypatch, tmp_path):
|
|
"""Warm/live reuse for a non-launch profile session must open that
|
|
profile's state.db, not the process launch DB.
|
|
|
|
App-global remote mode stores verification candidates in the resumed
|
|
profile's DB. ``_live_session_payload`` previously hard-coded
|
|
``_get_db()`` (launch), so the display projection missed those rows and
|
|
fell back to collapsed in-memory model history — while eager
|
|
``session.resume`` against the same profile still showed them.
|
|
"""
|
|
from hermes_state import SessionDB
|
|
|
|
launch_home = tmp_path / "launch"
|
|
profile_home = tmp_path / "profile"
|
|
launch_home.mkdir()
|
|
profile_home.mkdir()
|
|
|
|
launch_db = SessionDB(db_path=launch_home / "state.db")
|
|
profile_db = SessionDB(db_path=profile_home / "state.db")
|
|
profile_db.create_session("s-profile", source="tui")
|
|
profile_db.append_message("s-profile", role="user", content="do the thing")
|
|
profile_db.append_message(
|
|
"s-profile",
|
|
role="assistant",
|
|
content="long substantive answer",
|
|
finish_reason="verification_required",
|
|
)
|
|
profile_db.append_message(
|
|
"s-profile",
|
|
role="assistant",
|
|
content="terse verified reply",
|
|
finish_reason="stop",
|
|
)
|
|
model_history, display_history = profile_db.get_resume_conversations("s-profile")
|
|
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)
|
|
|
|
session = {
|
|
"session_key": "s-profile",
|
|
"profile_home": str(profile_home),
|
|
"agent": None,
|
|
"history": list(model_history),
|
|
"display_history_prefix": [],
|
|
"history_lock": threading.Lock(),
|
|
"created_at": 1.0,
|
|
"last_active": 1.0,
|
|
"running": False,
|
|
}
|
|
# Launch DB has no row for this session — the pre-fix path would miss
|
|
# candidates and fall back to collapsed in-memory history.
|
|
monkeypatch.setattr(server, "_get_db", lambda: launch_db)
|
|
|
|
payload = server._live_session_payload("live1", session, touch=False)
|
|
texts = [m.get("text") for m in payload.get("messages") or []]
|
|
|
|
assert "long substantive answer" in texts
|
|
assert texts == [m.get("text") for m in server._history_to_messages(display_history)]
|
|
|
|
|
|
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_deferred_history_acknowledges_and_reuses(monkeypatch):
|
|
history_started = threading.Event()
|
|
release_history = threading.Event()
|
|
build_started = threading.Event()
|
|
history_calls = []
|
|
auto_continue_calls = []
|
|
ancestor = {"role": "assistant", "content": "ancestor"}
|
|
loaded = {"role": "user", "content": "loaded"}
|
|
|
|
class FakeDB:
|
|
def get_session(self, target):
|
|
return {"id": target, "message_count": 1200}
|
|
|
|
def resolve_resume_session_id(self, target):
|
|
return target
|
|
|
|
def reopen_session(self, target):
|
|
assert target == "large-session"
|
|
|
|
def get_resume_conversations(self, target):
|
|
history_calls.append(("resume", target))
|
|
history_started.set()
|
|
assert release_history.wait(timeout=2.0)
|
|
return [loaded], [ancestor, loaded]
|
|
|
|
def get_ancestor_display_prefix(self, target):
|
|
history_calls.append(("prefix", target))
|
|
return [ancestor]
|
|
|
|
monkeypatch.setattr(server, "_get_db", lambda: FakeDB())
|
|
monkeypatch.setattr(server, "_enable_gateway_prompts", lambda: None)
|
|
monkeypatch.setattr(server, "_schedule_session_cap_enforcement", lambda: None)
|
|
monkeypatch.setattr(
|
|
server,
|
|
"_start_agent_build",
|
|
lambda _sid, _session: build_started.set(),
|
|
)
|
|
monkeypatch.setattr(
|
|
server,
|
|
"_maybe_schedule_auto_continue",
|
|
lambda sid, session, stored_id: auto_continue_calls.append(
|
|
(sid, session, stored_id)
|
|
),
|
|
)
|
|
|
|
try:
|
|
first = server._methods["session.resume"](
|
|
"r1",
|
|
{
|
|
"session_id": "large-session",
|
|
"source": "desktop",
|
|
"defer_history": True,
|
|
},
|
|
)
|
|
|
|
assert first["result"]["hydrating"] is True
|
|
assert first["result"]["messages"] == []
|
|
assert first["result"]["message_count"] == 1200
|
|
assert history_started.wait(timeout=1.0)
|
|
|
|
second = server._methods["session.resume"](
|
|
"r2",
|
|
{"session_id": "large-session", "defer_history": True},
|
|
)
|
|
assert second["result"]["session_id"] == first["result"]["session_id"]
|
|
assert second["result"]["hydrating"] is True
|
|
assert second["result"]["messages"] == []
|
|
|
|
release_history.set()
|
|
sid = first["result"]["session_id"]
|
|
assert server._sessions[sid]["resume_history_ready"].wait(timeout=1.0)
|
|
assert build_started.wait(timeout=1.0)
|
|
assert history_calls == [
|
|
("resume", "large-session"),
|
|
("prefix", "large-session"),
|
|
]
|
|
assert server._sessions[sid]["history"] == [loaded]
|
|
assert server._sessions[sid]["display_history_prefix"] == [ancestor]
|
|
assert server._sessions[sid]["resume_message_count"] == 2
|
|
assert auto_continue_calls == [(sid, server._sessions[sid], "large-session")]
|
|
finally:
|
|
release_history.set()
|
|
for sid, session in list(server._sessions.items()):
|
|
if session.get("session_key") == "large-session":
|
|
lease = session.get("active_session_lease")
|
|
if lease is not None:
|
|
lease.release()
|
|
server._sessions.pop(sid, None)
|
|
|
|
|
|
def test_session_resume_deferred_history_failure_can_retry(monkeypatch):
|
|
first_released = threading.Event()
|
|
build_started = threading.Event()
|
|
attempts = 0
|
|
|
|
class FakeDB:
|
|
def get_session(self, target):
|
|
return {"id": target, "message_count": 1}
|
|
|
|
def resolve_resume_session_id(self, target):
|
|
return target
|
|
|
|
def reopen_session(self, _target):
|
|
pass
|
|
|
|
def get_resume_conversations(self, _target):
|
|
nonlocal attempts
|
|
attempts += 1
|
|
if attempts == 1:
|
|
first_released.set()
|
|
raise RuntimeError("sqlite read failed")
|
|
loaded = [{"role": "user", "content": "retry loaded"}]
|
|
return loaded, loaded
|
|
|
|
def get_ancestor_display_prefix(self, _target):
|
|
return []
|
|
|
|
monkeypatch.setattr(server, "_get_db", lambda: FakeDB())
|
|
monkeypatch.setattr(
|
|
server,
|
|
"_claim_active_session_slot",
|
|
lambda *_args, **_kwargs: pytest.fail(
|
|
"resume must not claim a session slot before the first prompt"
|
|
),
|
|
)
|
|
monkeypatch.setattr(server, "_enable_gateway_prompts", lambda: None)
|
|
monkeypatch.setattr(server, "_schedule_session_cap_enforcement", lambda: None)
|
|
monkeypatch.setattr(
|
|
server,
|
|
"_start_agent_build",
|
|
lambda _sid, _session: build_started.set(),
|
|
)
|
|
|
|
try:
|
|
first = server._methods["session.resume"](
|
|
"r1",
|
|
{"session_id": "retry-session", "defer_history": True},
|
|
)
|
|
first_sid = first["result"]["session_id"]
|
|
assert first_released.wait(timeout=1.0)
|
|
assert first_sid not in server._sessions
|
|
|
|
second = server._methods["session.resume"](
|
|
"r2",
|
|
{"session_id": "retry-session", "defer_history": True},
|
|
)
|
|
second_sid = second["result"]["session_id"]
|
|
assert second_sid != first_sid
|
|
assert server._sessions[second_sid]["resume_history_ready"].wait(timeout=1.0)
|
|
assert build_started.wait(timeout=1.0)
|
|
finally:
|
|
for sid, session in list(server._sessions.items()):
|
|
if session.get("session_key") == "retry-session":
|
|
lease = session.get("active_session_lease")
|
|
if lease is not None:
|
|
lease.release()
|
|
server._sessions.pop(sid, None)
|
|
|
|
|
|
def test_session_resume_deferred_history_close_cancels_build(monkeypatch):
|
|
history_started = threading.Event()
|
|
release_history = threading.Event()
|
|
build_started = threading.Event()
|
|
|
|
class FakeDB:
|
|
def get_session(self, target):
|
|
return {"id": target, "message_count": 1}
|
|
|
|
def resolve_resume_session_id(self, target):
|
|
return target
|
|
|
|
def reopen_session(self, _target):
|
|
pass
|
|
|
|
def get_resume_conversations(self, _target):
|
|
history_started.set()
|
|
assert release_history.wait(timeout=2.0)
|
|
loaded = [{"role": "user", "content": "late"}]
|
|
return loaded, loaded
|
|
|
|
def get_ancestor_display_prefix(self, _target):
|
|
return []
|
|
|
|
monkeypatch.setattr(server, "_get_db", lambda: FakeDB())
|
|
monkeypatch.setattr(server, "_enable_gateway_prompts", lambda: None)
|
|
monkeypatch.setattr(server, "_schedule_session_cap_enforcement", lambda: None)
|
|
monkeypatch.setattr(
|
|
server,
|
|
"_start_agent_build",
|
|
lambda _sid, _session: build_started.set(),
|
|
)
|
|
|
|
response = {}
|
|
try:
|
|
response = server._methods["session.resume"](
|
|
"r1",
|
|
{"session_id": "cancel-session", "defer_history": True},
|
|
)
|
|
sid = response["result"]["session_id"]
|
|
session = server._sessions[sid]
|
|
assert history_started.wait(timeout=1.0)
|
|
|
|
assert server._close_session_by_id(sid, end_reason="tui_close") is True
|
|
assert session["resume_history_ready"].is_set()
|
|
assert session["resume_history_error"] == "session resume cancelled"
|
|
|
|
release_history.set()
|
|
time.sleep(0.05)
|
|
assert not build_started.is_set()
|
|
assert sid not in server._sessions
|
|
finally:
|
|
release_history.set()
|
|
server._sessions.pop(response.get("result", {}).get("session_id", ""), None)
|
|
|
|
|
|
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.get_shared_session_db", 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.get_shared_session_db", 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(monkeypatch):
|
|
"""A bare billing bucket ("custom"/"auto") 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). ``"openrouter"`` is NOT a bare bucket
|
|
— it is a fully routable provider; see #57588. 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", "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. It must actually resolve in the registry — a stale/renamed
|
|
# provider is dropped (see TestStaleProviderNameFallsBack).
|
|
cfg = {
|
|
"custom_providers": [
|
|
{
|
|
"name": "myendpoint",
|
|
"base_url": "https://myendpoint.invalid/v1",
|
|
"api_key": "sk-test",
|
|
"model": "m",
|
|
}
|
|
]
|
|
}
|
|
import hermes_cli.runtime_provider as rp
|
|
|
|
monkeypatch.setattr(rp, "load_config", lambda: cfg)
|
|
monkeypatch.setattr("hermes_cli.config.load_config", lambda: cfg)
|
|
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_openrouter_session_resume_restores_provider():
|
|
"""OpenRouter is a fully routable provider — sessions that used OpenRouter must
|
|
restore the "openrouter" provider override on resume, not fall through to whatever
|
|
the current global model is. (#57588)
|
|
"""
|
|
# OpenRouter session with no explicit model_config.provider (the common case
|
|
# for sessions that never used /model): billing_provider="openrouter" should
|
|
# be restored as the provider override.
|
|
ov = server._stored_session_runtime_overrides(
|
|
{"model": "anthropic/claude-opus-4.8", "billing_provider": "openrouter"}
|
|
)
|
|
assert ov["provider_override"] == "openrouter"
|
|
assert ov["model_override"]["provider"] == "openrouter"
|
|
assert ov["model_override"]["model"] == "anthropic/claude-opus-4.8"
|
|
|
|
# When an explicit model_config.provider exists, it takes precedence over
|
|
# billing_provider (this path was already correct).
|
|
ov = server._stored_session_runtime_overrides(
|
|
{
|
|
"model": "anthropic/claude-opus-4.8",
|
|
"billing_provider": "openrouter",
|
|
"model_config": {"provider": "openrouter", "base_url": "https://openrouter.ai/api/v1"},
|
|
}
|
|
)
|
|
assert ov["provider_override"] == "openrouter"
|
|
|
|
|
|
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_session_close_settles_active_turn_before_teardown(monkeypatch):
|
|
"""Close must not tear down agent resources while their turn is unwinding."""
|
|
turn_started = threading.Event()
|
|
release_turn = threading.Event()
|
|
teardown_started = threading.Event()
|
|
response = {}
|
|
|
|
def _turn():
|
|
turn_started.set()
|
|
assert release_turn.wait(timeout=2.0)
|
|
|
|
def _teardown(_session, *, end_reason="tui_close"):
|
|
if end_reason == "tui_close":
|
|
teardown_started.set()
|
|
|
|
session = _session()
|
|
run_thread = threading.Thread(target=_turn)
|
|
session["_run_thread"] = run_thread
|
|
server._sessions["settle-close"] = session
|
|
monkeypatch.setattr(server, "_teardown_session", _teardown)
|
|
monkeypatch.setattr(
|
|
server, "_TURN_SETTLE_BEFORE_CLOSE_SECONDS", 1.0, raising=False
|
|
)
|
|
|
|
close_thread = threading.Thread(
|
|
target=lambda: response.update(
|
|
server.handle_request(
|
|
{
|
|
"id": "close",
|
|
"method": "session.close",
|
|
"params": {"session_id": "settle-close"},
|
|
}
|
|
)
|
|
)
|
|
)
|
|
run_thread.start()
|
|
close_thread.start()
|
|
try:
|
|
assert turn_started.wait(timeout=1.0)
|
|
assert not teardown_started.wait(timeout=0.1)
|
|
release_turn.set()
|
|
close_thread.join(timeout=2.0)
|
|
finally:
|
|
release_turn.set()
|
|
run_thread.join(timeout=2.0)
|
|
close_thread.join(timeout=2.0)
|
|
server._sessions.pop("settle-close", None)
|
|
|
|
assert not close_thread.is_alive()
|
|
assert teardown_started.is_set()
|
|
assert response["result"] == {"closed": True}
|
|
|
|
|
|
def test_ws_orphan_reap_interrupts_isolated_turn_then_reaps(monkeypatch):
|
|
callbacks = []
|
|
interrupted = []
|
|
torn_down = []
|
|
|
|
class _Timer:
|
|
def __init__(self, _delay, callback):
|
|
callbacks.append(callback)
|
|
self.daemon = False
|
|
|
|
def start(self):
|
|
return None
|
|
|
|
class _Supervisor:
|
|
def interrupt(self, sid, *, request_id=None):
|
|
interrupted.append((sid, request_id))
|
|
|
|
session = _session(
|
|
agent=None,
|
|
agent_ready=threading.Event(),
|
|
transport=server._detached_ws_transport,
|
|
running=True,
|
|
_compute_host_active=True,
|
|
history=[{"role": "assistant", "content": "partial"}],
|
|
queued_prompt={"text": "must not run"},
|
|
)
|
|
server._sessions["isolated-sid"] = session
|
|
monkeypatch.setattr(server, "_WS_ORPHAN_REAP_GRACE_S", 0.01)
|
|
monkeypatch.setattr(server.threading, "Timer", _Timer)
|
|
monkeypatch.setattr(
|
|
server, "_load_cfg", lambda: {"dashboard": {"turn_isolation": True}}
|
|
)
|
|
monkeypatch.setattr(
|
|
server, "_get_compute_host_supervisor", lambda _cfg=None: _Supervisor()
|
|
)
|
|
monkeypatch.setattr(
|
|
server,
|
|
"_teardown_popped_session",
|
|
lambda claimed, *, end_reason: torn_down.append((claimed, end_reason)) or True,
|
|
)
|
|
|
|
try:
|
|
server._schedule_ws_orphan_reap("isolated-sid")
|
|
callbacks.pop(0)()
|
|
|
|
assert interrupted == [("isolated-sid", "client-gone-isolated-sid")]
|
|
assert session["_turn_cancel_requested"] is True
|
|
assert session["queued_prompt"] is None
|
|
assert session["history"] == [{"role": "assistant", "content": "partial"}]
|
|
assert len(callbacks) == 1
|
|
|
|
callbacks.pop(0)()
|
|
|
|
assert interrupted == [("isolated-sid", "client-gone-isolated-sid")]
|
|
assert len(callbacks) == 1
|
|
|
|
session["running"] = False
|
|
callbacks.pop(0)()
|
|
|
|
assert "isolated-sid" not in server._sessions
|
|
assert torn_down == [(session, "ws_orphan_reap")]
|
|
finally:
|
|
server._sessions.pop("isolated-sid", None)
|
|
|
|
|
|
def test_ws_orphan_reap_spares_turn_reattached_within_grace(monkeypatch):
|
|
callbacks = []
|
|
interrupted = []
|
|
|
|
class _Timer:
|
|
def __init__(self, _delay, callback):
|
|
callbacks.append(callback)
|
|
|
|
def start(self):
|
|
return None
|
|
|
|
class _LiveThread:
|
|
def is_alive(self):
|
|
return True
|
|
|
|
class _LiveTransport:
|
|
def write(self, *_args, **_kwargs):
|
|
return True
|
|
|
|
disconnecting_transport = _LiveTransport()
|
|
session = _session(
|
|
agent=types.SimpleNamespace(
|
|
interrupt=lambda: interrupted.append("interrupted")
|
|
),
|
|
transport=disconnecting_transport,
|
|
running=True,
|
|
_run_thread=_LiveThread(),
|
|
)
|
|
server._sessions["reattached-sid"] = session
|
|
monkeypatch.setattr(server, "_WS_ORPHAN_REAP_GRACE_S", 0.01)
|
|
monkeypatch.setattr(server.threading, "Timer", _Timer)
|
|
monkeypatch.setattr(server, "_load_cfg", lambda: {})
|
|
|
|
try:
|
|
server._close_sessions_for_transport(disconnecting_transport)
|
|
assert session["transport"] is server._detached_ws_transport
|
|
|
|
session["transport"] = _LiveTransport()
|
|
callbacks.pop(0)()
|
|
|
|
assert interrupted == []
|
|
assert "reattached-sid" in server._sessions
|
|
assert callbacks == []
|
|
finally:
|
|
server._sessions.pop("reattached-sid", None)
|
|
|
|
|
|
def test_session_resume_does_not_rebind_after_client_gone_interrupt_claim(monkeypatch):
|
|
class _DB:
|
|
def get_session(self, session_id):
|
|
assert session_id == "stored-sid"
|
|
return {"id": session_id, "cwd": "/tmp"}
|
|
|
|
def resolve_resume_session_id(self, session_id):
|
|
return session_id
|
|
|
|
live_transport = object()
|
|
session = _session(
|
|
session_key="stored-sid",
|
|
transport=server._detached_ws_transport,
|
|
running=True,
|
|
_client_gone_interrupt_requested=True,
|
|
)
|
|
server._sessions["live-sid"] = session
|
|
monkeypatch.setattr(server, "_get_db", lambda: _DB())
|
|
monkeypatch.setattr(server, "current_transport", lambda: live_transport)
|
|
|
|
try:
|
|
response = server.handle_request(
|
|
{
|
|
"id": "resume-after-claim",
|
|
"method": "session.resume",
|
|
"params": {"session_id": "stored-sid"},
|
|
}
|
|
)
|
|
|
|
assert response is not None
|
|
assert response["error"]["code"] == 4009
|
|
assert response["error"]["message"] == "session disconnect interrupt settling"
|
|
assert session["transport"] is server._detached_ws_transport
|
|
finally:
|
|
server._sessions.pop("live-sid", None)
|
|
|
|
|
|
def test_ws_orphan_reap_defers_running_turn_for_active_delegation(monkeypatch):
|
|
callbacks = []
|
|
interrupted = []
|
|
delegation_active = iter((True, False, False))
|
|
|
|
class _Timer:
|
|
def __init__(self, _delay, callback):
|
|
callbacks.append(callback)
|
|
|
|
def start(self):
|
|
return None
|
|
|
|
class _LiveThread:
|
|
def is_alive(self):
|
|
return True
|
|
|
|
def _interrupt():
|
|
interrupted.append("interrupted")
|
|
session["running"] = False
|
|
|
|
session = _session(
|
|
agent=types.SimpleNamespace(interrupt=_interrupt),
|
|
transport=server._detached_ws_transport,
|
|
running=True,
|
|
_run_thread=_LiveThread(),
|
|
)
|
|
server._sessions["delegating-turn"] = session
|
|
monkeypatch.setattr(server, "_WS_ORPHAN_REAP_GRACE_S", 0.01)
|
|
monkeypatch.setattr(server.threading, "Timer", _Timer)
|
|
monkeypatch.setattr(server, "_load_cfg", lambda: {})
|
|
monkeypatch.setattr(
|
|
server,
|
|
"_session_has_active_delegations",
|
|
lambda *_args, **_kwargs: next(delegation_active),
|
|
)
|
|
monkeypatch.setattr(server, "_teardown_popped_session", lambda *_args, **_kwargs: True)
|
|
|
|
try:
|
|
server._schedule_ws_orphan_reap("delegating-turn")
|
|
callbacks.pop(0)()
|
|
|
|
assert interrupted == []
|
|
assert len(callbacks) == 1
|
|
|
|
callbacks.pop(0)()
|
|
|
|
assert interrupted == ["interrupted"]
|
|
assert len(callbacks) == 1
|
|
|
|
callbacks.pop(0)()
|
|
assert "delegating-turn" not in server._sessions
|
|
finally:
|
|
server._sessions.pop("delegating-turn", None)
|
|
|
|
|
|
def test_ws_orphan_reap_interrupts_in_process_turn(monkeypatch):
|
|
callbacks = []
|
|
interrupted = []
|
|
|
|
class _Timer:
|
|
def __init__(self, _delay, callback):
|
|
callbacks.append(callback)
|
|
|
|
def start(self):
|
|
return None
|
|
|
|
class _LiveThread:
|
|
def is_alive(self):
|
|
return True
|
|
|
|
def _interrupt():
|
|
interrupted.append("interrupted")
|
|
session["running"] = False
|
|
|
|
session = _session(
|
|
agent=types.SimpleNamespace(interrupt=_interrupt),
|
|
transport=server._detached_ws_transport,
|
|
running=True,
|
|
_run_thread=_LiveThread(),
|
|
)
|
|
server._sessions["inline-sid"] = session
|
|
monkeypatch.setattr(server, "_WS_ORPHAN_REAP_GRACE_S", 0.01)
|
|
monkeypatch.setattr(server.threading, "Timer", _Timer)
|
|
monkeypatch.setattr(server, "_load_cfg", lambda: {})
|
|
|
|
try:
|
|
server._schedule_ws_orphan_reap("inline-sid")
|
|
callbacks.pop(0)()
|
|
|
|
assert interrupted == ["interrupted"]
|
|
assert session["_turn_cancel_requested"] is True
|
|
assert len(callbacks) == 1
|
|
finally:
|
|
server._sessions.pop("inline-sid", None)
|
|
|
|
|
|
def test_ws_disconnect_running_sidecar_still_closes_without_orphan_timer(monkeypatch):
|
|
closed = []
|
|
scheduled = []
|
|
transport = object()
|
|
server._sessions["sidecar-sid"] = _session(
|
|
transport=transport,
|
|
running=True,
|
|
close_on_disconnect=True,
|
|
)
|
|
monkeypatch.setattr(
|
|
server,
|
|
"_teardown_popped_session",
|
|
lambda session, *, end_reason: closed.append((session["_sid"], end_reason)) or True,
|
|
)
|
|
monkeypatch.setattr(
|
|
server, "_schedule_ws_orphan_reap", lambda sid: scheduled.append(sid)
|
|
)
|
|
|
|
try:
|
|
reaped, detached = server._close_sessions_for_transport(transport)
|
|
|
|
assert (reaped, detached) == (1, 0)
|
|
assert closed == [("sidecar-sid", "ws_disconnect")]
|
|
assert scheduled == []
|
|
finally:
|
|
server._sessions.pop("sidecar-sid", None)
|
|
|
|
|
|
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_reschedules_while_mid_turn_then_reaps(monkeypatch):
|
|
"""A detached session that is still running must keep the reap timer (#85578)."""
|
|
callbacks = []
|
|
torn_down = []
|
|
|
|
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)
|
|
),
|
|
)
|
|
live = _session(
|
|
transport=server._detached_ws_transport,
|
|
running=True,
|
|
)
|
|
server._sessions["midturn-sid"] = live
|
|
|
|
try:
|
|
server._schedule_ws_orphan_reap("midturn-sid")
|
|
callbacks.pop(0)()
|
|
|
|
assert "midturn-sid" in server._sessions
|
|
assert len(callbacks) == 1
|
|
assert torn_down == []
|
|
|
|
live["running"] = False
|
|
callbacks.pop(0)()
|
|
|
|
assert "midturn-sid" not in server._sessions
|
|
assert len(torn_down) == 1
|
|
assert torn_down[0][1] == "ws_orphan_reap"
|
|
finally:
|
|
server._sessions.pop("midturn-sid", None)
|
|
|
|
|
|
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_close_transport_rebinds_session_to_remaining_viewer(monkeypatch):
|
|
"""Closing a pop-out window's transport must re-bind the session to a
|
|
still-open window instead of stranding it on the drop sentinel (#83716)."""
|
|
reap_calls = []
|
|
monkeypatch.setattr(server, "_schedule_ws_orphan_reap", lambda sid: reap_calls.append(sid))
|
|
|
|
class _LiveTransport:
|
|
def write(self, *a, **k):
|
|
return True
|
|
|
|
main = _LiveTransport()
|
|
popout = _LiveTransport()
|
|
session = _session(transport=popout, running=False)
|
|
session["viewers"] = {main: 100.0, popout: 200.0}
|
|
server._sessions["multi-sid"] = session
|
|
|
|
reaped, detached = server._close_sessions_for_transport(popout)
|
|
|
|
assert reaped == 0 and detached == 0
|
|
assert session["transport"] is main
|
|
assert "multi-sid" not in reap_calls
|
|
assert server._ws_session_is_orphaned(session) is False
|
|
|
|
|
|
def test_close_transport_detaches_when_no_viewers_remain(monkeypatch):
|
|
"""The last viewer closing still lands the session on the drop sentinel
|
|
and schedules the grace reap (unchanged single-window behavior)."""
|
|
reap_calls = []
|
|
monkeypatch.setattr(server, "_schedule_ws_orphan_reap", lambda sid: reap_calls.append(sid))
|
|
|
|
class _LiveTransport:
|
|
def write(self, *a, **k):
|
|
return True
|
|
|
|
only = _LiveTransport()
|
|
session = _session(transport=only, running=False)
|
|
session["viewers"] = {only: 100.0}
|
|
server._sessions["solo-sid"] = session
|
|
|
|
reaped, detached = server._close_sessions_for_transport(only)
|
|
|
|
assert reaped == 0 and detached == 1
|
|
assert session["transport"] is server._detached_ws_transport
|
|
assert reap_calls == ["solo-sid"]
|
|
|
|
|
|
def test_close_transport_skips_dead_remaining_viewers(monkeypatch):
|
|
"""A viewer whose socket is already dead must not win the re-bind."""
|
|
reap_calls = []
|
|
monkeypatch.setattr(server, "_schedule_ws_orphan_reap", lambda sid: reap_calls.append(sid))
|
|
|
|
class _LiveTransport:
|
|
def write(self, *a, **k):
|
|
return True
|
|
|
|
dead = _LiveTransport()
|
|
dead._closed = True
|
|
owner = _LiveTransport()
|
|
session = _session(transport=owner, running=False)
|
|
session["viewers"] = {dead: 100.0, owner: 200.0}
|
|
server._sessions["dead-viewer-sid"] = session
|
|
|
|
reaped, detached = server._close_sessions_for_transport(owner)
|
|
|
|
assert detached == 1
|
|
assert session["transport"] is server._detached_ws_transport
|
|
assert reap_calls == ["dead-viewer-sid"]
|
|
|
|
|
|
def test_live_session_payload_registers_transport_as_viewer():
|
|
"""Resume/activate through _live_session_payload must register the caller
|
|
as a viewer so the disconnect path has something to re-bind to (#83716)."""
|
|
class _LiveTransport:
|
|
def write(self, *a, **k):
|
|
return True
|
|
|
|
t = _LiveTransport()
|
|
session = _session(transport=server._detached_ws_transport, running=False)
|
|
server._live_session_payload("viewer-sid", session, transport=t)
|
|
|
|
assert session["transport"] is t
|
|
assert t in session.get("viewers", {})
|
|
|
|
|
|
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_resume_rebind_cancels_pending_ws_orphan_reap(monkeypatch):
|
|
"""Re-binding a live transport via _live_session_payload must cancel the
|
|
pending ws-orphan reap Timer (storm killer, part 1)."""
|
|
cancelled = []
|
|
|
|
class _Timer:
|
|
def __init__(self, _delay, fn):
|
|
self.fn = fn
|
|
|
|
def start(self):
|
|
return None
|
|
|
|
def cancel(self):
|
|
cancelled.append(self)
|
|
|
|
class _LiveTransport:
|
|
def write(self, *a, **k):
|
|
return True
|
|
|
|
monkeypatch.setattr(server, "_WS_ORPHAN_REAP_GRACE_S", 0.01)
|
|
monkeypatch.setattr(server.threading, "Timer", _Timer)
|
|
session = _session(transport=server._detached_ws_transport, running=False)
|
|
server._sessions["cancel-sid"] = session
|
|
|
|
try:
|
|
server._schedule_ws_orphan_reap("cancel-sid")
|
|
assert "cancel-sid" in server._pending_ws_reaps
|
|
|
|
server._live_session_payload("cancel-sid", session, transport=_LiveTransport())
|
|
|
|
assert "cancel-sid" not in server._pending_ws_reaps
|
|
assert len(cancelled) == 1
|
|
finally:
|
|
server._sessions.pop("cancel-sid", None)
|
|
server._pending_ws_reaps.pop("cancel-sid", None)
|
|
|
|
|
|
def test_claim_or_reuse_live_winner_cancels_pending_reap(monkeypatch):
|
|
"""A resume that reuses the live winner cancels the winner's pending reap."""
|
|
cancelled = []
|
|
|
|
class _Timer:
|
|
def __init__(self, _delay, fn):
|
|
self.fn = fn
|
|
|
|
def start(self):
|
|
return None
|
|
|
|
def cancel(self):
|
|
cancelled.append(self)
|
|
|
|
monkeypatch.setattr(server, "_WS_ORPHAN_REAP_GRACE_S", 0.01)
|
|
monkeypatch.setattr(server.threading, "Timer", _Timer)
|
|
agent = types.SimpleNamespace(session_id="stored-claim")
|
|
winner = _session(
|
|
agent=agent,
|
|
session_key="stored-claim",
|
|
transport=server._detached_ws_transport,
|
|
running=False,
|
|
)
|
|
server._sessions["winner-sid"] = winner
|
|
|
|
try:
|
|
server._schedule_ws_orphan_reap("winner-sid")
|
|
assert "winner-sid" in server._pending_ws_reaps
|
|
|
|
live = server._claim_or_reuse_live(
|
|
"fresh-sid", "stored-claim", _session(), None
|
|
)
|
|
|
|
assert live == ("winner-sid", winner)
|
|
assert "winner-sid" not in server._pending_ws_reaps
|
|
assert len(cancelled) == 1
|
|
finally:
|
|
server._sessions.pop("winner-sid", None)
|
|
server._sessions.pop("fresh-sid", None)
|
|
server._pending_ws_reaps.pop("winner-sid", None)
|
|
|
|
|
|
def test_superseded_runtime_finalized_without_reclaimed_broadcast(monkeypatch):
|
|
"""When a resume mints a fresh runtime for a stored id whose prior runtime
|
|
is sentinel-parked, the old record is finalized quietly with end_reason
|
|
superseded_by_resume — no session.reclaimed broadcast, and its pending
|
|
reap Timer is cancelled (storm killer, part 2)."""
|
|
cancelled = []
|
|
broadcasts = []
|
|
torn_down = []
|
|
|
|
class _Timer:
|
|
def __init__(self, _delay, fn):
|
|
self.fn = fn
|
|
|
|
def start(self):
|
|
return None
|
|
|
|
def cancel(self):
|
|
cancelled.append(self)
|
|
|
|
monkeypatch.setattr(server, "_WS_ORPHAN_REAP_GRACE_S", 0.01)
|
|
monkeypatch.setattr(server.threading, "Timer", _Timer)
|
|
monkeypatch.setattr(
|
|
server,
|
|
"_broadcast_global_event",
|
|
lambda event, payload=None: broadcasts.append((event, payload)),
|
|
)
|
|
monkeypatch.setattr(
|
|
server,
|
|
"_teardown_popped_session",
|
|
lambda popped, *, end_reason: torn_down.append((popped, end_reason)) or True,
|
|
)
|
|
monkeypatch.setattr(server, "_register_session_cwd", lambda _s: None)
|
|
|
|
old_agent = types.SimpleNamespace(session_id="stored-super")
|
|
old = _session(
|
|
agent=old_agent,
|
|
session_key="stored-super",
|
|
transport=server._detached_ws_transport,
|
|
running=False,
|
|
)
|
|
server._sessions["old-sid"] = old
|
|
fresh = _session(session_key="stored-super")
|
|
|
|
try:
|
|
server._schedule_ws_orphan_reap("old-sid")
|
|
assert "old-sid" in server._pending_ws_reaps
|
|
|
|
# The old runtime looks live to _find_live_session_by_key, so make it
|
|
# invisible the way a real mint path would (its client is gone and the
|
|
# resume slow path only mints after the fast path found no live match:
|
|
# mark it finalized-for-lookup via a different stored key is wrong —
|
|
# instead simulate the mint race by removing it from lookup).
|
|
old["_finalized"] = False
|
|
monkeypatch.setattr(server, "_find_live_session_by_key", lambda _k, *_a: None)
|
|
|
|
result = server._claim_or_reuse_live("new-sid", "stored-super", fresh, None)
|
|
|
|
assert result is None
|
|
assert server._sessions.get("new-sid") is fresh
|
|
assert "old-sid" not in server._sessions
|
|
assert "old-sid" not in server._pending_ws_reaps
|
|
assert len(cancelled) == 1
|
|
assert torn_down == [(old, "superseded_by_resume")]
|
|
assert broadcasts == [] # no session.reclaimed storm
|
|
finally:
|
|
server._sessions.pop("old-sid", None)
|
|
server._sessions.pop("new-sid", None)
|
|
server._pending_ws_reaps.pop("old-sid", None)
|
|
server._pending_ws_reaps.pop("new-sid", None)
|
|
|
|
|
|
def test_superseded_by_resume_is_recoverable_end_reason():
|
|
from hermes_state_common import _RECOVERABLE_END_REASONS
|
|
|
|
assert "superseded_by_resume" in _RECOVERABLE_END_REASONS
|
|
# And quiet: it must NOT trigger the session.reclaimed broadcast.
|
|
assert "superseded_by_resume" not in server._RECLAIM_END_REASONS
|
|
|
|
|
|
def test_lazy_unpersisted_resume_rebinds_transport_and_cancels_reap(monkeypatch):
|
|
"""The lazy/unpersisted resume branch (no state.db row yet — every fresh
|
|
Bot Chat) must ALSO rebind the transport and cancel the pending reap when
|
|
it hands back a sentinel-parked live record. Found by live WS E2E after
|
|
the #93361 merge: the unit-covered paths (_live_session_payload,
|
|
_reuse_live_response, _claim_or_reuse_live) all cancelled, but this branch
|
|
returned the record while leaving it on the drop sentinel with the reap
|
|
Timer armed — the storm survived for unpersisted sessions (storm killer,
|
|
part 3)."""
|
|
cancelled = []
|
|
|
|
class _Timer:
|
|
def __init__(self, _delay, fn):
|
|
self.fn = fn
|
|
|
|
def start(self):
|
|
return None
|
|
|
|
def cancel(self):
|
|
cancelled.append(self)
|
|
|
|
class _LiveTransport:
|
|
def write(self, *a, **k):
|
|
return True
|
|
|
|
live_transport = _LiveTransport()
|
|
monkeypatch.setattr(server, "_WS_ORPHAN_REAP_GRACE_S", 0.01)
|
|
monkeypatch.setattr(server.threading, "Timer", _Timer)
|
|
monkeypatch.setattr(server, "current_transport", lambda: live_transport)
|
|
# get_session/get_session_by_title miss -> forces the unpersisted branch
|
|
monkeypatch.setattr(
|
|
server,
|
|
"_get_db",
|
|
lambda: types.SimpleNamespace(
|
|
get_session=lambda _t: None,
|
|
get_session_by_title=lambda _t: None,
|
|
),
|
|
)
|
|
|
|
session = _session(
|
|
session_key="stored-lazy",
|
|
transport=server._detached_ws_transport,
|
|
running=False,
|
|
history=[],
|
|
profile_home=None,
|
|
)
|
|
server._sessions["lazy-sid"] = session
|
|
|
|
try:
|
|
server._schedule_ws_orphan_reap("lazy-sid")
|
|
assert "lazy-sid" in server._pending_ws_reaps
|
|
|
|
resp = _dispatch_sync(
|
|
{
|
|
"id": "lz1",
|
|
"method": "session.resume",
|
|
"params": {"session_id": "stored-lazy", "omit_messages": True},
|
|
},
|
|
transport=live_transport,
|
|
)
|
|
|
|
assert resp is not None and resp["result"]["session_id"] == "lazy-sid"
|
|
assert session["transport"] is live_transport
|
|
assert "lazy-sid" not in server._pending_ws_reaps
|
|
assert len(cancelled) == 1
|
|
finally:
|
|
server._sessions.pop("lazy-sid", None)
|
|
server._pending_ws_reaps.pop("lazy-sid", None)
|
|
|
|
|
|
def test_ws_orphan_reap_still_fires_when_never_resumed(monkeypatch):
|
|
"""Nobody re-resumes: the reap fires normally and unregisters its Timer."""
|
|
callbacks = []
|
|
torn_down = []
|
|
|
|
class _Timer:
|
|
def __init__(self, _delay, fn):
|
|
callbacks.append(fn)
|
|
|
|
def start(self):
|
|
return None
|
|
|
|
def cancel(self):
|
|
return None
|
|
|
|
monkeypatch.setattr(server, "_WS_ORPHAN_REAP_GRACE_S", 0.01)
|
|
monkeypatch.setattr(server.threading, "Timer", _Timer)
|
|
monkeypatch.setattr(
|
|
server,
|
|
"_teardown_popped_session",
|
|
lambda popped, *, end_reason: torn_down.append((popped, end_reason)) or True,
|
|
)
|
|
session = _session(transport=server._detached_ws_transport, running=False)
|
|
server._sessions["lonely-sid"] = session
|
|
|
|
try:
|
|
server._schedule_ws_orphan_reap("lonely-sid")
|
|
assert "lonely-sid" in server._pending_ws_reaps
|
|
callbacks.pop(0)()
|
|
|
|
assert "lonely-sid" not in server._sessions
|
|
assert "lonely-sid" not in server._pending_ws_reaps
|
|
assert torn_down == [(session, "ws_orphan_reap")]
|
|
finally:
|
|
server._sessions.pop("lonely-sid", None)
|
|
server._pending_ws_reaps.pop("lonely-sid", None)
|
|
|
|
|
|
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_ws_orphan_reap_defers_running_turn_with_fresh_activity(monkeypatch):
|
|
"""#98028/#100325: a client-absent turn whose activity clock is fresh is
|
|
NOT interrupted — it keeps running detached and the reaper re-polls at the
|
|
grace interval. Once the clock goes stale the wedged-turn interrupt fires,
|
|
and after the turn settles the session is reaped as before."""
|
|
callbacks = []
|
|
delays = []
|
|
interrupted = []
|
|
torn_down = []
|
|
|
|
class _Timer:
|
|
def __init__(self, delay, callback):
|
|
delays.append(delay)
|
|
callbacks.append(callback)
|
|
self.daemon = False
|
|
|
|
def start(self):
|
|
return None
|
|
|
|
activity = {"seconds_since_activity": 1.0}
|
|
agent = types.SimpleNamespace(
|
|
get_activity_summary=lambda: dict(activity),
|
|
interrupt=lambda message=None: interrupted.append("interrupted"),
|
|
)
|
|
|
|
class _DeadThread:
|
|
def is_alive(self):
|
|
return False
|
|
|
|
session = _session(
|
|
agent=agent,
|
|
transport=server._detached_ws_transport,
|
|
running=True,
|
|
_run_thread=_DeadThread(),
|
|
)
|
|
server._sessions["fresh-sid"] = session
|
|
monkeypatch.setattr(server, "_WS_ORPHAN_REAP_GRACE_S", 0.01)
|
|
monkeypatch.setattr(server, "_WS_ORPHAN_ACTIVITY_STALE_S", 300.0)
|
|
monkeypatch.setattr(server.threading, "Timer", _Timer)
|
|
monkeypatch.setattr(server, "_load_cfg", lambda: {})
|
|
monkeypatch.setattr(
|
|
server,
|
|
"_teardown_popped_session",
|
|
lambda claimed, *, end_reason: torn_down.append((claimed, end_reason)) or True,
|
|
)
|
|
|
|
try:
|
|
server._schedule_ws_orphan_reap("fresh-sid")
|
|
|
|
# Two grace cycles with fresh activity: no interrupt, reschedule at
|
|
# the GRACE interval (not the 1s interrupt-settle poll).
|
|
for _ in range(2):
|
|
callbacks.pop(0)()
|
|
assert interrupted == []
|
|
assert not session.get("_client_gone_interrupt_requested")
|
|
assert delays[-1] == server._WS_ORPHAN_REAP_GRACE_S
|
|
assert "fresh-sid" in server._sessions
|
|
|
|
# Activity goes stale (turn wedged) -> interrupt fires on next poll.
|
|
activity["seconds_since_activity"] = 301.0
|
|
callbacks.pop(0)()
|
|
assert interrupted == ["interrupted"]
|
|
assert session["_client_gone_interrupt_requested"] is True
|
|
|
|
# Turn settles -> reap proceeds exactly as today.
|
|
session["running"] = False
|
|
callbacks.pop(0)()
|
|
assert "fresh-sid" not in server._sessions
|
|
assert torn_down == [(session, "ws_orphan_reap")]
|
|
finally:
|
|
server._sessions.pop("fresh-sid", None)
|
|
|
|
|
|
def test_ws_orphan_activity_gate_zero_restores_interrupt_at_grace(monkeypatch):
|
|
"""ws_orphan_activity_stale_s=0 opts out: fresh activity no longer defers
|
|
the client-gone interrupt (pre-#98028 behaviour)."""
|
|
callbacks = []
|
|
interrupted = []
|
|
|
|
class _Timer:
|
|
def __init__(self, _delay, callback):
|
|
callbacks.append(callback)
|
|
self.daemon = False
|
|
|
|
def start(self):
|
|
return None
|
|
|
|
class _LiveThread:
|
|
def is_alive(self):
|
|
return True
|
|
|
|
agent = types.SimpleNamespace(
|
|
get_activity_summary=lambda: {"seconds_since_activity": 0.5},
|
|
interrupt=lambda message=None: interrupted.append("interrupted"),
|
|
)
|
|
session = _session(
|
|
agent=agent,
|
|
transport=server._detached_ws_transport,
|
|
running=True,
|
|
_run_thread=_LiveThread(),
|
|
)
|
|
server._sessions["optout-sid"] = session
|
|
monkeypatch.setattr(server, "_WS_ORPHAN_REAP_GRACE_S", 0.01)
|
|
monkeypatch.setattr(server, "_WS_ORPHAN_ACTIVITY_STALE_S", 0.0)
|
|
monkeypatch.setattr(server.threading, "Timer", _Timer)
|
|
monkeypatch.setattr(server, "_load_cfg", lambda: {})
|
|
|
|
try:
|
|
server._schedule_ws_orphan_reap("optout-sid")
|
|
callbacks.pop(0)()
|
|
assert interrupted == ["interrupted"]
|
|
assert session["_client_gone_interrupt_requested"] is True
|
|
finally:
|
|
server._sessions.pop("optout-sid", None)
|
|
|
|
|
|
def test_ws_orphan_activity_gate_unreadable_summary_stays_eligible(monkeypatch):
|
|
"""A broken/opaque activity summary must fail CLOSED (not fresh): the
|
|
wedged-turn interrupt-at-grace safety net is preserved."""
|
|
|
|
def _boom():
|
|
raise RuntimeError("summary unavailable")
|
|
|
|
agent = types.SimpleNamespace(get_activity_summary=_boom)
|
|
monkeypatch.setattr(server, "_WS_ORPHAN_ACTIVITY_STALE_S", 300.0)
|
|
assert server._ws_orphan_turn_activity_is_fresh({"agent": agent}) is False
|
|
# No agent / no summary method: same conservative answer.
|
|
assert server._ws_orphan_turn_activity_is_fresh({"agent": None}) is False
|
|
assert (
|
|
server._ws_orphan_turn_activity_is_fresh(
|
|
{"agent": types.SimpleNamespace()}
|
|
)
|
|
is False
|
|
)
|
|
# Never-stamped clock (None) is not fresh either.
|
|
agent2 = types.SimpleNamespace(
|
|
get_activity_summary=lambda: {"seconds_since_activity": None}
|
|
)
|
|
assert server._ws_orphan_turn_activity_is_fresh({"agent": agent2}) 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,
|
|
archive_dropped=False,
|
|
reject_active_turn_lease=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_boolean_ordinal(monkeypatch):
|
|
"""A JSON `true` ordinal must return 4004, not coerce to turn 1.
|
|
|
|
bool is an int subclass, so `int(True) == 1`: a client bug that sends
|
|
`truncate_before_user_ordinal: true` with confirm_truncate would aim a
|
|
confirmed rewind at the SECOND user turn and hard-truncate everything
|
|
after the first — the same silent-loss class as #82756.
|
|
"""
|
|
history = [
|
|
{"role": "user", "content": "first"},
|
|
{"role": "assistant", "content": "reply 1"},
|
|
{"role": "user", "content": "second"},
|
|
{"role": "assistant", "content": "reply 2"},
|
|
]
|
|
server._sessions["bool-trunc-sid"] = _session(history=list(history))
|
|
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": "bool-trunc-sid",
|
|
"text": "new turn",
|
|
"truncate_before_user_ordinal": True,
|
|
"confirm_truncate": True,
|
|
},
|
|
}
|
|
)
|
|
assert resp.get("error") is not None
|
|
assert resp["error"]["code"] == 4004
|
|
assert "must be an integer" in resp["error"]["message"]
|
|
assert server._sessions["bool-trunc-sid"]["history"] == history
|
|
finally:
|
|
server._sessions.pop("bool-trunc-sid", None)
|
|
|
|
|
|
def test_prompt_submit_refuses_confirm_truncate_without_target(monkeypatch):
|
|
"""confirm_truncate with no ordinal is leaked rewind state — fail fast.
|
|
|
|
The desktop auto-attaches confirm_truncate whenever it builds truncation
|
|
params (#82756); a bare flag on an ordinary submit means the client's
|
|
composer state is corrupted. Refusing loudly surfaces the client bug
|
|
instead of quietly ignoring the flag.
|
|
"""
|
|
history = [
|
|
{"role": "user", "content": "first"},
|
|
{"role": "assistant", "content": "reply 1"},
|
|
]
|
|
server._sessions["bare-confirm-sid"] = _session(history=list(history))
|
|
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": "bare-confirm-sid",
|
|
"text": "new turn",
|
|
"confirm_truncate": True,
|
|
},
|
|
}
|
|
)
|
|
assert resp.get("error") is not None
|
|
assert resp["error"]["code"] == 4004
|
|
assert "confirm_truncate requires" in resp["error"]["message"]
|
|
assert server._sessions["bare-confirm-sid"]["history"] == history
|
|
finally:
|
|
server._sessions.pop("bare-confirm-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,
|
|
active_only=False,
|
|
archive_dropped=False,
|
|
reject_active_turn_lease=False,
|
|
):
|
|
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_truncates_by_message_id(monkeypatch):
|
|
"""#82756: truncate_before_message_id resolves target message and cuts history accurately."""
|
|
replaced = []
|
|
|
|
class _FakeDB:
|
|
def replace_messages(
|
|
self,
|
|
key,
|
|
messages,
|
|
active_only=False,
|
|
archive_dropped=False,
|
|
reject_active_turn_lease=False,
|
|
):
|
|
replaced.append((key, list(messages)))
|
|
|
|
history = [
|
|
{"id": "msg-1", "role": "user", "content": "first"},
|
|
{"role": "assistant", "content": "reply 1"},
|
|
{"id": "msg-2", "role": "user", "content": "second"},
|
|
{"role": "assistant", "content": "reply 2"},
|
|
]
|
|
server._sessions["msg-id-trunc-sid"] = _session(history=list(history))
|
|
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
|
|
monkeypatch.setattr(
|
|
server, "_start_agent_build", lambda *a, **k: None
|
|
)
|
|
monkeypatch.setattr(
|
|
server, "_start_inflight_turn", lambda *a, **k: None
|
|
)
|
|
|
|
try:
|
|
resp = server.handle_request(
|
|
{
|
|
"id": "1",
|
|
"method": "prompt.submit",
|
|
"params": {
|
|
"session_id": "msg-id-trunc-sid",
|
|
"text": "new turn",
|
|
"truncate_before_message_id": "msg-2",
|
|
"confirm_truncate": True,
|
|
},
|
|
}
|
|
)
|
|
assert resp.get("result") is not None
|
|
assert len(replaced) == 1
|
|
assert replaced[0][1] == history[:2]
|
|
finally:
|
|
server._sessions.pop("msg-id-trunc-sid", None)
|
|
|
|
|
|
def test_prompt_submit_truncation_falls_back_to_sid_when_session_key_null(monkeypatch):
|
|
"""#81904: a NULL session_key must not FK-fail the truncation persist.
|
|
|
|
CLI-origin sessions resumed in the Desktop have no session_key; the
|
|
truncation path used to call replace_messages(None, ...), whose reinsert
|
|
violated the messages.session_id FK ("FOREIGN KEY constraint failed" →
|
|
"Restore failed"). The persist must key off the session id instead —
|
|
for CLI-origin rows the durable sessions.id IS the requested sid.
|
|
"""
|
|
replaced = []
|
|
|
|
class _FakeDB:
|
|
def replace_messages(
|
|
self,
|
|
key,
|
|
messages,
|
|
active_only=False,
|
|
archive_dropped=False,
|
|
reject_active_turn_lease=False,
|
|
):
|
|
replaced.append((key, list(messages)))
|
|
|
|
history = [
|
|
{"_row_id": 101, "role": "user", "content": "first"},
|
|
{"_row_id": 102, "role": "assistant", "content": "reply 1"},
|
|
{"_row_id": 103, "role": "user", "content": "second"},
|
|
{"_row_id": 104, "role": "assistant", "content": "reply 2"},
|
|
]
|
|
server._sessions["null-key-trunc-sid"] = _session(
|
|
history=list(history), session_key=None
|
|
)
|
|
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
|
|
monkeypatch.setattr(server, "_start_agent_build", lambda *a, **k: None)
|
|
monkeypatch.setattr(server, "_start_inflight_turn", lambda *a, **k: None)
|
|
|
|
try:
|
|
resp = server.handle_request(
|
|
{
|
|
"id": "1",
|
|
"method": "prompt.submit",
|
|
"params": {
|
|
"session_id": "null-key-trunc-sid",
|
|
"text": "new turn",
|
|
"truncate_before_row_id": 103,
|
|
"truncate_before_user_ordinal": 1,
|
|
"confirm_truncate": True,
|
|
},
|
|
}
|
|
)
|
|
assert resp.get("result") is not None
|
|
assert len(replaced) == 1
|
|
# Keyed by the session id, never None (the FK-violating value).
|
|
assert replaced[0][0] == "null-key-trunc-sid"
|
|
assert replaced[0][1] == history[:2]
|
|
finally:
|
|
server._sessions.pop("null-key-trunc-sid", None)
|
|
|
|
|
|
def test_prompt_submit_refuses_ordinal_and_message_id_mismatch(monkeypatch):
|
|
"""#82756: A mismatch between truncate_before_user_ordinal and truncate_before_message_id must return 4030."""
|
|
history = [
|
|
{"id": "msg-1", "role": "user", "content": "first"},
|
|
{"role": "assistant", "content": "reply 1"},
|
|
{"id": "msg-2", "role": "user", "content": "second"},
|
|
{"role": "assistant", "content": "reply 2"},
|
|
]
|
|
server._sessions["mismatch-trunc-sid"] = _session(history=list(history))
|
|
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": "mismatch-trunc-sid",
|
|
"text": "new turn",
|
|
"truncate_before_message_id": "msg-2", # ordinal index 1
|
|
"truncate_before_user_ordinal": 0, # mismatch (stale 0)
|
|
"confirm_truncate": True,
|
|
},
|
|
}
|
|
)
|
|
assert resp.get("error") is not None
|
|
assert resp["error"]["code"] == 4030
|
|
assert "does not match" in resp["error"]["message"]
|
|
finally:
|
|
server._sessions.pop("mismatch-trunc-sid", None)
|
|
|
|
|
|
def test_prompt_submit_refuses_ordinal_only_when_history_has_row_ids(monkeypatch):
|
|
"""A durable session must not trust an ordinal without a row-id target."""
|
|
replaced = []
|
|
|
|
class _FakeDB:
|
|
def replace_messages(
|
|
self,
|
|
key,
|
|
messages,
|
|
active_only=False,
|
|
archive_dropped=False,
|
|
reject_active_turn_lease=False,
|
|
):
|
|
replaced.append((key, list(messages)))
|
|
|
|
history = [
|
|
{"_row_id": 101, "role": "user", "content": "first"},
|
|
{"_row_id": 102, "role": "assistant", "content": "reply 1"},
|
|
{"_row_id": 103, "role": "user", "content": "second"},
|
|
{"_row_id": 104, "role": "assistant", "content": "reply 2"},
|
|
]
|
|
server._sessions["ordinal-only-durable-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:
|
|
resp = server.handle_request(
|
|
{
|
|
"id": "1",
|
|
"method": "prompt.submit",
|
|
"params": {
|
|
"session_id": "ordinal-only-durable-sid",
|
|
"text": "retry",
|
|
"truncate_before_user_ordinal": 1,
|
|
"confirm_truncate": True,
|
|
},
|
|
}
|
|
)
|
|
|
|
assert resp["error"]["code"] == 4004
|
|
assert "truncate_before_row_id" in resp["error"]["message"]
|
|
assert server._sessions["ordinal-only-durable-sid"]["history"] == history
|
|
assert server._sessions["ordinal-only-durable-sid"]["running"] is False
|
|
assert replaced == []
|
|
finally:
|
|
server._sessions.pop("ordinal-only-durable-sid", None)
|
|
|
|
|
|
def test_prompt_submit_refuses_ordinal_only_when_durable_history_is_unstamped(monkeypatch):
|
|
"""Durability comes from state.db, not optional stamps on the live copy."""
|
|
replaced = []
|
|
history = [
|
|
{"role": "user", "content": "first"},
|
|
{"role": "assistant", "content": "reply 1"},
|
|
{"role": "user", "content": "second"},
|
|
{"role": "assistant", "content": "reply 2"},
|
|
]
|
|
|
|
class _FakeDB:
|
|
def get_messages_as_conversation(self, key, **kwargs):
|
|
assert key == "session-key"
|
|
assert kwargs["include_row_ids"] is True
|
|
return [dict(message, _row_id=100 + index) for index, message in enumerate(history)]
|
|
|
|
def replace_messages(self, key, messages, active_only=False, archive_dropped=False):
|
|
replaced.append((key, list(messages)))
|
|
|
|
server._sessions["unstamped-durable-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:
|
|
resp = server.handle_request(
|
|
{
|
|
"id": "1",
|
|
"method": "prompt.submit",
|
|
"params": {
|
|
"session_id": "unstamped-durable-sid",
|
|
"text": "retry",
|
|
"truncate_before_user_ordinal": 1,
|
|
"confirm_truncate": True,
|
|
},
|
|
}
|
|
)
|
|
|
|
assert resp["error"]["code"] == 4004
|
|
assert "truncate_before_row_id" in resp["error"]["message"]
|
|
assert server._sessions["unstamped-durable-sid"]["history"] == history
|
|
assert replaced == []
|
|
finally:
|
|
server._sessions.pop("unstamped-durable-sid", None)
|
|
|
|
|
|
def test_prompt_submit_truncates_by_row_id(monkeypatch):
|
|
"""#82959: prompt.submit with truncate_before_row_id must cut at the target row id."""
|
|
replaced = []
|
|
|
|
class _FakeDB:
|
|
def replace_messages(
|
|
self,
|
|
key,
|
|
messages,
|
|
active_only=False,
|
|
archive_dropped=False,
|
|
reject_active_turn_lease=False,
|
|
):
|
|
replaced.append((key, list(messages)))
|
|
|
|
history = [
|
|
{"_row_id": 101, "role": "user", "content": "first"},
|
|
{"_row_id": 102, "role": "assistant", "content": "reply 1"},
|
|
{"_row_id": 103, "role": "user", "content": "second"},
|
|
{"_row_id": 104, "role": "assistant", "content": "reply 2"},
|
|
]
|
|
sess = _session(history=list(history))
|
|
server._sessions["row-id-trunc-sid"] = sess
|
|
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
|
|
started = []
|
|
monkeypatch.setattr(
|
|
server, "_start_agent_build", lambda *a, **k: started.append(k)
|
|
)
|
|
|
|
try:
|
|
resp = server.handle_request(
|
|
{
|
|
"id": "1",
|
|
"method": "prompt.submit",
|
|
"params": {
|
|
"session_id": "row-id-trunc-sid",
|
|
"text": "new turn",
|
|
"truncate_before_row_id": 103,
|
|
"truncate_before_user_ordinal": 1,
|
|
"confirm_truncate": True,
|
|
},
|
|
}
|
|
)
|
|
assert resp.get("error") is None
|
|
assert len(sess["history"]) == 2
|
|
assert sess["history"][-1]["content"] == "reply 1"
|
|
assert len(replaced) == 1
|
|
assert replaced[0][1] == history[:2]
|
|
finally:
|
|
server._sessions.pop("row-id-trunc-sid", None)
|
|
|
|
|
|
def test_prompt_submit_truncates_by_string_row_id(monkeypatch):
|
|
"""#82959: String row IDs in history match correctly against integer truncate_before_row_id."""
|
|
replaced = []
|
|
|
|
class _FakeDB:
|
|
def replace_messages(
|
|
self,
|
|
key,
|
|
messages,
|
|
active_only=False,
|
|
archive_dropped=False,
|
|
reject_active_turn_lease=False,
|
|
):
|
|
replaced.append((key, list(messages)))
|
|
|
|
history = [
|
|
{"_row_id": "101", "role": "user", "content": "first"},
|
|
{"_row_id": "102", "role": "assistant", "content": "reply 1"},
|
|
{"_row_id": "103", "role": "user", "content": "second"},
|
|
{"_row_id": "104", "role": "assistant", "content": "reply 2"},
|
|
]
|
|
sess = _session(history=list(history))
|
|
server._sessions["str-row-id-trunc-sid"] = sess
|
|
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
|
|
monkeypatch.setattr(server, "_start_agent_build", lambda *a, **k: None)
|
|
|
|
try:
|
|
resp = server.handle_request(
|
|
{
|
|
"id": "1",
|
|
"method": "prompt.submit",
|
|
"params": {
|
|
"session_id": "str-row-id-trunc-sid",
|
|
"text": "new turn",
|
|
"truncate_before_row_id": 103,
|
|
"confirm_truncate": True,
|
|
},
|
|
}
|
|
)
|
|
assert resp.get("error") is None
|
|
assert len(sess["history"]) == 2
|
|
finally:
|
|
server._sessions.pop("str-row-id-trunc-sid", None)
|
|
|
|
|
|
def test_prompt_submit_refuses_ordinal_and_row_id_mismatch(monkeypatch):
|
|
"""#82959: A mismatch between truncate_before_user_ordinal and truncate_before_row_id must return 4030."""
|
|
history = [
|
|
{"_row_id": 201, "role": "user", "content": "first"},
|
|
{"_row_id": 202, "role": "assistant", "content": "reply 1"},
|
|
{"_row_id": 203, "role": "user", "content": "second"},
|
|
{"_row_id": 204, "role": "assistant", "content": "reply 2"},
|
|
]
|
|
server._sessions["row-mismatch-sid"] = _session(history=list(history))
|
|
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": "row-mismatch-sid",
|
|
"text": "new turn",
|
|
"truncate_before_row_id": 203, # user turn ordinal 1
|
|
"truncate_before_user_ordinal": 0, # mismatch (stale 0)
|
|
"confirm_truncate": True,
|
|
},
|
|
}
|
|
)
|
|
assert resp.get("error") is not None
|
|
assert resp["error"]["code"] == 4030
|
|
assert "does not match" in resp["error"]["message"]
|
|
finally:
|
|
server._sessions.pop("row-mismatch-sid", None)
|
|
|
|
|
|
def test_prompt_submit_refuses_boolean_row_id(monkeypatch):
|
|
"""Boolean truncate_before_row_id must return 4004."""
|
|
history = [
|
|
{"_row_id": 301, "role": "user", "content": "first"},
|
|
{"_row_id": 302, "role": "assistant", "content": "reply 1"},
|
|
]
|
|
server._sessions["bool-row-sid"] = _session(history=list(history))
|
|
try:
|
|
resp = server.handle_request(
|
|
{
|
|
"id": "1",
|
|
"method": "prompt.submit",
|
|
"params": {
|
|
"session_id": "bool-row-sid",
|
|
"text": "new turn",
|
|
"truncate_before_row_id": True,
|
|
"confirm_truncate": True,
|
|
},
|
|
}
|
|
)
|
|
assert resp.get("error") is not None
|
|
assert resp["error"]["code"] == 4004
|
|
assert "must be an integer" in resp["error"]["message"]
|
|
finally:
|
|
server._sessions.pop("bool-row-sid", None)
|
|
|
|
|
|
def test_prompt_submit_row_id_not_found(monkeypatch):
|
|
"""Unknown truncate_before_row_id must return 4018."""
|
|
history = [
|
|
{"_row_id": 401, "role": "user", "content": "first"},
|
|
]
|
|
server._sessions["missing-row-sid"] = _session(history=list(history))
|
|
try:
|
|
resp = server.handle_request(
|
|
{
|
|
"id": "1",
|
|
"method": "prompt.submit",
|
|
"params": {
|
|
"session_id": "missing-row-sid",
|
|
"text": "new turn",
|
|
"truncate_before_row_id": 999,
|
|
"confirm_truncate": True,
|
|
},
|
|
}
|
|
)
|
|
assert resp.get("error") is not None
|
|
assert resp["error"]["code"] == 4018
|
|
assert "no longer in session history" in resp["error"]["message"]
|
|
finally:
|
|
server._sessions.pop("missing-row-sid", None)
|
|
|
|
|
|
def test_prompt_submit_row_id_ignores_platform_id_fallback(monkeypatch):
|
|
"""truncate_before_row_id must not match string platform IDs."""
|
|
history = [
|
|
{"id": "999", "role": "user", "content": "first"},
|
|
{"role": "assistant", "content": "reply 1"},
|
|
]
|
|
server._sessions["string-id-sid"] = _session(history=list(history))
|
|
try:
|
|
resp = server.handle_request({
|
|
"id": "1",
|
|
"method": "prompt.submit",
|
|
"params": {
|
|
"session_id": "string-id-sid",
|
|
"text": "new turn",
|
|
"truncate_before_row_id": 999,
|
|
"confirm_truncate": True,
|
|
}
|
|
})
|
|
assert resp.get("error") is not None
|
|
assert resp["error"]["code"] == 4018
|
|
finally:
|
|
server._sessions.pop("string-id-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,
|
|
archive_dropped=False,
|
|
reject_active_turn_lease=False,
|
|
):
|
|
replaced.append((key, list(messages)))
|
|
|
|
history = [
|
|
{"_row_id": 101, "role": "user", "content": "first"},
|
|
{"_row_id": 102, "role": "assistant", "content": "ok"},
|
|
{"_row_id": 103, "role": "user", "content": "second"},
|
|
{"_row_id": 104, "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_row_id": 101,
|
|
"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_row_id": 101,
|
|
"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,
|
|
archive_dropped=False,
|
|
reject_active_turn_lease=False,
|
|
):
|
|
replaced.append((key, list(messages)))
|
|
|
|
history = [
|
|
{"_row_id": 101, "role": "user", "content": "first"},
|
|
{"_row_id": 102, "role": "assistant", "content": "ok"},
|
|
{"_row_id": 103, "role": "user", "content": "second"},
|
|
{"_row_id": 104, "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_row_id": 101,
|
|
"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": []}
|
|
|
|
|
|
def test_run_prompt_submit_rejects_worker_when_close_wins_publication(
|
|
monkeypatch, tmp_path
|
|
):
|
|
"""A close claimed during message.start must prevent the worker from running."""
|
|
_configure_immediate_prompt_run(monkeypatch, tmp_path, immediate_threads=False)
|
|
emit_entered = threading.Event()
|
|
release_emit = threading.Event()
|
|
dispatch_results = []
|
|
turns = []
|
|
popped = []
|
|
sid = "close-wins-publication"
|
|
session = _session(
|
|
session_key="close-wins-publication-key",
|
|
agent=_RecordingAgent(turns),
|
|
running=True,
|
|
)
|
|
|
|
def _blocking_emit(event, *_args, **_kwargs):
|
|
if event == "message.start":
|
|
emit_entered.set()
|
|
assert release_emit.wait(timeout=2.0)
|
|
|
|
monkeypatch.setattr(server, "_emit", _blocking_emit)
|
|
server._sessions[sid] = session
|
|
dispatch_thread = threading.Thread(
|
|
target=lambda: dispatch_results.append(
|
|
server._run_prompt_submit("rid", sid, session, "turn")
|
|
)
|
|
)
|
|
|
|
try:
|
|
dispatch_thread.start()
|
|
assert emit_entered.wait(timeout=1.0)
|
|
popped.append(server._pop_session_by_id(sid))
|
|
assert popped == [session]
|
|
release_emit.set()
|
|
dispatch_thread.join(timeout=2.0)
|
|
finally:
|
|
release_emit.set()
|
|
dispatch_thread.join(timeout=2.0)
|
|
run_thread = session.get("_run_thread")
|
|
if run_thread is not None and run_thread.is_alive():
|
|
run_thread.join(timeout=2.0)
|
|
server._sessions.pop(sid, None)
|
|
|
|
assert dispatch_results == [False]
|
|
assert session["running"] is False
|
|
assert turns == []
|
|
|
|
|
|
@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.get_shared_session_db", _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_ensure_session_db_row_stamps_launch_profile_name(monkeypatch):
|
|
"""A launch-profile session row is stamped with the ACTUAL profile name,
|
|
never NULL. NULL-as-launch-profile rows vanish from the desktop sidebar
|
|
(profile-keyed matching) and break @session:<profile>/<id> deep links, and
|
|
the #94724 one-shot backfill cannot keep repairing rows minted after it
|
|
ran (#99222)."""
|
|
created = []
|
|
|
|
class _FakeDB:
|
|
def create_session(self, key, **kwargs):
|
|
created.append({"key": key, "profile_name": kwargs.get("profile_name")})
|
|
|
|
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
|
|
monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")
|
|
monkeypatch.setattr(server, "_current_profile_name", lambda: "default")
|
|
|
|
server._ensure_session_db_row({"session_key": "k1"})
|
|
|
|
assert created and created[0]["key"] == "k1"
|
|
assert created[0]["profile_name"] == "default"
|
|
|
|
|
|
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_pet_gallery_quoted_false_enabled_reports_disabled(tmp_path, monkeypatch):
|
|
"""display.pet.enabled: "false" (quoted) must report enabled=False.
|
|
|
|
The old check was bool(value) — bool('false') is True, so a hand-edited
|
|
quoted YAML value kept the petdex mascot enabled against the operator's
|
|
explicit intent.
|
|
"""
|
|
import yaml
|
|
|
|
monkeypatch.setattr(server, "_hermes_home", tmp_path)
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
(tmp_path / "config.yaml").write_text(
|
|
yaml.safe_dump({"display": {"pet": {"enabled": "false"}}})
|
|
)
|
|
|
|
response = server.handle_request(
|
|
{"id": "1", "method": "pet.gallery", "params": {}}
|
|
)
|
|
assert response["result"]["enabled"] is False
|
|
|
|
|
|
def test_pet_info_known_revision_elides_spritesheet(monkeypatch):
|
|
"""pet.info with a matching knownRevision must not resend the sheet bytes.
|
|
|
|
The spritesheet payload is multi-MB; resending it on every backstop
|
|
refresh stalls the WS write loop (#54730). A caller passing the revision
|
|
it already holds gets metadata plus spritesheetUnchanged instead.
|
|
"""
|
|
|
|
class _FakePet:
|
|
slug = "codex"
|
|
display_name = "Codex"
|
|
exists = True
|
|
spritesheet = None
|
|
|
|
payload = {
|
|
"slug": "codex",
|
|
"displayName": "Codex",
|
|
"mime": "image/png",
|
|
"spritesheetBase64": "A" * 1024,
|
|
"spritesheetRevision": "123:456",
|
|
"frameW": 192,
|
|
"frameH": 208,
|
|
"scale": 0.33,
|
|
}
|
|
|
|
monkeypatch.setattr(server, "_pet_active_selection", lambda: (True, _FakePet(), 0.33))
|
|
monkeypatch.setattr(server, "_pet_sprite_payload", lambda pet, *, scale: dict(payload))
|
|
|
|
# Matching revision: bytes elided, unchanged marker set.
|
|
resp = server.handle_request(
|
|
{"id": "1", "method": "pet.info", "params": {"knownRevision": "123:456"}}
|
|
)
|
|
assert resp["result"]["enabled"] is True
|
|
assert "spritesheetBase64" not in resp["result"]
|
|
assert resp["result"]["spritesheetUnchanged"] is True
|
|
assert resp["result"]["spritesheetRevision"] == "123:456"
|
|
|
|
# Stale revision: full payload still flows.
|
|
resp = server.handle_request(
|
|
{"id": "2", "method": "pet.info", "params": {"knownRevision": "999:999"}}
|
|
)
|
|
assert resp["result"]["spritesheetBase64"] == "A" * 1024
|
|
assert "spritesheetUnchanged" not in resp["result"]
|
|
|
|
# No revision (legacy callers): full payload.
|
|
resp = server.handle_request({"id": "3", "method": "pet.info", "params": {}})
|
|
assert resp["result"]["spritesheetBase64"] == "A" * 1024
|
|
|
|
|
|
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, **_route: {"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, **_route: 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 **_kw: 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 **_kw: 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 **_kw: 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 **_kw: 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 **_kw: 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_setup_readiness_scopes_to_requested_profile(monkeypatch, tmp_path):
|
|
"""#94071: the Desktop preflights a freshly created bot on its target
|
|
backend. ``profile`` binds THAT profile's home + .env — launch-process
|
|
credentials must not make an unconfigured bot look ready, and the bot's
|
|
own .env must be what the strict check sees."""
|
|
from agent import secret_scope
|
|
from hermes_constants import get_hermes_home
|
|
|
|
bot_home = tmp_path / "profiles" / "bot"
|
|
bot_home.mkdir(parents=True)
|
|
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-launch-profile-secret-0000")
|
|
monkeypatch.setattr("hermes_cli.profiles.profile_exists", lambda name: name == "bot")
|
|
monkeypatch.setattr(server, "_profile_home", lambda profile: bot_home if profile == "bot" else None)
|
|
seen = {}
|
|
|
|
def fake_resolve(requested=None, **kwargs):
|
|
seen["home"] = Path(str(get_hermes_home())).resolve()
|
|
seen["secret"] = secret_scope.get_secret("OPENROUTER_API_KEY")
|
|
return {"provider": "openrouter", "api_key": seen["secret"] or "", "source": "env"}
|
|
|
|
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", fake_resolve)
|
|
|
|
secret_scope.set_multiplex_active(True)
|
|
try:
|
|
status = server.handle_request(
|
|
{"id": "1", "method": "setup.status", "params": {"profile": "bot"}}
|
|
)
|
|
assert status["result"] == {"provider_configured": False, "profile": "bot"}
|
|
|
|
(bot_home / ".env").write_text("OPENROUTER_API_KEY=sk-or-bot-profile-secret-00001\n")
|
|
status = server.handle_request(
|
|
{"id": "2", "method": "setup.status", "params": {"profile": "bot"}}
|
|
)
|
|
runtime = server.handle_request(
|
|
{"id": "3", "method": "setup.runtime_check", "params": {"profile": "bot"}}
|
|
)
|
|
finally:
|
|
secret_scope.set_multiplex_active(False)
|
|
|
|
assert status["result"] == {"provider_configured": True, "profile": "bot"}
|
|
assert runtime["result"]["ok"] is True
|
|
assert runtime["result"]["profile"] == "bot"
|
|
assert seen == {"home": bot_home.resolve(), "secret": "sk-or-bot-profile-secret-00001"}
|
|
assert Path(str(get_hermes_home())).resolve() != bot_home.resolve()
|
|
|
|
|
|
def test_setup_readiness_unknown_profile_never_answers_for_launch_profile(monkeypatch):
|
|
monkeypatch.setattr("hermes_cli.main._has_any_provider_configured", lambda **_kw: True)
|
|
monkeypatch.setattr(
|
|
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
|
lambda requested=None, **kw: {"provider": "openrouter", "api_key": "sk-or-launch-0000000000", "source": "env"},
|
|
)
|
|
monkeypatch.setattr("hermes_cli.profiles.profile_exists", lambda name: False)
|
|
|
|
for method in ("setup.status", "setup.runtime_check"):
|
|
resp = server.handle_request({"id": "1", "method": method, "params": {"profile": "ghost"}})
|
|
assert resp["result"]["ok"] is False
|
|
assert resp["result"]["profile"] == "ghost"
|
|
assert "does not exist" in resp["result"]["error"]
|
|
|
|
|
|
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)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("provider_flag", "failure_text"),
|
|
[
|
|
(" --provider custom:new-provider", "Unknown provider 'removed-provider'"),
|
|
("", ""),
|
|
],
|
|
)
|
|
def test_config_set_model_recovers_failed_profile_resume_after_build_completes(
|
|
monkeypatch, tmp_path, provider_flag, failure_text
|
|
):
|
|
"""Recovery waits for the real failed build and uses its owning profile.
|
|
|
|
Both the failed and replacement generations cross the real deferred-build
|
|
boundary. Provider resolution is the only model-switch leaf replaced.
|
|
"""
|
|
from agent.secret_scope import current_secret_scope
|
|
from hermes_constants import get_hermes_home
|
|
|
|
launch_url = "https://launch.example/v1"
|
|
profile_url = "https://profile.example/v1"
|
|
launch_home = tmp_path / "launch"
|
|
profile_home = tmp_path / "profiles" / "work"
|
|
launch_home.mkdir()
|
|
profile_home.mkdir(parents=True)
|
|
(launch_home / "config.yaml").write_text(
|
|
"model:\n"
|
|
" default: launch/model\n"
|
|
" provider: custom:new-provider\n"
|
|
"providers:\n"
|
|
" new-provider:\n"
|
|
f" base_url: {launch_url}\n"
|
|
" key_env: LAUNCH_API_KEY\n",
|
|
encoding="utf-8",
|
|
)
|
|
(launch_home / ".env").write_text(
|
|
"LAUNCH_API_KEY=launch-secret\n", encoding="utf-8"
|
|
)
|
|
(profile_home / "config.yaml").write_text(
|
|
"model:\n"
|
|
" default: old/model\n"
|
|
" provider: custom:new-provider\n"
|
|
"providers:\n"
|
|
" new-provider:\n"
|
|
f" base_url: {profile_url}\n"
|
|
" key_env: PROFILE_API_KEY\n",
|
|
encoding="utf-8",
|
|
)
|
|
(profile_home / ".env").write_text(
|
|
"PROFILE_API_KEY=profile-secret\n", encoding="utf-8"
|
|
)
|
|
monkeypatch.setenv("HERMES_HOME", str(launch_home))
|
|
monkeypatch.delenv("HERMES_MODEL", raising=False)
|
|
monkeypatch.delenv("HERMES_INFERENCE_MODEL", raising=False)
|
|
|
|
class ControlledReady:
|
|
def __init__(self):
|
|
self._event = threading.Event()
|
|
self.wait_entered = threading.Event()
|
|
|
|
def wait(self, timeout=None):
|
|
self.wait_entered.set()
|
|
return self._event.wait(timeout)
|
|
|
|
def is_set(self):
|
|
return self._event.is_set()
|
|
|
|
def set(self):
|
|
self._event.set()
|
|
|
|
old_ready = ControlledReady()
|
|
reasoning = {"effort": "high"}
|
|
old_override = {"model": "old/model", "provider": "removed-provider"}
|
|
session = _session(
|
|
agent_ready=old_ready,
|
|
agent_error=None,
|
|
model_override=old_override,
|
|
resume_runtime_overrides={
|
|
"model_override": old_override,
|
|
"provider_override": "removed-provider",
|
|
"reasoning_config_override": reasoning,
|
|
},
|
|
profile_home=str(profile_home),
|
|
)
|
|
session["agent"] = None
|
|
server._sessions["sid"] = session
|
|
old_finally_entered = threading.Event()
|
|
release_old_finally = threading.Event()
|
|
switch_called = threading.Event()
|
|
seen = {"switch": None, "build": None, "persisted": []}
|
|
make_calls = 0
|
|
|
|
def fake_switch_model(**kwargs):
|
|
provider = kwargs["user_providers"]["new-provider"]
|
|
secrets = dict(current_secret_scope() or {})
|
|
api_key = secrets[provider["key_env"]]
|
|
seen["switch"] = {
|
|
"home": get_hermes_home(),
|
|
"secrets": secrets,
|
|
"base_url": provider["base_url"],
|
|
"api_key": api_key,
|
|
"current_provider": kwargs["current_provider"],
|
|
"current_api_key": kwargs["current_api_key"],
|
|
}
|
|
switch_called.set()
|
|
return types.SimpleNamespace(
|
|
success=True,
|
|
new_model="new/model",
|
|
target_provider="custom:new-provider",
|
|
api_key=api_key,
|
|
base_url=provider["base_url"],
|
|
api_mode="chat_completions",
|
|
warning_message="",
|
|
model_info=None,
|
|
error_message="",
|
|
)
|
|
|
|
class FakeDb:
|
|
def __init__(self, *_args, **_kwargs):
|
|
pass
|
|
|
|
def get_session(self, _key):
|
|
return {"model_config": {}}
|
|
|
|
def update_session_meta(self, key, model_config, model):
|
|
seen["persisted"].append(
|
|
{
|
|
"key": key,
|
|
"model": model,
|
|
"config": json.loads(model_config),
|
|
}
|
|
)
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
def fake_make_agent(_sid, _key, **kwargs):
|
|
nonlocal make_calls
|
|
make_calls += 1
|
|
if make_calls == 1:
|
|
raise RuntimeError(failure_text)
|
|
override = kwargs["model_override"]
|
|
seen["build"] = {
|
|
"home": get_hermes_home(),
|
|
"secrets": dict(current_secret_scope() or {}),
|
|
"overrides": kwargs,
|
|
}
|
|
return types.SimpleNamespace(
|
|
model=override["model"],
|
|
provider="custom",
|
|
base_url=override["base_url"],
|
|
api_key=override["api_key"],
|
|
api_mode=override["api_mode"],
|
|
reasoning_config=kwargs.get("reasoning_config_override"),
|
|
service_tier=None,
|
|
_session_db=kwargs.get("session_db"),
|
|
)
|
|
|
|
real_transfer = server._transfer_db_to_agent
|
|
|
|
def barrier_transfer(agent, db):
|
|
if agent is None and not old_finally_entered.is_set():
|
|
old_finally_entered.set()
|
|
assert release_old_finally.wait(timeout=10)
|
|
return real_transfer(agent, db)
|
|
|
|
monkeypatch.setattr("hermes_cli.model_switch.switch_model", fake_switch_model)
|
|
monkeypatch.setattr(
|
|
"hermes_cli.model_selection_guards.combined_selection_warning",
|
|
lambda *args, **kwargs: None,
|
|
)
|
|
monkeypatch.setattr("hermes_state.get_shared_session_db", FakeDb)
|
|
monkeypatch.setattr(server, "_make_agent", fake_make_agent)
|
|
monkeypatch.setattr(server, "_transfer_db_to_agent", barrier_transfer)
|
|
monkeypatch.setattr(
|
|
"tui_gateway.entry.ensure_mcp_discovery_started", lambda: None
|
|
)
|
|
monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None)
|
|
monkeypatch.setattr(server, "_start_notification_poller", lambda *a, **k: None)
|
|
monkeypatch.setattr(server, "_notify_session_boundary", lambda *a, **k: None)
|
|
monkeypatch.setattr(server, "_session_info", lambda *a, **k: {})
|
|
monkeypatch.setattr(server, "_probe_config_health", lambda *_args: None)
|
|
monkeypatch.setattr(server, "_schedule_mcp_late_refresh", lambda *a, **k: None)
|
|
monkeypatch.setattr(server, "_emit", lambda *a, **k: None)
|
|
|
|
response = {}
|
|
|
|
def run_request():
|
|
try:
|
|
response["value"] = server.handle_request(
|
|
{
|
|
"id": "1",
|
|
"method": "config.set",
|
|
"params": {
|
|
"session_id": "sid",
|
|
"key": "model",
|
|
"value": f"new/model{provider_flag}",
|
|
},
|
|
}
|
|
)
|
|
except BaseException as exc:
|
|
response["error"] = exc
|
|
|
|
request_thread = threading.Thread(target=run_request)
|
|
old_build_thread = None
|
|
try:
|
|
server._start_agent_build("sid", session)
|
|
old_build_thread = session["_agent_build_thread"]
|
|
assert old_finally_entered.wait(timeout=10)
|
|
assert session["agent_error"] == failure_text
|
|
assert not old_ready.is_set()
|
|
|
|
request_thread.start()
|
|
assert old_ready.wait_entered.wait(timeout=2), (
|
|
"model recovery did not wait for the failed build generation"
|
|
)
|
|
assert not switch_called.is_set()
|
|
release_old_finally.set()
|
|
request_thread.join(timeout=10)
|
|
|
|
assert not request_thread.is_alive()
|
|
assert "error" not in response
|
|
assert response["value"]["result"]["value"] == "new/model"
|
|
assert make_calls == 2
|
|
assert seen["switch"] == {
|
|
"home": profile_home,
|
|
"secrets": {"PROFILE_API_KEY": "profile-secret"},
|
|
"base_url": profile_url,
|
|
"api_key": "profile-secret",
|
|
"current_provider": (
|
|
"custom:new-provider" if provider_flag else "custom"
|
|
),
|
|
"current_api_key": "profile-secret" if not provider_flag else "",
|
|
}
|
|
assert seen["build"]["home"] == profile_home
|
|
assert seen["build"]["secrets"] == {
|
|
"PROFILE_API_KEY": "profile-secret"
|
|
}
|
|
overrides = seen["build"]["overrides"]
|
|
assert overrides["model_override"] == session["model_override"]
|
|
assert overrides["provider_override"] == "custom:new-provider"
|
|
assert overrides["reasoning_config_override"] == reasoning
|
|
assert session["agent_error"] is None
|
|
assert session["agent"].model == "new/model"
|
|
assert session["agent"].base_url == profile_url
|
|
assert session["agent"].api_key == "profile-secret"
|
|
assert seen["persisted"] == [
|
|
{
|
|
"key": "session-key",
|
|
"model": "new/model",
|
|
"config": {
|
|
"model": "new/model",
|
|
"provider": "custom:new-provider",
|
|
"base_url": profile_url,
|
|
"api_mode": "chat_completions",
|
|
"reasoning_config": reasoning,
|
|
},
|
|
}
|
|
]
|
|
finally:
|
|
release_old_finally.set()
|
|
old_ready.set()
|
|
request_thread.join(timeout=10)
|
|
if old_build_thread is not None:
|
|
old_build_thread.join(timeout=10)
|
|
new_build_thread = session.get("_agent_build_thread")
|
|
if new_build_thread is not None:
|
|
new_build_thread.join(timeout=10)
|
|
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 = []
|
|
writes = []
|
|
|
|
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))
|
|
# Persistence now flows through the single owner (hermes_cli.personality),
|
|
# never _write_config_key / agent.system_prompt.
|
|
import hermes_cli.personality as personality_mod
|
|
|
|
monkeypatch.setattr(
|
|
personality_mod,
|
|
"persist_personality",
|
|
lambda name: writes.append(("display.personality", name)) or True,
|
|
)
|
|
monkeypatch.setattr(
|
|
server,
|
|
"_write_config_key",
|
|
lambda path, value: writes.append((path, value)),
|
|
)
|
|
|
|
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
|
|
assert ("display.personality", "helpful") in writes
|
|
assert not any(path == "agent.system_prompt" for path, _ in writes)
|
|
|
|
|
|
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_config_ceiling_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)
|
|
monkeypatch.setattr(
|
|
server, "_load_cfg", lambda: {"compression": {"context_total_ceiling_seconds": 300}}
|
|
)
|
|
|
|
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 len(calls) == 1
|
|
(sid_arg,), kwargs = calls[0]
|
|
assert sid_arg == "sid"
|
|
assert kwargs["route_name"] == "session.compress"
|
|
assert kwargs["command"] == "/compress"
|
|
assert kwargs["wait"] is True
|
|
# #97948: the waiter follows compression.context_total_ceiling_seconds
|
|
# (+30s slack) instead of a hard-coded 120s, and registers a late-ack
|
|
# handler so a compress that outlives it is still adopted.
|
|
assert kwargs["timeout"] == 330.0
|
|
assert callable(kwargs["on_late_ack"])
|
|
|
|
|
|
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.
|
|
|
|
Staged into the session home's ``attachments/`` dir (bind-mounted into
|
|
container backends) rather than the workspace (#76577).
|
|
"""
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
home = tmp_path / "home"
|
|
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), profile_home=str(home))
|
|
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 = home / "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"] == f"@file:{stored}"
|
|
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()
|
|
home = tmp_path / "home"
|
|
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), profile_home=str(home))
|
|
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 = home / "attachments" / "outside.txt"
|
|
assert resp["result"]["attached"] is True
|
|
assert resp["result"]["uploaded"] is True
|
|
assert resp["result"]["ref_text"] == f"@file:{stored}"
|
|
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 or the home
|
|
# attachments dir.
|
|
assert not (workspace / ".hermes" / "desktop-attachments").exists()
|
|
assert not (tmp_path / "home" / "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), profile_home=str(tmp_path / "home"))
|
|
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==",
|
|
},
|
|
}
|
|
)
|
|
|
|
stored = tmp_path / "home" / "attachments" / "my exam schedule.csv"
|
|
assert resp["result"]["attached"] is True
|
|
assert resp["result"]["ref_text"] == f"@file:`{stored}`"
|
|
assert stored.read_text(encoding="utf-8") == "a,b\n"
|
|
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_commands_catalog_includes_desktop_meta_without_skills():
|
|
resp = server.handle_request(
|
|
{"id": "1", "method": "commands.catalog", "params": {}}
|
|
)
|
|
|
|
commands = resp["result"]["commands"]
|
|
assert commands["/review"] == {"argument_mode": "text", "desktop": None}
|
|
assert commands["/clear"]["desktop"] == "terminal"
|
|
assert commands["/model"]["desktop"] == "hidden"
|
|
assert commands["/compact"]["argument_mode"] == commands["/compress"]["argument_mode"]
|
|
|
|
for skill in resp["result"]["skills"]:
|
|
assert skill not in commands
|
|
|
|
|
|
def test_commands_catalog_includes_plugin_commands(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins.get_plugin_commands",
|
|
lambda: {
|
|
"lcm": {
|
|
"description": "Latent consistency",
|
|
"args_hint": "<prompt>",
|
|
"argument_mode": "text",
|
|
}
|
|
},
|
|
)
|
|
|
|
resp = server.handle_request(
|
|
{"id": "1", "method": "commands.catalog", "params": {}}
|
|
)
|
|
|
|
assert resp["result"]["commands"]["/lcm"] == {
|
|
"argument_mode": "text",
|
|
"desktop": None,
|
|
}
|
|
pairs = dict(resp["result"]["pairs"])
|
|
assert "/lcm" in pairs
|
|
plugin_cat = next(
|
|
c for c in resp["result"]["categories"] if c["name"] == "Plugin commands"
|
|
)
|
|
assert "/lcm" in dict(plugin_cat["pairs"])
|
|
|
|
|
|
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),
|
|
session_key="",
|
|
)
|
|
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),
|
|
session_key="",
|
|
)
|
|
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_rollback_restore_preserves_composite_carrier_scaffold(monkeypatch, tmp_path):
|
|
"""A checkpoint restore drops the live ask but keeps compacted context."""
|
|
from agent.context_compressor import (
|
|
HISTORICAL_TASK_HEADING,
|
|
SUMMARY_PREFIX,
|
|
_SUMMARY_END_MARKER,
|
|
)
|
|
from hermes_state import SessionDB
|
|
|
|
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"}
|
|
|
|
carrier = {
|
|
"role": "user",
|
|
"content": (
|
|
f"{SUMMARY_PREFIX}\n{HISTORICAL_TASK_HEADING}\nold task\n\n"
|
|
f"{_SUMMARY_END_MARKER}\n\nREAL ASK"
|
|
),
|
|
}
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
db.create_session("rollback-carrier", source="tui")
|
|
db.append_message("rollback-carrier", "user", carrier["content"])
|
|
db.append_message("rollback-carrier", "assistant", "answer")
|
|
durable = db.get_messages_as_conversation("rollback-carrier")
|
|
agent = types.SimpleNamespace(
|
|
_checkpoint_mgr=_Mgr(),
|
|
_session_messages=list(durable),
|
|
_last_flushed_db_idx=len(durable),
|
|
_db_flush_scan_prefix=list(durable),
|
|
)
|
|
server._sessions["sid"] = _session(
|
|
agent=agent,
|
|
history=list(durable),
|
|
session_key="rollback-carrier",
|
|
)
|
|
monkeypatch.setattr(server, "_get_db", lambda: db)
|
|
try:
|
|
resp = server.handle_request(
|
|
{
|
|
"id": "1",
|
|
"method": "rollback.restore",
|
|
"params": {"session_id": "sid", "hash": "abc123"},
|
|
}
|
|
)
|
|
|
|
assert "result" in resp, resp
|
|
assert resp["result"]["success"] is True
|
|
assert resp["result"]["history_removed"] == 2
|
|
remaining = server._sessions["sid"]["history"]
|
|
assert len(remaining) == 1
|
|
assert remaining[0]["display_kind"] == "hidden"
|
|
assert SUMMARY_PREFIX in remaining[0]["content"]
|
|
assert "REAL ASK" not in remaining[0]["content"]
|
|
cold = db.get_messages_as_conversation(
|
|
"rollback-carrier", include_row_ids=True
|
|
)
|
|
assert len(cold) == 1
|
|
assert cold[0]["content"] == remaining[0]["content"]
|
|
assert cold[0]["display_kind"] == "hidden"
|
|
assert cold[0]["_row_id"] == remaining[0]["_row_id"]
|
|
assert agent._session_messages == remaining
|
|
assert agent._last_flushed_db_idx == 1
|
|
assert agent._db_flush_scan_prefix == remaining
|
|
inactive = db.get_messages_as_conversation(
|
|
"rollback-carrier", include_inactive=True
|
|
)
|
|
assert any("REAL ASK" in str(message.get("content")) for message in inactive)
|
|
assert any(message.get("content") == "answer" for message in inactive)
|
|
finally:
|
|
server._sessions.pop("sid", None)
|
|
db.close()
|
|
|
|
|
|
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_rpc_drops_queued_duplicate_of_inflight_user():
|
|
"""#84417: Desktop ``session.redirect`` must purge stale self-duplicates.
|
|
|
|
Production path: renderer steers via ``session.redirect`` (not
|
|
``prompt.submit``). A self-copy of the live original user text already in
|
|
the server queue must not survive a successful redirect — otherwise
|
|
post-turn ``_drain_queued_prompt`` restarts prompt P after Q is handled.
|
|
Unrelated next-turn envelopes stay.
|
|
"""
|
|
original = "deepseek released a new flash model — I changed all settings to flash"
|
|
agent = types.SimpleNamespace(
|
|
_supports_active_turn_redirect=True,
|
|
redirect=lambda text: True,
|
|
)
|
|
session = _session(agent=agent, running=True)
|
|
session["inflight_turn"] = {
|
|
"user": original,
|
|
"assistant": "partial",
|
|
"streaming": True,
|
|
"error": "",
|
|
}
|
|
session["queued_prompt"] = {"text": original, "transport": "ws-1"}
|
|
session["queued_prompts"] = [
|
|
{"text": original, "transport": "ws-1"},
|
|
{"text": "unrelated later task", "transport": "ws-1"},
|
|
]
|
|
server._sessions["sid"] = session
|
|
try:
|
|
resp = server.handle_request(
|
|
{
|
|
"id": "1",
|
|
"method": "session.redirect",
|
|
"params": {
|
|
"session_id": "sid",
|
|
"text": "what about the pricing instead?",
|
|
},
|
|
}
|
|
)
|
|
finally:
|
|
server._sessions.pop("sid", None)
|
|
|
|
assert resp["result"]["status"] == "redirected"
|
|
assert session["inflight_turn"]["user"] == original
|
|
assert session["inflight_turn"]["corrections"] == [
|
|
"what about the pricing instead?"
|
|
]
|
|
# Self-duplicates of the live original are gone; legitimate follow-up kept.
|
|
assert session.get("queued_prompt") == {
|
|
"text": "unrelated later task",
|
|
"transport": "ws-1",
|
|
}
|
|
assert not session.get("queued_prompts")
|
|
|
|
|
|
def test_session_redirect_build_window_scrubs_stale_p_when_queuing_q():
|
|
"""#84417: build-window queue of Q must not leave P ahead of Q."""
|
|
original = "live original P"
|
|
session = _session(running=True)
|
|
session["agent"] = None # async agent build window
|
|
session["inflight_turn"] = {
|
|
"user": original,
|
|
"assistant": "",
|
|
"streaming": True,
|
|
"error": "",
|
|
}
|
|
session["queued_prompt"] = {"text": original, "transport": "ws-1"}
|
|
server._sessions["sid"] = session
|
|
try:
|
|
resp = server.handle_request(
|
|
{
|
|
"id": "1",
|
|
"method": "session.redirect",
|
|
"params": {"session_id": "sid", "text": "correction Q"},
|
|
}
|
|
)
|
|
finally:
|
|
server._sessions.pop("sid", None)
|
|
|
|
assert resp["result"] == {"status": "queued", "text": "correction Q"}
|
|
assert session["queued_prompt"]["text"] == "correction Q"
|
|
assert not session.get("queued_prompts")
|
|
|
|
|
|
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_carries_arrival_order_offsets():
|
|
"""Each correction records how much assistant text had already streamed.
|
|
|
|
Resuming clients rebuild ARRIVAL order from these boundaries: the
|
|
correction bubble lands after the output the user had already seen and
|
|
before the output it redirected (#73793), instead of above the whole
|
|
reply.
|
|
"""
|
|
session = {}
|
|
server._start_inflight_turn(session, "remove the session counts")
|
|
server._append_inflight_delta(session, "Moving.")
|
|
server._record_inflight_correction(session, "hurry up")
|
|
server._append_inflight_delta(session, "Still.")
|
|
server._record_inflight_correction(session, "and the worktree ones")
|
|
server._append_inflight_delta(session, "Done soon.")
|
|
|
|
snapshot = server._inflight_snapshot(session)
|
|
assert snapshot is not None
|
|
|
|
assert snapshot["corrections"] == ["hurry up", "and the worktree ones"]
|
|
assert snapshot["correction_offsets"] == [len("Moving."), len("Moving.Still.")]
|
|
|
|
|
|
def test_inflight_snapshot_omits_offsets_when_not_fully_recorded():
|
|
"""A pre-upgrade in-memory turn may carry corrections without offsets.
|
|
|
|
The parallel list is only sent when every correction has one, so clients
|
|
can trust the pairing and older snapshots degrade to the no-offset path.
|
|
"""
|
|
session = {}
|
|
server._start_inflight_turn(session, "prompt")
|
|
turn = session["inflight_turn"]
|
|
turn["corrections"] = ["legacy correction"]
|
|
|
|
snapshot = server._inflight_snapshot(session)
|
|
assert snapshot is not None
|
|
|
|
assert snapshot["corrections"] == ["legacy correction"]
|
|
assert "correction_offsets" not in snapshot
|
|
|
|
|
|
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"
|
|
|
|
|
|
def test_session_info_includes_turn_started_at():
|
|
agent = types.SimpleNamespace(tools=[], model="", provider="")
|
|
session = {
|
|
"history": [],
|
|
"inflight_turn": {"started_at": 1_700_000_123.5},
|
|
"running": True,
|
|
}
|
|
|
|
assert server._session_info(agent, session)["turn_started_at"] == 1_700_000_123.5
|
|
|
|
session["inflight_turn"] = None
|
|
session["running"] = False
|
|
assert server._session_info(agent, session)["turn_started_at"] is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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,
|
|
session_key="",
|
|
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_merges_on_personality_pivot_marker(monkeypatch):
|
|
"""A personality pivot injected mid-turn must merge like a model switch.
|
|
|
|
`/personality` applies immediately — there is no deferred queue for it the
|
|
way `pending_model_switch` defers a mid-turn model change — so choosing a
|
|
personality while a turn is running bumps `history_version` from the RPC
|
|
thread. The mid-turn reconciliation only recognized the model-switch
|
|
marker, so the pivot read as a genuine desync and the finished turn was
|
|
dropped from session history: the user saw the reply and it was never
|
|
stored (#82756).
|
|
"""
|
|
session_ref: dict[str, dict | None] = {"s": None}
|
|
|
|
class _PivotAgent:
|
|
def run_conversation(
|
|
self, prompt, conversation_history=None, stream_callback=None, **_kwargs
|
|
):
|
|
# Real injection point, mid-turn, exactly as the personality RPC
|
|
# would reach it from the other thread.
|
|
server._apply_personality_to_session(
|
|
"sid", session_ref["s"], "Answer tersely.", "terse"
|
|
)
|
|
return {
|
|
"final_response": "agent reply",
|
|
"messages": list(conversation_history)
|
|
+ [
|
|
{"role": "user", "content": "hi"},
|
|
{"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=_PivotAgent(),
|
|
history=[{"role": "user", "content": "hello"}],
|
|
)
|
|
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, "_session_info", lambda *a, **k: {})
|
|
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')}"
|
|
|
|
final_history = server._sessions["sid"]["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, (
|
|
"the personality pivot discarded the finished turn instead of "
|
|
f"merging it (got {len(assistant_msgs)} assistant replies)"
|
|
)
|
|
|
|
pivots = [
|
|
e
|
|
for e in final_history
|
|
if isinstance(e, dict) and e.get("display_kind") == "personality_switch"
|
|
]
|
|
assert len(pivots) == 1, f"expected exactly 1 pivot, got {len(pivots)}"
|
|
|
|
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, "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 get_messages_as_conversation(self, *_args, **_kwargs):
|
|
return []
|
|
|
|
def replace_messages(
|
|
self,
|
|
session_id,
|
|
messages,
|
|
active_only=False,
|
|
archive_dropped=False,
|
|
reject_active_turn_lease=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 get_messages_as_conversation(self, *_args, **_kwargs):
|
|
return []
|
|
|
|
def replace_messages(
|
|
self,
|
|
session_id,
|
|
messages,
|
|
active_only=False,
|
|
archive_dropped=False,
|
|
reject_active_turn_lease=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 get_messages_as_conversation(self, *_args, **_kwargs):
|
|
return []
|
|
|
|
def replace_messages(
|
|
self,
|
|
session_id,
|
|
messages,
|
|
active_only=False,
|
|
archive_dropped=False,
|
|
reject_active_turn_lease=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)
|
|
|
|
|
|
def test_prompt_submit_truncate_translates_display_prefix_ordinal(monkeypatch):
|
|
"""Full-lineage Desktop ordinals must truncate the tip segment (#82462).
|
|
|
|
After compression, session["history"] is the tip while display_history_prefix
|
|
still holds ancestor user turns the UI shows. A client ordinal that includes
|
|
those ancestors must map into the tip, not 4018.
|
|
"""
|
|
|
|
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()
|
|
|
|
tip_history = [
|
|
{"role": "user", "content": "post-compress A"},
|
|
{"role": "assistant", "content": "reply A"},
|
|
{"role": "user", "content": "post-compress B"},
|
|
{"role": "assistant", "content": "reply B"},
|
|
]
|
|
display_prefix = [
|
|
{"role": "user", "content": "pre-compress 1"},
|
|
{"role": "assistant", "content": "pre reply 1"},
|
|
{"role": "user", "content": "pre-compress 2"},
|
|
{"role": "assistant", "content": "pre reply 2"},
|
|
]
|
|
# Desktop lineage: pre1=0, pre2=1, postA=2, postB=3
|
|
desktop_ordinal_for_post_b = 3
|
|
|
|
server._sessions["sid"] = _session(
|
|
agent=_Agent(),
|
|
history=tip_history,
|
|
display_history_prefix=display_prefix,
|
|
)
|
|
|
|
class _StubDb:
|
|
def __init__(self):
|
|
self.replaced = []
|
|
|
|
def get_messages_as_conversation(self, *_args, **_kwargs):
|
|
# Empty durable transcript: proves this ephemeral-style session
|
|
# may take the ordinal-only path past the durability gate.
|
|
return []
|
|
|
|
def replace_messages(
|
|
self,
|
|
session_id,
|
|
messages,
|
|
active_only=False,
|
|
archive_dropped=False,
|
|
reject_active_turn_lease=False,
|
|
):
|
|
assert reject_active_turn_lease is True
|
|
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 post B",
|
|
"truncate_before_user_ordinal": desktop_ordinal_for_post_b,
|
|
"confirm_truncate": True,
|
|
},
|
|
}
|
|
)
|
|
assert resp.get("result"), f"got error: {resp.get('error')}"
|
|
assert seen["prompt"] == "edited post B"
|
|
assert seen["history"] == tip_history[:2]
|
|
assert stub_db.replaced == [("session-key", tip_history[:2])]
|
|
finally:
|
|
server._sessions.pop("sid", None)
|
|
|
|
|
|
def test_prompt_submit_truncate_oor_includes_structured_user_turn_count(monkeypatch):
|
|
"""4018 after compaction must carry recovery fields for Desktop (#82462)."""
|
|
|
|
tip_history = [
|
|
{"role": "user", "content": "post-compress A"},
|
|
{"role": "assistant", "content": "reply A"},
|
|
]
|
|
display_prefix = [
|
|
{"role": "user", "content": "pre-compress 1"},
|
|
{"role": "assistant", "content": "pre reply 1"},
|
|
]
|
|
# Ordinal 0 points at the ancestor prefix — not editable from the tip.
|
|
server._sessions["sid"] = _session(
|
|
history=tip_history,
|
|
display_history_prefix=display_prefix,
|
|
)
|
|
|
|
try:
|
|
monkeypatch.setattr(
|
|
server, "_start_inflight_turn", lambda *a, **k: pytest.fail("must not start a turn")
|
|
)
|
|
resp = server.handle_request(
|
|
{
|
|
"id": "1",
|
|
"method": "prompt.submit",
|
|
"params": {
|
|
"session_id": "sid",
|
|
"text": "edit ancestor",
|
|
"truncate_before_user_ordinal": 0,
|
|
"confirm_truncate": True,
|
|
},
|
|
}
|
|
)
|
|
err = resp.get("error") or {}
|
|
assert err.get("code") == 4018
|
|
assert "no longer in session history" in (err.get("message") or "")
|
|
data = err.get("data") or {}
|
|
assert data.get("user_turn_count") == 1
|
|
assert data.get("ordinal") == 0
|
|
assert data.get("segment_ordinal") == -1
|
|
assert data.get("prefix_user_count") == 1
|
|
finally:
|
|
server._sessions.pop("sid", None)
|
|
|
|
|
|
def test_prompt_submit_row_id_accepts_full_lineage_ordinal(monkeypatch):
|
|
"""Desktop sends rowId + a full-lineage ordinal; both must agree (#82462).
|
|
|
|
After compression the client's visible-user ordinal counts the ancestor
|
|
prefix turns while the gateway resolves the row id tip-relative. The
|
|
reconcile cross-check must treat `tip_ordinal + prefix_user_count` as
|
|
agreement, not #82756 drift — and the cut stays aimed by the row id.
|
|
"""
|
|
from agent.context_compressor import (
|
|
HISTORICAL_TASK_HEADING,
|
|
SUMMARY_PREFIX,
|
|
_SUMMARY_END_MARKER,
|
|
)
|
|
|
|
tip_history = [
|
|
{"_row_id": 501, "role": "user", "content": "post-compress A"},
|
|
{"_row_id": 502, "role": "assistant", "content": "reply A"},
|
|
{"_row_id": 503, "role": "user", "content": "post-compress B"},
|
|
{"_row_id": 504, "role": "assistant", "content": "reply B"},
|
|
]
|
|
display_prefix = [
|
|
{"role": "user", "content": "pre-compress 1"},
|
|
{"role": "assistant", "content": "pre reply 1"},
|
|
{
|
|
# Legacy pure handoffs did not always carry display_kind=hidden.
|
|
# They are physically user rows but not visible/user-originated
|
|
# turns, so they must not shift the Desktop lineage ordinal.
|
|
"role": "user",
|
|
"content": (
|
|
f"{SUMMARY_PREFIX}\n{HISTORICAL_TASK_HEADING}\nold task\n\n"
|
|
f"{_SUMMARY_END_MARKER}"
|
|
),
|
|
},
|
|
{"role": "user", "content": "pre-compress 2"},
|
|
{"role": "assistant", "content": "pre reply 2"},
|
|
]
|
|
|
|
replaced = []
|
|
|
|
class _FakeDB:
|
|
def replace_messages(
|
|
self,
|
|
key,
|
|
messages,
|
|
active_only=False,
|
|
archive_dropped=False,
|
|
reject_active_turn_lease=False,
|
|
):
|
|
assert reject_active_turn_lease is True
|
|
replaced.append((key, list(messages)))
|
|
|
|
sess = _session(history=list(tip_history), display_history_prefix=display_prefix)
|
|
server._sessions["lineage-row-sid"] = sess
|
|
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
|
|
monkeypatch.setattr(server, "_start_agent_build", lambda *a, **k: None)
|
|
|
|
try:
|
|
resp = server.handle_request(
|
|
{
|
|
"id": "1",
|
|
"method": "prompt.submit",
|
|
"params": {
|
|
"session_id": "lineage-row-sid",
|
|
"text": "edited post B",
|
|
# Row id resolves to tip ordinal 1; the client counted the
|
|
# 2 ancestor user turns, so its lineage ordinal is 3.
|
|
"truncate_before_row_id": 503,
|
|
"truncate_before_user_ordinal": 3,
|
|
"confirm_truncate": True,
|
|
},
|
|
}
|
|
)
|
|
assert resp.get("error") is None, f"got error: {resp.get('error')}"
|
|
assert sess["history"] == tip_history[:2]
|
|
finally:
|
|
# Release the slot the first turn claimed, as _finalize_session does in
|
|
# production. Popping alone leaks the lease, and the second session below
|
|
# uses the same session_key -- so without this the test fences itself out
|
|
# of its own key and never reaches the mismatch it is checking.
|
|
server._release_active_session_slot(sess)
|
|
server._sessions.pop("lineage-row-sid", None)
|
|
|
|
# A genuinely stale ordinal (matches neither the tip space nor the
|
|
# lineage space) must still refuse with the #82756 mismatch.
|
|
sess2 = _session(history=list(tip_history), display_history_prefix=display_prefix)
|
|
server._sessions["lineage-row-sid-2"] = sess2
|
|
monkeypatch.setattr(
|
|
server, "_start_agent_build", lambda *a, **k: pytest.fail("must not start a turn")
|
|
)
|
|
try:
|
|
resp = server.handle_request(
|
|
{
|
|
"id": "2",
|
|
"method": "prompt.submit",
|
|
"params": {
|
|
"session_id": "lineage-row-sid-2",
|
|
"text": "edited post B",
|
|
"truncate_before_row_id": 503,
|
|
"truncate_before_user_ordinal": 2,
|
|
"confirm_truncate": True,
|
|
},
|
|
}
|
|
)
|
|
assert resp.get("error") is not None
|
|
assert resp["error"]["code"] == 4030
|
|
finally:
|
|
server._release_active_session_slot(sess2)
|
|
server._sessions.pop("lineage-row-sid-2", 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.
|
|
|
|
prompt.submit retries a completed failed build once (fresh provider
|
|
resolution un-wedges sessions whose failure cause was fixed), so the
|
|
build stub here is a faithful failing build: it sets agent_error and
|
|
fires the session's CURRENT ready event (the retry installs a new one).
|
|
A no-op stub would leave that event unset and hang the patient wait."""
|
|
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
|
|
|
|
def _failing_build(sid, session):
|
|
session["agent_error"] = "No LLM provider configured"
|
|
session["agent_ready"].set()
|
|
|
|
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", _failing_build)
|
|
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
|
|
|
|
def _broken_shared(_db_path=None):
|
|
raise RuntimeError("locking protocol")
|
|
|
|
fake_mod.get_shared_session_db = _broken_shared
|
|
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"
|
|
|
|
|
|
def test_ensure_session_db_row_false_when_store_unavailable(monkeypatch):
|
|
"""Store unavailable → False, so prompt.submit can fail the send loudly
|
|
instead of streaming into a store that will never save it (#98924)."""
|
|
fake_mod = types.ModuleType("hermes_state")
|
|
|
|
class _BrokenSessionDB:
|
|
def __init__(self):
|
|
raise RuntimeError("utf-8 boom")
|
|
|
|
fake_mod.SessionDB = _BrokenSessionDB
|
|
|
|
def _broken_shared(_db_path=None):
|
|
raise RuntimeError("utf-8 boom")
|
|
|
|
fake_mod.get_shared_session_db = _broken_shared
|
|
monkeypatch.setitem(sys.modules, "hermes_state", fake_mod)
|
|
monkeypatch.setattr(server, "_db", None)
|
|
monkeypatch.setattr(server, "_db_error", None)
|
|
|
|
assert server._ensure_session_db_row({"session_key": "k1"}) is False
|
|
|
|
|
|
def test_ensure_session_db_row_true_when_row_persisted(monkeypatch):
|
|
created = []
|
|
|
|
class _FakeDB:
|
|
def create_session(self, key, **_kwargs):
|
|
created.append(key)
|
|
|
|
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
|
|
monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")
|
|
|
|
assert server._ensure_session_db_row({"session_key": "k1", "cwd": "/tmp"}) is True
|
|
assert created == ["k1"]
|
|
|
|
|
|
def test_prompt_submit_fails_loudly_when_store_unavailable(monkeypatch):
|
|
"""A send with no persistable store must fail the RPC with a real error
|
|
(desktop maps it to a toast) instead of streaming the message into a
|
|
store that will never save it (#98924)."""
|
|
monkeypatch.setattr(server, "_load_cfg", lambda: {"dashboard": {}})
|
|
monkeypatch.setattr(server, "_get_db", lambda: None)
|
|
monkeypatch.setattr(server, "_db_error", "utf-8 decode failure")
|
|
|
|
server._sessions["lost-sid"] = _session()
|
|
try:
|
|
resp = server.handle_request(
|
|
{
|
|
"id": "lost",
|
|
"method": "prompt.submit",
|
|
"params": {"session_id": "lost-sid", "text": "will vanish"},
|
|
}
|
|
)
|
|
finally:
|
|
server._sessions.pop("lost-sid", None)
|
|
|
|
assert resp["error"]["code"] == 5072
|
|
assert "session storage unavailable" in resp["error"]["message"]
|
|
|
|
|
|
@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_activate_lazy_info_reports_desktop_contract():
|
|
"""Activating an already-live *lazy* session (agent not built yet) must
|
|
still advertise desktop_contract. _live_session_payload falls back to
|
|
_fallback_session_info while session["agent"] is None; the desktop reads a
|
|
missing field as contract 0 and falsely warns "Backend out of date" against
|
|
a current backend (#68392). The sibling session.create path was fixed in
|
|
#36112; this pins the session.activate path."""
|
|
import threading
|
|
|
|
sid = "lazy-activate-contract"
|
|
server._sessions[sid] = {
|
|
"agent": None,
|
|
"created_at": 123.0,
|
|
"history": [],
|
|
"history_lock": threading.RLock(),
|
|
"last_active": 123.0,
|
|
"running": False,
|
|
"session_key": sid,
|
|
"transport": server._stdio_transport,
|
|
}
|
|
try:
|
|
resp = server.handle_request(
|
|
{
|
|
"id": "activate-lazy",
|
|
"method": "session.activate",
|
|
"params": {"session_id": sid},
|
|
}
|
|
)
|
|
info = resp["result"]["info"]
|
|
assert info["lazy"] is True
|
|
assert info["desktop_contract"] == server.DESKTOP_BACKEND_CONTRACT
|
|
finally:
|
|
server._sessions.pop(sid, 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.get_shared_session_db", 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.get_shared_session_db", ProfileDB2)
|
|
|
|
resp = server.handle_request(
|
|
{
|
|
"id": "1",
|
|
"method": "session.most_recent",
|
|
"params": {"profile": "mlperf"},
|
|
}
|
|
)
|
|
assert resp["result"]["session_id"] == "ml-tip"
|
|
|
|
|
|
def test_handoff_request_uses_session_profile_home(monkeypatch, tmp_path):
|
|
"""Handoff validation must read the owning session's gateway config."""
|
|
import contextlib
|
|
|
|
from gateway.config import GatewayConfig, HomeChannel, Platform, PlatformConfig
|
|
from hermes_cli.config import get_hermes_home
|
|
from tui_gateway import methods_session
|
|
|
|
methods_session.register(server)
|
|
profile_home = tmp_path / "profiles" / "coder"
|
|
profile_home.mkdir(parents=True)
|
|
seen_homes = []
|
|
|
|
def load_config():
|
|
home = get_hermes_home()
|
|
seen_homes.append(home)
|
|
config = GatewayConfig()
|
|
if home == profile_home:
|
|
config.platforms[Platform.DISCORD] = PlatformConfig(
|
|
enabled=True,
|
|
home_channel=HomeChannel(
|
|
platform=Platform.DISCORD,
|
|
chat_id="discord-home",
|
|
name="Hermes / #chat-coding",
|
|
),
|
|
)
|
|
return config
|
|
|
|
class ProfileDB:
|
|
def get_session(self, _key):
|
|
return {"id": _key}
|
|
|
|
def request_handoff(self, _key, platform):
|
|
return platform == "discord"
|
|
|
|
@contextlib.contextmanager
|
|
def profile_db(_session):
|
|
yield ProfileDB()
|
|
|
|
monkeypatch.setattr("gateway.config.load_gateway_config", load_config)
|
|
monkeypatch.setattr(server, "_ensure_session_db_row", lambda _session: None)
|
|
monkeypatch.setattr(server, "_session_db", profile_db)
|
|
server._sessions["handoff-profile"] = {
|
|
"running": False,
|
|
"session_key": "desktop-coder-session",
|
|
"profile_home": str(profile_home),
|
|
}
|
|
try:
|
|
resp = server.handle_request(
|
|
{
|
|
"id": "1",
|
|
"method": "handoff.request",
|
|
"params": {
|
|
"session_id": "handoff-profile",
|
|
"platform": "discord",
|
|
},
|
|
}
|
|
)
|
|
finally:
|
|
server._sessions.pop("handoff-profile", None)
|
|
|
|
assert "result" in resp, resp
|
|
assert resp["result"]["queued"] is True
|
|
assert seen_homes == [profile_home]
|
|
assert get_hermes_home() != profile_home
|
|
|
|
|
|
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.get_shared_session_db", 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.get_shared_session_db", 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.get_shared_session_db", 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_history_ships_durable_row_ids(monkeypatch):
|
|
"""session.history must request row-id stamps — clients resolve truncation
|
|
targets by content against this projection (#87059 client half)."""
|
|
seen: dict = {}
|
|
|
|
class _Db:
|
|
def get_messages_as_conversation(self, _key, include_ancestors=False, include_row_ids=False, **_kwargs):
|
|
seen["include_row_ids"] = include_row_ids
|
|
|
|
return [
|
|
{"role": "user", "content": "hello", "_row_id": 41},
|
|
{"role": "assistant", "content": "hi"},
|
|
]
|
|
|
|
server._sessions["rowid-hist-sid"] = {
|
|
"session_key": "rowid-sess",
|
|
"history": [],
|
|
"history_lock": __import__("threading").Lock(),
|
|
"running": False,
|
|
"agent": None,
|
|
"created_at": 1.0,
|
|
"last_active": 1.0,
|
|
}
|
|
monkeypatch.setattr(server, "_get_db", lambda: _Db())
|
|
try:
|
|
resp = server.handle_request(
|
|
{"id": "1", "method": "session.history", "params": {"session_id": "rowid-hist-sid"}}
|
|
)
|
|
assert "result" in resp, resp
|
|
assert seen.get("include_row_ids") is True
|
|
user_rows = [m for m in resp["result"]["messages"] if m.get("role") == "user"]
|
|
assert user_rows and user_rows[0].get("row_id") == 41
|
|
finally:
|
|
server._sessions.pop("rowid-hist-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.get_shared_session_db", 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.get_shared_session_db", 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.get_shared_session_db", 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_create_persists_seeded_branch_child(monkeypatch):
|
|
"""A desktop branch (session.create with parent_session_id + seeded
|
|
messages) must persist its row + transcript immediately (#93959).
|
|
|
|
The renderer re-fetches the fresh child via REST and defer_history
|
|
hydration right after create; both read the DB. An unpersisted child
|
|
404s/hydrates empty, the client fail-latch refuses to bind it, and the
|
|
user gets an infinite spinner whose optimistic row vanishes on restart.
|
|
"""
|
|
|
|
class _FakeAgent:
|
|
def __init__(self):
|
|
self.model = "test-model"
|
|
|
|
seen: dict = {}
|
|
|
|
class _FakeDB:
|
|
def get_session_title(self, key):
|
|
seen["parent_title"] = key
|
|
return "My Parent Session"
|
|
|
|
def get_next_title_in_lineage(self, current):
|
|
return f"{current} #2"
|
|
|
|
def create_session(self, key, **kwargs):
|
|
seen["created"] = key
|
|
seen["parent"] = kwargs.get("parent_session_id")
|
|
seen["branched_from"] = (kwargs.get("model_config") or {}).get("_branched_from")
|
|
|
|
def append_messages_batch(self, session_id, messages, **kwargs):
|
|
seen["messages"] = list(messages)
|
|
|
|
def set_session_title(self, key, title):
|
|
seen["title"] = title
|
|
return True
|
|
|
|
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
|
|
monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_db=None, **_kw: _FakeAgent())
|
|
monkeypatch.setattr(server, "_SlashWorker", lambda *a, **k: 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, "load_permanent_allowlist", lambda: None)
|
|
|
|
seeded = [
|
|
{"role": "user", "content": "hello from parent"},
|
|
{"role": "assistant", "content": "parent reply"},
|
|
]
|
|
|
|
resp = server.handle_request(
|
|
{
|
|
"id": "1",
|
|
"method": "session.create",
|
|
"params": {
|
|
"cols": 96,
|
|
"source": "desktop",
|
|
"parent_session_id": "20260823_084113_6de211",
|
|
"messages": seeded,
|
|
},
|
|
}
|
|
)
|
|
|
|
assert "result" in resp, resp
|
|
key = resp["result"]["stored_session_id"]
|
|
|
|
# Row persisted up front with lineage linkage and a lineage title —
|
|
# not deferred to the first prompt.
|
|
assert seen.get("created") == key
|
|
assert seen.get("parent") == "20260823_084113_6de211"
|
|
assert seen.get("branched_from") == "20260823_084113_6de211"
|
|
assert seen.get("title") == "My Parent Session #2"
|
|
|
|
# Seeded transcript copied into the durable row so REST prefetch and
|
|
# defer_history hydration both find it immediately.
|
|
assert len(seen.get("messages") or []) == 2
|
|
assert seen["messages"][0]["content"] == "hello from parent"
|
|
|
|
# The live record no longer queues the title — the DB already holds it.
|
|
runtime_sid = resp["result"]["session_id"]
|
|
assert server._sessions[runtime_sid]["pending_title"] is None
|
|
|
|
server._sessions.pop(runtime_sid, None)
|
|
|
|
|
|
def test_session_create_branch_seed_failure_does_not_break_create(monkeypatch):
|
|
"""Best-effort persistence: a broken DB must not fail session.create."""
|
|
|
|
class _FakeAgent:
|
|
def __init__(self):
|
|
self.model = "test-model"
|
|
|
|
class _BrokenDB:
|
|
def get_session_title(self, key):
|
|
raise RuntimeError("db down")
|
|
|
|
monkeypatch.setattr(server, "_get_db", lambda: _BrokenDB())
|
|
monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_db=None, **_kw: _FakeAgent())
|
|
monkeypatch.setattr(server, "_SlashWorker", lambda *a, **k: 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, "load_permanent_allowlist", lambda: None)
|
|
|
|
resp = server.handle_request(
|
|
{
|
|
"id": "1",
|
|
"method": "session.create",
|
|
"params": {
|
|
"source": "desktop",
|
|
"parent_session_id": "parent-1",
|
|
"messages": [{"role": "user", "content": "seed"}],
|
|
},
|
|
}
|
|
)
|
|
|
|
# Create itself still succeeds — the lazy first-prompt path remains as
|
|
# the fallback for the seed.
|
|
assert "result" in resp
|
|
|
|
server._sessions.pop(resp["result"]["stored_session_id"], None)
|
|
|
|
|
|
def test_session_create_seed_failure_after_row_compensates(monkeypatch):
|
|
"""Partial-failure compensation (#93959 review): if the row commits but
|
|
the transcript copy fails, the just-created child is DELETED so the lazy
|
|
first-prompt fallback can retry cleanly. Without this, a durable empty
|
|
row defeats _ensure_session_db_row's INSERT OR IGNORE and the renderer
|
|
fail-latches on a transcript-less session again."""
|
|
|
|
class _FakeAgent:
|
|
def __init__(self):
|
|
self.model = "test-model"
|
|
|
|
seen: dict = {}
|
|
|
|
class _FakeDB:
|
|
def get_session_title(self, key):
|
|
return "Parent"
|
|
|
|
def get_next_title_in_lineage(self, current):
|
|
return f"{current} #2"
|
|
|
|
def create_session(self, key, **kwargs):
|
|
seen["created"] = key
|
|
|
|
def append_messages_batch(self, session_id, messages, **kwargs):
|
|
raise RuntimeError("transcript write failed")
|
|
|
|
def delete_session(self, session_id):
|
|
seen["deleted"] = session_id
|
|
return True
|
|
|
|
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
|
|
monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_db=None, **_kw: _FakeAgent())
|
|
monkeypatch.setattr(server, "_SlashWorker", lambda *a, **k: 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, "load_permanent_allowlist", lambda: None)
|
|
|
|
resp = server.handle_request(
|
|
{
|
|
"id": "1",
|
|
"method": "session.create",
|
|
"params": {
|
|
"source": "desktop",
|
|
"parent_session_id": "parent-1",
|
|
"title": "My Branch",
|
|
"messages": [{"role": "user", "content": "seed"}],
|
|
},
|
|
}
|
|
)
|
|
|
|
assert "result" in resp
|
|
key = resp["result"]["stored_session_id"]
|
|
# The half-written child was rolled back — no durable empty row left to
|
|
# shadow the lazy seed path.
|
|
assert seen.get("deleted") == key
|
|
# pending_title survived: it still lands via the lazy post-turn apply.
|
|
runtime_sid = resp["result"]["session_id"]
|
|
assert server._sessions[runtime_sid]["pending_title"] == "My Branch"
|
|
|
|
server._sessions.pop(runtime_sid, None)
|
|
|
|
|
|
def test_session_create_seed_disk_full_keeps_row_for_retry(monkeypatch):
|
|
"""Disk-full is NOT compensated: the row stays (deleting data on a full
|
|
disk can make things worse), create still succeeds, and the failure is
|
|
observable at warning level (#93959 review)."""
|
|
|
|
import logging as _logging
|
|
|
|
class _FakeAgent:
|
|
def __init__(self):
|
|
self.model = "test-model"
|
|
|
|
class _FakeDB:
|
|
def get_session_title(self, key):
|
|
return "Parent"
|
|
|
|
def get_next_title_in_lineage(self, current):
|
|
return f"{current} #2"
|
|
|
|
def create_session(self, key, **kwargs):
|
|
pass
|
|
|
|
def append_messages_batch(self, session_id, messages, **kwargs):
|
|
raise OSError(28, "No space left on device")
|
|
|
|
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
|
|
monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_db=None, **_kw: _FakeAgent())
|
|
monkeypatch.setattr(server, "_SlashWorker", lambda *a, **k: 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, "load_permanent_allowlist", lambda: None)
|
|
|
|
records: list = []
|
|
|
|
class _Capture(_logging.Handler):
|
|
def emit(self, record):
|
|
records.append(record)
|
|
|
|
handler = _Capture(level=_logging.WARNING)
|
|
root = _logging.getLogger()
|
|
root.addHandler(handler)
|
|
try:
|
|
resp = server.handle_request(
|
|
{
|
|
"id": "1",
|
|
"method": "session.create",
|
|
"params": {
|
|
"source": "desktop",
|
|
"parent_session_id": "parent-1",
|
|
"messages": [{"role": "user", "content": "seed"}],
|
|
},
|
|
}
|
|
)
|
|
finally:
|
|
root.removeHandler(handler)
|
|
|
|
assert "result" in resp
|
|
# The failure surfaced at WARNING (observable), not buried at debug.
|
|
warnings = [r for r in records if r.levelno >= _logging.WARNING]
|
|
assert any("seeded-branch persistence failed" in r.getMessage() for r in warnings)
|
|
|
|
server._sessions.pop(resp["result"]["stored_session_id"], None)
|
|
|
|
|
|
def test_session_create_without_parent_still_defers_row(monkeypatch):
|
|
"""Plain drafts keep the lazy-row contract: no parent + no explicit branch
|
|
intent means no eager persistence (the original draft-hygiene invariant)."""
|
|
|
|
class _FakeAgent:
|
|
def __init__(self):
|
|
self.model = "test-model"
|
|
|
|
calls: dict = {"create": 0}
|
|
|
|
class _FakeDB:
|
|
def create_session(self, *a, **k):
|
|
calls["create"] += 1
|
|
|
|
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
|
|
monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_db=None, **_kw: _FakeAgent())
|
|
monkeypatch.setattr(server, "_SlashWorker", lambda *a, **k: 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, "load_permanent_allowlist", lambda: None)
|
|
|
|
resp = server.handle_request(
|
|
{"id": "1", "method": "session.create", "params": {"cols": 80}}
|
|
)
|
|
sid = resp["result"]["session_id"]
|
|
server._sessions[sid]["agent_ready"].wait(timeout=2.0)
|
|
|
|
assert calls["create"] == 0, "plain drafts must not persist eagerly"
|
|
|
|
server._sessions.pop(sid, 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.get_shared_session_db", 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_session_branch_uses_persisted_display_history_after_compaction(monkeypatch, tmp_path):
|
|
"""A live branch must copy the complete visible transcript, not the compacted model tail."""
|
|
profile_home = tmp_path / "profiles" / "mlperf"
|
|
profile_home.mkdir(parents=True)
|
|
seen: dict = {"msgs": []}
|
|
|
|
display_history = [
|
|
{"role": "user", "content": "first question", "timestamp": 1.0},
|
|
{"role": "assistant", "content": "first answer", "timestamp": 2.0},
|
|
{"role": "assistant", "content": "", "tool_calls": [{"id": "call-1"}]},
|
|
{"role": "tool", "content": "tool output", "tool_call_id": "call-1"},
|
|
{"role": "user", "content": "second question", "timestamp": 3.0},
|
|
{"role": "assistant", "content": "second answer", "timestamp": 4.0},
|
|
]
|
|
|
|
class LaunchDB:
|
|
def get_session_title(self, _key):
|
|
return "launch"
|
|
|
|
class ProfileDB:
|
|
def __init__(self, db_path=None):
|
|
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 get_resume_conversations(self, key):
|
|
assert key == "parent-key"
|
|
# The model projection has already been compacted to a summary + tail;
|
|
# the display projection still contains every visible turn.
|
|
return (
|
|
[{"role": "assistant", "content": "compact summary"}],
|
|
display_history,
|
|
)
|
|
|
|
def create_session(self, _new_key, **_kwargs):
|
|
return None
|
|
|
|
def append_message(self, **kwargs):
|
|
seen["msgs"].append(kwargs)
|
|
|
|
def append_messages_batch(self, session_id, messages, **kwargs):
|
|
for message in messages:
|
|
seen["msgs"].append(dict(message, 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, *args, **kwargs):
|
|
return None
|
|
|
|
def close(self):
|
|
return None
|
|
|
|
class FakeAgent:
|
|
model = "test-model"
|
|
session_id = None
|
|
|
|
parent = {
|
|
"session_key": "parent-key",
|
|
# This is the model-fed projection after compaction: the old turns are
|
|
# absent here even though the display projection above retains them.
|
|
"history": [
|
|
{"role": "assistant", "content": "compact summary"},
|
|
{"role": "user", "content": "second question"},
|
|
{"role": "assistant", "content": "second answer"},
|
|
],
|
|
"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: LaunchDB())
|
|
monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB)
|
|
monkeypatch.setattr(server, "_claim_active_session_slot", lambda *args, **kwargs: (None, None))
|
|
monkeypatch.setattr(server, "_make_agent", lambda *args, **kwargs: FakeAgent())
|
|
monkeypatch.setattr(server, "_set_session_context", lambda *args, **kwargs: {})
|
|
monkeypatch.setattr(server, "_clear_session_context", lambda *args, **kwargs: None)
|
|
monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")
|
|
monkeypatch.setattr(server, "_session_cwd", lambda _session: str(tmp_path))
|
|
monkeypatch.setattr(server, "_register_session_cwd", lambda *args, **kwargs: None)
|
|
monkeypatch.setattr(server, "_attach_worker", lambda *args, **kwargs: None)
|
|
|
|
try:
|
|
response = server.handle_request(
|
|
{
|
|
"id": "1",
|
|
"method": "session.branch",
|
|
"params": {"session_id": "parent", "count": 4},
|
|
}
|
|
)
|
|
|
|
assert "result" in response, response
|
|
assert [message["content"] for message in seen["msgs"]] == [
|
|
"first question",
|
|
"first answer",
|
|
"second question",
|
|
"second answer",
|
|
]
|
|
assert [message["text"] for message in response["result"]["messages"]] == [
|
|
"first question",
|
|
"first answer",
|
|
"second question",
|
|
"second answer",
|
|
]
|
|
finally:
|
|
for key in list(server._sessions):
|
|
server._sessions.pop(key, 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.get_shared_session_db", 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_wires_live_title_rename_callback(monkeypatch):
|
|
"""The gateway hands the agent a hook so a new title repaints the sidebar.
|
|
|
|
Titling itself moved into the shared turn prologue (agent/turn_context.py),
|
|
so the gateway's only remaining job is delivering the rename event. Asserted
|
|
by calling the hook the gateway installed and checking what it emits.
|
|
"""
|
|
|
|
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."},
|
|
],
|
|
}
|
|
|
|
agent = _Agent()
|
|
server._sessions["sid"] = _session(agent=agent)
|
|
emitted = []
|
|
monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
|
|
monkeypatch.setattr(
|
|
server, "_emit", lambda kind, sid, payload=None, **kw: emitted.append((kind, payload))
|
|
)
|
|
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": "Tell me about Rome"},
|
|
}
|
|
)
|
|
|
|
hook = getattr(agent, "_on_session_title", None)
|
|
assert callable(hook), "gateway did not install a live title-rename hook"
|
|
# Titling is two-stage, and a local surface wants both: the sidebar renames
|
|
# off the derived slice instantly and sharpens when the model's lands. Only
|
|
# the lanes that spend a rate-limited remote rename filter by stage.
|
|
hook("tell me about rome", "derived")
|
|
hook("Founding of Rome", "llm")
|
|
assert [payload["title"] for kind, payload in emitted if kind == "session.title"] == [
|
|
"tell me about rome",
|
|
"Founding of Rome",
|
|
]
|
|
assert (
|
|
"session.title",
|
|
{"session_id": "session-key", "title": "Founding of Rome"},
|
|
) in emitted
|
|
|
|
|
|
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",
|
|
}
|
|
turn_started_at = resp["result"]["turn_started_at"]
|
|
assert turn_started_at == server._sessions["sid-live"]["inflight_turn"]["started_at"]
|
|
assert turn_started_at > 0
|
|
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"]["turn_started_at"] 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)
|
|
# No ``platform.system`` fake: the resolved system string only flows into
|
|
# ``launch_chrome_debug`` / ``manual_chrome_debug_command`` /
|
|
# ``get_chrome_debug_candidates``, all of which are mocked below — the
|
|
# host's real value never reaches unmocked code.
|
|
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,
|
|
"_teardown_popped_session",
|
|
lambda session, *, end_reason: seen.append((session["_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()
|
|
|
|
|
|
@pytest.mark.parametrize("close_on_disconnect", [True, False])
|
|
def test_close_sessions_for_transport_skips_session_rebound_before_claim(
|
|
monkeypatch, close_on_disconnect
|
|
):
|
|
"""A resume between snapshot and claim keeps either session type alive."""
|
|
reaps = []
|
|
teardowns = []
|
|
monkeypatch.setattr(
|
|
server, "_schedule_ws_orphan_reap", lambda sid: reaps.append(sid)
|
|
)
|
|
monkeypatch.setattr(
|
|
server,
|
|
"_teardown_popped_session",
|
|
lambda session, *, end_reason: teardowns.append((session, end_reason)) or True,
|
|
)
|
|
old_transport = object() # the disconnecting transport
|
|
new_transport = object() # live rebind target (no _closed attr → alive)
|
|
session = {"transport": old_transport, "close_on_disconnect": close_on_disconnect}
|
|
original_sessions_lock = server._sessions_lock
|
|
rebound = threading.Event()
|
|
|
|
class _SnapshotInterlock:
|
|
"""Rebind in a second thread immediately after the ownership snapshot."""
|
|
|
|
def __init__(self):
|
|
self._snapshot_released = False
|
|
|
|
def __enter__(self):
|
|
original_sessions_lock.acquire()
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, traceback):
|
|
original_sessions_lock.release()
|
|
if not self._snapshot_released:
|
|
self._snapshot_released = True
|
|
|
|
def _resume_rebind():
|
|
with server._session_resume_lock:
|
|
session["transport"] = new_transport
|
|
rebound.set()
|
|
|
|
thread = threading.Thread(target=_resume_rebind)
|
|
thread.start()
|
|
assert rebound.wait(timeout=1)
|
|
thread.join(timeout=1)
|
|
return False
|
|
|
|
monkeypatch.setattr(server, "_sessions_lock", _SnapshotInterlock())
|
|
server._sessions.clear()
|
|
server._sessions["rebound"] = session
|
|
try:
|
|
reaped, detached = server._close_sessions_for_transport(old_transport)
|
|
assert reaped == 0
|
|
assert detached == 0
|
|
assert server._sessions["rebound"] is session
|
|
assert session["transport"] is new_transport
|
|
assert teardowns == []
|
|
assert reaps == []
|
|
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_perf_readouts_present():
|
|
"""cache_hit_pct / avg_latency_s / avg_tps mirror the classic CLI bar."""
|
|
from collections import deque
|
|
|
|
class _PerfAgent:
|
|
model = "x"
|
|
session_prompt_tokens = 27_873
|
|
session_cache_read_tokens = 24_369
|
|
_api_latency_history = deque([2.1, 4.3], maxlen=10)
|
|
_api_output_history = deque([130, 190], maxlen=10)
|
|
|
|
usage = server._get_usage(_PerfAgent())
|
|
assert usage["cache_hit_pct"] == 87
|
|
assert usage["avg_latency_s"] == 3.2
|
|
assert usage["avg_tps"] == 50.0 # true throughput sum(out)/sum(lat), not mean of ratios
|
|
|
|
|
|
def test_get_usage_perf_readouts_omitted_without_data():
|
|
"""Zero cache reads / empty history omit the keys — never fabricate 0s."""
|
|
|
|
class _ColdAgent:
|
|
model = "x"
|
|
session_prompt_tokens = 100
|
|
session_cache_read_tokens = 0
|
|
|
|
usage = server._get_usage(_ColdAgent())
|
|
assert "cache_hit_pct" not in usage
|
|
assert "avg_latency_s" not in usage
|
|
assert "avg_tps" not in usage
|
|
|
|
|
|
def test_get_usage_perf_readouts_guard_negative_latency():
|
|
"""Odd provider timings (negative durations seen in logs) are dropped."""
|
|
from collections import deque
|
|
|
|
class _WeirdAgent:
|
|
model = "x"
|
|
_api_latency_history = deque([-0.8], maxlen=10)
|
|
_api_output_history = deque([100], maxlen=10)
|
|
|
|
usage = server._get_usage(_WeirdAgent())
|
|
assert "avg_latency_s" not in usage
|
|
assert "avg_tps" not in usage
|
|
|
|
|
|
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"] == ""
|
|
|
|
|
|
BRANCH_REASONING = "the parent's chain of thought"
|
|
BRANCH_REASONING_CONTENT = "the parent's reasoning content"
|
|
BRANCH_REASONING_DETAILS = [
|
|
{"type": "reasoning.text", "text": "keep the parent's plan", "format": "unknown"}
|
|
]
|
|
BRANCH_CODEX_REASONING_ITEMS = [
|
|
{"id": "rs_1", "type": "reasoning", "encrypted_content": "opaque-blob"}
|
|
]
|
|
BRANCH_CODEX_MESSAGE_ITEMS = [
|
|
{
|
|
"id": "msg_1",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "done"}],
|
|
}
|
|
]
|
|
|
|
|
|
def _branch_history():
|
|
return [
|
|
{"role": "user", "content": "hello"},
|
|
{
|
|
"role": "assistant",
|
|
"content": "done",
|
|
"reasoning": BRANCH_REASONING,
|
|
"reasoning_content": BRANCH_REASONING_CONTENT,
|
|
"reasoning_details": BRANCH_REASONING_DETAILS,
|
|
"codex_reasoning_items": BRANCH_CODEX_REASONING_ITEMS,
|
|
"codex_message_items": BRANCH_CODEX_MESSAGE_ITEMS,
|
|
},
|
|
# Timeline marker: rides as role=user but must keep its tag through
|
|
# the branch copy, or it re-enters the truncate ordinal address space
|
|
# as a phantom user turn after a restart (#82756).
|
|
{
|
|
"role": "user",
|
|
"content": "[System: personality changed]",
|
|
"display_kind": "personality_switch",
|
|
},
|
|
]
|
|
|
|
|
|
def _branched_marker(db, session_key):
|
|
return next(
|
|
(
|
|
m
|
|
for m in db.get_messages_as_conversation(session_key)
|
|
if m.get("display_kind") == "personality_switch"
|
|
),
|
|
None,
|
|
)
|
|
|
|
|
|
def _branched_assistant(db, session_key):
|
|
return next(
|
|
m
|
|
for m in db.get_messages_as_conversation(session_key)
|
|
if m["role"] == "assistant"
|
|
)
|
|
|
|
|
|
def test_persist_branch_seed_keeps_reasoning_fields(monkeypatch, tmp_path):
|
|
"""The seed write must carry the parent's reasoning fields.
|
|
|
|
A branch is a draft until its first submit, so this is the only write that
|
|
ever persists the copied transcript. Persisting role/content alone left the
|
|
branch resuming without the parent's reasoning, preserved thinking blocks or
|
|
Codex encrypted-reasoning/message-item continuation state.
|
|
"""
|
|
from hermes_state import SessionDB
|
|
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
session = _session(
|
|
session_key="branch-key",
|
|
parent_session_id="parent-key",
|
|
history=_branch_history(),
|
|
)
|
|
try:
|
|
db.create_session("branch-key", source="tui")
|
|
monkeypatch.setattr(server, "_get_db", lambda: db)
|
|
|
|
server._persist_branch_seed(session)
|
|
|
|
assistant = _branched_assistant(db, "branch-key")
|
|
assert assistant["reasoning"] == BRANCH_REASONING
|
|
assert assistant["reasoning_content"] == BRANCH_REASONING_CONTENT
|
|
assert assistant["reasoning_details"] == BRANCH_REASONING_DETAILS
|
|
assert assistant["codex_reasoning_items"] == BRANCH_CODEX_REASONING_ITEMS
|
|
assert assistant["codex_message_items"] == BRANCH_CODEX_MESSAGE_ITEMS
|
|
marker = _branched_marker(db, "branch-key")
|
|
assert marker is not None, (
|
|
"the branch seed dropped display_kind: the marker re-entered the "
|
|
"truncate ordinal address space as a phantom user turn (#82756)"
|
|
)
|
|
assert session["_branch_seed_persisted"] is True
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_session_branch_keeps_reasoning_fields(monkeypatch, tmp_path):
|
|
"""session.branch copies the live transcript with its reasoning fields.
|
|
|
|
Same drop as the seed path: the copy loop wrote only role/content, so the
|
|
new session row replayed without the reasoning context the parent had.
|
|
"""
|
|
from hermes_state import SessionDB
|
|
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
server._sessions["sid"] = _session(history=_branch_history())
|
|
try:
|
|
db.create_session("session-key", source="tui")
|
|
monkeypatch.setattr(server, "_get_db", lambda: db)
|
|
monkeypatch.setattr(server, "_new_session_key", lambda: "branch-key")
|
|
monkeypatch.setattr(server, "_start_agent_build", lambda sid, session: None)
|
|
monkeypatch.setattr(server, "_wait_agent", lambda session, rid: None)
|
|
monkeypatch.setattr(
|
|
server, "_claim_active_session_slot", lambda *args, **kwargs: (None, None)
|
|
)
|
|
monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")
|
|
monkeypatch.setattr(server, "_session_cwd", lambda session: str(tmp_path))
|
|
monkeypatch.setattr(
|
|
server, "_make_agent", lambda *args, **kwargs: types.SimpleNamespace()
|
|
)
|
|
monkeypatch.setattr(server, "_init_session", lambda *args, **kwargs: None)
|
|
|
|
resp = server.handle_request(
|
|
{"id": "1", "method": "session.branch", "params": {"session_id": "sid"}}
|
|
)
|
|
|
|
assert resp.get("result"), f"got error: {resp.get('error')}"
|
|
assistant = _branched_assistant(db, "branch-key")
|
|
assert assistant["reasoning"] == BRANCH_REASONING
|
|
assert assistant["reasoning_content"] == BRANCH_REASONING_CONTENT
|
|
assert assistant["reasoning_details"] == BRANCH_REASONING_DETAILS
|
|
assert assistant["codex_reasoning_items"] == BRANCH_CODEX_REASONING_ITEMS
|
|
assert assistant["codex_message_items"] == BRANCH_CODEX_MESSAGE_ITEMS
|
|
marker = _branched_marker(db, "branch-key")
|
|
assert marker is not None, (
|
|
"session.branch dropped display_kind: the marker re-entered the "
|
|
"truncate ordinal address space as a phantom user turn (#82756)"
|
|
)
|
|
finally:
|
|
server._sessions.pop("sid", None)
|
|
db.close()
|
|
|
|
|
|
# ── _save_cfg comment-preservation regression tests ────────────────────────
|
|
#
|
|
# Until ~mid-2026 _save_cfg used yaml.safe_dump on a deep-loaded config dict.
|
|
# Every /personality (or /reasoning, /details_mode, /prompt, ...) write
|
|
# silently rewrote ~/.hermes/config.yaml top-to-bottom — top-level keys
|
|
# reordered alphabetically, comments stripped, kaomoji/Chinese in stored
|
|
# personality prompts mangled to \uXXXX escapes. These tests pin the
|
|
# user-visible behavior of the comment-preserving replacement so we don't
|
|
# regress.
|
|
|
|
|
|
def test_save_cfg_preserves_user_comments(tmp_path, monkeypatch):
|
|
"""The TUI gateway must not strip user-edited comments on setting writes."""
|
|
cfg_path = tmp_path / "config.yaml"
|
|
cfg_path.write_text(
|
|
"# top of file note\n"
|
|
"model:\n"
|
|
" # provider rationale\n"
|
|
" default: claude-opus-4-7\n"
|
|
"display:\n"
|
|
" skin: default # trailing skin note\n",
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(server, "_hermes_home", tmp_path)
|
|
|
|
server._save_cfg(
|
|
{
|
|
"model": {"default": "claude-opus-4-7"},
|
|
"display": {"skin": "mono"},
|
|
}
|
|
)
|
|
|
|
text = cfg_path.read_text(encoding="utf-8")
|
|
assert "# top of file note" in text
|
|
assert "# provider rationale" in text
|
|
assert "# trailing skin note" in text
|
|
|
|
import yaml as _yaml
|
|
|
|
parsed = _yaml.safe_load(text)
|
|
assert parsed["display"]["skin"] == "mono"
|
|
|
|
|
|
def test_save_cfg_preserves_top_level_key_order(tmp_path, monkeypatch):
|
|
"""Top-level keys must keep the file's hand-edited ordering instead of
|
|
being rewritten alphabetically by the underlying YAML dumper."""
|
|
cfg_path = tmp_path / "config.yaml"
|
|
cfg_path.write_text(
|
|
"model:\n"
|
|
" default: claude-opus-4-7\n"
|
|
"toolsets:\n"
|
|
" - hermes-cli\n"
|
|
"agent:\n"
|
|
" max_turns: 90\n"
|
|
"display:\n"
|
|
" skin: default\n",
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(server, "_hermes_home", tmp_path)
|
|
|
|
# Caller's dict iteration order is intentionally alphabetical to confirm
|
|
# the helper consults disk order, not caller order.
|
|
server._save_cfg(
|
|
{
|
|
"agent": {"max_turns": 90},
|
|
"display": {"skin": "mono"},
|
|
"model": {"default": "claude-opus-4-7"},
|
|
"toolsets": ["hermes-cli"],
|
|
}
|
|
)
|
|
|
|
text = cfg_path.read_text(encoding="utf-8")
|
|
top_keys = [
|
|
line.split(":", 1)[0]
|
|
for line in text.splitlines()
|
|
if line and not line.startswith(" ") and not line.startswith("-")
|
|
and not line.startswith("#")
|
|
]
|
|
assert top_keys == ["model", "toolsets", "agent", "display"]
|
|
|
|
|
|
def test_save_cfg_keeps_unicode_personalities_readable(tmp_path, monkeypatch):
|
|
"""The catgirl/kawaii personality prompts must stay readable on disk
|
|
instead of being \\uXXXX-escaped on every unrelated setting write."""
|
|
cfg_path = tmp_path / "config.yaml"
|
|
cfg_path.write_text(
|
|
"agent:\n"
|
|
" personalities:\n"
|
|
" catgirl: \"nya (=^・ω・^=) 你好\"\n"
|
|
"display:\n"
|
|
" skin: default\n",
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(server, "_hermes_home", tmp_path)
|
|
|
|
# Simulate an unrelated /skin write — must not corrupt the catgirl
|
|
# personality string sitting in agent.personalities.
|
|
server._save_cfg(
|
|
{
|
|
"agent": {"personalities": {"catgirl": "nya (=^・ω・^=) 你好"}},
|
|
"display": {"skin": "mono"},
|
|
}
|
|
)
|
|
|
|
text = cfg_path.read_text(encoding="utf-8")
|
|
assert "你好" in text
|
|
assert "(=^・ω・^=)" in text
|
|
assert "\\u4f60" not in text
|
|
|
|
|
|
def test_personality_marker_does_not_shift_truncate_ordinal(monkeypatch):
|
|
"""A personality pivot must not occupy a slot in the ordinal address space.
|
|
|
|
``_apply_personality_to_session`` injects its pivot as ``role=user`` so
|
|
strict OpenAI-compatible providers accept it mid-conversation. Untagged, the
|
|
ordinal filter (``role == "user" and not display_kind``) counted it as a
|
|
real user turn while no client renders it as one, so every rewind issued
|
|
after a personality change resolved one turn too early and
|
|
``replace_messages()`` hard-deleted the extra span (#82756, third occurrence
|
|
after #70516 / #80763).
|
|
|
|
The sibling ``test_prompt_submit_truncate_ordinal_skips_display_kind_rows``
|
|
pins the filter itself; this one pins the producer, which is where the
|
|
invariant was actually broken.
|
|
"""
|
|
|
|
class _Agent:
|
|
def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
|
|
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()
|
|
|
|
class _StubDb:
|
|
def __init__(self):
|
|
self.replaced = []
|
|
|
|
def get_messages_as_conversation(self, *_args, **_kwargs):
|
|
return []
|
|
|
|
def replace_messages(
|
|
self,
|
|
session_id,
|
|
messages,
|
|
active_only=False,
|
|
archive_dropped=False,
|
|
reject_active_turn_lease=False,
|
|
):
|
|
self.replaced.append((session_id, list(messages)))
|
|
|
|
session = _session(
|
|
agent=_Agent(),
|
|
history=[
|
|
{"role": "user", "content": "first"},
|
|
{"role": "assistant", "content": "first reply"},
|
|
],
|
|
)
|
|
server._sessions["personality-ordinal-sid"] = session
|
|
stub_db = _StubDb()
|
|
|
|
try:
|
|
monkeypatch.setattr(server, "_session_info", lambda *a, **k: {})
|
|
monkeypatch.setattr(server, "_emit", lambda *a: None)
|
|
|
|
# Real production injection point — not a hand-written marker dict.
|
|
server._apply_personality_to_session(
|
|
"personality-ordinal-sid", session, "talk like a pirate", "pirate"
|
|
)
|
|
|
|
marker = session["history"][-1]
|
|
assert marker["role"] == "user", "provider compatibility: pivot rides as a user turn"
|
|
|
|
# Two more real turns land after the personality change.
|
|
session["history"].extend(
|
|
[
|
|
{"role": "user", "content": "second"},
|
|
{"role": "assistant", "content": "second reply"},
|
|
{"role": "user", "content": "third"},
|
|
{"role": "assistant", "content": "third reply"},
|
|
]
|
|
)
|
|
history_before = list(session["history"])
|
|
third_index = history_before.index({"role": "user", "content": "third"})
|
|
|
|
monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
|
|
monkeypatch.setattr(server, "_get_usage", lambda _a: {})
|
|
monkeypatch.setattr(server, "render_message", lambda _t, _c: "")
|
|
monkeypatch.setattr(server, "_get_db", lambda: stub_db)
|
|
|
|
# The client counts three user bubbles (first=0, second=1, third=2) —
|
|
# it never sees the pivot. Rewinding to "third" must cut exactly there.
|
|
resp = server.handle_request(
|
|
{
|
|
"id": "1",
|
|
"method": "prompt.submit",
|
|
"params": {
|
|
"session_id": "personality-ordinal-sid",
|
|
"text": "third, reworded",
|
|
"truncate_before_user_ordinal": 2,
|
|
"confirm_truncate": True,
|
|
},
|
|
}
|
|
)
|
|
assert resp.get("result"), f"got error: {resp.get('error')}"
|
|
|
|
expected = history_before[:third_index]
|
|
assert stub_db.replaced == [("session-key", expected)], (
|
|
"the pivot shifted the ordinal: the cut landed at "
|
|
f"{len(stub_db.replaced[0][1]) if stub_db.replaced else None} instead of {third_index}"
|
|
)
|
|
# The turn before the target must survive — that is the span the three
|
|
# reported incidents lost.
|
|
assert {"role": "user", "content": "second"} in expected
|
|
# And the mechanism that keeps it out of the address space, so a future
|
|
# producer cannot regress this by dropping the tag.
|
|
assert marker.get("display_kind"), (
|
|
"an untagged role=user pivot silently consumes a truncate ordinal slot"
|
|
)
|
|
finally:
|
|
server._sessions.pop("personality-ordinal-sid", None)
|
|
|
|
|
|
def test_prompt_submit_truncation_archives_instead_of_deleting(monkeypatch):
|
|
"""The rewind write must be recoverable, not a hard DELETE.
|
|
|
|
#70516 / #80763 / #82756 all ended at this one call, and all three were
|
|
unrecoverable for the same reason: `replace_messages` DELETEs the rows and
|
|
the FTS entry goes with them. Guarding the *aim* of a rewind still leaves
|
|
every other way of aiming it wrong terminal, so the write itself has to
|
|
stop destroying. The storage-layer contract this relies on is pinned in
|
|
tests/hermes_state/test_replace_messages_archive_siblings.py.
|
|
"""
|
|
|
|
captured = {}
|
|
|
|
class _Agent:
|
|
def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
|
|
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()
|
|
|
|
class _StubDb:
|
|
def get_messages_as_conversation(self, *_args, **_kwargs):
|
|
return []
|
|
|
|
def replace_messages(
|
|
self,
|
|
session_id,
|
|
messages,
|
|
active_only=False,
|
|
archive_dropped=False,
|
|
reject_active_turn_lease=False,
|
|
):
|
|
captured["active_only"] = active_only
|
|
captured["archive_dropped"] = archive_dropped
|
|
captured["reject_active_turn_lease"] = reject_active_turn_lease
|
|
|
|
server._sessions["archive-trunc-sid"] = _session(
|
|
agent=_Agent(),
|
|
history=[
|
|
{"role": "user", "content": "first"},
|
|
{"role": "assistant", "content": "first reply"},
|
|
{"role": "user", "content": "second"},
|
|
{"role": "assistant", "content": "second reply"},
|
|
],
|
|
)
|
|
|
|
try:
|
|
monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
|
|
monkeypatch.setattr(server, "_session_info", lambda *a, **k: {})
|
|
monkeypatch.setattr(server, "_emit", lambda *a: None)
|
|
monkeypatch.setattr(server, "_get_usage", lambda _a: {})
|
|
monkeypatch.setattr(server, "render_message", lambda _t, _c: "")
|
|
monkeypatch.setattr(server, "_get_db", lambda: _StubDb())
|
|
|
|
resp = server.handle_request(
|
|
{
|
|
"id": "1",
|
|
"method": "prompt.submit",
|
|
"params": {
|
|
"session_id": "archive-trunc-sid",
|
|
"text": "second, reworded",
|
|
"truncate_before_user_ordinal": 1,
|
|
"confirm_truncate": True,
|
|
},
|
|
}
|
|
)
|
|
|
|
assert resp.get("result"), f"got error: {resp.get('error')}"
|
|
assert captured.get("archive_dropped") is True, (
|
|
"a rewind must soft-archive the turns it drops, not DELETE them"
|
|
)
|
|
# #80216: still must not touch rows archived by an earlier compaction.
|
|
assert captured.get("active_only") is True
|
|
assert captured.get("reject_active_turn_lease") is True
|
|
finally:
|
|
server._sessions.pop("archive-trunc-sid", None)
|
|
|
|
|
|
def test_insert_message_rows_sets_row_id_on_fresh_dicts(tmp_path):
|
|
"""#82959: _insert_message_rows must assign _row_id on freshly inserted message dicts."""
|
|
from hermes_state import SessionDB
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
db.create_session("fresh-msg-row-id-sid", "cli")
|
|
msg = {"role": "user", "content": "fresh turn without pre-existing _row_id"}
|
|
with db._lock:
|
|
db._insert_message_rows(db._conn, "fresh-msg-row-id-sid", [msg])
|
|
assert "_row_id" in msg, "New message dict did not receive _row_id"
|
|
assert isinstance(msg["_row_id"], int) and msg["_row_id"] > 0
|
|
|
|
|
|
def test_prompt_submit_unmatched_row_id_refuses_even_with_ordinal(monkeypatch):
|
|
"""#82959: Unknown row_id must refuse (4018), not fall back to a client ordinal."""
|
|
replaced = []
|
|
|
|
class _FakeDB:
|
|
def replace_messages(
|
|
self,
|
|
key,
|
|
messages,
|
|
active_only=False,
|
|
archive_dropped=False,
|
|
reject_active_turn_lease=False,
|
|
):
|
|
replaced.append((key, list(messages)))
|
|
|
|
history = [
|
|
{"_row_id": 101, "role": "user", "content": "first"},
|
|
{"_row_id": 102, "role": "assistant", "content": "reply 1"},
|
|
{"_row_id": 103, "role": "user", "content": "second"},
|
|
{"_row_id": 104, "role": "assistant", "content": "reply 2"},
|
|
]
|
|
sess = _session(history=list(history))
|
|
server._sessions["fallback-row-id-sid"] = sess
|
|
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
|
|
monkeypatch.setattr(
|
|
server, "_start_agent_build", lambda *a, **k: pytest.fail("must not start a turn")
|
|
)
|
|
|
|
try:
|
|
# Stale row_id 999 not in history — even with a valid ordinal, refuse.
|
|
resp = server.handle_request(
|
|
{
|
|
"id": "1",
|
|
"method": "prompt.submit",
|
|
"params": {
|
|
"session_id": "fallback-row-id-sid",
|
|
"text": "new turn",
|
|
"truncate_before_row_id": 999,
|
|
"truncate_before_user_ordinal": 1,
|
|
"confirm_truncate": True,
|
|
},
|
|
}
|
|
)
|
|
assert resp.get("error") is not None
|
|
assert resp["error"]["code"] == 4018
|
|
assert replaced == []
|
|
assert len(sess["history"]) == 4
|
|
finally:
|
|
server._sessions.pop("fallback-row-id-sid", None)
|
|
|
|
|
|
def test_prompt_submit_unmatched_message_id_refuses_even_with_ordinal(monkeypatch):
|
|
"""#82959: Unknown message_id must refuse; no silent ordinal degradation."""
|
|
replaced = []
|
|
|
|
class _FakeDB:
|
|
def replace_messages(
|
|
self,
|
|
key,
|
|
messages,
|
|
active_only=False,
|
|
archive_dropped=False,
|
|
reject_active_turn_lease=False,
|
|
):
|
|
replaced.append((key, list(messages)))
|
|
|
|
# Production-shaped history: no renderer "id" keys on user dicts.
|
|
history = [
|
|
{"_row_id": 201, "role": "user", "content": "first"},
|
|
{"_row_id": 202, "role": "assistant", "content": "reply 1"},
|
|
{"_row_id": 203, "role": "user", "content": "second"},
|
|
{"_row_id": 204, "role": "assistant", "content": "reply 2"},
|
|
]
|
|
sess = _session(history=list(history))
|
|
server._sessions["synthetic-msg-id-sid"] = sess
|
|
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
|
|
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": "synthetic-msg-id-sid",
|
|
"text": "fresh start",
|
|
"truncate_before_message_id": "user-1723456789-0",
|
|
"truncate_before_user_ordinal": 0,
|
|
"confirm_truncate": True,
|
|
"confirm_empty_truncate": True,
|
|
},
|
|
}
|
|
)
|
|
assert resp.get("error") is not None
|
|
assert resp["error"]["code"] == 4018
|
|
assert replaced == []
|
|
assert len(sess["history"]) == 4
|
|
finally:
|
|
server._sessions.pop("synthetic-msg-id-sid", None)
|
|
|
|
|
|
def test_prompt_submit_row_id_resolves_via_db_when_memory_lacks_stamps(monkeypatch):
|
|
"""#82959: After turn rewrite strips _row_id, resolve against durable DB history."""
|
|
replaced = []
|
|
# Live memory after turn completion: provider-format, no _row_id stamps.
|
|
live_history = [
|
|
{"role": "user", "content": "first"},
|
|
{"role": "assistant", "content": "reply 1"},
|
|
{"role": "user", "content": "second"},
|
|
{"role": "assistant", "content": "reply 2"},
|
|
]
|
|
durable_history = [
|
|
{"_row_id": 501, "role": "user", "content": "first"},
|
|
{"_row_id": 502, "role": "assistant", "content": "reply 1"},
|
|
{"_row_id": 503, "role": "user", "content": "second"},
|
|
{"_row_id": 504, "role": "assistant", "content": "reply 2"},
|
|
]
|
|
|
|
class _FakeDB:
|
|
def replace_messages(
|
|
self,
|
|
key,
|
|
messages,
|
|
active_only=False,
|
|
archive_dropped=False,
|
|
reject_active_turn_lease=False,
|
|
):
|
|
replaced.append((key, list(messages)))
|
|
|
|
def get_messages_as_conversation(self, key, repair_alternation=False, include_row_ids=False):
|
|
assert include_row_ids is True
|
|
return list(durable_history)
|
|
|
|
sess = _session(history=list(live_history), session_key="db-row-key")
|
|
server._sessions["db-row-resolve-sid"] = sess
|
|
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
|
|
monkeypatch.setattr(server, "_start_agent_build", lambda *a, **k: None)
|
|
|
|
try:
|
|
resp = server.handle_request(
|
|
{
|
|
"id": "1",
|
|
"method": "prompt.submit",
|
|
"params": {
|
|
"session_id": "db-row-resolve-sid",
|
|
"text": "new turn",
|
|
"truncate_before_row_id": 503,
|
|
"confirm_truncate": True,
|
|
},
|
|
}
|
|
)
|
|
assert resp.get("error") is None, resp
|
|
assert len(sess["history"]) == 2
|
|
assert sess["history"][-1]["content"] == "reply 1"
|
|
assert len(replaced) == 1
|
|
# Healing: live list should now carry stamps for subsequent rewinds.
|
|
assert sess["history"][0].get("_row_id") == 501
|
|
finally:
|
|
server._sessions.pop("db-row-resolve-sid", None)
|
|
|
|
|
|
def test_prompt_submit_row_id_real_sessiondb_resolve_without_memory_stamps(
|
|
monkeypatch, tmp_path
|
|
):
|
|
"""#82959 production path: real SessionDB insert → live history without
|
|
_row_id (turn rewrite) → truncate_before_row_id cuts durable + memory.
|
|
|
|
No hand-seeded ids and no MagicMock state manager — the contract that
|
|
#82766 review said unit fixtures must exercise.
|
|
"""
|
|
from hermes_state import SessionDB
|
|
|
|
db = SessionDB(db_path=tmp_path / "rowid-trunc.db")
|
|
session_key = "real-db-row-trunc"
|
|
db.create_session(session_key, "cli")
|
|
msgs = [
|
|
{"role": "user", "content": "first"},
|
|
{"role": "assistant", "content": "reply 1"},
|
|
{"role": "user", "content": "second"},
|
|
{"role": "assistant", "content": "reply 2"},
|
|
{"role": "user", "content": "third"},
|
|
{"role": "assistant", "content": "reply 3"},
|
|
]
|
|
with db._lock:
|
|
db._insert_message_rows(db._conn, session_key, msgs)
|
|
db._conn.commit()
|
|
row_ids = [m["_row_id"] for m in msgs]
|
|
assert all(isinstance(r, int) and r > 0 for r in row_ids)
|
|
|
|
# After turn completion gateway rewrites history as provider-format dicts
|
|
# without _row_id — production-shaped live memory.
|
|
live_history = [{"role": m["role"], "content": m["content"]} for m in msgs]
|
|
assert all("_row_id" not in m for m in live_history)
|
|
|
|
sess = _session(history=list(live_history), session_key=session_key)
|
|
sid = "real-db-row-trunc-sid"
|
|
server._sessions[sid] = sess
|
|
monkeypatch.setattr(server, "_get_db", lambda: db)
|
|
monkeypatch.setattr(server, "_start_agent_build", lambda *a, **k: None)
|
|
monkeypatch.setattr(server, "_start_inflight_turn", lambda *a, **k: None)
|
|
|
|
try:
|
|
# Cut before second user turn (row_ids[2]) — leave first exchange only.
|
|
resp = server.handle_request(
|
|
{
|
|
"id": "1",
|
|
"method": "prompt.submit",
|
|
"params": {
|
|
"session_id": sid,
|
|
"text": "rewound second",
|
|
"truncate_before_row_id": row_ids[2],
|
|
"truncate_before_user_ordinal": 1,
|
|
"confirm_truncate": True,
|
|
},
|
|
}
|
|
)
|
|
assert resp.get("error") is None, resp
|
|
assert len(sess["history"]) == 2
|
|
assert sess["history"][0]["content"] == "first"
|
|
assert sess["history"][1]["content"] == "reply 1"
|
|
# Durable active transcript matches the cut (archive_dropped keeps
|
|
# inactive rows; get_messages_as_conversation returns active only).
|
|
active = db.get_messages_as_conversation(session_key)
|
|
assert len(active) == 2
|
|
assert active[0]["content"] == "first"
|
|
assert active[1]["content"] == "reply 1"
|
|
# Heal stamps for subsequent rewinds when memory lined up with DB.
|
|
assert sess["history"][0].get("_row_id") is not None
|
|
finally:
|
|
server._sessions.pop(sid, None)
|
|
|
|
|
|
def test_prompt_submit_row_id_real_sessiondb_unknown_refuses_despite_ordinal(
|
|
monkeypatch, tmp_path
|
|
):
|
|
"""#82959 fail-closed: unknown row_id + valid ordinal must not truncate
|
|
real SessionDB (the mass-delete class when durable id cannot resolve).
|
|
"""
|
|
from hermes_state import SessionDB
|
|
|
|
db = SessionDB(db_path=tmp_path / "rowid-refuse.db")
|
|
session_key = "real-db-row-refuse"
|
|
db.create_session(session_key, "cli")
|
|
msgs = [
|
|
{"role": "user", "content": "first"},
|
|
{"role": "assistant", "content": "reply 1"},
|
|
{"role": "user", "content": "second"},
|
|
{"role": "assistant", "content": "reply 2"},
|
|
]
|
|
with db._lock:
|
|
db._insert_message_rows(db._conn, session_key, msgs)
|
|
db._conn.commit()
|
|
|
|
live_history = [{"role": m["role"], "content": m["content"]} for m in msgs]
|
|
sess = _session(history=list(live_history), session_key=session_key)
|
|
sid = "real-db-row-refuse-sid"
|
|
server._sessions[sid] = sess
|
|
monkeypatch.setattr(server, "_get_db", lambda: db)
|
|
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")
|
|
)
|
|
|
|
n_before = len(db.get_messages_as_conversation(session_key))
|
|
try:
|
|
resp = server.handle_request(
|
|
{
|
|
"id": "1",
|
|
"method": "prompt.submit",
|
|
"params": {
|
|
"session_id": sid,
|
|
"text": "stale durable id",
|
|
"truncate_before_row_id": 999_999_999,
|
|
"truncate_before_user_ordinal": 0,
|
|
"confirm_truncate": True,
|
|
"confirm_empty_truncate": True,
|
|
},
|
|
}
|
|
)
|
|
assert resp.get("error") is not None
|
|
assert resp["error"]["code"] == 4018
|
|
assert len(sess["history"]) == 4
|
|
assert len(db.get_messages_as_conversation(session_key)) == n_before
|
|
finally:
|
|
server._sessions.pop(sid, None)
|
|
|
|
|
|
def test_prompt_submit_row_id_misaligned_memory_refuses_content_swap(
|
|
monkeypatch, tmp_path
|
|
):
|
|
"""#82959 heal-path guard: equal-length but content-misaligned live memory
|
|
must refuse (4018), not zip-stamp durable ids positionally and cut the
|
|
wrong turn. Probe 4a from the PR #83202 review.
|
|
"""
|
|
from hermes_state import SessionDB
|
|
|
|
db = SessionDB(db_path=tmp_path / "rowid-misalign-content.db")
|
|
session_key = "real-db-row-misalign-content"
|
|
db.create_session(session_key, "cli")
|
|
msgs = [
|
|
{"role": "user", "content": "A"},
|
|
{"role": "assistant", "content": "ra"},
|
|
{"role": "user", "content": "B"},
|
|
{"role": "assistant", "content": "rb"},
|
|
]
|
|
with db._lock:
|
|
db._insert_message_rows(db._conn, session_key, msgs)
|
|
db._conn.commit()
|
|
rid_b = msgs[2]["_row_id"]
|
|
original_row_ids = [message["_row_id"] for message in msgs]
|
|
|
|
# Same length + same role pattern, but content positions swapped: a
|
|
# positional stamp would mark live "B" with durable A's row id and the
|
|
# cut would keep the very turn the user rewound past.
|
|
live_history = [
|
|
{"role": "user", "content": "B"},
|
|
{"role": "assistant", "content": "rb"},
|
|
{"role": "user", "content": "A"},
|
|
{"role": "assistant", "content": "ra"},
|
|
]
|
|
sess = _session(history=list(live_history), session_key=session_key)
|
|
sid = "misalign-content-sid"
|
|
server._sessions[sid] = sess
|
|
monkeypatch.setattr(server, "_get_db", lambda: db)
|
|
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")
|
|
)
|
|
|
|
n_before = len(db.get_messages_as_conversation(session_key))
|
|
try:
|
|
resp = server.handle_request(
|
|
{
|
|
"id": "1",
|
|
"method": "prompt.submit",
|
|
"params": {
|
|
"session_id": sid,
|
|
"text": "rewind B",
|
|
"truncate_before_row_id": rid_b,
|
|
"rebind_survivor_row_ids": [*original_row_ids, 999_999],
|
|
"confirm_truncate": True,
|
|
},
|
|
}
|
|
)
|
|
assert resp.get("error") is not None
|
|
assert resp["error"]["code"] == 4018
|
|
# Fail-closed: nothing stamped onto the misaligned dicts, nothing cut.
|
|
assert all("_row_id" not in m for m in sess["history"])
|
|
assert len(sess["history"]) == 4
|
|
assert len(db.get_messages_as_conversation(session_key)) == n_before
|
|
finally:
|
|
server._sessions.pop(sid, None)
|
|
|
|
|
|
def test_prompt_submit_row_id_misaligned_memory_role_shift_targets_real_turn(
|
|
monkeypatch, tmp_path
|
|
):
|
|
"""#82959 heal-path guard: equal-length but role-misaligned live memory
|
|
must not be positionally stamped. The content-verified DB fallback still
|
|
resolves the REAL target turn in live order — the cut drops exactly the
|
|
addressed user turn, never a positionally mis-aimed one. Probe 4b from
|
|
the PR #83202 review.
|
|
"""
|
|
from hermes_state import SessionDB
|
|
|
|
db = SessionDB(db_path=tmp_path / "rowid-misalign-role.db")
|
|
session_key = "real-db-row-misalign-role"
|
|
db.create_session(session_key, "cli")
|
|
msgs = [
|
|
{"role": "user", "content": "A"},
|
|
{"role": "assistant", "content": "ra"},
|
|
{"role": "user", "content": "B"},
|
|
{"role": "assistant", "content": "rb"},
|
|
]
|
|
with db._lock:
|
|
db._insert_message_rows(db._conn, session_key, msgs)
|
|
db._conn.commit()
|
|
rid_b = msgs[2]["_row_id"]
|
|
original_row_ids = [message["_row_id"] for message in msgs]
|
|
|
|
live_history = [
|
|
{"role": "user", "content": "A"},
|
|
{"role": "assistant", "content": "ra"},
|
|
{"role": "assistant", "content": "rb"},
|
|
{"role": "user", "content": "B"},
|
|
]
|
|
sess = _session(history=list(live_history), session_key=session_key)
|
|
sid = "misalign-role-sid"
|
|
server._sessions[sid] = sess
|
|
monkeypatch.setattr(server, "_get_db", lambda: db)
|
|
monkeypatch.setattr(server, "_start_agent_build", lambda *a, **k: None)
|
|
monkeypatch.setattr(server, "_start_inflight_turn", lambda *a, **k: None)
|
|
|
|
try:
|
|
resp = server.handle_request(
|
|
{
|
|
"id": "1",
|
|
"method": "prompt.submit",
|
|
"params": {
|
|
"session_id": sid,
|
|
"text": "rewind B",
|
|
"truncate_before_row_id": rid_b,
|
|
"rebind_survivor_row_ids": [*original_row_ids, 999_999],
|
|
"confirm_truncate": True,
|
|
},
|
|
}
|
|
)
|
|
assert resp.get("error") is None, resp
|
|
# The addressed turn ("B") is dropped exactly; earlier live turns
|
|
# survive untouched. Before the guard, a positional zip-stamp put a
|
|
# user-row id on an assistant dict and the pre-guard fallback cut at
|
|
# a mis-aimed index. (Fresh _row_id stamps on survivors are expected —
|
|
# replace_messages re-inserts and re-stamps the surviving dicts.)
|
|
survivors = [(m["role"], m["content"]) for m in sess["history"]]
|
|
assert survivors == [
|
|
("user", "A"),
|
|
("assistant", "ra"),
|
|
("assistant", "rb"),
|
|
]
|
|
# The live list was too misaligned to bind old survivors to their new
|
|
# physical rows safely. All requested IDs known to the pre-write active
|
|
# transcript are therefore cleared; an unrelated archived/ancestor ID
|
|
# remains absent from the bounded map and keeps its identity.
|
|
assert resp["result"]["survivor_row_id_map"] == {
|
|
str(row_id): None for row_id in original_row_ids
|
|
}
|
|
finally:
|
|
server._sessions.pop(sid, None)
|
|
|
|
|
|
def test_prompt_submit_row_id_db_fallback_ordinal_mapping_verifies_content(
|
|
monkeypatch,
|
|
):
|
|
"""#82959 db-fallback guard: when live/durable lengths differ, mapping the
|
|
durable user-ordinal onto live indices must verify the mapped turn shows
|
|
the durable target's content — a repaired user;user merge shifts ordinals
|
|
and would otherwise cut an extra turn silently.
|
|
"""
|
|
replaced = []
|
|
|
|
# Durable transcript (repaired): the merge collapsed two user turns, so
|
|
# durable user-ordinal 1 ("second") maps onto a DIFFERENT live user turn.
|
|
durable_history = [
|
|
{"_row_id": 601, "role": "user", "content": "first\nfollow-up"},
|
|
{"_row_id": 603, "role": "assistant", "content": "reply 1"},
|
|
{"_row_id": 604, "role": "user", "content": "second"},
|
|
{"_row_id": 605, "role": "assistant", "content": "reply 2"},
|
|
]
|
|
# Live memory (unrepaired, longer): user ordinal 1 here is "follow-up",
|
|
# NOT "second".
|
|
live_history = [
|
|
{"role": "user", "content": "first"},
|
|
{"role": "user", "content": "follow-up"},
|
|
{"role": "assistant", "content": "reply 1"},
|
|
{"role": "user", "content": "second"},
|
|
{"role": "assistant", "content": "reply 2"},
|
|
]
|
|
|
|
class _FakeDB:
|
|
def replace_messages(
|
|
self,
|
|
key,
|
|
messages,
|
|
active_only=False,
|
|
archive_dropped=False,
|
|
reject_active_turn_lease=False,
|
|
):
|
|
replaced.append((key, list(messages)))
|
|
|
|
def get_messages_as_conversation(self, key, repair_alternation=False, include_row_ids=False):
|
|
return [dict(m) for m in durable_history]
|
|
|
|
sess = _session(history=list(live_history), session_key="db-fallback-verify-key")
|
|
sid = "db-fallback-verify-sid"
|
|
server._sessions[sid] = sess
|
|
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:
|
|
resp = server.handle_request(
|
|
{
|
|
"id": "1",
|
|
"method": "prompt.submit",
|
|
"params": {
|
|
"session_id": sid,
|
|
"text": "rewind to second",
|
|
"truncate_before_row_id": 604,
|
|
"confirm_truncate": True,
|
|
},
|
|
}
|
|
)
|
|
# Mapped live turn ("follow-up") does not match durable target
|
|
# ("second") — refuse instead of cutting the wrong turn.
|
|
assert resp.get("error") is not None
|
|
assert resp["error"]["code"] == 4018
|
|
assert replaced == []
|
|
assert len(sess["history"]) == 5
|
|
finally:
|
|
server._sessions.pop(sid, None)
|
|
|
|
|
|
@pytest.mark.parametrize("turn_isolation", [False, True])
|
|
def test_prompt_submit_consecutive_rewinds_with_returned_survivor_row_ids(
|
|
monkeypatch, tmp_path, turn_isolation
|
|
):
|
|
"""#83202 review (consecutive-rewind staleness): replace_messages re-inserts
|
|
the surviving prefix as NEW rows, so the pre-rewind client row ids die on
|
|
the first rewind. The submit response must return the fresh survivor ids,
|
|
and a second rewind using them must succeed where the stale id fail-closes.
|
|
"""
|
|
from hermes_state import SessionDB
|
|
|
|
db = SessionDB(db_path=tmp_path / "rowid-consec.db")
|
|
session_key = "real-db-consec-rewind"
|
|
db.create_session(session_key, "cli")
|
|
msgs = [
|
|
{"role": "user", "content": "first"},
|
|
{"role": "assistant", "content": "reply 1"},
|
|
{"role": "user", "content": "second"},
|
|
{"role": "assistant", "content": "reply 2"},
|
|
{"role": "user", "content": "third"},
|
|
{"role": "assistant", "content": "reply 3"},
|
|
]
|
|
with db._lock:
|
|
db._insert_message_rows(db._conn, session_key, msgs)
|
|
db._conn.commit()
|
|
original_row_ids = [m["_row_id"] for m in msgs]
|
|
|
|
sess = _session(history=[dict(m) for m in msgs], session_key=session_key)
|
|
sid = "real-db-consec-rewind-sid"
|
|
server._sessions[sid] = sess
|
|
monkeypatch.setattr(server, "_get_db", lambda: db)
|
|
monkeypatch.setattr(
|
|
server,
|
|
"_load_cfg",
|
|
lambda: {"dashboard": {"turn_isolation": turn_isolation}},
|
|
)
|
|
monkeypatch.setattr(
|
|
server,
|
|
"_submit_prompt_to_compute_host",
|
|
lambda *_args, **_kwargs: server._ok("host", {"status": "streaming"}),
|
|
)
|
|
monkeypatch.setattr(server, "_start_agent_build", lambda *a, **k: None)
|
|
monkeypatch.setattr(server, "_start_inflight_turn", lambda *a, **k: None)
|
|
|
|
try:
|
|
# Rewind 1: cut before "third" (last user turn). Survivors: turns
|
|
# "first" + "second" (+ assistant replies) — re-inserted as NEW rows.
|
|
resp1 = server.handle_request(
|
|
{
|
|
"id": "1",
|
|
"method": "prompt.submit",
|
|
"params": {
|
|
"session_id": sid,
|
|
"text": "rewound third",
|
|
"truncate_before_row_id": original_row_ids[4],
|
|
"truncate_before_user_ordinal": 2,
|
|
"rebind_survivor_row_ids": [*original_row_ids, 999_999],
|
|
"confirm_truncate": True,
|
|
},
|
|
}
|
|
)
|
|
assert resp1.get("error") is None, resp1
|
|
assert "survivor_user_row_ids" not in resp1["result"]
|
|
row_id_map = resp1["result"].get("survivor_row_id_map")
|
|
assert isinstance(row_id_map, dict)
|
|
survivors = [
|
|
row_id_map[str(original_row_ids[0])],
|
|
row_id_map[str(original_row_ids[2])],
|
|
]
|
|
# They must be NEW rows — the old ids are archived (active=0) now.
|
|
assert set(survivors).isdisjoint(set(original_row_ids))
|
|
assert row_id_map == {
|
|
str(original_row_ids[0]): survivors[0],
|
|
str(original_row_ids[1]): sess["history"][1]["_row_id"],
|
|
str(original_row_ids[2]): survivors[1],
|
|
str(original_row_ids[3]): sess["history"][3]["_row_id"],
|
|
str(original_row_ids[4]): None,
|
|
str(original_row_ids[5]): None,
|
|
}
|
|
assert "999999" not in row_id_map
|
|
sess["running"] = False
|
|
|
|
# Rewind 2a: the STALE pre-rewind id for "second" must fail closed.
|
|
stale_resp = server.handle_request(
|
|
{
|
|
"id": "2",
|
|
"method": "prompt.submit",
|
|
"params": {
|
|
"session_id": sid,
|
|
"text": "rewound second (stale id)",
|
|
"truncate_before_row_id": original_row_ids[2],
|
|
"truncate_before_user_ordinal": 1,
|
|
"confirm_truncate": True,
|
|
},
|
|
}
|
|
)
|
|
assert stale_resp.get("error") is not None
|
|
assert stale_resp["error"]["code"] == 4018
|
|
assert len(sess["history"]) == 4 # nothing cut
|
|
|
|
# Rewind 2b: the RETURNED survivor id for "second" must succeed.
|
|
resp2 = server.handle_request(
|
|
{
|
|
"id": "3",
|
|
"method": "prompt.submit",
|
|
"params": {
|
|
"session_id": sid,
|
|
"text": "rewound second (fresh id)",
|
|
"truncate_before_row_id": survivors[1],
|
|
"truncate_before_user_ordinal": 1,
|
|
"confirm_truncate": True,
|
|
},
|
|
}
|
|
)
|
|
assert resp2.get("error") is None, resp2
|
|
assert len(sess["history"]) == 2
|
|
assert sess["history"][0]["content"] == "first"
|
|
active = db.get_messages_as_conversation(session_key)
|
|
assert [m["content"] for m in active] == ["first", "reply 1"]
|
|
# And the second response rebinds again: one surviving user turn.
|
|
survivors2 = resp2["result"].get("survivor_user_row_ids")
|
|
assert isinstance(survivors2, list) and len(survivors2) == 1
|
|
finally:
|
|
server._sessions.pop(sid, None)
|
|
|
|
|
|
def test_prompt_submit_rebind_map_clears_active_row_hidden_by_sequence_repair(
|
|
monkeypatch, tmp_path
|
|
):
|
|
"""The bounded map classifies physical active IDs before user;user repair."""
|
|
from hermes_state import SessionDB
|
|
|
|
db = SessionDB(db_path=tmp_path / "rowid-repaired-wedge.db")
|
|
session_key = "real-db-rowid-repaired-wedge"
|
|
db.create_session(session_key, "cli")
|
|
physical = [
|
|
{"role": "user", "content": "first fragment"},
|
|
{"role": "user", "content": "second fragment"},
|
|
{"role": "assistant", "content": "combined reply"},
|
|
{"role": "user", "content": "target"},
|
|
{"role": "assistant", "content": "target reply"},
|
|
]
|
|
with db._lock:
|
|
db._insert_message_rows(db._conn, session_key, physical)
|
|
db._conn.commit()
|
|
physical_ids = [message["_row_id"] for message in physical]
|
|
repaired = db.get_messages_as_conversation(
|
|
session_key, repair_alternation=True, include_row_ids=True
|
|
)
|
|
# Provider repair merges the wedge and necessarily drops the second
|
|
# physical user's row identity from the replay view.
|
|
assert physical_ids[1] not in {
|
|
server._message_row_id(message) for message in repaired
|
|
}
|
|
|
|
sess = _session(
|
|
history=[dict(message) for message in repaired], session_key=session_key
|
|
)
|
|
sid = "rowid-repaired-wedge-sid"
|
|
server._sessions[sid] = sess
|
|
monkeypatch.setattr(server, "_get_db", lambda: db)
|
|
monkeypatch.setattr(server, "_start_agent_build", lambda *a, **k: None)
|
|
monkeypatch.setattr(server, "_start_inflight_turn", lambda *a, **k: None)
|
|
|
|
try:
|
|
response = server.handle_request(
|
|
{
|
|
"id": "1",
|
|
"method": "prompt.submit",
|
|
"params": {
|
|
"session_id": sid,
|
|
"text": "retry target",
|
|
"truncate_before_row_id": physical_ids[3],
|
|
"rebind_survivor_row_ids": [*physical_ids, 999_999],
|
|
"confirm_truncate": True,
|
|
},
|
|
}
|
|
)
|
|
assert response.get("error") is None, response
|
|
row_id_map = response["result"]["survivor_row_id_map"]
|
|
assert row_id_map[str(physical_ids[1])] is None
|
|
assert "999999" not in row_id_map
|
|
finally:
|
|
server._sessions.pop(sid, None)
|
|
|
|
|
|
def test_prompt_submit_unconfirmed_truncation_refuses_before_target_resolution(
|
|
monkeypatch,
|
|
):
|
|
"""Consent (4029) is checked BEFORE target resolution: an unconfirmed
|
|
submit carrying truncation params must not pay the durable-transcript
|
|
read or heal-stamp live history (simplify review on #83785), and an
|
|
out-of-range unconfirmed ordinal refuses 4029, not 4018 — the baseline
|
|
precedence before the row-id feature.
|
|
"""
|
|
db_reads = []
|
|
|
|
class _SpyDB:
|
|
def get_messages_as_conversation(self, *a, **k):
|
|
db_reads.append(1)
|
|
return []
|
|
|
|
def replace_messages(self, *a, **k):
|
|
pytest.fail("must not write")
|
|
|
|
hist = [
|
|
{"role": "user", "content": "first"},
|
|
{"role": "assistant", "content": "r1"},
|
|
{"role": "user", "content": "second"},
|
|
{"role": "assistant", "content": "r2"},
|
|
]
|
|
sess = _session(history=list(hist), session_key="consent-precedence-key")
|
|
sid = "consent-precedence-sid"
|
|
server._sessions[sid] = sess
|
|
monkeypatch.setattr(server, "_get_db", lambda: _SpyDB())
|
|
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:
|
|
# Unconfirmed row_id: 4029, no DB read, no heal stamps on history.
|
|
resp = server.handle_request(
|
|
{
|
|
"id": "1",
|
|
"method": "prompt.submit",
|
|
"params": {"session_id": sid, "text": "x", "truncate_before_row_id": 3},
|
|
}
|
|
)
|
|
assert (resp.get("error") or {}).get("code") == 4029, resp
|
|
assert db_reads == []
|
|
assert all("_row_id" not in m for m in sess["history"])
|
|
|
|
# Malformed param still beats consent: bool row_id is 4004.
|
|
resp = server.handle_request(
|
|
{
|
|
"id": "2",
|
|
"method": "prompt.submit",
|
|
"params": {"session_id": sid, "text": "x", "truncate_before_row_id": True},
|
|
}
|
|
)
|
|
assert (resp.get("error") or {}).get("code") == 4004, resp
|
|
|
|
# Unconfirmed out-of-range ordinal: 4029 (consent), not 4018 (range).
|
|
resp = server.handle_request(
|
|
{
|
|
"id": "3",
|
|
"method": "prompt.submit",
|
|
"params": {
|
|
"session_id": sid,
|
|
"text": "x",
|
|
"truncate_before_user_ordinal": 99,
|
|
},
|
|
}
|
|
)
|
|
assert (resp.get("error") or {}).get("code") == 4029, resp
|
|
assert len(sess["history"]) == 4
|
|
finally:
|
|
server._sessions.pop(sid, None)
|
|
|
|
|
|
def test_persist_live_session_system_prompt_uses_profile_home(monkeypatch, tmp_path):
|
|
"""Issue #50233: _persist_live_session_system_prompt must re-bind
|
|
HERMES_HOME to the session's profile before rebuilding the system
|
|
prompt. Without this, a /model switch rebuilds the prompt with the
|
|
root profile's SOUL.md and skills instead of the session's profile.
|
|
"""
|
|
profile_home = tmp_path / "profile-work"
|
|
profile_home.mkdir()
|
|
(profile_home / "SOUL.md").write_text(
|
|
"# Work persona\nYou are a work agent.", encoding="utf-8"
|
|
)
|
|
|
|
built_homes = []
|
|
|
|
class FakeAgent:
|
|
model = "test-model"
|
|
provider = "test"
|
|
_cached_system_prompt = None
|
|
_session_db = None
|
|
|
|
def _build_system_prompt(self, system_message=None):
|
|
from hermes_constants import get_hermes_home
|
|
home = get_hermes_home()
|
|
built_homes.append(str(home))
|
|
soul = (
|
|
(home / "SOUL.md").read_text(encoding="utf-8")
|
|
if (home / "SOUL.md").exists()
|
|
else ""
|
|
)
|
|
return f"System prompt from {home}\n{soul}"
|
|
|
|
class FakeDB:
|
|
def update_system_prompt(self, session_id, prompt):
|
|
pass
|
|
|
|
agent = FakeAgent()
|
|
agent._session_db = FakeDB()
|
|
session = {
|
|
"agent": agent,
|
|
"session_key": "test-key",
|
|
"profile_home": str(profile_home),
|
|
}
|
|
|
|
server._persist_live_session_system_prompt(session)
|
|
|
|
# The system prompt must have been built while the override pointed
|
|
# to the profile home, not the root ~/.hermes.
|
|
assert len(built_homes) == 1, f"expected 1 build, got {built_homes}"
|
|
assert str(profile_home) in built_homes[0], (
|
|
f"system prompt built with wrong home: {built_homes[0]}"
|
|
)
|
|
assert "Work persona" in agent._cached_system_prompt
|
|
|
|
# The override must have been reset after the call.
|
|
from hermes_constants import get_hermes_home_override
|
|
assert get_hermes_home_override() is None
|
|
|
|
|
|
def test_persist_live_session_system_prompt_no_profile_is_unchanged(monkeypatch):
|
|
"""Sessions without a profile_home must not set/clear any override —
|
|
the function should behave identically to before the fix."""
|
|
class FakeAgent:
|
|
model = "test"
|
|
_cached_system_prompt = None
|
|
_session_db = None
|
|
|
|
def _build_system_prompt(self, system_message=None):
|
|
return "plain prompt"
|
|
|
|
class FakeDB:
|
|
def update_system_prompt(self, session_id, prompt):
|
|
pass
|
|
|
|
agent = FakeAgent()
|
|
agent._session_db = FakeDB()
|
|
session = {
|
|
"agent": agent,
|
|
"session_key": "test-key",
|
|
"profile_home": None,
|
|
}
|
|
|
|
# Should not raise, should still build and cache.
|
|
server._persist_live_session_system_prompt(session)
|
|
assert agent._cached_system_prompt == "plain prompt"
|
|
|
|
|
|
def test_persist_live_session_system_prompt_restores_pre_existing_override(tmp_path):
|
|
"""reset_hermes_home_override() restores the previous ContextVar state,
|
|
not just the unset case: when a caller already holds an override, the
|
|
persist call must scope to the session's profile and then hand the
|
|
caller's override back, rather than clearing it to None."""
|
|
from hermes_constants import get_hermes_home_override
|
|
|
|
outer_home = tmp_path / "profile-outer"
|
|
outer_home.mkdir()
|
|
inner_home = tmp_path / "profile-inner"
|
|
inner_home.mkdir()
|
|
(inner_home / "SOUL.md").write_text(
|
|
"# Inner persona\nYou are the inner agent.", encoding="utf-8"
|
|
)
|
|
|
|
built_homes = []
|
|
|
|
class FakeAgent:
|
|
model = "test-model"
|
|
provider = "test"
|
|
_cached_system_prompt = None
|
|
_session_db = None
|
|
|
|
def _build_system_prompt(self, system_message=None):
|
|
from hermes_constants import get_hermes_home
|
|
built_homes.append(str(get_hermes_home()))
|
|
return "inner prompt"
|
|
|
|
class FakeDB:
|
|
def update_system_prompt(self, session_id, prompt):
|
|
pass
|
|
|
|
agent = FakeAgent()
|
|
agent._session_db = FakeDB()
|
|
session = {
|
|
"agent": agent,
|
|
"session_key": "test-key",
|
|
"profile_home": str(inner_home),
|
|
}
|
|
|
|
outer_token = set_hermes_home_override(outer_home)
|
|
try:
|
|
server._persist_live_session_system_prompt(session)
|
|
|
|
# The prompt was built under the session's profile, not the outer one.
|
|
assert built_homes == [str(inner_home)]
|
|
# The caller's pre-existing override survived, instead of being reset
|
|
# to None.
|
|
assert get_hermes_home_override() == str(outer_home)
|
|
finally:
|
|
reset_hermes_home_override(outer_token)
|
|
assert get_hermes_home_override() is None
|
|
|
|
|
|
def test_persist_live_session_system_prompt_binds_session_cwd(monkeypatch, tmp_path):
|
|
"""The prompt rebuild after a live model switch must record the SESSION's
|
|
working directory, not the process TERMINAL_CWD.
|
|
|
|
The function runs on the RPC dispatcher thread (model.switch, config.set
|
|
model). On that thread the _SESSION_CWD contextvar is not set, so
|
|
resolve_agent_cwd() falls back to TERMINAL_CWD, which the desktop pins
|
|
to the home directory. The wrong cwd line then persists into the stored
|
|
prompt. Later turns restore the stored bytes without change (the
|
|
prologue rebuilds only when _cached_system_prompt is None), so the
|
|
poisoned line never self-heals.
|
|
"""
|
|
session_cwd = tmp_path / "project"
|
|
session_cwd.mkdir()
|
|
process_cwd = tmp_path / "home-fallback"
|
|
process_cwd.mkdir()
|
|
monkeypatch.setenv("TERMINAL_CWD", str(process_cwd))
|
|
|
|
persisted = {}
|
|
|
|
class FakeAgent:
|
|
model = "test-model"
|
|
provider = "test"
|
|
session_id = "cwd-test-session"
|
|
_cached_system_prompt = None
|
|
_session_db = None
|
|
|
|
def _build_system_prompt(self, system_message=None):
|
|
# The real builder embeds resolve_agent_cwd() via
|
|
# prompt_builder.build_environment_hints().
|
|
from agent.runtime_cwd import resolve_agent_cwd
|
|
|
|
return f"Current working directory: {resolve_agent_cwd()}"
|
|
|
|
class FakeDB:
|
|
def update_system_prompt(self, session_id, prompt):
|
|
persisted["prompt"] = prompt
|
|
|
|
agent = FakeAgent()
|
|
agent._session_db = FakeDB()
|
|
session = {
|
|
"agent": agent,
|
|
"session_key": "cwd-test-session",
|
|
"cwd": str(session_cwd),
|
|
"explicit_cwd": True,
|
|
"profile_home": None,
|
|
}
|
|
|
|
# A bare thread has no _SESSION_CWD contextvar — the RPC dispatcher shape.
|
|
result = {}
|
|
|
|
def dispatcher_thread():
|
|
server._persist_live_session_system_prompt(session)
|
|
result["cached"] = agent._cached_system_prompt
|
|
|
|
t = threading.Thread(target=dispatcher_thread)
|
|
t.start()
|
|
t.join()
|
|
|
|
expected = f"Current working directory: {session_cwd}"
|
|
assert result["cached"] == expected, result["cached"]
|
|
assert persisted["prompt"] == expected, persisted["prompt"]
|
|
|
|
|
|
def test_workspace_move_rehomes_running_session(monkeypatch, tmp_path):
|
|
"""An explicit Move-to-project must win for a RUNNING session: the stored
|
|
row and the live runtime session re-anchor together, never a UI-vs-db
|
|
disagreement (#86626)."""
|
|
target = "stored-running-session"
|
|
new_cwd = tmp_path / "dest-project"
|
|
new_cwd.mkdir()
|
|
captured = {}
|
|
|
|
class FakeDB:
|
|
def get_session(self, session_id):
|
|
return {"id": session_id}
|
|
|
|
def update_session_cwd(self, session_id, cwd, branch=None, root=None, replace_git_meta=True):
|
|
captured["row_update"] = (session_id, cwd)
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
import contextlib
|
|
|
|
@contextlib.contextmanager
|
|
def _fake_db(_params):
|
|
yield FakeDB()
|
|
|
|
monkeypatch.setattr(server, "_profile_db", _fake_db)
|
|
monkeypatch.setattr(
|
|
server,
|
|
"_git_branch_for_cwd",
|
|
lambda cwd: "main",
|
|
)
|
|
monkeypatch.setattr(
|
|
server,
|
|
"_git_common_repo_root_for_cwd",
|
|
lambda cwd: str(new_cwd),
|
|
)
|
|
|
|
live = {"session_key": target, "running": True, "cwd": str(tmp_path / "old-project")}
|
|
server._sessions["live-sid"] = live
|
|
monkeypatch.setattr(server, "_register_session_cwd", lambda _session: None)
|
|
|
|
res = server._methods["session.workspace.move"](
|
|
"rid",
|
|
{"session_key": target, "cwd": str(new_cwd)},
|
|
)
|
|
|
|
assert "error" not in res, res
|
|
assert captured["row_update"] == (target, str(new_cwd))
|
|
assert live["cwd"] == str(new_cwd)
|
|
assert live.get("explicit_cwd") is True
|