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
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:
+103
-1
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user