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:
Nick 2026-05-17 21:51:33 +00:00 committed by Teknium
parent 2fc77c53f0
commit 0a83247e9f
29 changed files with 2048 additions and 105 deletions

View file

@ -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 ──────────────────────────────────────────────