feat(#19): ephemeral-template (Echo) session creation

create_session could only mint foundational sessions; an ephemeral template
(agent_id="echo") returned 422 ephemeral_requires_config because ratatoskr never
sent the required config block — Echo was uncreatable, surfacing as an opaque
session_api_failed at the CLI. Thread an opaque, role/model-agnostic config
passthrough through the create path so Echo sessions are creatable.

- sessions.py: create_session(config=...) verbatim passthrough (PRE-004 Mapping /
  PRE-005 config-xor-bifrost guards); SessionInfo gains kind + config, captured
  defensively (.get) on both create and list.
- cli.py: --system-prompt flag builds config={"system_prompt": ...} (validation:
  non-empty, requires --new+--agent, xor bifrost); _amain surfaces kind=; the
  --whoami renderer now reads allowed_roles/default_role (was reading the dead
  allowed_models/default_model) and tolerates a malformed capabilities shape.
- contract #2 amended (Amendment 2026-07-18); Heid-panel contract-reviewed +
  diff-scoped bug-hunted (one whoami null-join gap found + fixed).

Canonical grounding: config.role, never config.model (worldtree-dev althing
01KXT976NN91DRBZBPXNZ2BVZR; ADR-0012 role cutover). Verified end-to-end against
the live v0.16.2 target. TDD across create + CLI; full suite green (534).

Closes #19.
This commit is contained in:
vh
2026-07-18 12:02:18 -07:00
parent 5d06a274bf
commit c7016f23a6
7 changed files with 481 additions and 10 deletions
+142
View File
@@ -222,6 +222,148 @@ class TestCreateSession:
assert route.call_count == 0
class TestCreateSessionEphemeral:
"""Amendment 2026-07-18 — ephemeral-template (Echo) session creation."""
@respx.mock
async def test_happy_ephemeral_create(self) -> None:
"""happy_ephemeral_create [happy,tracer]: config threaded; kind/config captured."""
import json as _json
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
201,
json={
"session_id": "eph-1",
"agent_id": "echo",
"kind": "ephemeral",
"message_count": 0,
"created_at": "2026-07-18T09:30:13+00:00",
"last_active": "2026-07-18T09:30:13+00:00",
"metadata": {},
"config": {"system_prompt": "You are X.", "role": "echo"},
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
info = await create_session(
client, "echo", config={"system_prompt": "You are X."}
)
body = _json.loads(route.calls[0].request.content)
assert body == {"agent_id": "echo", "config": {"system_prompt": "You are X."}}
assert info.kind == "ephemeral"
assert info.config == {"system_prompt": "You are X.", "role": "echo"}
@respx.mock
async def test_foundational_omits_config(self) -> None:
"""foundational_omits_config [trace]: no config key; kind/config None when absent."""
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") as client:
info = await create_session(client, "mimir")
body = _json.loads(route.calls[0].request.content)
assert body == {"agent_id": "mimir"}
assert info.kind is None
assert info.config is None
@respx.mock
async def test_foundational_captures_kind(self) -> None:
"""foundational_captures_kind [happy]: kind='foundational' captured; config None."""
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
201,
json={
"session_id": "s1",
"agent_id": "mimir",
"kind": "foundational",
"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") as client:
info = await create_session(client, "mimir")
assert info.kind == "foundational"
assert info.config is None
@respx.mock
async def test_ephemeral_requires_config_422(self) -> None:
"""ephemeral_requires_config_422 [error]: 422 → SessionApiFailed carrying the code."""
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
422,
json={"detail": {"error_code": "ephemeral_requires_config"}},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await create_session(client, "echo")
assert exc.value.status == 422
assert b"ephemeral_requires_config" in exc.value.body
@respx.mock
async def test_model_not_allowed_422(self) -> None:
"""model_not_allowed_422 [error]: config.model → 422 (wrapper passes config verbatim)."""
import json as _json
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
422, json={"detail": {"error_code": "model_not_allowed"}}
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await create_session(
client, "echo", config={"system_prompt": "x", "model": "glm5-turbo"}
)
body = _json.loads(route.calls[0].request.content)
assert body["config"] == {"system_prompt": "x", "model": "glm5-turbo"}
assert exc.value.status == 422
@respx.mock
async def test_config_and_bifrost_conflict(self) -> None:
"""config_and_bifrost_conflict [adversarial]: both → AssertionError (PRE-005); no HTTP."""
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, content=b"{}")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await create_session(
client,
"echo",
config={"system_prompt": "x"},
bifrost=BifrostBinding(endpoint_url="https://p.example"),
consumer_key="ck",
)
assert route.call_count == 0
@respx.mock
async def test_config_not_a_mapping(self) -> None:
"""config_not_a_mapping [adversarial]: non-Mapping → AssertionError (PRE-004); no HTTP."""
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, content=b"{}")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await create_session(client, "echo", config="not-a-dict") # type: ignore[arg-type]
assert route.call_count == 0
class TestCreateSessionBifrostBind:
"""Issue #17 slice 1 — the create_session Bifrost-bind primitive."""