feat(500): one end-of-turn request - the completion-section check folds into the reply check
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m7s
CI & Build / Python tests (push) Failing after 1m25s
CI & Build / Build & push image (push) Skipped

The Stop hook sent the finished reply twice: scribe_report_check.sh checked a
task-closing reply for the completion sections in shell and reported to
/report-check, and scribe_reply_check.sh sent the same reply to /reply-rules
for the rule hold. Now the reply goes once. When the turn closed a task the
hook adds the close count and ids, and the server runs the section check
(services/report_check, the same three patterns) beside the reply hold,
folding both into one reason.

The report_check adherence log is still written for every checked reply
(milestone 409's number). A section hold marks the session, so its rewrite is
sent back once with rewrite:true to record how it came out, and is never held.
The block reason carries the completion shape's one line and points at
list_reply_shapes rather than at the skill. #5496 (step 4 of milestone 500).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 15:38:17 -04:00
co-authored by Claude Opus 5.5
parent ae66d83213
commit 6071007fe2
15 changed files with 348 additions and 433 deletions
+103 -1
View File
@@ -197,7 +197,8 @@ async def test_the_route_passes_the_reply_and_all_three_ledgers():
with patch.object(routes.moment_delivery_svc, "reply_hold", hold):
resp = await routes.reply_rules.__wrapped__()
body = await resp.get_json()
assert body == {"reason": "Held.", "rule_ids": [11], "moments": ["reply.report"]}
assert body == {"reason": "Held.", "rule_ids": [11], "moments": ["reply.report"],
"report_check": ""}
assert hold.await_args.args == (7, "done")
assert hold.await_args.kwargs == {
"project_id": 3, "exclude": frozenset({5}), "held": frozenset({4}),
@@ -205,6 +206,55 @@ async def test_the_route_passes_the_reply_and_all_three_ledgers():
}
async def _reply_route(body, *, hold_reason="", checked=None):
from scribe.routes import plugin as routes
hold = AsyncMock(return_value={"reason": hold_reason, "rule_ids": [11] if hold_reason else [],
"moments": ["reply.report"]})
check = AsyncMock(return_value=checked or {"outcome": "passed", "reason": ""})
app = Quart(__name__)
async with app.test_request_context("/api/plugin/reply-rules", method="POST", json=body,
query_string={"project_id": "3"}):
g.user = type("U", (), {"id": 7})()
with patch.object(routes.moment_delivery_svc, "reply_hold", hold), \
patch.object(routes.report_check_svc, "check_reply", check):
resp = await routes.reply_rules.__wrapped__()
return await resp.get_json(), hold, check
async def test_a_turn_that_closed_nothing_is_not_section_checked():
_body, _hold, check = await _reply_route({"reply": "done"})
check.assert_not_awaited()
async def test_a_closing_turn_is_section_checked_and_both_holds_fold_into_one_reason():
"""One end-of-turn request (milestone 500 step 4): the section check and
the rule hold answer together, in one `reason`."""
body, _hold, check = await _reply_route(
{"reply": "done", "closed": 1, "closed_task_ids": [41, True, "x"], "rewrite": False},
hold_reason="RULE HOLD", checked={"outcome": "blocked", "reason": "SECTIONS"},
)
assert body["reason"] == "SECTIONS\n\nRULE HOLD"
assert body["report_check"] == "blocked"
# JSON `true` is not task 1, and a string is not an id.
assert check.await_args.kwargs == {"task_ids": [41], "rewrite": False, "project_id": 3}
async def test_a_task_created_already_done_is_checked_without_an_id():
_body, _hold, check = await _reply_route({"reply": "done", "closed": 1, "closed_task_ids": []})
assert check.await_args.kwargs["task_ids"] == []
async def test_the_rewrite_is_recorded_and_held_by_nothing():
body, hold, check = await _reply_route(
{"reply": "done", "closed": 1, "closed_task_ids": [41], "rewrite": True},
checked={"outcome": "passed_after_rewrite", "reason": ""},
)
assert check.await_args.kwargs["rewrite"] is True
hold.assert_not_awaited()
assert body["reason"] == "" and body["report_check"] == "passed_after_rewrite"
# ── the hook ────────────────────────────────────────────────────────────
@@ -251,6 +301,58 @@ def test_the_hook_blocks_in_the_servers_words_and_records_the_hold(tmp_path):
assert seen[1]["exclude_rule_ids"] == ["11"]
def _closing_transcript(tmp_path, *replies):
lines = [
{"type": "user", "message": {"role": "user", "content": "please finish it"}},
{"type": "assistant", "message": {"content": [
{"type": "tool_use", "id": "toolu_1", "name": "mcp__plugin_scribe_scribe__update_task",
"input": {"task_id": 41, "status": "done"}}]}},
{"type": "user", "message": {"content": [
{"type": "tool_result", "tool_use_id": "toolu_1", "is_error": False, "content": "{}"}]}},
] + [{"type": "assistant", "message": {"content": [{"type": "text", "text": r}]}} for r in replies]
path = tmp_path / "t.jsonl"
path.write_text("\n".join(json.dumps(x, separators=(",", ":")) for x in lines) + "\n")
return path
SECTION_HOLD = json.dumps({"reason": "Rewrite as a completion report.", "rule_ids": [],
"moments": ["reply.report"], "report_check": "blocked"}).encode()
def test_a_turn_that_closed_nothing_sends_no_close(tmp_path):
t = _transcript(tmp_path, "Done.")
with http_sink(by_path={"/api/plugin/reply-rules": QUIET}) as (port, seen):
_run(tmp_path, port, t)
assert "closed" not in json.loads(seen[0]["_body"])
def test_a_section_hold_blocks_once_then_the_rewrite_is_reported_and_never_held(tmp_path):
"""The completion-section check, folded into the one Stop request: the
first stop is held in the server's words, the rewrite goes back once with
`rewrite: true` so its outcome is recorded, and nothing after that is sent."""
t = _closing_transcript(tmp_path, "All done, pushed it.")
with http_sink(by_path={"/api/plugin/reply-rules": SECTION_HOLD}) as (port, seen):
out = json.loads(_run(tmp_path, port, t))
assert out == {"decision": "block", "reason": "Rewrite as a completion report."}
first = json.loads(seen[0]["_body"])
assert (first["closed"], first["closed_task_ids"], first["rewrite"]) == (1, [41], False)
rewritten = _closing_transcript(tmp_path, "All done, pushed it.", "Where this sits: …")
assert _run(tmp_path, port, rewritten, active=True) == ""
assert json.loads(seen[1]["_body"])["rewrite"] is True
# The marker is spent: a further stop in the loop sends nothing.
assert _run(tmp_path, port, rewritten, active=True) == ""
assert len(seen) == 2
def test_a_rule_hold_on_a_closing_turn_does_not_mark_a_rewrite(tmp_path):
t = _closing_transcript(tmp_path, "Done.")
with http_sink(by_path={"/api/plugin/reply-rules": HELD}) as (port, seen):
_run(tmp_path, port, t)
assert _run(tmp_path, port, t, active=True) == ""
assert len(seen) == 1
def test_the_hook_says_nothing_when_nothing_holds(tmp_path):
t = _transcript(tmp_path, "Done.")
with http_sink(by_path={"/api/plugin/reply-rules": QUIET}) as (port, _seen):