Files
FabledScribe/tests/test_mcp_tool_planning.py
T
bvandeusenandClaude Opus 5 441a1ac31d
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 1m2s
CI & Build / integration (push) Successful in 1m0s
CI & Build / Python tests (push) Successful in 1m39s
CI & Build / Build & push image (push) Successful in 39s
fix(#4016): records that cite each other are created together, and a guessed id is refused
Sessions predicted the ids their next creates would get and wrote them into
plan bodies and reference notes before the records existed. The database
never collides; the sequence is shared by every session and user, so any
concurrent create took the guessed numbers and the references pointed at
someone else's records.

- create_records (new MCP tool) and start_planning(body=, steps=) create
  their records in ONE transaction: insert, flush for the real ids, rewrite
  {{ref:N}} / {{ref:milestone}} placeholders as #id "title", commit. No
  prediction, no waiting, no stub records left behind when a batch fails.
  Ids need not be consecutive and nothing depends on it.
- Every MCP create/update of a note, task or milestone refuses a #N sitting
  just above the highest assigned id (within 50): that can only be a guess.
  Refusal, not warning. Numbers far above the max (PRs, forge issues) pass.
- notes.build_note splits validation out of create_note so the batch
  validates records exactly as a single create does.
- writing-plans and using-scribe say to pass steps up front and never write
  an unassigned id; plugin version minted.

Integration test runs six concurrent batches and checks each resolves its
placeholders to its own records, and that a failing batch writes nothing.

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

51 lines
2.2 KiB
Python

from unittest.mock import AsyncMock, patch
import pytest
from tests.helpers import fake_task
pytestmark = pytest.mark.usefixtures("_bind_user")
@pytest.mark.asyncio
async def test_start_planning_tool_delegates_to_service():
payload = {"milestone": {"id": 5}, "applicable_rules": [], "subscribed_rulebooks": [],
"applicable_rules_truncated": False, "project_goal": "", "open_task_count": 0}
with patch("scribe.mcp.tools.tasks.planning_svc.start_planning",
AsyncMock(return_value=payload)) as mock:
from scribe.mcp.tools.tasks import start_planning
out = await start_planning(project_id=3, title="Plan it")
assert out["milestone"]["id"] == 5
# No body and no steps reach the service as None, so it seeds the template
# and takes the single-milestone path.
assert mock.call_args.kwargs == {
"user_id": 7, "project_id": 3, "title": "Plan it", "body": None, "steps": None,
}
@pytest.mark.asyncio
async def test_get_task_augments_plan_with_rules():
applicable = {"rules": [{"id": 1, "title": "r"}], "truncated": False,
"subscribed_rulebooks": [{"id": 2, "title": "rb"}]}
with patch("scribe.mcp.tools.tasks.notes_svc.get_note_for_user",
AsyncMock(return_value=(fake_task(task_kind="plan", id=9, project_id=3), "owner"))), \
patch("scribe.mcp.tools.tasks.rulebooks_svc.get_applicable_rules",
AsyncMock(return_value=applicable)):
from scribe.mcp.tools.tasks import get_task
out = await get_task(task_id=9)
assert out["applicable_rules"] == [{"id": 1, "title": "r"}]
assert out["subscribed_rulebooks"] == [{"id": 2, "title": "rb"}]
assert out["applicable_rules_truncated"] is False
@pytest.mark.asyncio
async def test_get_task_work_kind_has_no_rules():
with patch("scribe.mcp.tools.tasks.notes_svc.get_note_for_user",
AsyncMock(return_value=(fake_task(task_kind="work", id=9, project_id=3), "owner"))), \
patch("scribe.mcp.tools.tasks.rulebooks_svc.get_applicable_rules",
AsyncMock()) as mock_rules:
from scribe.mcp.tools.tasks import get_task
out = await get_task(task_id=9)
assert "applicable_rules" not in out
assert not mock_rules.called