feat(#17): create_session Bifrost-bind primitive (slice 1)
Slice 1 of issue #17 (Bifrost-binding the chat client) — the client-side BIND primitive, TDD'd against docs/contracts/issues/17.contract.md. - BifrostBinding{endpoint_url, scope=None} frozen dataclass (#160 shape) - create_session(..., bifrost=, consumer_key=): carries the bifrost body field and OVERRIDES the bearer to the consumer key per-request (INV-001 — never falls back to the canary key) - BifrostConsumerKeyMissing: raised BEFORE any HTTP when a binding lacks a non-empty key (PRE-001) - BifrostHandshakeFailed: 502 on a BOUND create -> carries detail.bifrost_error (both-shape unwrap per the persona_state wire lesson); gated on bifrost!=None so an unbound 502 stays SessionApiFailed (INV-002) - endpoint_for_plane: memory->:8391 / affect->:8390, invalid->ValueError 7 new tests; full suite 442 green; ruff clean.
This commit is contained in:
@@ -9,11 +9,15 @@ from ratatoskr.sessions import (
|
||||
AgentNotAvailable,
|
||||
AgentNotFound,
|
||||
AuthScopeDenied,
|
||||
BifrostBinding,
|
||||
BifrostConsumerKeyMissing,
|
||||
BifrostHandshakeFailed,
|
||||
InvalidCursor,
|
||||
PersonaNotConfigured,
|
||||
SessionApiFailed,
|
||||
SessionPage,
|
||||
create_session,
|
||||
endpoint_for_plane,
|
||||
get_persona_state,
|
||||
list_agents,
|
||||
list_sessions,
|
||||
@@ -206,6 +210,186 @@ class TestCreateSession:
|
||||
assert route.call_count == 0
|
||||
|
||||
|
||||
class TestCreateSessionBifrostBind:
|
||||
"""Issue #17 slice 1 — the create_session Bifrost-bind primitive."""
|
||||
|
||||
@respx.mock
|
||||
async def test_bind_happy_consumer_key_and_body(self) -> None:
|
||||
"""bind_happy [tracer]: a bifrost binding makes the body carry the
|
||||
`bifrost` field AND overrides the bearer to the consumer key (NOT the
|
||||
client's canary default), 201 → SessionInfo. Proves the bind path
|
||||
end-to-end (FN create_session STEPS 1-2, POST-001, INV-001)."""
|
||||
import json as _json
|
||||
|
||||
route = respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(
|
||||
201,
|
||||
json={
|
||||
"session_id": "s-bound",
|
||||
"agent_id": "ratatoskr:sindra",
|
||||
"message_count": 0,
|
||||
"created_at": "2026-06-18T12:00:00+00:00",
|
||||
"last_active": "2026-06-18T12:00:00+00:00",
|
||||
"metadata": {},
|
||||
},
|
||||
)
|
||||
)
|
||||
binding = BifrostBinding(endpoint_url="http://10.100.10.50:8391")
|
||||
async with httpx.AsyncClient(
|
||||
base_url="https://w.example",
|
||||
headers={"Authorization": "Bearer canary-key"},
|
||||
) as client:
|
||||
info = await create_session(
|
||||
client,
|
||||
"ratatoskr:sindra",
|
||||
end_user_id="smoke-user",
|
||||
bifrost=binding,
|
||||
consumer_key="consumer-key",
|
||||
)
|
||||
req = route.calls[0].request
|
||||
body = _json.loads(req.content)
|
||||
# body carries the bifrost field alongside agent_id/end_user_id
|
||||
assert body == {
|
||||
"agent_id": "ratatoskr:sindra",
|
||||
"end_user_id": "smoke-user",
|
||||
"bifrost": {
|
||||
"endpoint_url": "http://10.100.10.50:8391",
|
||||
"scope": None,
|
||||
},
|
||||
}
|
||||
# bearer overridden to the consumer key (INV-001: never the canary default)
|
||||
assert req.headers["Authorization"] == "Bearer consumer-key"
|
||||
assert info.session_id == "s-bound"
|
||||
assert info.agent_id == "ratatoskr:sindra"
|
||||
|
||||
@respx.mock
|
||||
async def test_bind_without_consumer_key_raises_before_http(self) -> None:
|
||||
"""missing_key [adversarial]: bifrost set but consumer_key None →
|
||||
BifrostConsumerKeyMissing BEFORE any HTTP (PRE-001, INV-001: never fall
|
||||
back to the canary key)."""
|
||||
route = respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(201, content=b"{}")
|
||||
)
|
||||
binding = BifrostBinding(endpoint_url="http://10.100.10.50:8391")
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(BifrostConsumerKeyMissing):
|
||||
await create_session(client, "ratatoskr:sindra", bifrost=binding)
|
||||
assert route.call_count == 0
|
||||
|
||||
@respx.mock
|
||||
async def test_bind_with_empty_consumer_key_raises_before_http(self) -> None:
|
||||
"""empty_key [adversarial]: empty-string consumer_key is also rejected
|
||||
before HTTP (PRE-001 requires a NON-EMPTY str)."""
|
||||
route = respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(201, content=b"{}")
|
||||
)
|
||||
binding = BifrostBinding(endpoint_url="http://10.100.10.50:8391")
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(BifrostConsumerKeyMissing):
|
||||
await create_session(
|
||||
client, "ratatoskr:sindra", bifrost=binding, consumer_key=""
|
||||
)
|
||||
assert route.call_count == 0
|
||||
|
||||
@respx.mock
|
||||
async def test_bind_handshake_failure_maps_to_502(self) -> None:
|
||||
"""handshake_502 [adversarial]: a bound create that 502s with
|
||||
detail.bifrost_error → BifrostHandshakeFailed carrying the bifrost_error
|
||||
+ raw body (POST-002, INV-002 bind-time failure). 'bifrost.auth_rejected'
|
||||
is the canary-key-instead-of-consumer-key tell."""
|
||||
respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(
|
||||
502,
|
||||
json={
|
||||
"error_code": "bifrost_handshake_failed",
|
||||
"detail": {"bifrost_error": "bifrost.auth_rejected"},
|
||||
},
|
||||
)
|
||||
)
|
||||
binding = BifrostBinding(endpoint_url="http://10.100.10.50:8391")
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(BifrostHandshakeFailed) as exc_info:
|
||||
await create_session(
|
||||
client, "ratatoskr:sindra", bifrost=binding, consumer_key="ck"
|
||||
)
|
||||
assert exc_info.value.bifrost_error == "bifrost.auth_rejected"
|
||||
# the raw 502 body is carried for debugging
|
||||
assert exc_info.value.body
|
||||
|
||||
@respx.mock
|
||||
async def test_bind_ephemeral_rejection_is_session_api_failed(self) -> None:
|
||||
"""ephemeral_422 [boundary]: 422 ephemeral_does_not_accept_bifrost is a
|
||||
generic create failure → SessionApiFailed, NOT a distinct exception
|
||||
(POST-003 — deliberate, an operator config error)."""
|
||||
respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(
|
||||
422, json={"error_code": "ephemeral_does_not_accept_bifrost"}
|
||||
)
|
||||
)
|
||||
binding = BifrostBinding(endpoint_url="http://10.100.10.50:8391")
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(SessionApiFailed) as exc_info:
|
||||
await create_session(
|
||||
client, "echo", bifrost=binding, consumer_key="ck"
|
||||
)
|
||||
assert exc_info.value.status == 422
|
||||
|
||||
@respx.mock
|
||||
async def test_unbound_create_unchanged_no_auth_override(self) -> None:
|
||||
"""unbound_unchanged [regression]: with no bifrost, the body is the
|
||||
pre-#17 shape AND create_session sends NO per-request Authorization
|
||||
override — the client's default canary bearer governs (INV-001: the two
|
||||
call sites never cross)."""
|
||||
import json as _json
|
||||
|
||||
route = respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(
|
||||
201,
|
||||
json={
|
||||
"session_id": "s1",
|
||||
"agent_id": "mimir",
|
||||
"message_count": 0,
|
||||
"created_at": "2026-04-15T12:00:00+00:00",
|
||||
"last_active": "2026-04-15T12:00:00+00:00",
|
||||
"metadata": {},
|
||||
},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(
|
||||
base_url="https://w.example",
|
||||
headers={"Authorization": "Bearer canary-key"},
|
||||
) as client:
|
||||
await create_session(client, "mimir")
|
||||
req = route.calls[0].request
|
||||
body = _json.loads(req.content)
|
||||
assert body == {"agent_id": "mimir"}
|
||||
# the client default bearer is used unchanged — no consumer-key override
|
||||
assert req.headers["Authorization"] == "Bearer canary-key"
|
||||
|
||||
|
||||
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_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")
|
||||
|
||||
|
||||
def _list_item(
|
||||
*,
|
||||
session_id: str = "s1",
|
||||
|
||||
Reference in New Issue
Block a user