c62b4eecb3
Cut ratatoskr's consumer agent-lifecycle routes over to worldtree-sdk (issue #20 slice-4). Five routes now flow through `ratatoskr.wt` over the SDK's `client.agents.*`, returning open-world dicts and mapping the SDK's undiscriminated `ApiError` floor by route+(status,error_code) per INV-CUT-2: - `list_agents` → `agents.list` - `get_persona_state`→ `agents.persona_state` (404 persona_not_configured / 404 agent_not_available / 403 auth_scope_denied) - `define_agent` → `agents.define` (429→Tier3QuotaExceeded(retry_after=0), 403→Tier3UserIdUnsupported, 422 layer_deferred→…) - `patch_agent` → `agents.patch` (404→Tier3AgentNotFound, 422 field_not_mutable) - `delete_agent` → `agents.delete` (404→Tier3AgentNotFound; NOT hide-existence) Rewired call-sites: the `python -m ratatoskr.tier3` CLI (define/patch/delete) and the web `_agents_endpoint` / `_persona_state_endpoint`, both catching the SDK's `ConnectFailed` transport-failure normalization. Deleted the hand-rolled paths: `sessions.list_agents` / `get_persona_state` / `AgentInfo`, and `tier3.define/patch/delete_agent` / `Tier3AgentInfo` / parse+extract helpers. model→role fold (scope B): the define/patch response echoes `role` (spec 1.2 / b128), read off the open-world dict; `LocalAgentEntry.model`→`.role`, local-index schema v1→2 (old index discarded, no-backwards-compat). The Tier-3 caller-semantic exceptions move to `sessions.py`: running the CLI as `__main__` while `wt` imports `ratatoskr.tier3` bound two copies of each exception class, so a raised `Tier3AgentNotFound` escaped the CLI's `except` as an uncaught traceback. Homing them in `sessions` (never `__main__`) makes the class identity single. The live smoke — not the unit tests, which call `main()` in-process — caught this. Error-map rows + slice-4 notes added to the cutover contract; coverage-map re-anchored. LIVE-SMOKE on personal :8081 (b128): define(thoughtful-character) → patch → list(6 agents) → persona_state(→PersonaNotConfigured mapped) → delete → index empty; non-existent-id patch via `-m` → [agent_not_found] exit 20. Suite 465 green.
250 lines
11 KiB
Python
250 lines
11 KiB
Python
"""Tests for ratatoskr.sessions per docs/contracts/issues/2.contract.md."""
|
|
|
|
import httpx
|
|
import pytest
|
|
import respx
|
|
|
|
from ratatoskr.sessions import (
|
|
SessionApiFailed,
|
|
create_character,
|
|
delete_character,
|
|
endpoint_for_plane,
|
|
get_capabilities,
|
|
get_character_state,
|
|
get_me,
|
|
get_session_bifrost,
|
|
list_character_models,
|
|
)
|
|
|
|
|
|
class TestEndpointForPlane:
|
|
"""Issue #17 — endpoint_for_plane: plane name → Worldtree-visible base URL."""
|
|
|
|
def test_memory_plane_maps_to_8391(self) -> None:
|
|
"""memory [tracer]: 'memory' → http://<host>:8391 (POST-001)."""
|
|
assert endpoint_for_plane("memory", "10.100.10.50") == "http://10.100.10.50:8391"
|
|
|
|
def test_affect_plane_maps_to_8390(self) -> None:
|
|
"""affect: 'affect' → http://<host>:8390 (POST-001)."""
|
|
assert endpoint_for_plane("affect", "10.100.10.50") == "http://10.100.10.50:8390"
|
|
|
|
def test_combined_plane_maps_to_8392(self) -> None:
|
|
"""combined [#18 composite]: 'combined' → http://<host>:8392 (POST-001)."""
|
|
assert endpoint_for_plane("combined", "10.100.10.50") == "http://10.100.10.50:8392"
|
|
|
|
def test_unknown_plane_raises_value_error(self) -> None:
|
|
"""unknown_plane [adversarial]: any other plane → ValueError (PRE-001)."""
|
|
with pytest.raises(ValueError):
|
|
endpoint_for_plane("persona", "10.100.10.50")
|
|
|
|
|
|
class TestGetMe:
|
|
"""docs/contracts/issues/2.contract.md FN get_me (slice: capabilities+me)."""
|
|
|
|
@respx.mock
|
|
async def test_happy_authenticated(self) -> None:
|
|
"""happy_authenticated [happy,tracer]: 200 → parsed identity dict verbatim."""
|
|
respx.get("https://w.example/me").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={
|
|
"user_id": "alice",
|
|
"scopes": ["conversations.read", "conversations.write"],
|
|
"tier": "user",
|
|
"key_id": "a1b2c3d4",
|
|
"key_label": "alice phone",
|
|
},
|
|
)
|
|
)
|
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
me = await get_me(client)
|
|
assert me["user_id"] == "alice"
|
|
assert me["tier"] == "user"
|
|
assert me["key_id"] == "a1b2c3d4"
|
|
assert me["scopes"] == ["conversations.read", "conversations.write"]
|
|
|
|
@respx.mock
|
|
async def test_anonymous_dev_mode(self) -> None:
|
|
"""anonymous_dev_mode: 200 anonymous shape → dict with tier=anonymous."""
|
|
respx.get("https://w.example/me").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={
|
|
"user_id": "anonymous",
|
|
"scopes": ["conversations.read"],
|
|
"tier": "anonymous",
|
|
},
|
|
)
|
|
)
|
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
me = await get_me(client)
|
|
assert me["tier"] == "anonymous"
|
|
assert "key_id" not in me # optional fields omitted, not null
|
|
|
|
@respx.mock
|
|
async def test_401_raises_session_api_failed(self) -> None:
|
|
"""401_raises [error]: bad/absent key → SessionApiFailed(status=401)."""
|
|
respx.get("https://w.example/me").mock(
|
|
return_value=httpx.Response(401, json={"detail": "auth_invalid"})
|
|
)
|
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
with pytest.raises(SessionApiFailed) as exc:
|
|
await get_me(client)
|
|
assert exc.value.status == 401
|
|
|
|
|
|
class TestGetCapabilities:
|
|
"""docs/contracts/issues/2.contract.md FN get_capabilities (slice: capabilities+me)."""
|
|
|
|
@respx.mock
|
|
async def test_happy(self) -> None:
|
|
"""happy [happy]: 200 → ephemeral_templates dict verbatim."""
|
|
respx.get("https://w.example/capabilities").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={
|
|
"ephemeral_templates": {
|
|
"echo": {
|
|
"allowed_models": ["glm5-turbo", "glm4.7"],
|
|
"default_model": "glm5-turbo",
|
|
"system_prompt_max_bytes": 32768,
|
|
}
|
|
}
|
|
},
|
|
)
|
|
)
|
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
caps = await get_capabilities(client)
|
|
echo = caps["ephemeral_templates"]["echo"]
|
|
assert echo["default_model"] == "glm5-turbo"
|
|
assert echo["system_prompt_max_bytes"] == 32768
|
|
|
|
@respx.mock
|
|
async def test_non_200_raises(self) -> None:
|
|
"""non_200_raises [error]: 500 → SessionApiFailed(status=500)."""
|
|
respx.get("https://w.example/capabilities").mock(
|
|
return_value=httpx.Response(500, content=b"boom")
|
|
)
|
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
with pytest.raises(SessionApiFailed) as exc:
|
|
await get_capabilities(client)
|
|
assert exc.value.status == 500
|
|
|
|
|
|
class TestGetSessionBifrost:
|
|
"""#2 contract — get_session_bifrost (GET /admin/sessions/{id}/bifrost, #176)."""
|
|
|
|
@respx.mock
|
|
async def test_happy_uses_admin_bearer(self) -> None:
|
|
"""happy [happy,tracer]: 200 → binding dict; request carries the ADMIN bearer (override)."""
|
|
route = respx.get("https://w.example/admin/sessions/s1/bifrost").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={
|
|
"endpoint_url": "https://bifrost.example/mcp",
|
|
"consumer_id": "alice",
|
|
"connected": True,
|
|
"capabilities_granted": ["tools:call", "tools:read"],
|
|
"tools": [{"name": "bifrost.alice.echo", "description": "echo"}],
|
|
},
|
|
)
|
|
)
|
|
async with httpx.AsyncClient(
|
|
base_url="https://w.example",
|
|
headers={"Authorization": "Bearer consumer-key"},
|
|
) as client:
|
|
state = await get_session_bifrost(client, "s1", admin_key="admin-xyz")
|
|
assert state["connected"] is True
|
|
assert state["tools"][0]["name"] == "bifrost.alice.echo"
|
|
# the request overrode the client's default consumer bearer with the admin key
|
|
assert route.calls[0].request.headers["Authorization"] == "Bearer admin-xyz"
|
|
|
|
@respx.mock
|
|
async def test_403_scope_denied(self) -> None:
|
|
"""403 [error]: admin key lacks admin.sessions.read → SessionApiFailed(403)."""
|
|
respx.get("https://w.example/admin/sessions/s1/bifrost").mock(
|
|
return_value=httpx.Response(403, json={"error_code": "auth_scope_denied"})
|
|
)
|
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
with pytest.raises(SessionApiFailed) as exc:
|
|
await get_session_bifrost(client, "s1", admin_key="k")
|
|
assert exc.value.status == 403
|
|
|
|
@respx.mock
|
|
async def test_404_not_bound(self) -> None:
|
|
"""404 [error]: session_not_bifrost_bound → SessionApiFailed(404)."""
|
|
respx.get("https://w.example/admin/sessions/s1/bifrost").mock(
|
|
return_value=httpx.Response(404, json={"error_code": "session_not_bifrost_bound"})
|
|
)
|
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
with pytest.raises(SessionApiFailed) as exc:
|
|
await get_session_bifrost(client, "s1", admin_key="k")
|
|
assert exc.value.status == 404
|
|
|
|
@respx.mock
|
|
async def test_empty_admin_key_asserts(self) -> None:
|
|
"""empty_admin_key [adversarial]: '' → AssertionError; no HTTP issued."""
|
|
route = respx.get("https://w.example/admin/sessions/s1/bifrost").mock(
|
|
return_value=httpx.Response(200, json={})
|
|
)
|
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
with pytest.raises(AssertionError):
|
|
await get_session_bifrost(client, "s1", admin_key="")
|
|
assert route.call_count == 0
|
|
|
|
|
|
class TestTransientCharacters:
|
|
"""docs/contracts/issues/2.contract.md — transient-character wrappers (#161)."""
|
|
|
|
@respx.mock
|
|
async def test_list_models(self) -> None:
|
|
"""list_models [happy,tracer]: 200 → {items:[...]} verbatim."""
|
|
respx.get("https://w.example/models/available-for-characters").mock(
|
|
return_value=httpx.Response(200, json={"items": [{"name": "fast", "thinking": False}]})
|
|
)
|
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
models = await list_character_models(client)
|
|
assert models["items"][0]["name"] == "fast"
|
|
|
|
@respx.mock
|
|
async def test_create_body_and_response(self) -> None:
|
|
"""create [happy]: body is {character, state}; 201 → {character_id, ttl_expires_at}."""
|
|
import json as _json
|
|
|
|
route = respx.post("https://w.example/characters").mock(
|
|
return_value=httpx.Response(201, json={"character_id": "char_x", "ttl_expires_at": "t"})
|
|
)
|
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
out = await create_character(client, {"schema_version": "1", "name": "H"})
|
|
assert out["character_id"] == "char_x"
|
|
body = _json.loads(route.calls[0].request.content)
|
|
assert body == {"character": {"schema_version": "1", "name": "H"}, "state": None}
|
|
|
|
@respx.mock
|
|
async def test_get_state(self) -> None:
|
|
"""get_state [happy]: 200 → live PAD/emotions snapshot."""
|
|
respx.get("https://w.example/characters/char_x/state").mock(
|
|
return_value=httpx.Response(200, json={"schema_version": "1", "pad": [0.4, 0.1, -0.2]})
|
|
)
|
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
state = await get_character_state(client, "char_x")
|
|
assert state["pad"] == [0.4, 0.1, -0.2]
|
|
|
|
@respx.mock
|
|
async def test_delete_204(self) -> None:
|
|
"""delete [happy]: 204 → None."""
|
|
respx.delete("https://w.example/characters/char_x").mock(return_value=httpx.Response(204))
|
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
assert await delete_character(client, "char_x") is None
|
|
|
|
@respx.mock
|
|
async def test_create_403_scope(self) -> None:
|
|
"""create_403 [error]: key lacks character.write → SessionApiFailed(403)."""
|
|
respx.post("https://w.example/characters").mock(
|
|
return_value=httpx.Response(403, json={"error_code": "auth_scope_denied"})
|
|
)
|
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
with pytest.raises(SessionApiFailed) as exc:
|
|
await create_character(client, {"name": "H"})
|
|
assert exc.value.status == 403
|