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:
@@ -5,11 +5,13 @@ import pytest
|
||||
import respx
|
||||
|
||||
from ratatoskr.sessions import (
|
||||
AgentInfo,
|
||||
AgentNotFound,
|
||||
InvalidCursor,
|
||||
SessionApiFailed,
|
||||
SessionPage,
|
||||
create_session,
|
||||
list_agents,
|
||||
list_sessions,
|
||||
)
|
||||
|
||||
@@ -412,3 +414,145 @@ class TestListSessions:
|
||||
with pytest.raises(AssertionError):
|
||||
await list_sessions(client, cursor="")
|
||||
assert route.call_count == 0
|
||||
|
||||
|
||||
# ---- Issue #8: list_agents + AgentInfo --------------------------------------
|
||||
|
||||
|
||||
class TestListAgents:
|
||||
@respx.mock
|
||||
async def test_happy_full_shape(self) -> None:
|
||||
"""happy_full_shape [happy,tracer]: spec full-shape mimir example → all fields."""
|
||||
respx.get("https://w.example/agents").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{
|
||||
"agent_id": "mimir",
|
||||
"name": "Mimir",
|
||||
"description": "Keeper of the Well of Knowledge.",
|
||||
"version": "0.2.0",
|
||||
"capabilities": ["knowledge_base", "semantic_search"],
|
||||
"supported_models": ["default", "heavy"],
|
||||
"persona_traits": {
|
||||
"ocean": {
|
||||
"openness": 0.7,
|
||||
"conscientiousness": 0.9,
|
||||
"extraversion": 0.1,
|
||||
"agreeableness": 0.5,
|
||||
"neuroticism": 0.3,
|
||||
},
|
||||
"vibe": "contemplative",
|
||||
},
|
||||
"ui_hints": {"icon": "well", "color_hint": "#5b8aa3"},
|
||||
}
|
||||
],
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
agents = await list_agents(client)
|
||||
assert len(agents) == 1
|
||||
a = agents[0]
|
||||
assert isinstance(a, AgentInfo)
|
||||
assert a.agent_id == "mimir"
|
||||
assert a.name == "Mimir"
|
||||
assert a.description == "Keeper of the Well of Knowledge."
|
||||
assert a.version == "0.2.0"
|
||||
assert a.capabilities == ["knowledge_base", "semantic_search"]
|
||||
assert a.supported_models == ["default", "heavy"]
|
||||
assert a.persona_traits["vibe"] == "contemplative"
|
||||
assert a.ui_hints["icon"] == "well"
|
||||
|
||||
@respx.mock
|
||||
async def test_happy_minimum_shape(self) -> None:
|
||||
"""happy_minimum_shape: required-only agent → optional fields default."""
|
||||
respx.get("https://w.example/agents").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{
|
||||
"agent_id": "minimal",
|
||||
"name": "Minimal Agent",
|
||||
"description": "Just a sketch.",
|
||||
}
|
||||
],
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
agents = await list_agents(client)
|
||||
a = agents[0]
|
||||
assert a.agent_id == "minimal"
|
||||
assert a.version is None
|
||||
assert a.capabilities == []
|
||||
assert a.supported_models == []
|
||||
assert a.persona_traits == {}
|
||||
assert a.ui_hints == {}
|
||||
|
||||
@respx.mock
|
||||
async def test_happy_multi_agent(self) -> None:
|
||||
"""happy_multi_agent: 3 agents preserve order."""
|
||||
respx.get("https://w.example/agents").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{"agent_id": "a", "name": "A", "description": "x"},
|
||||
{"agent_id": "b", "name": "B", "description": "y"},
|
||||
{"agent_id": "c", "name": "C", "description": "z"},
|
||||
],
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
agents = await list_agents(client)
|
||||
assert [a.agent_id for a in agents] == ["a", "b", "c"]
|
||||
|
||||
@respx.mock
|
||||
async def test_happy_empty(self) -> None:
|
||||
"""happy_empty: 200 with [] returns empty list (no error)."""
|
||||
respx.get("https://w.example/agents").mock(
|
||||
return_value=httpx.Response(200, json=[])
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
agents = await list_agents(client)
|
||||
assert agents == []
|
||||
|
||||
@respx.mock
|
||||
async def test_omit_capabilities_empty_list(self) -> None:
|
||||
"""omit_capabilities_empty: explicit [] from server still defaults to []."""
|
||||
respx.get("https://w.example/agents").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{
|
||||
"agent_id": "a",
|
||||
"name": "A",
|
||||
"description": "x",
|
||||
"capabilities": [],
|
||||
}
|
||||
],
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
agents = await list_agents(client)
|
||||
assert agents[0].capabilities == []
|
||||
|
||||
@respx.mock
|
||||
async def test_500_raises_session_api_failed(self) -> None:
|
||||
"""500 → SessionApiFailed with status=500."""
|
||||
respx.get("https://w.example/agents").mock(
|
||||
return_value=httpx.Response(500, content=b"oops")
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(SessionApiFailed) as excinfo:
|
||||
await list_agents(client)
|
||||
assert excinfo.value.status == 500
|
||||
|
||||
@respx.mock
|
||||
async def test_401_raises_session_api_failed(self) -> None:
|
||||
"""401 → SessionApiFailed with status=401."""
|
||||
respx.get("https://w.example/agents").mock(
|
||||
return_value=httpx.Response(401, content=b'{"error":"unauthorized"}')
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(SessionApiFailed) as excinfo:
|
||||
await list_agents(client)
|
||||
assert excinfo.value.status == 401
|
||||
|
||||
Reference in New Issue
Block a user