Files
FabledScribe/tests/test_reply_shape_turn.py
T
bvandeusenandClaude Opus 5.5 eadb08c347
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
feat(500): reply shapes are delivered - the core every turn through the ledger, each slice at its moment, reply mounts before the reply (#5495)
- 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>
2026-10-09 14:45:25 -04:00

130 lines
5.6 KiB
Python

"""The core reply shape rides every turn (milestone 500 step 3).
Every turn ends in a reply, and the operator's prompt is the last point before
it is written, so the per-turn retrieval carries the core shape ahead of
everything else: in full the first time a session sees it, as its one-line
reminder after that, and in full again once a compaction has swept the
ledger. What is pinned here is that round trip across the shell/Python seam —
the route's order and keys, and the hook's ledger.
"""
from __future__ import annotations
import inspect
import json
import os
import subprocess
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from quart import Quart, g
from scribe.services import reply_shapes
from tests.helpers import http_sink, need_tools
ROOT = Path(__file__).resolve().parents[1]
HOOK = ROOT / "plugin" / "hooks" / "scribe_autoinject.sh"
SESSION_START = ROOT / "plugin" / "hooks" / "scribe_session_context.sh"
# ── the route ────────────────────────────────────────────────────────────
async def _retrieve(query_string, turn_context="CORE SHAPE"):
from scribe.routes import plugin as routes
turn = AsyncMock(return_value={"context": turn_context, "rule_ids": [173],
"shape_forms": {"core": reply_shapes.FULL}})
app = Quart(__name__)
async with app.test_request_context("/api/plugin/retrieve", query_string=query_string):
g.user = SimpleNamespace(id=7)
with patch.object(routes.plugin_ctx_svc, "build_prompt_rule_hint",
AsyncMock(return_value={"context": "RULE LINE", "rule_ids": [11],
"shown_rule_ids": [11]})), \
patch.object(routes.plugin_ctx_svc, "build_autoinject_hint",
AsyncMock(return_value={"context": "NOTES MENU", "note_ids": []})), \
patch.object(routes.lesson_rules_svc, "co_surfaced", AsyncMock(return_value="")), \
patch.object(routes.moment_delivery_svc, "deliver_for_turn", turn):
resp = await inspect.unwrap(routes.autoinject_retrieve)()
return await resp.get_json(), turn
async def test_the_turns_shape_leads_the_payload_and_its_key_comes_back():
body, _turn = await _retrieve({"q": "fix the flaky thing"})
ctx = body["context"]
assert ctx.index("CORE SHAPE") < ctx.index("RULE LINE") < ctx.index("NOTES MENU")
assert body["shape_keys"] == ["core"]
# Both arms' fresh rules reach the shared ledger.
assert body["rule_ids"] == [11, 173]
async def test_the_route_hands_the_ledger_and_the_rules_just_named_to_the_turn():
_body, turn = await _retrieve({"q": "x", "shapes_seen": "core,bogus",
"exclude_rule_ids": "9", "held_rule_ids": "4"})
kw = turn.await_args.kwargs
assert kw["seen"] == frozenset({"core"})
# A rule the prompt arm just named is not quoted again by the turn.
assert kw["exclude"] == frozenset({9, 11})
assert kw["held"] == frozenset({4})
# ── the hook ─────────────────────────────────────────────────────────────
def _env(tmp_path, port):
return {"PATH": os.environ["PATH"], "SCRIBE_URL": f"http://127.0.0.1:{port}",
"SCRIBE_TOKEN": "t", "TMPDIR": str(tmp_path), "HOME": str(tmp_path)}
def _prompt(tmp_path, port, session="s-shape"):
need_tools("bash", "curl", "awk")
out = subprocess.run(
["bash", str(HOOK)],
input=json.dumps({"session_id": session, "cwd": str(tmp_path),
"prompt": "please fix the importer"}),
capture_output=True, text=True, env=_env(tmp_path, port), timeout=30,
)
assert out.returncode == 0, out.stderr
return out.stdout
def _compact(tmp_path, session="s-shape"):
out = subprocess.run(
["bash", str(SESSION_START)],
input=json.dumps({"session_id": session, "source": "compact"}),
capture_output=True, text=True,
env={"PATH": os.environ["PATH"], "HOME": str(tmp_path), "TMPDIR": str(tmp_path)},
timeout=60,
)
assert out.returncode == 0, out.stderr
REPLY = json.dumps({"context": "Reply shape · Every reply", "note_ids": [], "rule_ids": [],
"shape_keys": ["core"]}).encode()
def test_the_core_goes_whole_once_then_as_a_reminder_then_whole_after_a_compaction(tmp_path):
with http_sink(by_path={"/api/plugin/retrieve": REPLY}) as (port, seen):
out = _prompt(tmp_path, port)
_prompt(tmp_path, port)
_compact(tmp_path)
_prompt(tmp_path, port)
asks = [e for e in seen if e["_path"] == "/api/plugin/retrieve"]
assert len(asks) == 3
assert "shapes_seen" not in asks[0]
assert asks[1]["shapes_seen"] == ["core"]
# The compaction swept the ledger: the session no longer holds the shape.
assert "shapes_seen" not in asks[2]
assert "Every reply" in json.loads(out)["hookSpecificOutput"]["additionalContext"]
def test_the_hook_and_the_route_agree_on_the_ledgers_names():
"""Rule 33 across the seam: a renamed field fails silently."""
from scribe.routes import plugin as routes
hook = HOOK.read_text()
assert "shapes_seen" in hook and "'.shape_keys'" in hook
assert "scribe_shapes_file" in hook and "scribe_shapes_append" in hook
route = inspect.getsource(routes.autoinject_retrieve)
assert 'request.args.get("shapes_seen")' in route and '"shape_keys"' in route