feat(500): reply shapes are delivered - the core every turn through the ledger, each slice at its moment, reply mounts before the reply (#5495)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m12s
CI & Build / Python tests (push) Failing after 1m31s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m12s
CI & Build / Python tests (push) Failing after 1m31s
CI & Build / Build & push image (push) Skipped
- Every turn (/api/plugin/retrieve, UserPromptSubmit): the core reply shape leads the payload - in full the first time, as its one-line reminder after that - followed by whatever is mounted on reply.report, under the shared rule ledger. Fresh keys come back as shape_keys. - The ledger is <sid>.shapes.ids in scribe-priorart (scribe_shapes_file / _seen / _append), so the compaction sweep that clears every .ids ledger is what brings the full core back after one. - At a moment (/api/plugin/moment): the slice for that reply - completion on work.finish, asks on reply.ask, plan on work.plan - ahead of the mounted rules. reachable_tools now lists tools reaching a shaped moment even on an install with nothing mounted. - Scribe's own tools (attach_moment_rules): reply_shape in the response, in full, since that door has no ledger. enter_project carries the core for clients with no prompt hook. - Telemetry: one AppLog row per delivery (plugin / reply_shape), each shape with full or pointer and the door (turn, hook, mcp). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+130
-14
@@ -185,18 +185,23 @@ async def _reachable(mounted, mappings=()):
|
||||
return await md.reachable_tools(1)
|
||||
|
||||
|
||||
async def test_an_install_with_nothing_mounted_keeps_the_hook_off_the_wire():
|
||||
listing = AsyncMock()
|
||||
with patch.object(rulebooks, "mounted_moments", AsyncMock(return_value=set())), \
|
||||
patch.object(moment_actions, "list_mappings", listing):
|
||||
assert await md.reachable_tools(1) == []
|
||||
listing.assert_not_awaited()
|
||||
# The tools the shipped defaults map onto a moment that carries a default reply
|
||||
# shape (milestone 500): closing work, a structured question, opening a plan.
|
||||
SHAPED = ["askuserquestion", "create_milestone", "enterplanmode", "exitplanmode",
|
||||
"skill", "start_planning", "update_milestone", "update_task"]
|
||||
|
||||
|
||||
async def test_only_the_tools_whose_moments_carry_a_mount_are_listed():
|
||||
assert await _reachable({"work.deliver"}) == ["bash", "skill"]
|
||||
assert await _reachable({"work.finish"}) == ["skill", "update_milestone", "update_task"]
|
||||
assert await _reachable({"skill.release"}) == ["skill"]
|
||||
async def test_an_install_with_nothing_mounted_still_asks_about_the_shaped_moments():
|
||||
"""The reply shapes are product, so every install has them: the hook asks
|
||||
about the acts that bring one, and about nothing else."""
|
||||
assert await _reachable(set()) == SHAPED
|
||||
|
||||
|
||||
async def test_only_the_tools_whose_moments_carry_a_mount_or_a_shape_are_listed():
|
||||
assert await _reachable({"work.deliver"}) == sorted([*SHAPED, "bash"])
|
||||
assert await _reachable({"work.finish"}) == SHAPED
|
||||
assert await _reachable({"skill.release"}) == SHAPED
|
||||
assert "bash" not in await _reachable(set())
|
||||
|
||||
|
||||
async def test_the_skill_loader_counts_whenever_anything_is_mounted():
|
||||
@@ -232,7 +237,8 @@ async def test_the_route_reads_the_event_and_the_ledger():
|
||||
with patch.object(routes.moment_delivery_svc, "deliver_for_act", deliver):
|
||||
resp = await routes.moment.__wrapped__()
|
||||
body = await resp.get_json()
|
||||
assert body == {"context": "a line", "rule_ids": [5], "moments": ["work.deliver"]}
|
||||
assert body == {"context": "a line", "rule_ids": [5], "moments": ["work.deliver"],
|
||||
"shape_keys": []}
|
||||
args, kw = deliver.await_args
|
||||
assert args == (7, "Bash", {"command": "git push"})
|
||||
assert kw == {"project_id": 3, "exclude": frozenset({9}), "held": frozenset({4})}
|
||||
@@ -245,7 +251,8 @@ async def test_the_route_answers_an_empty_event_with_nothing():
|
||||
async with app.test_request_context("/api/plugin/moment", method="POST", json={}):
|
||||
g.user = SimpleNamespace(id=7)
|
||||
resp = await routes.moment.__wrapped__()
|
||||
assert await resp.get_json() == {"context": "", "rule_ids": [], "moments": []}
|
||||
assert await resp.get_json() == {"context": "", "rule_ids": [], "moments": [],
|
||||
"shape_keys": []}
|
||||
|
||||
|
||||
def test_both_routes_are_on_the_app():
|
||||
@@ -273,13 +280,15 @@ def test_the_hook_and_the_routes_agree_on_every_name():
|
||||
assert "scribe_rules_live" in src and "scribe_rules_append" in src
|
||||
# The SHARED ledger, not one of its own.
|
||||
assert '"${TMPDIR:-/tmp}/scribe-priorart"' in src and ".rules.ids" in src
|
||||
for field in (".tools", ".rule_ids", ".context"):
|
||||
for field in (".tools", ".rule_ids", ".context", ".shape_keys"):
|
||||
assert f"'{field}'" in src
|
||||
assert "shapes_seen" in src and "scribe_shapes_append" in src
|
||||
|
||||
from scribe.routes import plugin as routes
|
||||
|
||||
route = inspect.getsource(routes.moment)
|
||||
for name in ("exclude_rule_ids", "held_rule_ids", "tool_name", "tool_input"):
|
||||
for name in ("exclude_rule_ids", "held_rule_ids", "tool_name", "tool_input",
|
||||
"shapes_seen", "shape_keys"):
|
||||
assert name in route
|
||||
|
||||
|
||||
@@ -380,3 +389,110 @@ def test_every_scribe_tool_the_defaults_name_attaches_its_moment_rules():
|
||||
f"attach its mounted rules — a client without the plugin would never "
|
||||
f"receive them"
|
||||
)
|
||||
|
||||
|
||||
# ── reply shapes ride the moments (milestone 500 step 3) ────────────────
|
||||
|
||||
from scribe.services import reply_shapes # noqa: E402
|
||||
|
||||
FINISH = [{"moment": "work.finish", "tool": "update_task", "match": "status=done", "via": "default"}]
|
||||
ASK = [{"moment": "reply.ask", "tool": "AskUserQuestion", "match": "", "via": "default"}]
|
||||
|
||||
|
||||
def test_an_act_brings_the_shape_for_the_reply_it_comes_before():
|
||||
blocks, forms = md.shapes_for_act(FINISH)
|
||||
assert forms == {"completion": reply_shapes.FULL}
|
||||
assert reply_shapes.SHAPES["completion"].text in blocks[0]
|
||||
_blocks, forms = md.shapes_for_act(ASK, frozenset({"asks"}))
|
||||
assert forms == {"asks": reply_shapes.POINTER}
|
||||
|
||||
|
||||
def test_an_act_at_an_unshaped_moment_brings_no_shape():
|
||||
assert md.shapes_for_act(PUSH) == ([], {})
|
||||
|
||||
|
||||
async def test_a_turn_carries_the_core_in_full_until_the_session_holds_it():
|
||||
with patch.object(md, "deliver_moments", AsyncMock(return_value=rp.RuleResult())):
|
||||
first = await md.deliver_for_turn(1)
|
||||
later = await md.deliver_for_turn(1, seen=frozenset({"core"}))
|
||||
assert first["shape_forms"] == {"core": reply_shapes.FULL}
|
||||
assert reply_shapes.core().text in first["context"]
|
||||
assert later["shape_forms"] == {"core": reply_shapes.POINTER}
|
||||
assert reply_shapes.core().text not in later["context"]
|
||||
assert reply_shapes.core().reminder in later["context"]
|
||||
|
||||
|
||||
async def test_a_turn_brings_what_is_mounted_on_reply_report_after_the_core():
|
||||
deliver = AsyncMock(return_value=rp.RuleResult(lines=["Preference that may apply"], rule_ids=[173]))
|
||||
with patch.object(md, "deliver_moments", deliver):
|
||||
out = await md.deliver_for_turn(1, project_id=2, exclude=frozenset({9}), held=frozenset({4}))
|
||||
assert out["context"].index(reply_shapes.core().title) < out["context"].index("Preference")
|
||||
assert out["rule_ids"] == [173]
|
||||
reached = deliver.await_args.args[1]
|
||||
assert [hit["moment"] for hit in reached] == ["reply.report"]
|
||||
assert deliver.await_args.kwargs == {"project_id": 2, "exclude": frozenset({9}),
|
||||
"held": frozenset({4})}
|
||||
|
||||
|
||||
async def test_a_failed_mount_lookup_still_delivers_the_core():
|
||||
with patch.object(md, "deliver_moments", AsyncMock(side_effect=RuntimeError("db down"))):
|
||||
out = await md.deliver_for_turn(1)
|
||||
assert reply_shapes.core().text in out["context"] and out["rule_ids"] == []
|
||||
|
||||
|
||||
async def test_a_turn_records_its_delivery():
|
||||
with patch.object(md, "deliver_moments", AsyncMock(return_value=rp.RuleResult())):
|
||||
await md.deliver_for_turn(1, seen=frozenset({"core"}))
|
||||
reply_shapes.record_delivery.assert_awaited_with(1, {"core": reply_shapes.POINTER}, via="turn")
|
||||
|
||||
|
||||
async def test_a_task_closed_through_the_tool_carries_the_completion_shape():
|
||||
data = {"id": 40, "status": "done", "project_id": 2}
|
||||
out = await _with(_stub_db([]), lambda: md.attach_moment_rules(
|
||||
1, "update_task", {"status": "done"}, data,
|
||||
))
|
||||
assert reply_shapes.SHAPES["completion"].text in out["reply_shape"]
|
||||
assert "moment_rules" not in out # nothing mounted, still shaped
|
||||
|
||||
|
||||
async def test_an_unshaped_act_through_a_tool_carries_no_shape():
|
||||
out = await _with(_stub_db([]), lambda: md.attach_moment_rules(
|
||||
1, "create_note", {"project_id": 2}, {"id": 3},
|
||||
))
|
||||
assert "reply_shape" not in out
|
||||
|
||||
|
||||
async def test_the_route_puts_the_shape_ahead_of_the_rules_and_returns_its_key():
|
||||
from scribe.routes import plugin as routes
|
||||
|
||||
deliver = AsyncMock(return_value=(ASK, rp.RuleResult(lines=["a line"], rule_ids=[5])))
|
||||
app = Quart(__name__)
|
||||
event = {"tool_name": "AskUserQuestion", "tool_input": {}}
|
||||
async with app.test_request_context("/api/plugin/moment", method="POST", json=event):
|
||||
g.user = SimpleNamespace(id=7)
|
||||
with patch.object(routes.moment_delivery_svc, "deliver_for_act", deliver):
|
||||
body = await (await routes.moment.__wrapped__()).get_json()
|
||||
assert body["shape_keys"] == ["asks"]
|
||||
assert body["context"].index("Who decides what") < body["context"].index("a line")
|
||||
|
||||
async with app.test_request_context("/api/plugin/moment", method="POST", json=event,
|
||||
query_string={"shapes_seen": "asks"}):
|
||||
g.user = SimpleNamespace(id=7)
|
||||
with patch.object(routes.moment_delivery_svc, "deliver_for_act", deliver):
|
||||
body = await (await routes.moment.__wrapped__()).get_json()
|
||||
assert body["shape_keys"] == []
|
||||
assert "Who decides what" not in body["context"]
|
||||
|
||||
|
||||
def test_the_hook_carries_the_shape_ledger_between_calls(tmp_path):
|
||||
replies = {"/api/plugin/moment-tools": b'{"tools":["askuserquestion"]}',
|
||||
"/api/plugin/moment": json.dumps(
|
||||
{"context": "Reply shape", "rule_ids": [], "moments": ["reply.ask"],
|
||||
"shape_keys": ["asks"]}).encode()}
|
||||
with http_sink(by_path=replies) as (port, seen):
|
||||
_hook(tmp_path, port, tool="AskUserQuestion")
|
||||
_hook(tmp_path, port, tool="AskUserQuestion")
|
||||
moment_calls = [e for e in seen if e["_path"] == "/api/plugin/moment"]
|
||||
assert "shapes_seen" not in moment_calls[0]
|
||||
assert moment_calls[1]["shapes_seen"] == ["asks"]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user