Files
FabledScribe/tests/test_cited_record_status.py
T
bvandeusenandClaude Opus 5 5c6175ad97
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 50s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / Python tests (push) Failing after 1m4s
CI & Build / Build & push image (push) Skipped
feat(placement): a record you only cite carries its status (#4154)
Step 1 made placement cheap for a task whose status CHANGES: create_task
and update_task return where it sits, and the report is written from
that. It did nothing for a task a reply merely cites.

This milestone's own step-6 review reported "#4014 is the open step of
milestone 409". #4014 had been done for four days; the open step was
#4015. The id did not come from a read — it came from a retrieval hint,
which carries an id, a kind and a title and says nothing about status,
while list_milestones said "8 of 9" and would not say which one. The
gap was there to be filled and the nearest-looking id filled it.

Two surfaces, one principle: the status arrives with the id.

1. get_project_milestone_summaries gains next_step — the earliest open
   step, {id, title, status} or None — carried through _BRIEF_FIELDS to
   enter_project, get_project and list_milestones. One extra flat query
   for the whole batch, so #2384's fan-out does not come back.

   OPEN_STEP_STATUSES moves to services/milestones.py and placement.py
   imports it; both surfaces now answer "what is next" and must not
   drift on what counts as open. Both step queries take the same
   readable_notes_clause (rule 78), so a row cannot name a step its own
   progress numbers exclude.

2. _record_kind renders a task's status: [task (done)], [issue (todo)].
   A finished step and an open one read identically before, which is
   exactly the line the misreport was taken from. Only tasks — is_task
   IS status-is-not-None on the model, so there is no fallback branch.

reporting-back gains the practice, owned and registered in the guidance
ownership table: a record you only mention is a record to read.

The guards are structural and each fails on the regression it names:
the query count is asserted rather than the payload shape, and the two
surfaces' agreement is pinned on the rendered ORDER BY, since a mocked
session hands back whatever order the test chose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
2026-09-18 12:08:00 -04:00

298 lines
12 KiB
Python

"""A record you merely CITE carries its status (#4154, milestone 409 step 8).
WHY THIS EXISTS
Step 1 (#4010) made placement cheap for a task whose status CHANGES: the write
returns where it sits, and the report is written from that. It does nothing for
a task a reply only mentions. In this milestone's own step-6 review the session
reported "#4014 is the open step of milestone 409". #4014 had been done for
four days; the open step was #4015. The id did not come from a read — it came
from a retrieval hint, which carries an id, a kind and a title and says nothing
about status, while `list_milestones` said "8 of 9" and would not say which one.
Two surfaces, one principle: the status arrives with the id.
1. A milestone summary row names its next open step, so the listing that
prompts the question also answers it.
2. An injected menu line renders a task's status, so a finished step cannot
read as live work.
THE ONE THAT MATTERS MOST
`test_the_listing_and_placement_agree_on_what_open_means` — two surfaces now
answer "what is next" and they must not drift. It is written as a behavioural
cross-check rather than `assert milestones.OPEN_STEP_STATUSES is placement._OPEN`,
which shares one object today and would therefore pass no matter what either
side did with it (rule 167: a guard has to be able to fail). This one fails if
either side changes its ordering or its notion of "open" alone.
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from sqlalchemy import true
from scribe.services import milestones as ms
from scribe.services import placement as pl
from tests.helpers import fake_note, make_mock_session
def _milestone_row(mid: int, project_id: int = 5):
"""A Milestone as the first query returns it — .to_dict() feeds the entry."""
row = MagicMock()
row.id, row.project_id, row.user_id = mid, project_id, 7
row.updated_at = None
row.to_dict.return_value = {"id": mid, "title": f"M{mid}", "project_id": project_id}
return row
def _sessions(results: list, counter: list[int], seen_sql: list | None = None):
"""A patched `async_session` handing out queued results and counting queries.
Every query in `get_project_milestone_summaries` pulls the next entry, so
the ORDER of `results` pins the order of the queries — which is what makes
the query-count assertion meaningful rather than incidental.
`seen_sql` collects the rendered statements. The ordering these surfaces
have to agree on lives in an ORDER BY, which no amount of feeding rows to a
mock can exercise — a stand-in hands back whatever order the test chose.
"""
session = make_mock_session()
async def _execute(stmt=None, *_a, **_kw):
counter[0] += 1
if seen_sql is not None:
seen_sql.append(str(stmt))
rows = results.pop(0) if results else []
r = MagicMock()
r.fetchall = MagicMock(return_value=rows)
r.scalars = MagicMock(return_value=MagicMock(all=lambda: rows))
return r
session.execute = _execute
return MagicMock(return_value=session)
def _order_by(sql: str) -> str:
"""The ORDER BY tail of a rendered statement, normalised."""
_, _, tail = sql.upper().partition("ORDER BY")
return " ".join(tail.split())
async def _summaries(milestones, counts, open_steps, counter=None, seen_sql=None):
counter = counter if counter is not None else [0]
results = [milestones, counts, open_steps]
with patch.object(ms, "async_session", _sessions(results, counter, seen_sql)), \
patch.object(ms.access_svc, "readable_notes_clause",
MagicMock(return_value=true())):
return await ms.get_project_milestone_summaries(7, [5])
async def _placement_sql(seen_sql: list):
"""Run `task_placement` far enough to render its sibling-steps query."""
session = make_mock_session()
milestone = MagicMock(id=409, project_id=5, user_id=7, title="M", status="active")
async def _execute(stmt=None, *_a, **_kw):
seen_sql.append(str(stmt))
r = MagicMock()
r.scalars = MagicMock(return_value=MagicMock(
first=lambda: milestone, all=lambda: [],
))
return r
session.execute = _execute
with patch.object(pl, "async_session", MagicMock(return_value=session)), \
patch.object(pl.access_svc, "can_read_project", AsyncMock(return_value=False)), \
patch.object(pl.access_svc, "readable_notes_clause",
MagicMock(return_value=true())):
await pl.task_placement(7, SimpleNamespace(
id=1, project_id=5, milestone_id=409, status="todo",
))
@pytest.mark.asyncio
async def test_a_milestone_row_names_its_next_open_step():
"""The listing that says "8 of 9" now says WHICH one, in the same read."""
rows = await _summaries(
[_milestone_row(409)],
[(409, "done", 8), (409, "todo", 1)],
[(409, 4154, "Step 8 — a cited record carries its status", "todo")],
)
assert rows[5][0]["next_step"] == {
"id": 4154,
"title": "Step 8 — a cited record carries its status",
"status": "todo",
}
@pytest.mark.asyncio
async def test_a_finished_plan_names_no_next_step_rather_than_omitting_the_key():
"""Always present, None included.
A key that disappears when the answer is "nothing left" makes a reader
test for its absence to learn the answer, and a reader who forgets is back
to guessing — which is the failure this step exists for.
"""
rows = await _summaries([_milestone_row(416)], [(416, "done", 9)], [])
assert rows[5][0]["next_step"] is None
assert "next_step" in rows[5][0]
@pytest.mark.asyncio
async def test_the_earliest_open_step_wins_not_the_earliest_step():
"""The done-first case, which is the shape every part-finished plan has.
Steps arrive in creation order, so a plan whose first two are closed must
name the third. Naming the first would reproduce the exact misreport: a
step that IS in the milestone, that IS plausible, and that is finished.
"""
rows = await _summaries(
[_milestone_row(409)],
[(409, "done", 2), (409, "todo", 2)],
# The query filters to open steps, so the closed ones never appear —
# this asserts the ORDER of what does: earliest open, not last written.
[(409, 4015, "Step 6", "in_progress"), (409, 4154, "Step 8", "todo")],
)
assert rows[5][0]["next_step"]["id"] == 4015
assert rows[5][0]["next_step"]["status"] == "in_progress"
@pytest.mark.asyncio
async def test_the_batch_does_not_fan_out_per_milestone():
"""#2384's shape must not come back through the new query.
Three queries for one milestone and three for forty — the cost is in the
number of QUERIES, not the number of plans. A per-milestone "what's next"
lookup would produce identical output and reproduce the pool exhaustion.
"""
counter = [0]
await _summaries(
[_milestone_row(i) for i in range(40)],
[(i, "todo", 1) for i in range(40)],
[(i, 1000 + i, f"S{i}", "todo") for i in range(40)],
counter=counter,
)
assert counter[0] == 3, f"{counter[0]} queries for 40 milestones"
@pytest.mark.asyncio
async def test_both_step_queries_take_the_same_visibility_clause():
"""Rule 78, and a correctness point on top of it.
`next_step` names a step; the counts beside it say how many there are. Read
through different clauses, a row could name a step its own progress numbers
exclude — one row describing two different milestones.
"""
seen = []
def _clause(uid):
seen.append(uid)
return true()
results = [[_milestone_row(409)], [(409, "todo", 1)], [(409, 1, "S", "todo")]]
with patch.object(ms, "async_session", _sessions(results, [0])), \
patch.object(ms.access_svc, "readable_notes_clause", _clause):
await ms.get_project_milestone_summaries(7, [5])
# Built ONCE and reused, so the two queries cannot be given different ones.
assert seen == [7]
@pytest.mark.asyncio
async def test_the_listing_and_placement_agree_on_what_open_means():
"""THE GUARD. Two surfaces answer "what is next"; they must not drift.
Behavioural on purpose — see the module docstring. `placement` answers for
a session that knows which step it is on, the listing for one that does
not, so the two are only equal when the current step is the first. That is
the case checked here, and it fails if either side's ordering or its open
set moves without the other.
"""
steps = [
SimpleNamespace(id=1, title="Step 1", status="done"),
SimpleNamespace(id=2, title="Step 2", status="done"),
SimpleNamespace(id=3, title="Step 3", status="todo"),
SimpleNamespace(id=4, title="Step 4", status="todo"),
]
# From the first step, placement's "the next open one after this" and the
# listing's "the earliest open one" are the same question.
from_placement = pl._next_open(steps, current_id=1)
listing_sql: list = []
rows = await _summaries(
[_milestone_row(409)],
[(409, "done", 2), (409, "todo", 2)],
[(409, s.id, s.title, s.status) for s in steps if s.status in ms.OPEN_STEP_STATUSES],
seen_sql=listing_sql,
)
assert rows[5][0]["next_step"] == from_placement
# And they agree on ORDERING, which the rows above cannot show: a stand-in
# session hands back whatever order this test chose, so "the first row
# wins" would pass against any ORDER BY at all. The database does the
# sorting in production, so the assertion belongs on the statement.
placement_sql: list = []
await _placement_sql(placement_sql)
steps_query = next(q for q in placement_sql if "ORDER BY" in q.upper())
assert _order_by(listing_sql[-1]) == _order_by(steps_query)
assert _order_by(listing_sql[-1]), "the open-steps query has no ORDER BY at all"
def test_a_task_line_in_the_menu_says_where_the_work_stands():
"""The other half: an id and a title with no status is what got cited."""
from scribe.services.plugin_context import _record_kind
assert _record_kind(fake_note(is_task=True, status="done")) == "task (done)"
assert _record_kind(fake_note(is_task=True, status="todo")) == "task (todo)"
assert _record_kind(
fake_note(is_task=True, task_kind="issue", status="in_progress")
) == "issue (in_progress)"
def test_a_record_with_no_status_gains_no_parenthesis():
"""Only tasks. A note or a snippet has no status to be wrong about, and a
marker that appeared on every line would stop being read."""
from scribe.services.plugin_context import _record_kind
assert _record_kind(fake_note(note_type="snippet")) == "snippet"
assert _record_kind(fake_note(note_type="process")) == "process"
assert _record_kind(fake_note()) == "note"
def test_the_listing_tools_say_what_next_step_is_and_how_to_use_it():
"""The contract every MCP client reads (rule 119, decision #4027).
The field is only worth adding if a caller knows it is there. Without this
the docstring can be tidied to a parameter list and the one surface that
reaches a non-Claude-Code client goes quiet about it.
"""
from tests.helpers import tool_doc
for module, name in (
("scribe.mcp.tools.milestones", "list_milestones"),
("scribe.mcp.tools.projects", "enter_project"),
("scribe.mcp.tools.projects", "get_project"),
):
doc = tool_doc(module, name).lower()
assert "next_step" in doc, f"{name} does not mention next_step"
# Naming the field is not enough — a reader has to be told that a null
# is an answer, or an absent next step reads as data not yet loaded.
assert "null" in doc or "none" in doc, f"{name} does not say when it is empty"
def test_the_reply_skill_says_a_cited_record_still_gets_read():
"""The other half of the fix is a practice, and it has one home.
Presence, not absence — the skill legitimately discusses recall in order to
warn against it, so an absence check here would be satisfied by the warning
itself (snippet #3352).
"""
import pathlib
skill = pathlib.Path(__file__).resolve().parents[1] / "plugin/skills/reporting-back/SKILL.md"
text = " ".join(skill.read_text().split()).lower()
assert "a record you only mention is a record to read" in text
# And it points at the field rather than restating how to compute it.
assert "next_step" in text