Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 387ac4ab2c |
@@ -206,3 +206,48 @@ TESTS:
|
||||
limit_above_max [adversarial]: limit=300 → AssertionError; no HTTP issued
|
||||
empty_cursor [adversarial]: cursor="" → AssertionError; no HTTP issued
|
||||
```
|
||||
|
||||
## Amendment 2026-06-30 — boot-time introspection reads (v1 coverage-audit: capabilities+me)
|
||||
|
||||
The v1 coverage-audit added two read-only server-introspection endpoints as
|
||||
cheap debug primitives (surfaced via a new `ratatoskr --whoami` one-shot). Both
|
||||
mirror `get_persona_state`: GET, 200 → parsed dict verbatim, any non-200 →
|
||||
`SessionApiFailed`. The frozen OpenAPI types both responses as freeform objects,
|
||||
so the wrappers return `dict[str, Any]` (not a typed dataclass).
|
||||
|
||||
```contract
|
||||
FN get_me(client: httpx.AsyncClient) -> dict[str, Any]
|
||||
BRIEF: GET /me — the authenticated principal's identity + key metadata (spec §GET /me). Boot-time whoami: verify the key without agent-config side effects. Returns parsed JSON verbatim; spec documents {user_id, scopes, tier, display_name?, key_id?, key_label?, ...} with optional fields OMITTED (not null). Read-only, rate-exempt, no audit emission.
|
||||
PRE: [PRE-001 hard] client is not None -- assert client is not None
|
||||
POST: [POST-001 return_value] on 200 returns resp.json() unmodified -- assert result == resp.json()
|
||||
ERROR_ROUTING:
|
||||
HTTP non-200 (incl. 401 bad/absent key when auth enabled):
|
||||
local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
flow_control: abort
|
||||
state_recovery: none (caller decides: bad key → re-key; degraded tier="unknown" is still a 200)
|
||||
STEPS:
|
||||
1. [setup, prescriptive] assert client is not None
|
||||
2. [sequential, prescriptive] resp = await client.get("/me")
|
||||
3. [branch, prescriptive] IF resp.status_code == 200: RETURN resp.json(); ELSE RAISE SessionApiFailed
|
||||
TESTS:
|
||||
happy_authenticated [happy,tracer]: 200 {user_id, scopes, tier, key_id} → dict returned verbatim
|
||||
anonymous_dev_mode: 200 {user_id:"anonymous", tier:"anonymous"} → dict; no key_* fields (omitted)
|
||||
401_raises [error]: 401 → SessionApiFailed(status=401)
|
||||
|
||||
FN get_capabilities(client: httpx.AsyncClient) -> dict[str, Any]
|
||||
BRIEF: GET /capabilities — server capability discovery (spec §Ephemeral Templates). Returns {ephemeral_templates: {echo: {allowed_models, default_model, system_prompt_max_bytes}}}. Any authenticated caller may read it (no instantiate scope). Parsed dict verbatim; any non-200 → SessionApiFailed.
|
||||
PRE: [PRE-001 hard] client is not None -- assert client is not None
|
||||
POST: [POST-001 return_value] on 200 returns resp.json() unmodified -- assert result == resp.json()
|
||||
ERROR_ROUTING:
|
||||
HTTP non-200:
|
||||
local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
flow_control: abort
|
||||
state_recovery: none
|
||||
STEPS:
|
||||
1. [setup, prescriptive] assert client is not None
|
||||
2. [sequential, prescriptive] resp = await client.get("/capabilities")
|
||||
3. [branch, prescriptive] IF resp.status_code == 200: RETURN resp.json(); ELSE RAISE SessionApiFailed
|
||||
TESTS:
|
||||
happy [happy]: 200 {ephemeral_templates:{echo:{...}}} → dict returned verbatim
|
||||
non_200_raises [error]: 500 → SessionApiFailed(status=500)
|
||||
```
|
||||
|
||||
@@ -48,7 +48,7 @@ resolved (§ Surface 1, scope-resolution table).
|
||||
|
||||
| Surface | Points | ✅ covered-live | ⬜ gap (in-scope) | 🚫 excluded-by-design |
|
||||
|---|---|---|---|---|
|
||||
| REST (OpenAPI 2.2.0, path groups) | 40 | 7 | 11 | 22 |
|
||||
| REST (OpenAPI 2.2.0, path groups) | 40 | 9 | 9 | 22 |
|
||||
| SSE events | 11 | 11 | 0 | 0 |
|
||||
| Bifrost provider planes | 8 verbs | 8 | 0 | (10 gated verbs deferred) |
|
||||
|
||||
@@ -75,6 +75,8 @@ sub-gap).
|
||||
| `POST /agents/define` | ✅ | `tier3.py:175` → `_run_define` | Tier-3 create |
|
||||
| `PATCH /agents/{id}` | ✅ | `tier3.py:219` → `_run_patch` | Tier-3 mutate (system_prompt/model) |
|
||||
| `DELETE /agents/{id}` | ✅ | `tier3.py:242` → `_run_delete` | Tier-3 hard-delete |
|
||||
| `GET /me` | ✅ | `sessions.py:411` `get_me` → `cli.py` `--whoami` | identity/whoami probe; 401→SessionApiFailed |
|
||||
| `GET /capabilities` | ✅ | `sessions.py:428` `get_capabilities` → `cli.py` `--whoami` | Echo ephemeral-template discovery |
|
||||
|
||||
**Sub-gaps inside ✅ path groups** (the method we use is live; a sibling method
|
||||
on the same path is an unwired frontier item — see frontier Tier 1):
|
||||
@@ -95,8 +97,6 @@ on the same path is an unwired frontier item — see frontier Tier 1):
|
||||
| `GET /admin/events` | ⬜ | design-brief §5 v1 **AdminEvents pane**; = issue **#11**, **blocked** on `admin.events.read` scope (infra-ops) |
|
||||
| `GET /admin/sessions/{id}/bifrost` | ⬜ | design-brief §5 v1 **BifrostState widget** — never built; admin-key-gated |
|
||||
| `GET /admin/sessions/{id}/tools` | ⬜ | design-brief §5 v1 **Tools widget** — never built; admin-key-gated |
|
||||
| `GET /capabilities` | ⬜ | server capability discovery — a turn flows through what's advertised |
|
||||
| `GET /me` | ⬜ | whoami / key-identity — "which key am I against" is a debug primitive |
|
||||
| (`GET /sessions` picker · resume) | ⬜ | sub-gaps above — presenter-wiring only, wrappers exist |
|
||||
|
||||
**Tier 2 — rounds out I/O coverage under A (postdates the design-brief):**
|
||||
|
||||
@@ -153,6 +153,8 @@ decision. Captures rationale that won't be obvious from code alone.
|
||||
|
||||
- `[2026-06-30]` **(b2) session-picker SHIPPED (`v0.18.7`) — bare TUI mode → startup picker (design-brief §4).** `list_sessions` had NO caller; now bare TUI mode (no `--session`/`--new`) resolves via `list_sessions` in `_resolve_then_run`: **0 sessions → `[no_sessions]` error, exit 14** (resume-only, honors §4 "no in-app session creation — `--new` flag only"); **exactly 1 → auto-resume** (§4 "picker only when >1"); **≥2 → new `SessionPickerApp`** (Textual `App[str|None]`, mirrors `AgentPickerApp`; ListView of sessions) → resume the pick (Esc/Ctrl-D → exit 0). cli `_parse` relaxed: bare TUI now VALID (was "pass exactly one" error); `--send` still requires one flag (non-interactive, no picker); `--agent` forbidden in bare mode; `run_tui` PRE-002 XOR→"not both". Direct in-session TDD (contract #6 amendment, validated OK): 3 widget pilot tests + 5 `_resolve_then_run` resolution tests + 3 cli validation tests. Suite **528 green**; touched code ruff-clean (mypy: only the `BINDINGS` list-invariance warning every App in tui.py already carries — consistent). **DESIGN NOTE — bare+0-sessions → error (clause-consistent). The friendlier auto-fall-through-to-new alternative is DEFERRED pending operator preference (it would create a session without `--new`, against the §4 negative clause).** **Frontier now: `GET /capabilities`+`GET /me` → BifrostState/Tools widgets (`GET /admin/sessions/{id}/{bifrost,tools}`, admin-key) → #11 AdminEvents (BLOCKED on `admin.events.read`).** heid-code-review NOT run on b1 or b2 (offered).
|
||||
|
||||
- `[2026-06-30]` **capabilities+me slice SHIPPED (`v0.18.8`) — `GET /me` + `GET /capabilities` consumed via a new `--whoami` one-shot.** `get_me`/`get_capabilities` added to sessions.py (mirror `get_persona_state`: 200→dict verbatim, non-200→`SessionApiFailed`; freeform dicts per the frozen OpenAPI). New `ratatoskr --whoami` CLI mode (mirrors `--send`'s non-interactive shape) fetches both + prints an identity+capabilities report; standalone probe (mutually exclusive with `--send`/`--session`/`--new`/`--agent`, opens no session; new `ParsedArgs.whoami` field + main() dispatch). **`/capabilities` is the Echo EPHEMERAL-TEMPLATE discovery endpoint** (`{ephemeral_templates:{echo:{allowed_models,default_model,system_prompt_max_bytes}}}`), NOT a generic server-caps endpoint (audit finding — the coverage-map's earlier "server capability discovery" framing was imprecise). `/me` = whoami (`{user_id,scopes,tier,key_id?,...}`, optionals omitted-not-null). Contract-skip privilege invoked (low-effort GET wrappers) but contract #2 amended (2 FNs, validated OK) to keep the sessions spec canonical + honest test citations. TDD: 5 wrapper tests + 5 cli tests (validation + mode + error). Suite **538 green**; touched code ruff-clean (mypy: only `no-any-return` on `resp.json()`→dict, identical to the pre-existing `get_persona_state`). **Coverage: REST 9/40 ✅ (up from 7).** TUI-surfacing of /me (footer identity line) + /capabilities DEFERRED — the one-shot is the minimal tracer. **Frontier now: BifrostState + Tools widgets (`GET /admin/sessions/{id}/{bifrost,tools}`, admin-key-gated) → #11 AdminEvents (BLOCKED on `admin.events.read`).**
|
||||
|
||||
_41 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._
|
||||
|
||||
_For per-issue TDD implementation notes, Volva findings, and contract amendments, see the git log — every per-issue commit carries a structured message capturing the trail._
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "ratatoskr"
|
||||
version = "0.18.7"
|
||||
version = "0.18.8"
|
||||
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
+86
-18
@@ -12,7 +12,7 @@ import signal
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
from typing import TextIO
|
||||
from typing import Any, TextIO
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -24,6 +24,8 @@ from ratatoskr.sessions import (
|
||||
SessionApiFailed,
|
||||
create_session,
|
||||
endpoint_for_plane,
|
||||
get_capabilities,
|
||||
get_me,
|
||||
)
|
||||
from ratatoskr.sse_client import (
|
||||
AffectUpdate,
|
||||
@@ -97,6 +99,9 @@ class ParsedArgs:
|
||||
bifrost: BifrostBinding | None = None
|
||||
bifrost_plane: str | None = None
|
||||
consumer_key: str | None = None
|
||||
# Standalone boot-time orientation probe: GET /me + GET /capabilities, print,
|
||||
# exit. Mutually exclusive with the session/turn flags (opens no session).
|
||||
whoami: bool = False
|
||||
|
||||
|
||||
class _ArgparseError(Exception):
|
||||
@@ -121,6 +126,7 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
|
||||
parser.add_argument("--api-key", dest="api_key")
|
||||
parser.add_argument("--server")
|
||||
parser.add_argument("--raw", action="store_true")
|
||||
parser.add_argument("--whoami", action="store_true")
|
||||
# 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)
|
||||
# Issue #17: bind the created session to our own Bifrost provider plane.
|
||||
@@ -141,23 +147,32 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
|
||||
# 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:
|
||||
raise UsageError("--end-user-id must be non-empty when passed")
|
||||
if ns.session and ns.new:
|
||||
raise UsageError("--session and --new are mutually exclusive")
|
||||
if not ns.session and not ns.new:
|
||||
# Bare TUI mode → startup session picker (design-brief §4). --send is
|
||||
# non-interactive (no picker can open), so it still requires one flag;
|
||||
# --agent belongs with --new (bare mode resumes, it doesn't create).
|
||||
if ns.send is not None:
|
||||
raise UsageError("--send requires --session or --new (no interactive picker)")
|
||||
if ns.agent:
|
||||
raise UsageError("--agent belongs with --new; bare TUI mode opens the session picker")
|
||||
if ns.session and ns.agent:
|
||||
raise UsageError("--agent is required with --new and forbidden with --session")
|
||||
if ns.new and not ns.agent and ns.send is not None:
|
||||
# Issue #8: --agent stays required for --send --new (non-interactive,
|
||||
# cannot prompt). Bare --new (TUI mode) accepts None — picker drives
|
||||
# the choice via list_agents in _resolve_then_run.
|
||||
raise UsageError("--agent is required when --new is passed in --send mode")
|
||||
if ns.whoami:
|
||||
# Standalone boot-time probe (GET /me + /capabilities): opens no session.
|
||||
if ns.send is not None or ns.session or ns.new or ns.agent:
|
||||
raise UsageError(
|
||||
"--whoami is a standalone probe (no --send/--session/--new/--agent)"
|
||||
)
|
||||
else:
|
||||
if ns.session and ns.new:
|
||||
raise UsageError("--session and --new are mutually exclusive")
|
||||
if not ns.session and not ns.new:
|
||||
# Bare TUI mode → startup session picker (design-brief §4). --send is
|
||||
# non-interactive (no picker can open), so it still requires one flag;
|
||||
# --agent belongs with --new (bare mode resumes, it doesn't create).
|
||||
if ns.send is not None:
|
||||
raise UsageError("--send requires --session or --new (no interactive picker)")
|
||||
if ns.agent:
|
||||
raise UsageError(
|
||||
"--agent belongs with --new; bare TUI mode opens the session picker"
|
||||
)
|
||||
if ns.session and ns.agent:
|
||||
raise UsageError("--agent is required with --new and forbidden with --session")
|
||||
if ns.new and not ns.agent and ns.send is not None:
|
||||
# Issue #8: --agent stays required for --send --new (non-interactive,
|
||||
# cannot prompt). Bare --new (TUI mode) accepts None — picker drives
|
||||
# the choice via list_agents in _resolve_then_run.
|
||||
raise UsageError("--agent is required when --new is passed in --send mode")
|
||||
|
||||
api_key = ns.api_key or os.environ.get("WORLDTREE_API_KEY") or ""
|
||||
if not api_key:
|
||||
@@ -206,6 +221,7 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
|
||||
bifrost=bifrost,
|
||||
bifrost_plane=bifrost_plane,
|
||||
consumer_key=consumer_key,
|
||||
whoami=ns.whoami,
|
||||
)
|
||||
|
||||
|
||||
@@ -556,6 +572,56 @@ async def _amain(args: ParsedArgs) -> int:
|
||||
loop.remove_signal_handler(signal.SIGINT)
|
||||
|
||||
|
||||
def _format_whoami(me: dict[str, Any], caps: dict[str, Any]) -> str:
|
||||
"""Render the --whoami report: identity (GET /me) + server capabilities."""
|
||||
lines = ["identity:"]
|
||||
lines.append(f" user_id: {me.get('user_id', '?')}")
|
||||
lines.append(f" tier: {me.get('tier', '?')}")
|
||||
lines.append(f" scopes: {', '.join(me.get('scopes', [])) or '(none)'}")
|
||||
for k in ("display_name", "key_id", "key_label"):
|
||||
if k in me:
|
||||
lines.append(f" {k}: {me[k]}")
|
||||
lines.append("capabilities:")
|
||||
templates = caps.get("ephemeral_templates", {})
|
||||
if templates:
|
||||
for name, spec in templates.items():
|
||||
models = ", ".join(spec.get("allowed_models", []))
|
||||
lines.append(
|
||||
f" ephemeral_template {name}: default={spec.get('default_model', '?')} "
|
||||
f"max_bytes={spec.get('system_prompt_max_bytes', '?')} models=[{models}]"
|
||||
)
|
||||
else:
|
||||
lines.append(" (no ephemeral templates advertised)")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
async def _whoami(args: ParsedArgs) -> int:
|
||||
"""--whoami one-shot: GET /me + GET /capabilities, print a compact report, exit.
|
||||
|
||||
A boot-time orientation probe (mirrors --send's non-interactive shape):
|
||||
"who am I against this server, and what does it offer." Opens no session.
|
||||
Errors land on stderr with the same [session_api_failed] / [network_error]
|
||||
vocab + exit codes as the other modes.
|
||||
"""
|
||||
assert isinstance(args, ParsedArgs)
|
||||
async with httpx.AsyncClient(
|
||||
base_url=args.server_url,
|
||||
headers={"Authorization": f"Bearer {args.api_key}", "User-Agent": USER_AGENT},
|
||||
timeout=httpx.Timeout(connect=10.0, read=10.0, write=10.0, pool=10.0),
|
||||
) as client:
|
||||
try:
|
||||
me = await get_me(client)
|
||||
caps = await get_capabilities(client)
|
||||
except SessionApiFailed as exc:
|
||||
sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n")
|
||||
return 20
|
||||
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
|
||||
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
|
||||
return 21
|
||||
sys.stdout.write(_format_whoami(me, caps))
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Sync entry point. Maps UsageError/_AuthError to exit codes BEFORE the event loop."""
|
||||
assert argv is None or all(isinstance(a, str) for a in argv)
|
||||
@@ -571,6 +637,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||
# argparse's --help / --version short-circuit via SystemExit(0). Pass the code
|
||||
# through verbatim — argparse already printed help to stdout.
|
||||
return int(exc.code) if exc.code is not None else 0
|
||||
if args.whoami:
|
||||
return asyncio.run(_whoami(args))
|
||||
if args.send_content is None:
|
||||
# TUI mode — lazy import preserves INV-001 (no textual in cli at module scope).
|
||||
from ratatoskr.tui import run_tui
|
||||
|
||||
@@ -406,3 +406,35 @@ async def get_persona_state(
|
||||
if resp.status_code == 403 and error_code == "auth_scope_denied":
|
||||
raise AuthScopeDenied(scope="persona.read")
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
|
||||
|
||||
async def get_me(client: httpx.AsyncClient) -> dict[str, Any]:
|
||||
"""GET /me — the authenticated principal's identity + key metadata (spec §GET /me).
|
||||
|
||||
Boot-time whoami: verify the key without agent-config side effects. Returns
|
||||
the parsed dict verbatim (freeform per the frozen OpenAPI; the spec documents
|
||||
`{user_id, scopes, tier, display_name?, key_id?, key_label?, ...}`, optional
|
||||
fields omitted-not-null). 401 (bad/absent key when auth is enabled) — like
|
||||
every other non-200 — surfaces as SessionApiFailed (get_persona_state
|
||||
precedent). Read-only, rate-exempt, no audit emission.
|
||||
"""
|
||||
assert client is not None
|
||||
resp = await client.get("/me")
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
|
||||
|
||||
async def get_capabilities(client: httpx.AsyncClient) -> dict[str, Any]:
|
||||
"""GET /capabilities — server capability discovery (spec §Ephemeral Templates).
|
||||
|
||||
Returns `{ephemeral_templates: {echo: {allowed_models, default_model,
|
||||
system_prompt_max_bytes}}}` — what the server offers before a client decides
|
||||
to instantiate. Any authenticated caller may read it (no scope). Parsed dict
|
||||
verbatim; any non-200 → SessionApiFailed.
|
||||
"""
|
||||
assert client is not None
|
||||
resp = await client.get("/capabilities")
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
|
||||
@@ -1564,3 +1564,72 @@ class TestBifrostBindCli:
|
||||
)
|
||||
rc = await _amain(args)
|
||||
assert rc == 22
|
||||
|
||||
|
||||
class TestWhoami:
|
||||
"""--whoami one-shot probe (slice: capabilities+me): GET /me + GET /capabilities."""
|
||||
|
||||
def test_whoami_standalone_accepted(self) -> None:
|
||||
"""whoami_standalone_accepted: --whoami alone → valid; whoami=True, no turn flags."""
|
||||
args = _parse_args(["--whoami", "--api-key", "k"])
|
||||
assert args.whoami is True
|
||||
assert args.send_content is None
|
||||
assert args.session_id is None
|
||||
assert args.new is False
|
||||
|
||||
def test_whoami_with_send_rejected(self) -> None:
|
||||
"""whoami_with_send_rejected [adversarial]: --whoami + --send → UsageError."""
|
||||
with pytest.raises(UsageError, match="standalone probe"):
|
||||
_parse_args(["--whoami", "--send", "hi", "--api-key", "k"])
|
||||
|
||||
def test_whoami_with_new_rejected(self) -> None:
|
||||
"""whoami_with_new_rejected [adversarial]: --whoami + --new → UsageError."""
|
||||
with pytest.raises(UsageError, match="standalone probe"):
|
||||
_parse_args(["--whoami", "--new", "--agent", "m", "--api-key", "k"])
|
||||
|
||||
@respx.mock
|
||||
def test_whoami_mode_prints_report(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""whoami_mode_prints_report [happy,tracer]: /me + /capabilities → stdout report; exit 0."""
|
||||
respx.get("https://w.example/me").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"user_id": "alice",
|
||||
"scopes": ["conversations.read", "conversations.write"],
|
||||
"tier": "user",
|
||||
"key_id": "a1b2c3d4",
|
||||
},
|
||||
)
|
||||
)
|
||||
respx.get("https://w.example/capabilities").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"ephemeral_templates": {
|
||||
"echo": {
|
||||
"allowed_models": ["glm5-turbo"],
|
||||
"default_model": "glm5-turbo",
|
||||
"system_prompt_max_bytes": 32768,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
rc = main(["--whoami", "--api-key", "k", "--server", "https://w.example"])
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "user_id: alice" in out
|
||||
assert "tier: user" in out
|
||||
assert "key_id: a1b2c3d4" in out
|
||||
assert "ephemeral_template echo" in out
|
||||
assert "glm5-turbo" in out
|
||||
|
||||
@respx.mock
|
||||
def test_whoami_me_auth_failure_exits_20(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""whoami_me_auth_failure [error]: /me 401 → exit 20 [session_api_failed]."""
|
||||
respx.get("https://w.example/me").mock(
|
||||
return_value=httpx.Response(401, json={"detail": "auth_invalid"})
|
||||
)
|
||||
rc = main(["--whoami", "--api-key", "k", "--server", "https://w.example"])
|
||||
assert rc == 20
|
||||
assert "[session_api_failed]" in capsys.readouterr().err
|
||||
|
||||
@@ -18,6 +18,8 @@ from ratatoskr.sessions import (
|
||||
SessionPage,
|
||||
create_session,
|
||||
endpoint_for_plane,
|
||||
get_capabilities,
|
||||
get_me,
|
||||
get_persona_state,
|
||||
list_agents,
|
||||
list_sessions,
|
||||
@@ -896,3 +898,96 @@ class TestGetPersonaState:
|
||||
with pytest.raises(PersonaNotConfigured) as exc_info:
|
||||
await get_persona_state(client, "domari")
|
||||
assert exc_info.value.agent_id == "domari"
|
||||
|
||||
|
||||
class TestGetMe:
|
||||
"""docs/contracts/issues/2.contract.md FN get_me (slice: capabilities+me)."""
|
||||
|
||||
@respx.mock
|
||||
async def test_happy_authenticated(self) -> None:
|
||||
"""happy_authenticated [happy,tracer]: 200 → parsed identity dict verbatim."""
|
||||
respx.get("https://w.example/me").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"user_id": "alice",
|
||||
"scopes": ["conversations.read", "conversations.write"],
|
||||
"tier": "user",
|
||||
"key_id": "a1b2c3d4",
|
||||
"key_label": "alice phone",
|
||||
},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
me = await get_me(client)
|
||||
assert me["user_id"] == "alice"
|
||||
assert me["tier"] == "user"
|
||||
assert me["key_id"] == "a1b2c3d4"
|
||||
assert me["scopes"] == ["conversations.read", "conversations.write"]
|
||||
|
||||
@respx.mock
|
||||
async def test_anonymous_dev_mode(self) -> None:
|
||||
"""anonymous_dev_mode: 200 anonymous shape → dict with tier=anonymous."""
|
||||
respx.get("https://w.example/me").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"user_id": "anonymous",
|
||||
"scopes": ["conversations.read"],
|
||||
"tier": "anonymous",
|
||||
},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
me = await get_me(client)
|
||||
assert me["tier"] == "anonymous"
|
||||
assert "key_id" not in me # optional fields omitted, not null
|
||||
|
||||
@respx.mock
|
||||
async def test_401_raises_session_api_failed(self) -> None:
|
||||
"""401_raises [error]: bad/absent key → SessionApiFailed(status=401)."""
|
||||
respx.get("https://w.example/me").mock(
|
||||
return_value=httpx.Response(401, json={"detail": "auth_invalid"})
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(SessionApiFailed) as exc:
|
||||
await get_me(client)
|
||||
assert exc.value.status == 401
|
||||
|
||||
|
||||
class TestGetCapabilities:
|
||||
"""docs/contracts/issues/2.contract.md FN get_capabilities (slice: capabilities+me)."""
|
||||
|
||||
@respx.mock
|
||||
async def test_happy(self) -> None:
|
||||
"""happy [happy]: 200 → ephemeral_templates dict verbatim."""
|
||||
respx.get("https://w.example/capabilities").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"ephemeral_templates": {
|
||||
"echo": {
|
||||
"allowed_models": ["glm5-turbo", "glm4.7"],
|
||||
"default_model": "glm5-turbo",
|
||||
"system_prompt_max_bytes": 32768,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
caps = await get_capabilities(client)
|
||||
echo = caps["ephemeral_templates"]["echo"]
|
||||
assert echo["default_model"] == "glm5-turbo"
|
||||
assert echo["system_prompt_max_bytes"] == 32768
|
||||
|
||||
@respx.mock
|
||||
async def test_non_200_raises(self) -> None:
|
||||
"""non_200_raises [error]: 500 → SessionApiFailed(status=500)."""
|
||||
respx.get("https://w.example/capabilities").mock(
|
||||
return_value=httpx.Response(500, content=b"boom")
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(SessionApiFailed) as exc:
|
||||
await get_capabilities(client)
|
||||
assert exc.value.status == 500
|
||||
|
||||
Reference in New Issue
Block a user