Files
FabledScribe/tests/test_services_placement.py
T
bvandeusenandClaude Opus 5 46d9134b10
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 57s
CI & Build / Python tests (push) Successful in 1m30s
CI & Build / Build & push image (push) Successful in 27s
feat(409): a task write returns where the task sits (#4010)
Step 1 of milestone 409 "Response shapes". An agent reporting finished work
is asked to say which milestone it belongs to, which step of how many, and
what is next. Without those facts to hand it reconstructs them, and a
reconstruction reads exactly like the truth when it is wrong.

create_task and update_task (MCP) and the REST create/update task routes now
return a placement block: project; and for a task in a milestone, the
milestone, position {step, of}, progress {completed, total, pct} and next
(the next open step, falling back to the earliest open one before it).

- Step order is creation order, not get_milestone listing order, which
  reshuffles on every update.
- Siblings are read through readable_notes_clause; position and progress
  are computed over that same readable set, so a collaborator is never shown
  a step title they cannot open, and a note share alone reveals no plan.
- Fail-open and omitted when empty, like every in-band decoration.
- _no_embedding moves into conftest as one opt-in fixture for both
  integration modules that need it.

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

49 lines
2.0 KiB
Python

"""Placement — the pure half: what "next" means, and that a lookup never breaks a write.
The query half (position over readable steps, what a collaborator may see)
needs Postgres and lives in test_integration_placement.py.
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from scribe.services import placement
from scribe.services.placement import _next_open
def _steps(*statuses):
return [SimpleNamespace(id=i, title=f"step {i}", status=s) for i, s in enumerate(statuses, start=1)]
def test_next_is_the_first_open_step_after_this_one():
steps = _steps("done", "in_progress", "done", "todo", "todo")
assert _next_open(steps, current_id=2)["id"] == 4
def test_next_falls_back_to_an_earlier_open_step():
"""The last step finishing does not mean the milestone is finished."""
steps = _steps("todo", "done", "done")
assert _next_open(steps, current_id=3) == {"id": 1, "title": "step 1", "status": "todo"}
def test_nothing_open_means_no_next():
assert _next_open(_steps("done", "cancelled", "done"), current_id=3) is None
async def test_a_task_with_no_project_or_milestone_has_no_placement_and_no_query():
boom = AsyncMock(side_effect=AssertionError("must not open a session"))
with patch.object(placement, "async_session", boom):
task = SimpleNamespace(id=1, project_id=None, milestone_id=None)
assert await placement.task_placement(7, task) is None
async def test_attach_omits_placement_rather_than_sending_it_empty():
with patch.object(placement, "task_placement", AsyncMock(return_value=None)):
data = await placement.attach_placement(7, {"id": 1}, SimpleNamespace(id=1))
assert "placement" not in data
async def test_a_failing_lookup_returns_the_payload_untouched():
with patch.object(placement, "task_placement", AsyncMock(side_effect=RuntimeError("db down"))):
data = await placement.attach_placement(7, {"id": 1}, SimpleNamespace(id=1))
assert data == {"id": 1}