mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-07 08:02:23 +00:00
feat: add TUI session orchestrator
Add a first-class active-session orchestrator for the Ink TUI: - list, activate, close, and launch live process-local TUI sessions - hydrate committed and in-flight output when switching sessions - dispatch a new prompt session from the +new row with session-scoped model picks - expose a clickable live-session count in the status chrome - preserve stable row order while initially focusing the current session - support mouse hit-testing for floating orchestrator overlays - add backend and frontend regression coverage for the lifecycle and UI helpers
This commit is contained in:
parent
2fc77c53f0
commit
0a83247e9f
29 changed files with 2048 additions and 105 deletions
|
|
@ -1732,6 +1732,48 @@ def test_config_set_verbose_updates_session_mode_and_agent(tmp_path, monkeypatch
|
|||
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):
|
||||
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 = {}
|
||||
|
|
@ -3843,6 +3885,191 @@ def test_prompt_submit_preserves_empty_response_without_error(monkeypatch):
|
|||
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_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):
|
||||
assert prompt == "write a long answer"
|
||||
assert conversation_history == []
|
||||
stream_callback("partial ")
|
||||
stream_callback("answer")
|
||||
started.set()
|
||||
assert release.wait(2), "test timed out waiting to finish fake model turn"
|
||||
return {
|
||||
"final_response": "partial answer complete",
|
||||
"messages": [
|
||||
{"role": "user", "content": "write a long answer"},
|
||||
{"role": "assistant", "content": "partial answer complete"},
|
||||
],
|
||||
}
|
||||
|
||||
server._sessions["sid-live"] = _session(agent=_Agent())
|
||||
monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None)
|
||||
monkeypatch.setattr(server, "render_message", lambda raw, cols: None)
|
||||
monkeypatch.setattr(server, "_get_db", lambda: None)
|
||||
monkeypatch.setattr(server, "_session_info", lambda agent: {"model": agent.model})
|
||||
|
||||
def _emit(event, sid, payload=None):
|
||||
if event == "message.complete":
|
||||
done.set()
|
||||
|
||||
monkeypatch.setattr(server, "_emit", _emit)
|
||||
|
||||
try:
|
||||
submit = server.handle_request(
|
||||
{
|
||||
"id": "submit",
|
||||
"method": "prompt.submit",
|
||||
"params": {"session_id": "sid-live", "text": "write a long answer"},
|
||||
}
|
||||
)
|
||||
assert submit["result"]["status"] == "streaming"
|
||||
assert started.wait(2), "fake model did not stream before activation"
|
||||
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "activate",
|
||||
"method": "session.activate",
|
||||
"params": {"session_id": "sid-live"},
|
||||
}
|
||||
)
|
||||
|
||||
inflight = resp["result"].get("inflight")
|
||||
assert inflight == {
|
||||
"assistant": "partial answer",
|
||||
"streaming": True,
|
||||
"user": "write a long answer",
|
||||
}
|
||||
assert resp["result"]["messages"] == []
|
||||
|
||||
release.set()
|
||||
assert done.wait(2), "fake model turn did not complete"
|
||||
completed = server.handle_request(
|
||||
{
|
||||
"id": "activate-done",
|
||||
"method": "session.activate",
|
||||
"params": {"session_id": "sid-live"},
|
||||
}
|
||||
)
|
||||
assert completed["result"].get("inflight") is None
|
||||
assert completed["result"]["messages"] == [
|
||||
{"role": "user", "text": "write a long answer"},
|
||||
{"role": "assistant", "text": "partial answer complete"},
|
||||
]
|
||||
finally:
|
||||
release.set()
|
||||
done.wait(2)
|
||||
server._sessions.pop("sid-live", None)
|
||||
|
||||
|
||||
def test_session_activate_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)
|
||||
|
||||
|
||||
# ── session.most_recent ──────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue