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:
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user