Files
FabledScribe/tests/test_report_check_hook.py
T
bvandeusenandClaude Opus 5 c7531d3700
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 43s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / Python tests (push) Successful in 1m31s
CI & Build / Build & push image (push) Successful in 23s
fix(409): the report check's prefilter no longer depends on compact JSON (#4014)
CI run #517 failed five of the new hook tests, all of them expecting a
request that never went out. The prefilter matched `"name":"…update_task"`
with no space after the colon, which is how Claude Code writes its
transcripts, while the tests wrote theirs with json.dumps defaults
(`"name": "…"`). The hook exited at the prefilter for every test transcript.
The silent-case tests passed for the same wrong reason.

- The prefilter now allows whitespace after the colon. The jq parse behind it
  never depended on formatting.
- The test transcripts are written compact, matching the real file. The
  silent-case tests now reach the turn parse rather than stopping at the
  prefilter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 18:37:13 -04:00

170 lines
7.0 KiB
Python

"""The Stop hook that checks a task-closing reply for the completion sections
(milestone 409 step 5).
Runs the real shell against synthetic transcripts in the shape Claude Code
writes (one content block per JSONL line) and the shared HTTP sink. What it
pins: silence on every turn that closed nothing; a block only when the
instance recorded it, in the words the instance returned; one rewrite at most,
recorded; and no block from another plugin's loop or a failed task write.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
from pathlib import Path
import pytest
from tests.helpers import http_sink
HOOK = Path(__file__).resolve().parents[1] / "plugin" / "hooks" / "scribe_report_check.sh"
TOOL = "mcp__plugin_scribe_scribe__update_task"
GOOD = ('**Where this sits:** milestone 12 "Move the backups offsite", step 3 of 5.\n'
"**What now works:** the sync runs nightly.\n**Needs you:** nothing.\n**Next:** alerts.")
BAD = "All done, pushed it."
REASON = "SERVER REASON: rewrite as a completion report"
def _env(tmp_path, url="http://127.0.0.1:9"):
for tool in ("jq", "curl", "bash"):
if shutil.which(tool) is None:
pytest.skip(f"hook runtime tool {tool!r} not installed")
return {"PATH": os.environ["PATH"], "SCRIBE_URL": url, "SCRIBE_TOKEN": "t",
"TMPDIR": str(tmp_path), "HOME": str(tmp_path)}
def _prompt(text="please finish it"):
return {"type": "user", "message": {"role": "user", "content": text}}
def _tool_use(tid="toolu_1", status="done", name=TOOL, task_id=41):
return {"type": "assistant", "message": {"content": [
{"type": "tool_use", "id": tid, "name": name, "input": {"task_id": task_id, "status": status}}]}}
def _result(tid="toolu_1", is_error=False):
return {"type": "user", "message": {"content": [
{"type": "tool_result", "tool_use_id": tid, "is_error": is_error, "content": "{}"}]}}
def _text(text):
return {"type": "assistant", "message": {"content": [{"type": "text", "text": text}]}}
def _transcript(tmp_path, lines):
path = tmp_path / "t.jsonl"
# Compact, like the file Claude Code writes ({"name":"…"}, no spaces).
path.write_text("\n".join(json.dumps(line, separators=(",", ":")) for line in lines) + "\n")
return path
def _run(env, transcript, active=False, session="s1"):
out = subprocess.run(
["bash", str(HOOK)],
input=json.dumps({"session_id": session, "transcript_path": str(transcript),
"cwd": str(transcript.parent), "hook_event_name": "Stop",
"stop_hook_active": active}),
capture_output=True, text=True, env=env, timeout=30,
)
assert out.returncode == 0, out.stderr
return out.stdout.strip()
def _closing_turn(reply):
return [_prompt(), _text("On it."), _tool_use(), _result(), _text(reply)]
def test_a_turn_that_closed_nothing_is_silent_and_reports_nothing(tmp_path):
with http_sink(b'{"status":"ok","reason":"x"}') as (port, seen):
env = _env(tmp_path, f"http://127.0.0.1:{port}")
t = _transcript(tmp_path, [_prompt(), _tool_use(status="in_progress"), _result(), _text(BAD)])
assert _run(env, t) == ""
assert seen == []
def test_a_complete_report_passes_silently_and_is_recorded(tmp_path):
with http_sink(b'{"status":"ok"}') as (port, seen):
env = _env(tmp_path, f"http://127.0.0.1:{port}")
assert _run(env, _transcript(tmp_path, _closing_turn(GOOD))) == ""
assert [q["outcome"] for q in seen] == [["passed"]]
assert seen[0]["task_ids"] == ["41"]
def test_a_missing_section_blocks_once_in_the_servers_words_then_records_the_rewrite(tmp_path):
reply = json.dumps({"status": "ok", "reason": REASON}).encode()
with http_sink(reply) as (port, seen):
env = _env(tmp_path, f"http://127.0.0.1:{port}")
out = json.loads(_run(env, _transcript(tmp_path, _closing_turn(BAD))))
assert out == {"decision": "block", "reason": REASON}
assert seen[0]["outcome"] == ["blocked"]
assert seen[0]["missing"] == ["where it sits,needs you,next"]
# The rewrite: Claude Code sets stop_hook_active; the hook records and never blocks again.
rewritten = _transcript(tmp_path, _closing_turn(BAD) + [_text(GOOD)])
assert _run(env, rewritten, active=True) == ""
assert seen[1]["outcome"] == ["passed_after_rewrite"]
assert _run(env, rewritten, active=True) == ""
assert len(seen) == 2
def test_a_rewrite_that_still_misses_is_recorded_and_not_blocked(tmp_path):
reply = json.dumps({"status": "ok", "reason": REASON}).encode()
with http_sink(reply) as (port, seen):
env = _env(tmp_path, f"http://127.0.0.1:{port}")
t = _transcript(tmp_path, _closing_turn(BAD))
_run(env, t)
assert _run(env, t, active=True) == ""
assert [q["outcome"][0] for q in seen] == ["blocked", "missing_after_rewrite"]
def test_another_hooks_block_loop_is_left_alone(tmp_path):
with http_sink(b'{"status":"ok","reason":"x"}') as (port, seen):
env = _env(tmp_path, f"http://127.0.0.1:{port}")
assert _run(env, _transcript(tmp_path, _closing_turn(BAD)), active=True) == ""
assert seen == []
def test_a_task_write_that_failed_closed_nothing(tmp_path):
with http_sink(b'{"status":"ok","reason":"x"}') as (port, seen):
env = _env(tmp_path, f"http://127.0.0.1:{port}")
t = _transcript(tmp_path, [_prompt(), _tool_use(), _result(is_error=True), _text(BAD)])
assert _run(env, t) == ""
assert seen == []
def test_a_task_closed_in_an_earlier_turn_does_not_count(tmp_path):
with http_sink(b'{"status":"ok","reason":"x"}') as (port, seen):
env = _env(tmp_path, f"http://127.0.0.1:{port}")
t = _transcript(tmp_path, _closing_turn(GOOD) + [_prompt("thanks, what else?"), _text(BAD)])
assert _run(env, t) == ""
assert seen == []
def test_a_reply_not_yet_written_is_not_judged(tmp_path):
with http_sink(b'{"status":"ok","reason":"x"}') as (port, seen):
env = _env(tmp_path, f"http://127.0.0.1:{port}")
t = _transcript(tmp_path, [_prompt(), _tool_use(), _result()])
assert _run(env, t) == ""
assert seen == []
def test_no_block_without_a_recorded_check(tmp_path):
t = _transcript(tmp_path, _closing_turn(BAD))
# Unreachable instance.
assert _run(_env(tmp_path), t) == ""
# An instance that answered but returned no reason.
with http_sink(b'{"status":"ok"}') as (port, seen):
assert _run(_env(tmp_path, f"http://127.0.0.1:{port}"), t, session="s2") == ""
assert seen[0]["outcome"] == ["blocked"]
def test_a_bare_id_does_not_count_as_placing_the_work(tmp_path):
reply = json.dumps({"status": "ok", "reason": REASON}).encode()
with http_sink(reply) as (port, seen):
env = _env(tmp_path, f"http://127.0.0.1:{port}")
bare = "Closed #41.\n**Needs you:** nothing.\n**Next:** #42."
assert json.loads(_run(env, _transcript(tmp_path, _closing_turn(bare))))["decision"] == "block"
assert seen[0]["missing"] == ["where it sits"]