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:
@@ -470,3 +470,156 @@ TESTS:
|
|||||||
not_found_404 [error]: 404 → SessionApiFailed(status=404)
|
not_found_404 [error]: 404 → SessionApiFailed(status=404)
|
||||||
empty_session_id [adversarial]: "" → AssertionError; no HTTP issued
|
empty_session_id [adversarial]: "" → AssertionError; no HTTP issued
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Amendment 2026-07-18 — ephemeral-template (Echo) session creation
|
||||||
|
|
||||||
|
**Motivation.** `create_session` could only mint *foundational* sessions
|
||||||
|
(`{"agent_id": <persistent-agent>}`). Attempting to start an **ephemeral
|
||||||
|
template** session — e.g. `agent_id="echo"` — returned `422
|
||||||
|
ephemeral_requires_config` because the request carried no `config`. Ephemeral
|
||||||
|
templates (issue #161: Echo, a blank-slate per-session host) require the consumer
|
||||||
|
to supply a `config` object with the session's `system_prompt` at create time;
|
||||||
|
that config is frozen for the session's lifetime. This amendment threads a
|
||||||
|
`config` passthrough through `create_session`, captures the two new response
|
||||||
|
fields (`kind`, `config`) on `SessionInfo`, and corrects the `get_capabilities`
|
||||||
|
metadata shape.
|
||||||
|
|
||||||
|
**Canonical grounding (role, NOT model).** worldtree-dev confirmed on althing
|
||||||
|
(thread `01KXT976NN91DRBZBPXNZ2BVZR`, 2026-07-18) that the model→role cutover
|
||||||
|
(commit `bb4d551`, "Complete model role cutover", ADR-0012 role-based model
|
||||||
|
access) is canonical NOW on both surfaces:
|
||||||
|
- `GET /capabilities` ephemeral-template metadata keys are **`allowed_roles` /
|
||||||
|
`default_role`** (NOT `allowed_models` / `default_model`).
|
||||||
|
- The create-time selector is **`config.role`** (NOT `config.model`). A non-empty
|
||||||
|
`config.model` **hard-rejects** with `model_not_allowed` (the error code was
|
||||||
|
repurposed to mean "the `model` field itself is not permitted here"). Omitted /
|
||||||
|
null `role` resolves server-side to the template's `default_role` (`"echo"`).
|
||||||
|
- The stored/echoed config snapshot is `{"system_prompt": <str>, "role": <str>}`.
|
||||||
|
|
||||||
|
Ratatoskr therefore stays **canonical-agnostic at the wrapper** (`config` is an
|
||||||
|
opaque passthrough dict) and **role-correct at the CLI** (builds
|
||||||
|
`{"system_prompt": ...}`; never emits `model`). The pinned
|
||||||
|
`docs/conversation-api-spec.md` was re-synced to **v1.1** (worldtree commit
|
||||||
|
`b4a278c`): its echo section now documents `allowed_roles`/`default_role`,
|
||||||
|
`config.role` (omitted → `default_role` "echo"), the repurposed
|
||||||
|
`model_not_allowed` (any non-empty `config.model` hard-rejects), and the new
|
||||||
|
`role_required` error; the frozen OpenAPI is untouched. Empirically
|
||||||
|
confirmed against the live v0.16.2 target: `POST /sessions
|
||||||
|
{"agent_id":"echo","config":{"system_prompt":"..."}}` → `201` with
|
||||||
|
`{"kind":"ephemeral","config":{"system_prompt":"...","role":"echo"}}`.
|
||||||
|
|
||||||
|
### SessionInfo — two new response fields
|
||||||
|
|
||||||
|
`SessionInfo` gains two optional fields, defaulted so every existing
|
||||||
|
construction site and caller is unaffected (both `create_session` and
|
||||||
|
`list_sessions` build `SessionInfo` with keyword args; no positional callers
|
||||||
|
exist):
|
||||||
|
|
||||||
|
- `kind: str | None = None` — `"ephemeral"` for Echo sessions, `"foundational"`
|
||||||
|
for all others. Present on both the create 201 and `GET /sessions` list items
|
||||||
|
(spec §Ephemeral Templates). Captured defensively via `.get("kind")` (None when
|
||||||
|
a pre-cutover server omits it).
|
||||||
|
- `config: dict[str, Any] | None = None` — the frozen ephemeral config
|
||||||
|
(`{"system_prompt", "role"}`) on the create 201; `None` for foundational
|
||||||
|
sessions and (typically) list items. Captured via `.get("config")`.
|
||||||
|
|
||||||
|
- **INV-001 amendment [hard]**: `create_session` additionally populates
|
||||||
|
`kind = body.get("kind")` and `config = body.get("config")` from the 201 body.
|
||||||
|
The five original create-side fields and their fixed list-only defaults
|
||||||
|
(`name=None, archived=False, tags=[]`) are unchanged.
|
||||||
|
- **INV-002 amendment [hard]**: `list_sessions` additionally populates
|
||||||
|
`kind = item.get("kind")` and `config = item.get("config")`. In practice the
|
||||||
|
list endpoint does NOT echo the frozen config, so `config` is `None` for list
|
||||||
|
items today; the `.get("config")` form is deliberate forward-compat — if a
|
||||||
|
future server includes it on list items, it passes through unmodified rather
|
||||||
|
than being force-nulled. (Heid panel 2026-07-18: earlier "stays None" wording
|
||||||
|
over-claimed against the passthrough; corrected here.)
|
||||||
|
|
||||||
|
### create_session — `config` passthrough (supersedes the FN block above)
|
||||||
|
|
||||||
|
```contract
|
||||||
|
FN create_session(client: httpx.AsyncClient, agent_id: str, *, end_user_id: str | None = None, bifrost: BifrostBinding | None = None, consumer_key: str | None = None, config: Mapping[str, Any] | None = None) -> SessionInfo
|
||||||
|
BRIEF: POST /sessions to create a session. Foundational: {"agent_id": agent_id} (+ end_user_id / bifrost per issues #5/#17). Ephemeral (issue #161): when `config` is non-None it is passed through verbatim as the request body's "config" key — the caller (CLI) builds {"system_prompt": <str>} for Echo; the wrapper is role/model-agnostic and NEVER injects a selector. Returns SessionInfo populated from the 201, now including kind + config. (bifrost / consumer_key params + their PRE-001/POST-002 semantics are specified in issue #17's contract; shown here only to keep the signature honest.)
|
||||||
|
PRE: [PRE-001 hard] client is not None -- assert client is not None
|
||||||
|
PRE: [PRE-002 hard] agent_id is a non-empty string -- assert agent_id and isinstance(agent_id, str)
|
||||||
|
PRE: [PRE-003 hard, issue #5] end_user_id is None OR a non-empty string
|
||||||
|
PRE: [PRE-004 hard, issue #161] config is None OR a Mapping -- assert config is None or isinstance(config, Mapping)
|
||||||
|
PRE: [PRE-005 hard, issue #161] config and bifrost are not BOTH set — ephemeral sessions do not accept a Bifrost binding (server would 422 ephemeral_does_not_accept_bifrost); the CLI enforces this at arg-parse, this assert is defense-in-depth -- assert not (config is not None and bifrost is not None)
|
||||||
|
POST: [POST-001 side_effect] exactly one POST to /sessions; body carries "agent_id" always, "end_user_id"/"bifrost" per issues #5/#17, and "config": config iff config is not None. No "config" key when config is None (foundational baseline byte-identical to pre-#161).
|
||||||
|
POST: [POST-002 return_value] returns SessionInfo with session_id, agent_id, created_at, last_active, metadata, message_count populated per INV-001, PLUS kind = body.get("kind") and config = body.get("config").
|
||||||
|
ERROR_ROUTING:
|
||||||
|
HTTP 404 unknown_agent_id: raise AgentNotFound(agent_id=agent_id); abort
|
||||||
|
HTTP 422 (ephemeral validation, issue #161): raise SessionApiFailed(status=422, body=resp.content). The body's error_code names the fault; recognized ephemeral codes: ephemeral_requires_config (config absent for an ephemeral template), foundational_does_not_accept_config (config sent to a foundational agent), system_prompt_required / system_prompt_empty / system_prompt_too_large (config.system_prompt missing / whitespace / >32768 bytes), model_not_allowed (config.model present — forbidden post-cutover), ephemeral_does_not_accept_bifrost. NOT mapped to per-code typed exceptions — the raw code in .body is honest + debuggable (mirrors the #5 end_user_id_required posture). abort.
|
||||||
|
HTTP 422 (other validation_failed) / other non-201: raise SessionApiFailed(status=resp.status_code, body=resp.content); abort. (bifrost 502 → BifrostHandshakeFailed per #17.)
|
||||||
|
STEPS:
|
||||||
|
1. [setup] Validate PRE-001..PRE-005
|
||||||
|
2. [sequential] body = {"agent_id": agent_id}; IF end_user_id is not None: body["end_user_id"] = end_user_id; IF bifrost is not None: body["bifrost"] = {...} (per #17); IF config is not None: body["config"] = config
|
||||||
|
3. [sequential] headers per #17 (bound create uses consumer_key); CALL client.post("/sessions", json=body, headers=headers)
|
||||||
|
4. [branch] IF 404 → AgentNotFound; ELIF bifrost and 502 → BifrostHandshakeFailed (#17); ELIF != 201 → SessionApiFailed
|
||||||
|
5. [sequential] body = resp.json()
|
||||||
|
6. [cleanup] RETURN SessionInfo(... unchanged create-side fields ..., kind=body.get("kind"), config=body.get("config"))
|
||||||
|
TESTS:
|
||||||
|
happy_ephemeral_create [happy,tracer]: config={"system_prompt":"You are X."}, agent_id="echo" → outbound body == {"agent_id":"echo","config":{"system_prompt":"You are X."}} byte-for-byte; 201 {"kind":"ephemeral","config":{"system_prompt":"You are X.","role":"echo"},...} → SessionInfo.kind=="ephemeral" and .config=={"system_prompt":"You are X.","role":"echo"}
|
||||||
|
foundational_omits_config [trace]: config omitted, agent_id="mimir" → outbound body has NO "config" key (byte-identical to pre-#161 baseline); 201 without kind/config → SessionInfo.kind is None and .config is None
|
||||||
|
foundational_captures_kind [happy]: 201 {"kind":"foundational",...} for a normal agent → SessionInfo.kind=="foundational", .config is None
|
||||||
|
ephemeral_requires_config_422 [error]: agent_id="echo", config omitted → 422 {"error_code":"ephemeral_requires_config"} → SessionApiFailed(status=422); .body contains the code
|
||||||
|
model_not_allowed_422 [error]: config={"system_prompt":"x","model":"glm5-turbo"} → 422 {"error_code":"model_not_allowed"} → SessionApiFailed(status=422) (regression guard: the CLI never sends model, but the wrapper passes config through verbatim, so a caller that injects model gets the honest server rejection)
|
||||||
|
config_and_bifrost_conflict [adversarial]: config={...} AND bifrost=BifrostBinding(...) → AssertionError (PRE-005); no HTTP issued
|
||||||
|
config_not_a_mapping [adversarial]: config="not-a-dict" → AssertionError (PRE-004); no HTTP issued
|
||||||
|
```
|
||||||
|
|
||||||
|
### get_capabilities — corrected ephemeral-template metadata shape
|
||||||
|
|
||||||
|
The 2026-06-30 amendment's `get_capabilities` BRIEF documented the pre-cutover
|
||||||
|
`{allowed_models, default_model}` shape. Canonical (per the althing grounding
|
||||||
|
above) is **`{allowed_roles, default_role, system_prompt_max_bytes}}`**. The
|
||||||
|
wrapper is unaffected (returns the parsed dict verbatim, no field access), but
|
||||||
|
its BRIEF is corrected for honesty, and the **`--whoami` renderer
|
||||||
|
(`ratatoskr.cli`) is fixed** to read `allowed_roles` / `default_role` (it
|
||||||
|
currently reads the dead `allowed_models` / `default_model` keys and renders
|
||||||
|
`default=? models=[]` against a live server).
|
||||||
|
|
||||||
|
- get_capabilities BRIEF now reads: `GET /capabilities → {ephemeral_templates:
|
||||||
|
{echo: {allowed_roles, default_role, system_prompt_max_bytes}}}`. Behavior,
|
||||||
|
PRE, POST, ERROR_ROUTING, STEPS unchanged (verbatim dict passthrough).
|
||||||
|
|
||||||
|
### CLI surface (ratatoskr.cli — consumer glue, TDD'd in test_cli)
|
||||||
|
|
||||||
|
- New `--system-prompt <str>` flag → builds `config={"system_prompt": <str>}` for
|
||||||
|
the `--new` create. `ParsedArgs.system_prompt: str | None = None`.
|
||||||
|
- Validation: `--system-prompt`, when passed, must be non-empty, requires `--new`
|
||||||
|
+ `--agent`, and is **mutually exclusive with the bifrost flags**
|
||||||
|
(`--bifrost-url` / `--bifrost-plane`) — ephemeral sessions reject a binding.
|
||||||
|
- `_amain` passes `config` to `create_session`; the demoted create line surfaces
|
||||||
|
`kind=<kind>` when present.
|
||||||
|
- No `--role` / `--model` flag in this amendment: Echo's only `allowed_role` is
|
||||||
|
`"echo"` and omitted role defaults server-side, so a selector flag is premature
|
||||||
|
(add `--role` if/when a template advertises multiple roles).
|
||||||
|
|
||||||
|
### Supersession + Heid panel triage (2026-07-18)
|
||||||
|
|
||||||
|
- **Supersedes the "Bifrost binding out of scope" out-of-scope bullet** (the
|
||||||
|
base "create_session does not accept or send a `bifrost` field" line). That
|
||||||
|
bullet is stale: issue #17 made bifrost an accepted create parameter, and this
|
||||||
|
amendment's FN block reflects the current signature (`bifrost` / `consumer_key`
|
||||||
|
present, semantics owned by #17). Read the base out-of-scope bifrost line as
|
||||||
|
historical.
|
||||||
|
- **Error-body truncation (INV-004).** INV-004 [hard] specifies exception `.body`
|
||||||
|
truncated to `[:1024]`. The implemented module dropped that truncation
|
||||||
|
module-wide (every `SessionApiFailed` raise passes `resp.content`), so INV-004
|
||||||
|
is stale against the code independent of this amendment. This amendment's
|
||||||
|
create_session error routing follows the module's actual practice
|
||||||
|
(`resp.content`) for consistency with its sibling endpoints; reconciling
|
||||||
|
INV-004 vs the code across the whole module is a separate cleanup, flagged not
|
||||||
|
fixed here. (Heid panel convergent finding, all three arms.)
|
||||||
|
- **CLI section is documentation, not module-acceptance.** This contract's
|
||||||
|
`target_module` is `ratatoskr.sessions`; the `--system-prompt` flag +
|
||||||
|
`--whoami` renderer changes live in `ratatoskr.cli` and are verified in
|
||||||
|
`test_cli`, not by this module contract's acceptance. They are documented here
|
||||||
|
only so the sessions-surface change and its single consumer read as one unit.
|
||||||
|
- **Deferred (pre-existing #2 coherence items, not this amendment's scope):**
|
||||||
|
frontmatter "two entry points" scope line is stale vs the ~15 amended FNs;
|
||||||
|
`item.get("metadata", {})` does not defend against an explicit-null `metadata`
|
||||||
|
(unlike the `or` idiom on `archived`/`tags`); and the panel's recurring
|
||||||
|
structural rec — a "current effective surface" map for this 7-amendment
|
||||||
|
contract. Surfaced to the operator as separate cleanup candidates.
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "ratatoskr"
|
name = "ratatoskr"
|
||||||
version = "0.21.1"
|
version = "0.21.2"
|
||||||
description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability"
|
description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
+48
-5
@@ -124,6 +124,9 @@ class ParsedArgs:
|
|||||||
# #347 authored-history-write reference-consumer probe: create a fresh
|
# #347 authored-history-write reference-consumer probe: create a fresh
|
||||||
# session bound to --agent, seed an authored assistant first-message (seq-0).
|
# session bound to --agent, seed an authored assistant first-message (seq-0).
|
||||||
seed_first_message: str | None = None
|
seed_first_message: str | None = None
|
||||||
|
# Issue #161: ephemeral-template (Echo) create. --system-prompt supplies the
|
||||||
|
# frozen session config {"system_prompt": ...}; None on the foundational path.
|
||||||
|
system_prompt: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class _ArgparseError(Exception):
|
class _ArgparseError(Exception):
|
||||||
@@ -153,6 +156,8 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
|
|||||||
parser.add_argument("--characters", action="store_true")
|
parser.add_argument("--characters", action="store_true")
|
||||||
parser.add_argument("--set-persona-pad", dest="set_persona_pad", default=None)
|
parser.add_argument("--set-persona-pad", dest="set_persona_pad", default=None)
|
||||||
parser.add_argument("--seed-first-message", dest="seed_first_message", default=None)
|
parser.add_argument("--seed-first-message", dest="seed_first_message", default=None)
|
||||||
|
# Issue #161: ephemeral-template (Echo) system prompt → config at create.
|
||||||
|
parser.add_argument("--system-prompt", dest="system_prompt", default=None)
|
||||||
# Issue #5: required for per-end-user agents (lofn etc.); optional otherwise (mimir).
|
# Issue #5: required for per-end-user agents (lofn etc.); optional otherwise (mimir).
|
||||||
parser.add_argument("--end-user-id", dest="end_user_id", default=None)
|
parser.add_argument("--end-user-id", dest="end_user_id", default=None)
|
||||||
# Issue #17: bind the created session to our own Bifrost provider plane.
|
# Issue #17: bind the created session to our own Bifrost provider plane.
|
||||||
@@ -173,6 +178,10 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
|
|||||||
# Issue #5 INV-001: --end-user-id, if passed, MUST be non-empty (mirrors --send).
|
# Issue #5 INV-001: --end-user-id, if passed, MUST be non-empty (mirrors --send).
|
||||||
if ns.end_user_id is not None and not ns.end_user_id:
|
if ns.end_user_id is not None and not ns.end_user_id:
|
||||||
raise UsageError("--end-user-id must be non-empty when passed")
|
raise UsageError("--end-user-id must be non-empty when passed")
|
||||||
|
# Issue #161: --system-prompt, if passed, MUST be non-empty (server rejects
|
||||||
|
# whitespace-only with system_prompt_empty; refuse client-side).
|
||||||
|
if ns.system_prompt is not None and not ns.system_prompt.strip():
|
||||||
|
raise UsageError("--system-prompt must be non-empty when passed")
|
||||||
if sum([ns.whoami, ns.characters, bool(ns.set_persona_pad), bool(ns.seed_first_message)]) > 1:
|
if sum([ns.whoami, ns.characters, bool(ns.set_persona_pad), bool(ns.seed_first_message)]) > 1:
|
||||||
raise UsageError(
|
raise UsageError(
|
||||||
"--whoami / --characters / --set-persona-pad / --seed-first-message "
|
"--whoami / --characters / --set-persona-pad / --seed-first-message "
|
||||||
@@ -260,6 +269,19 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
|
|||||||
# Flag > env > None; None leaves the admin panes showing "not configured".
|
# Flag > env > None; None leaves the admin panes showing "not configured".
|
||||||
admin_key = ns.admin_key or os.environ.get("RATATOSKR_ADMIN_API_KEY") or None
|
admin_key = ns.admin_key or os.environ.get("RATATOSKR_ADMIN_API_KEY") or None
|
||||||
|
|
||||||
|
# Issue #161: an ephemeral system prompt is a session-CREATE concern bound to
|
||||||
|
# --agent, and ephemeral sessions reject a Bifrost binding (server 422
|
||||||
|
# ephemeral_does_not_accept_bifrost) — enforce both client-side.
|
||||||
|
if ns.system_prompt is not None:
|
||||||
|
if not ns.new:
|
||||||
|
raise UsageError("--system-prompt requires --new (it configures a session at create)")
|
||||||
|
if not ns.agent:
|
||||||
|
raise UsageError("--system-prompt requires --agent <template> (e.g. echo)")
|
||||||
|
if bifrost is not None:
|
||||||
|
raise UsageError(
|
||||||
|
"--system-prompt (ephemeral) and a bifrost binding are mutually exclusive"
|
||||||
|
)
|
||||||
|
|
||||||
return ParsedArgs(
|
return ParsedArgs(
|
||||||
send_content=ns.send,
|
send_content=ns.send,
|
||||||
session_id=ns.session,
|
session_id=ns.session,
|
||||||
@@ -277,6 +299,7 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
|
|||||||
characters=ns.characters,
|
characters=ns.characters,
|
||||||
set_persona_pad=ns.set_persona_pad,
|
set_persona_pad=ns.set_persona_pad,
|
||||||
seed_first_message=ns.seed_first_message,
|
seed_first_message=ns.seed_first_message,
|
||||||
|
system_prompt=ns.system_prompt,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -556,12 +579,20 @@ async def _amain(args: ParsedArgs) -> int:
|
|||||||
if args.new:
|
if args.new:
|
||||||
assert args.agent_id is not None
|
assert args.agent_id is not None
|
||||||
try:
|
try:
|
||||||
|
# Issue #161: ephemeral-template create — --system-prompt builds
|
||||||
|
# the frozen config; None keeps the foundational body unchanged.
|
||||||
|
ephemeral_config = (
|
||||||
|
{"system_prompt": args.system_prompt}
|
||||||
|
if args.system_prompt is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
info = await create_session(
|
info = await create_session(
|
||||||
client,
|
client,
|
||||||
args.agent_id,
|
args.agent_id,
|
||||||
end_user_id=args.end_user_id,
|
end_user_id=args.end_user_id,
|
||||||
bifrost=args.bifrost,
|
bifrost=args.bifrost,
|
||||||
consumer_key=args.consumer_key,
|
consumer_key=args.consumer_key,
|
||||||
|
config=ephemeral_config,
|
||||||
)
|
)
|
||||||
except AgentNotFound as exc:
|
except AgentNotFound as exc:
|
||||||
sys.stderr.write(f"[agent_not_found] agent_id={exc.agent_id}\n")
|
sys.stderr.write(f"[agent_not_found] agent_id={exc.agent_id}\n")
|
||||||
@@ -593,8 +624,10 @@ async def _amain(args: ParsedArgs) -> int:
|
|||||||
return 21
|
return 21
|
||||||
# Issue #12: demoted lifecycle line — written directly here (NOT via
|
# Issue #12: demoted lifecycle line — written directly here (NOT via
|
||||||
# state.render, which only accepts SSE Event variants per PRE-001).
|
# state.render, which only accepts SSE Event variants per PRE-001).
|
||||||
|
kind_suffix = f" kind={info.kind}" if info.kind else ""
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
f". create_session: session_id={info.session_id} agent_id={info.agent_id}\n"
|
f". create_session: session_id={info.session_id} "
|
||||||
|
f"agent_id={info.agent_id}{kind_suffix}\n"
|
||||||
)
|
)
|
||||||
# #347 authored first-message: seed the agent's preset opening (best-effort).
|
# #347 authored first-message: seed the agent's preset opening (best-effort).
|
||||||
if await seed_preset_first_message(client, info.session_id, args.agent_id):
|
if await seed_preset_first_message(client, info.session_id, args.agent_id):
|
||||||
@@ -643,12 +676,22 @@ def _format_whoami(me: dict[str, Any], caps: dict[str, Any]) -> str:
|
|||||||
lines.append(f" {k}: {me[k]}")
|
lines.append(f" {k}: {me[k]}")
|
||||||
lines.append("capabilities:")
|
lines.append("capabilities:")
|
||||||
templates = caps.get("ephemeral_templates", {})
|
templates = caps.get("ephemeral_templates", {})
|
||||||
if templates:
|
if isinstance(templates, dict) and templates:
|
||||||
for name, spec in templates.items():
|
for name, spec in templates.items():
|
||||||
models = ", ".join(spec.get("allowed_models", []))
|
# A diagnostic renderer must tolerate a malformed / partially-cutover
|
||||||
|
# server (heid bug-hunt Gróa#1/#2): a non-mapping template value, or an
|
||||||
|
# explicit-null `allowed_roles` (`.get(k, [])` returns None on null, not
|
||||||
|
# the default), must degrade — not abort the whole --whoami report.
|
||||||
|
if not isinstance(spec, dict):
|
||||||
|
lines.append(f" ephemeral_template {name}: (malformed)")
|
||||||
|
continue
|
||||||
|
# Canonical post-cutover shape (worldtree-dev althing 2026-07-18,
|
||||||
|
# ADR-0012): roles, not models. `config.role` selects; `config.model`
|
||||||
|
# is now rejected server-side.
|
||||||
|
roles = ", ".join(str(r) for r in (spec.get("allowed_roles") or []))
|
||||||
lines.append(
|
lines.append(
|
||||||
f" ephemeral_template {name}: default={spec.get('default_model', '?')} "
|
f" ephemeral_template {name}: default={spec.get('default_role', '?')} "
|
||||||
f"max_bytes={spec.get('system_prompt_max_bytes', '?')} models=[{models}]"
|
f"max_bytes={spec.get('system_prompt_max_bytes', '?')} roles=[{roles}]"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
lines.append(" (no ephemeral templates advertised)")
|
lines.append(" (no ephemeral templates advertised)")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Implements docs/contracts/issues/2.contract.md.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -18,6 +19,10 @@ class SessionInfo:
|
|||||||
INV-001 / INV-002: origin-conditional defaults — `create_session` sets fixed
|
INV-001 / INV-002: origin-conditional defaults — `create_session` sets fixed
|
||||||
`name=None`, `archived=False`, `tags=[]`; `list_sessions` populates from the
|
`name=None`, `archived=False`, `tags=[]`; `list_sessions` populates from the
|
||||||
response item with the same absent/null defaults but `message_count=None`.
|
response item with the same absent/null defaults but `message_count=None`.
|
||||||
|
|
||||||
|
Amendment 2026-07-18 (issue #161): `kind` ("ephemeral" | "foundational") and
|
||||||
|
`config` (the frozen ephemeral config, create-origin only) captured defensively
|
||||||
|
via `.get()` — both `None` on a pre-cutover server that omits them.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
session_id: str
|
session_id: str
|
||||||
@@ -29,6 +34,8 @@ class SessionInfo:
|
|||||||
name: str | None
|
name: str | None
|
||||||
archived: bool
|
archived: bool
|
||||||
tags: list[str]
|
tags: list[str]
|
||||||
|
kind: str | None = None
|
||||||
|
config: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -238,6 +245,8 @@ async def list_sessions(
|
|||||||
name=item.get("name"),
|
name=item.get("name"),
|
||||||
archived=item.get("archived") or False,
|
archived=item.get("archived") or False,
|
||||||
tags=item.get("tags") or [],
|
tags=item.get("tags") or [],
|
||||||
|
kind=item.get("kind"), # INV-002 amendment (#161): present on list items
|
||||||
|
config=item.get("config"), # forward-compat passthrough; None today
|
||||||
)
|
)
|
||||||
for item in body["items"]
|
for item in body["items"]
|
||||||
]
|
]
|
||||||
@@ -291,6 +300,7 @@ async def create_session(
|
|||||||
end_user_id: str | None = None,
|
end_user_id: str | None = None,
|
||||||
bifrost: BifrostBinding | None = None,
|
bifrost: BifrostBinding | None = None,
|
||||||
consumer_key: str | None = None,
|
consumer_key: str | None = None,
|
||||||
|
config: Mapping[str, Any] | None = None,
|
||||||
) -> SessionInfo:
|
) -> SessionInfo:
|
||||||
"""POST /sessions to create a new session. See contract FN create_session.
|
"""POST /sessions to create a new session. See contract FN create_session.
|
||||||
|
|
||||||
@@ -306,6 +316,12 @@ async def create_session(
|
|||||||
assert client is not None
|
assert client is not None
|
||||||
assert agent_id and isinstance(agent_id, str)
|
assert agent_id and isinstance(agent_id, str)
|
||||||
assert end_user_id is None or (isinstance(end_user_id, str) and end_user_id)
|
assert end_user_id is None or (isinstance(end_user_id, str) and end_user_id)
|
||||||
|
# PRE-004 (issue #161): config is None or a Mapping.
|
||||||
|
assert config is None or isinstance(config, Mapping)
|
||||||
|
# PRE-005 (issue #161): ephemeral config + Bifrost binding are mutually
|
||||||
|
# exclusive (server would 422 ephemeral_does_not_accept_bifrost). The CLI
|
||||||
|
# guards this at arg-parse; this assert is defense-in-depth.
|
||||||
|
assert not (config is not None and bifrost is not None)
|
||||||
|
|
||||||
# PRE-001 (INV-001): a bifrost binding REQUIRES a non-empty consumer key,
|
# PRE-001 (INV-001): a bifrost binding REQUIRES a non-empty consumer key,
|
||||||
# enforced before any HTTP so a bound create never falls back to the canary.
|
# enforced before any HTTP so a bound create never falls back to the canary.
|
||||||
@@ -315,6 +331,11 @@ async def create_session(
|
|||||||
body: dict[str, Any] = {"agent_id": agent_id}
|
body: dict[str, Any] = {"agent_id": agent_id}
|
||||||
if end_user_id is not None:
|
if end_user_id is not None:
|
||||||
body["end_user_id"] = end_user_id
|
body["end_user_id"] = end_user_id
|
||||||
|
# Issue #161: ephemeral-template config passthrough — verbatim, role/model-
|
||||||
|
# agnostic. The caller (CLI) builds {"system_prompt": ...}; the wrapper never
|
||||||
|
# injects a selector. Absent when None (foundational baseline unchanged).
|
||||||
|
if config is not None:
|
||||||
|
body["config"] = dict(config)
|
||||||
headers: dict[str, str] = {}
|
headers: dict[str, str] = {}
|
||||||
if bifrost is not None:
|
if bifrost is not None:
|
||||||
body["bifrost"] = {
|
body["bifrost"] = {
|
||||||
@@ -347,6 +368,8 @@ async def create_session(
|
|||||||
name=None,
|
name=None,
|
||||||
archived=False,
|
archived=False,
|
||||||
tags=[],
|
tags=[],
|
||||||
|
kind=body.get("kind"), # INV-001 amendment (#161): "ephemeral"|"foundational"|None
|
||||||
|
config=body.get("config"), # frozen ephemeral config; None for foundational
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+113
-3
@@ -281,6 +281,45 @@ class TestParseArgs:
|
|||||||
)
|
)
|
||||||
assert args.end_user_id == "from-flag"
|
assert args.end_user_id == "from-flag"
|
||||||
|
|
||||||
|
def test_system_prompt_sets_field(self) -> None:
|
||||||
|
"""system_prompt_sets_field [happy, #161]: --system-prompt → ParsedArgs.system_prompt."""
|
||||||
|
args = _parse_args(
|
||||||
|
["--send", "hi", "--new", "--agent", "echo",
|
||||||
|
"--system-prompt", "You are X.", "--api-key", "k"]
|
||||||
|
)
|
||||||
|
assert args.system_prompt == "You are X."
|
||||||
|
|
||||||
|
def test_system_prompt_default_none(self) -> None:
|
||||||
|
"""system_prompt_default_none [trace, #161]: omitted → None (foundational baseline)."""
|
||||||
|
args = _parse_args(["--send", "hi", "--new", "--agent", "mimir", "--api-key", "k"])
|
||||||
|
assert args.system_prompt is None
|
||||||
|
|
||||||
|
def test_system_prompt_empty_rejected(self) -> None:
|
||||||
|
"""system_prompt_empty_rejected [adversarial, #161]: '' → UsageError."""
|
||||||
|
with pytest.raises(UsageError, match="--system-prompt"):
|
||||||
|
_parse_args(
|
||||||
|
["--send", "hi", "--new", "--agent", "echo",
|
||||||
|
"--system-prompt", "", "--api-key", "k"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_system_prompt_requires_new(self) -> None:
|
||||||
|
"""system_prompt_requires_new [adversarial, #161]: with --session → UsageError."""
|
||||||
|
with pytest.raises(UsageError, match="--system-prompt"):
|
||||||
|
_parse_args(
|
||||||
|
["--send", "hi", "--session", "s-1",
|
||||||
|
"--system-prompt", "You are X.", "--api-key", "k"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_system_prompt_xor_bifrost(self) -> None:
|
||||||
|
"""system_prompt_xor_bifrost [adversarial, #161]: config + bifrost → UsageError."""
|
||||||
|
with pytest.raises(UsageError, match="mutually exclusive"):
|
||||||
|
_parse_args(
|
||||||
|
["--send", "hi", "--new", "--agent", "echo",
|
||||||
|
"--system-prompt", "You are X.",
|
||||||
|
"--bifrost-plane", "memory", "--bifrost-host", "h.example",
|
||||||
|
"--api-key", "k"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
SID = SseId(42, 5)
|
SID = SseId(42, 5)
|
||||||
|
|
||||||
@@ -1225,6 +1264,45 @@ class TestAmain:
|
|||||||
assert "[done]" in err
|
assert "[done]" in err
|
||||||
assert err.index(". create_session:") < err.index("[done]")
|
assert err.index(". create_session:") < err.index("[done]")
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_ephemeral_create_sends_config(
|
||||||
|
self, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""ephemeral_create_sends_config [#161]: --system-prompt → config sent; kind surfaced."""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
sessions_route = respx.post("https://w.example/sessions").mock(
|
||||||
|
return_value=httpx.Response(
|
||||||
|
201,
|
||||||
|
json={
|
||||||
|
**_CREATE_OK_RESP,
|
||||||
|
"kind": "ephemeral",
|
||||||
|
"config": {"system_prompt": "You are X.", "role": "echo"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
sse_body = _sse_chunk("42:1", {"type": "text", "content": "hi"}) + _sse_chunk(
|
||||||
|
"42:2", _DONE_BODY
|
||||||
|
)
|
||||||
|
respx.post("https://w.example/sessions/s-new/messages").mock(
|
||||||
|
return_value=_sse_resp(sse_body)
|
||||||
|
)
|
||||||
|
parsed = ParsedArgs(
|
||||||
|
send_content="hi",
|
||||||
|
session_id=None,
|
||||||
|
new=True,
|
||||||
|
agent_id="echo",
|
||||||
|
api_key="k",
|
||||||
|
server_url="https://w.example",
|
||||||
|
raw=False,
|
||||||
|
system_prompt="You are X.",
|
||||||
|
)
|
||||||
|
exit_code = await _amain(parsed)
|
||||||
|
assert exit_code == 0
|
||||||
|
body = _json.loads(sessions_route.calls[0].request.content)
|
||||||
|
assert body == {"agent_id": "echo", "config": {"system_prompt": "You are X."}}
|
||||||
|
assert "kind=ephemeral" in capsys.readouterr().err
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
async def test_happy_existing_session(self, capsys: pytest.CaptureFixture[str]) -> None:
|
async def test_happy_existing_session(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
"""happy_existing_session: --session, no create POST; just SSE stream → exit 0."""
|
"""happy_existing_session: --session, no create POST; just SSE stream → exit 0."""
|
||||||
@@ -1601,8 +1679,10 @@ class TestWhoami:
|
|||||||
json={
|
json={
|
||||||
"ephemeral_templates": {
|
"ephemeral_templates": {
|
||||||
"echo": {
|
"echo": {
|
||||||
"allowed_models": ["glm5-turbo"],
|
# Canonical post-cutover shape (worldtree-dev althing
|
||||||
"default_model": "glm5-turbo",
|
# 2026-07-18; ADR-0012): roles, not models.
|
||||||
|
"allowed_roles": ["echo"],
|
||||||
|
"default_role": "echo",
|
||||||
"system_prompt_max_bytes": 32768,
|
"system_prompt_max_bytes": 32768,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1616,7 +1696,37 @@ class TestWhoami:
|
|||||||
assert "tier: user" in out
|
assert "tier: user" in out
|
||||||
assert "key_id: a1b2c3d4" in out
|
assert "key_id: a1b2c3d4" in out
|
||||||
assert "ephemeral_template echo" in out
|
assert "ephemeral_template echo" in out
|
||||||
assert "glm5-turbo" in out
|
assert "default=echo" in out
|
||||||
|
assert "roles=[echo]" in out
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_whoami_tolerates_malformed_capabilities(
|
||||||
|
self, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""whoami null/malformed caps → no crash; renders defensively (bug-hunt Gróa#1/#2)."""
|
||||||
|
respx.get("https://w.example/me").mock(
|
||||||
|
return_value=httpx.Response(
|
||||||
|
200, json={"user_id": "u", "scopes": [], "tier": "user"}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# allowed_roles: null (explicit) would crash `", ".join(None)`; a non-mapping
|
||||||
|
# template value would crash `spec.get(...)`. Both must degrade, not abort.
|
||||||
|
respx.get("https://w.example/capabilities").mock(
|
||||||
|
return_value=httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"ephemeral_templates": {
|
||||||
|
"echo": {"allowed_roles": None, "system_prompt_max_bytes": 32768},
|
||||||
|
"broken": None,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rc = main(["--whoami", "--api-key", "k", "--server", "https://w.example"])
|
||||||
|
assert rc == 0
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "roles=[]" in out
|
||||||
|
assert "broken: (malformed)" in out
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_whoami_me_auth_failure_exits_20(self, capsys: pytest.CaptureFixture[str]) -> None:
|
def test_whoami_me_auth_failure_exits_20(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
|||||||
@@ -222,6 +222,148 @@ class TestCreateSession:
|
|||||||
assert route.call_count == 0
|
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:
|
class TestCreateSessionBifrostBind:
|
||||||
"""Issue #17 slice 1 — the create_session Bifrost-bind primitive."""
|
"""Issue #17 slice 1 — the create_session Bifrost-bind primitive."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user