CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m12s
CI & Build / Python tests (push) Failing after 1m29s
CI & Build / Build & push image (push) Skipped
services/reply_shapes.py is the single source of the default reply shapes: the core (every reply, ~1,800 chars against a 2,200 budget) and three slices - completion on work.finish, asks on reply.ask, plan on work.plan. Read through list_reply_shapes (MCP, read-only) and GET /api/retrieval/reply-shapes, one service behind both doors. Nothing delivers them yet; that is step 3. The skill still carries its copy until step 4 shrinks it to the long-form reference. The software-only vocabulary guard moves into tests/helpers.py (DEV_ONLY, dev_only_hits) rather than becoming a fourth copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
140 lines
5.1 KiB
Python
140 lines
5.1 KiB
Python
"""The default reply shapes and the moments they ride (milestone 500 step 1).
|
|
|
|
What this pins is what delivery will depend on: every shape rides a moment that
|
|
exists, the core is small enough to pay for on every turn, no slice grows back
|
|
into the skill it replaced, the text speaks for any kind of work, and both
|
|
doors hand out the same shapes.
|
|
"""
|
|
import pytest
|
|
|
|
from scribe.services import moments, reply_shapes
|
|
from tests.helpers import FakeMCP, dev_only_hits
|
|
|
|
|
|
def test_there_is_a_core_and_at_least_one_slice():
|
|
"""The sweeps below are vacuous over an empty catalog (rule 167)."""
|
|
assert reply_shapes.CORE_KEY in reply_shapes.SHAPES
|
|
assert len(reply_shapes.SHAPES) >= 2
|
|
|
|
|
|
def test_every_shape_is_keyed_by_its_own_key():
|
|
for key, shape in reply_shapes.SHAPES.items():
|
|
assert key == shape.key
|
|
|
|
|
|
@pytest.mark.parametrize("key", list(reply_shapes.SHAPES))
|
|
def test_every_shape_rides_a_catalog_moment(key):
|
|
"""A shape on a moment nothing reaches would never arrive — and an
|
|
operator's preference mounted beside it would sit on a dead moment too."""
|
|
assert reply_shapes.SHAPES[key].moment in moments.MOMENTS
|
|
|
|
|
|
def test_the_core_rides_the_reply_moment():
|
|
"""It is the reply's shape; a preference about every reply is mounted
|
|
on `reply.report`, so that is where the core says it lives."""
|
|
assert reply_shapes.core().moment == "reply.report"
|
|
|
|
|
|
def test_no_two_slices_share_a_moment():
|
|
"""One moment, one default. Two slices on a moment would arrive together
|
|
and leave the reader to reconcile them."""
|
|
slices = [s.moment for s in reply_shapes.SHAPES.values() if s.key != reply_shapes.CORE_KEY]
|
|
assert len(slices) == len(set(slices))
|
|
|
|
|
|
def test_the_core_fits_its_budget():
|
|
"""It is paid for in every session. The budget is the ceiling, so a
|
|
sentence added to the core has to replace one."""
|
|
assert len(reply_shapes.core().text) <= reply_shapes.CORE_BUDGET_CHARS
|
|
|
|
|
|
@pytest.mark.parametrize("key", [k for k in reply_shapes.SHAPES if k != reply_shapes.CORE_KEY])
|
|
def test_each_slice_fits_its_budget(key):
|
|
assert len(reply_shapes.SHAPES[key].text) <= reply_shapes.SLICE_BUDGET_CHARS
|
|
|
|
|
|
def test_the_budget_guard_can_fail():
|
|
long = "x" * (reply_shapes.CORE_BUDGET_CHARS + 1)
|
|
assert not len(long) <= reply_shapes.CORE_BUDGET_CHARS
|
|
|
|
|
|
@pytest.mark.parametrize("key", list(reply_shapes.SHAPES))
|
|
def test_no_shape_assumes_a_particular_domain(key):
|
|
shape = reply_shapes.SHAPES[key]
|
|
assert not dev_only_hits(shape.title + "\n" + shape.text), key
|
|
|
|
|
|
@pytest.mark.parametrize("key", list(reply_shapes.SHAPES))
|
|
def test_every_shape_says_what_it_is_and_when_it_arrives(key):
|
|
shape = reply_shapes.SHAPES[key]
|
|
assert shape.title.strip() and shape.delivered.strip() and shape.text.strip()
|
|
|
|
|
|
def test_the_core_carries_the_length_discipline():
|
|
"""Milestone 409's live read: replies that had every section were still
|
|
too long. Delivery cannot fix that; the core has to say it."""
|
|
text = reply_shapes.core().text.lower()
|
|
assert "shortest reply that carries the answer" in text
|
|
assert "what can go" in text
|
|
|
|
|
|
def test_the_core_is_not_a_slice():
|
|
"""It rides every turn, whatever moment the turn reaches — so a moment
|
|
lookup never hands it out a second time."""
|
|
every = [s.moment for s in reply_shapes.SHAPES.values()]
|
|
assert reply_shapes.CORE_KEY not in {s.key for s in reply_shapes.for_moments(every)}
|
|
|
|
|
|
def test_a_moment_brings_its_own_slice_and_nothing_else():
|
|
got = reply_shapes.for_moments(["work.finish", "work.run"])
|
|
assert [s.key for s in got] == ["completion"]
|
|
assert reply_shapes.for_moments([]) == []
|
|
|
|
|
|
def test_the_payload_carries_every_shape_with_its_moments_meaning():
|
|
data = reply_shapes.catalog()
|
|
assert [s["key"] for s in data["shapes"]] == list(reply_shapes.SHAPES)
|
|
assert data["total"] == len(reply_shapes.SHAPES)
|
|
for row in data["shapes"]:
|
|
assert row["means"] == moments.MOMENTS[row["moment"]].means
|
|
|
|
|
|
async def test_the_tool_returns_the_catalog():
|
|
from scribe.mcp.tools import moments as tool
|
|
|
|
assert await tool.list_reply_shapes() == reply_shapes.catalog()
|
|
|
|
|
|
def test_the_tool_is_registered_as_a_read():
|
|
from scribe.mcp.server import _READ_ONLY_TOOLS
|
|
from scribe.mcp.tools import moments as tool
|
|
|
|
mcp = FakeMCP()
|
|
tool.register(mcp)
|
|
assert "list_reply_shapes" in mcp.names
|
|
assert "list_reply_shapes" in _READ_ONLY_TOOLS
|
|
|
|
|
|
def test_both_doors_read_one_catalog():
|
|
"""Rule 33 parity: the session and the Settings view cannot show
|
|
different defaults."""
|
|
from scribe.mcp.tools import moments as tool
|
|
from scribe.routes import retrieval as routes
|
|
|
|
assert tool.shapes_svc is reply_shapes
|
|
assert routes.shapes_svc is reply_shapes
|
|
|
|
|
|
async def test_the_route_returns_the_catalog():
|
|
from types import SimpleNamespace
|
|
|
|
from quart import Quart, g
|
|
|
|
from scribe.routes import retrieval as routes
|
|
|
|
app = Quart(__name__)
|
|
async with app.test_request_context("/api/retrieval/reply-shapes"):
|
|
g.user = SimpleNamespace(id=7)
|
|
resp = await routes.reply_shapes_route.__wrapped__()
|
|
assert await resp.get_json() == reply_shapes.catalog()
|