feat(sessions,cli,tui): issue #8 — startup agent picker (v0.3.0)
Adds GET /agents fetch + ListView picker for bare `--new` (TUI mode without --agent). Three in-place amendments: - ratatoskr.sessions: new `list_agents()` + `AgentInfo` frozen dataclass with omit-when-null/empty defaults mirroring SessionInfo's INV-001/INV-002 origin-conditional pattern. Non-200 responses raise the existing SessionApiFailed (no new exception). - ratatoskr.cli: `_parse_args` softens `--agent` from absolute to mode-conditional — required for `--send --new`, optional for bare `--new`, forbidden with `--session` (unchanged INV-004). - ratatoskr.tui: new `AgentPickerApp(App[str | None])` — separate Textual App (not Screen-within-RatatoskrApp) so list_agents errors land on real stderr before any alt-screen opens (preserves issue #6's INV-001). `_resolve_then_run` gains a pre-create branch: fetch agents → empty list → exit 13; non-200 → exit 20; network error → exit 21; picker dismissed → exit 0; otherwise thread chosen agent_id into create_session. Contract: docs/contracts/issues/8.contract.md (drift-check clean). Tests: +18 (227 total, was 209). Live smoke against personal Worldtree (:8081) returned 12 agents; programmatic picker drive auto-picked lofn and created a real session with `end_user_id="ratatoskr-tui"`.
This commit is contained in:
@@ -1577,3 +1577,319 @@ class TestRunTui:
|
||||
)
|
||||
with pytest.raises(AssertionError):
|
||||
run_tui(bad_args)
|
||||
|
||||
|
||||
# ---- Issue #8: startup agent picker ----------------------------------------
|
||||
|
||||
|
||||
def _args_new_no_agent(**overrides) -> ParsedArgs:
|
||||
"""ParsedArgs for bare --new (no --agent) — TUI-mode picker entry."""
|
||||
base = dict(
|
||||
send_content=None,
|
||||
session_id=None,
|
||||
new=True,
|
||||
agent_id=None, # Issue #8: bare --new, picker drives the choice
|
||||
api_key="k",
|
||||
server_url="https://w.example",
|
||||
raw=False,
|
||||
)
|
||||
base.update(overrides)
|
||||
return ParsedArgs(**base)
|
||||
|
||||
|
||||
_AGENTS_RESP = [
|
||||
{
|
||||
"agent_id": "mimir",
|
||||
"name": "Mimir",
|
||||
"description": "Keeper of the Well of Knowledge.",
|
||||
},
|
||||
{
|
||||
"agent_id": "lofn",
|
||||
"name": "Lofn",
|
||||
"description": "Mediator of secret affairs.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class TestAgentPickerApp:
|
||||
def test_picker_renders_rows(self) -> None:
|
||||
"""picker_renders_rows: AgentPickerApp composes one ListItem per agent."""
|
||||
from textual.widgets import ListView
|
||||
|
||||
from ratatoskr.sessions import AgentInfo
|
||||
from ratatoskr.tui import AgentPickerApp
|
||||
|
||||
agents = [
|
||||
AgentInfo(
|
||||
agent_id="a", name="A", description="x",
|
||||
version=None, capabilities=[], supported_models=[],
|
||||
persona_traits={}, ui_hints={},
|
||||
),
|
||||
AgentInfo(
|
||||
agent_id="b", name="B", description="y",
|
||||
version=None, capabilities=[], supported_models=[],
|
||||
persona_traits={}, ui_hints={},
|
||||
),
|
||||
]
|
||||
app = AgentPickerApp(agents)
|
||||
|
||||
async def probe() -> None:
|
||||
async with app.run_test() as pilot:
|
||||
lv = app.query_one("#agent-list", ListView)
|
||||
assert len(lv.children) == 2
|
||||
await pilot.pause()
|
||||
app.exit(None)
|
||||
|
||||
import asyncio
|
||||
asyncio.run(probe())
|
||||
|
||||
def test_picker_pick_returns_agent_id(self) -> None:
|
||||
"""picker_pick_returns_agent_id: highlight idx 1 + Enter → exit value == 'b'."""
|
||||
from ratatoskr.sessions import AgentInfo
|
||||
from ratatoskr.tui import AgentPickerApp
|
||||
|
||||
agents = [
|
||||
AgentInfo(
|
||||
agent_id="a", name="A", description="x",
|
||||
version=None, capabilities=[], supported_models=[],
|
||||
persona_traits={}, ui_hints={},
|
||||
),
|
||||
AgentInfo(
|
||||
agent_id="b", name="B", description="y",
|
||||
version=None, capabilities=[], supported_models=[],
|
||||
persona_traits={}, ui_hints={},
|
||||
),
|
||||
]
|
||||
app = AgentPickerApp(agents)
|
||||
|
||||
async def drive() -> str | None:
|
||||
async with app.run_test() as pilot:
|
||||
from textual.widgets import ListView
|
||||
|
||||
lv = app.query_one("#agent-list", ListView)
|
||||
lv.index = 1
|
||||
await pilot.pause()
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
return app.return_value
|
||||
|
||||
import asyncio
|
||||
chosen = asyncio.run(drive())
|
||||
assert chosen == "b"
|
||||
|
||||
def test_picker_esc_returns_none(self) -> None:
|
||||
"""picker_esc_returns_none: Esc → exit value is None."""
|
||||
from ratatoskr.sessions import AgentInfo
|
||||
from ratatoskr.tui import AgentPickerApp
|
||||
|
||||
agents = [
|
||||
AgentInfo(
|
||||
agent_id="a", name="A", description="x",
|
||||
version=None, capabilities=[], supported_models=[],
|
||||
persona_traits={}, ui_hints={},
|
||||
),
|
||||
]
|
||||
app = AgentPickerApp(agents)
|
||||
|
||||
async def drive() -> str | None:
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.press("escape")
|
||||
await pilot.pause()
|
||||
return app.return_value
|
||||
|
||||
import asyncio
|
||||
chosen = asyncio.run(drive())
|
||||
assert chosen is None
|
||||
|
||||
|
||||
class TestResolveThenRunWithPicker:
|
||||
"""Issue #8: picker integration in _resolve_then_run."""
|
||||
|
||||
@respx.mock
|
||||
def test_picker_happy_path(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""picker_happy_path [happy,tracer]: agents fetched → picker exits with id → create_session.
|
||||
|
||||
Patches AgentPickerApp.run_async to return 'lofn' (simulating user pick);
|
||||
asserts list_agents fired once, POST /sessions body carries agent_id=lofn,
|
||||
and RatatoskrApp opens with the chosen identity.
|
||||
"""
|
||||
agents_route = respx.get("https://w.example/agents").mock(
|
||||
return_value=httpx.Response(200, json=_AGENTS_RESP)
|
||||
)
|
||||
sessions_route = respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(
|
||||
201,
|
||||
json={
|
||||
**_CREATE_OK_RESP,
|
||||
"agent_id": "lofn",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
from ratatoskr.tui import AgentPickerApp
|
||||
|
||||
async def picker_returns_lofn(self, *a, **kw):
|
||||
return "lofn"
|
||||
|
||||
monkeypatch.setattr(AgentPickerApp, "run_async", picker_returns_lofn)
|
||||
|
||||
snapshot: dict = {}
|
||||
|
||||
async def capture_main(self, *a, **kw):
|
||||
snapshot["session_id"] = self.session_id
|
||||
snapshot["agent_id"] = self.agent_id
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(RatatoskrApp, "run_async", capture_main)
|
||||
from ratatoskr.tui import run_tui
|
||||
rc = run_tui(_args_new_no_agent())
|
||||
assert rc == 0
|
||||
assert agents_route.call_count == 1
|
||||
assert sessions_route.call_count == 1
|
||||
import json as _json
|
||||
body = _json.loads(sessions_route.calls[0].request.content)
|
||||
assert body == {"agent_id": "lofn"}
|
||||
assert snapshot["agent_id"] == "lofn"
|
||||
|
||||
@respx.mock
|
||||
def test_picker_esc_clean_exit(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""picker_esc_clean_exit: picker returns None → exit 0; no create_session; no main App."""
|
||||
agents_route = respx.get("https://w.example/agents").mock(
|
||||
return_value=httpx.Response(200, json=_AGENTS_RESP)
|
||||
)
|
||||
sessions_route = respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(201, json=_CREATE_OK_RESP)
|
||||
)
|
||||
|
||||
from ratatoskr.tui import AgentPickerApp
|
||||
|
||||
async def picker_dismissed(self, *a, **kw):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(AgentPickerApp, "run_async", picker_dismissed)
|
||||
|
||||
main_called = False
|
||||
|
||||
async def sentinel(self, *a, **kw):
|
||||
nonlocal main_called
|
||||
main_called = True
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(RatatoskrApp, "run_async", sentinel)
|
||||
from ratatoskr.tui import run_tui
|
||||
rc = run_tui(_args_new_no_agent())
|
||||
assert rc == 0
|
||||
assert agents_route.call_count == 1
|
||||
assert sessions_route.call_count == 0
|
||||
assert main_called is False
|
||||
|
||||
@respx.mock
|
||||
def test_picker_skipped_when_agent_id_provided(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""picker_skipped_when_agent_id_provided: --new --agent mimir → list_agents NOT called."""
|
||||
agents_route = respx.get("https://w.example/agents").mock(
|
||||
return_value=httpx.Response(200, json=_AGENTS_RESP)
|
||||
)
|
||||
sessions_route = respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(201, json=_CREATE_OK_RESP)
|
||||
)
|
||||
|
||||
async def fake_main(self, *a, **kw):
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(RatatoskrApp, "run_async", fake_main)
|
||||
from ratatoskr.tui import run_tui
|
||||
rc = run_tui(_args_new()) # agent_id="mimir"
|
||||
assert rc == 0
|
||||
assert agents_route.call_count == 0
|
||||
assert sessions_route.call_count == 1
|
||||
|
||||
@respx.mock
|
||||
def test_picker_skipped_when_session_mode(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""picker_skipped_when_session_mode: --session s-1 → no list_agents, no create_session."""
|
||||
agents_route = respx.get("https://w.example/agents").mock(
|
||||
return_value=httpx.Response(200, json=_AGENTS_RESP)
|
||||
)
|
||||
sessions_route = respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(201, json=_CREATE_OK_RESP)
|
||||
)
|
||||
|
||||
async def fake_main(self, *a, **kw):
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(RatatoskrApp, "run_async", fake_main)
|
||||
from ratatoskr.tui import run_tui
|
||||
rc = run_tui(_args_existing())
|
||||
assert rc == 0
|
||||
assert agents_route.call_count == 0
|
||||
assert sessions_route.call_count == 0
|
||||
|
||||
@respx.mock
|
||||
def test_picker_list_agents_session_api_failed(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""list_agents 500 → stderr [session_api_failed]; exit 20; picker NOT opened."""
|
||||
respx.get("https://w.example/agents").mock(
|
||||
return_value=httpx.Response(500, content=b"oops")
|
||||
)
|
||||
|
||||
from ratatoskr.tui import AgentPickerApp
|
||||
|
||||
picker_called = False
|
||||
|
||||
async def sentinel(self, *a, **kw):
|
||||
nonlocal picker_called
|
||||
picker_called = True
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(AgentPickerApp, "run_async", sentinel)
|
||||
|
||||
main_called = False
|
||||
|
||||
async def main_sentinel(self, *a, **kw):
|
||||
nonlocal main_called
|
||||
main_called = True
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(RatatoskrApp, "run_async", main_sentinel)
|
||||
from ratatoskr.tui import run_tui
|
||||
rc = run_tui(_args_new_no_agent())
|
||||
assert rc == 20
|
||||
err = capsys.readouterr().err
|
||||
assert "[session_api_failed]" in err
|
||||
assert "status=500" in err
|
||||
assert picker_called is False
|
||||
assert main_called is False
|
||||
|
||||
@respx.mock
|
||||
def test_picker_empty_list(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""list_agents returns [] → stderr [no_agents]; exit 13; picker NOT opened."""
|
||||
respx.get("https://w.example/agents").mock(
|
||||
return_value=httpx.Response(200, json=[])
|
||||
)
|
||||
|
||||
from ratatoskr.tui import AgentPickerApp
|
||||
|
||||
picker_called = False
|
||||
|
||||
async def sentinel(self, *a, **kw):
|
||||
nonlocal picker_called
|
||||
picker_called = True
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(AgentPickerApp, "run_async", sentinel)
|
||||
from ratatoskr.tui import run_tui
|
||||
rc = run_tui(_args_new_no_agent())
|
||||
assert rc == 13
|
||||
err = capsys.readouterr().err
|
||||
assert "[no_agents]" in err
|
||||
assert picker_called is False
|
||||
|
||||
Reference in New Issue
Block a user