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:
vh
2026-06-18 00:21:36 -07:00
parent f533464c54
commit 7be162e84d
4 changed files with 301 additions and 4 deletions
+184
View File
@@ -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",