feat(planning): start_planning hands back the active plan that already covers the work (#4079)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Successful in 1m10s
CI & Build / TypeScript typecheck (push) Successful in 1m15s
CI & Build / Python tests (push) Failing after 1m22s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Successful in 1m10s
CI & Build / TypeScript typecheck (push) Successful in 1m15s
CI & Build / Python tests (push) Failing after 1m22s
CI & Build / Build & push image (push) Skipped
Step 4 of milestone 415 "An existing plan is found before a new one is made". A session that could not see an existing plan made a second one beside it. start_planning and create_milestone now ask first: an ACTIVE milestone in the project with the same title, or one that reads as the same plan (title, design and steps against milestone embeddings), is returned with its progress and a pointer to create_records(milestone_id=...). Nothing is created; force=true bypasses. - dedup.find_matching_plan / plan_gate / plan_match_response; access-checked before either arm (rule 78), fail-open like the other gates. - Done milestones never block; the semantic arm needs 200+ chars of candidate. - kb_plan_match_threshold (default 0.90) is a setting, in the Settings view, and pinned against the Python default by test_settings_defaults_agree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
@@ -4,10 +4,15 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from scribe.services.dedup import (
|
||||
PLAN_MATCH_DEFAULT_THRESHOLD,
|
||||
DuplicateMatch,
|
||||
duplicate_response,
|
||||
find_duplicate_note,
|
||||
find_duplicate_rule,
|
||||
find_matching_plan,
|
||||
get_plan_match_threshold,
|
||||
plan_candidate_text,
|
||||
plan_match_response,
|
||||
)
|
||||
from tests.helpers import fake_note, make_mock_session
|
||||
|
||||
@@ -342,3 +347,78 @@ def test_every_kind_has_a_suggestion_and_none_proposes_merging_notes():
|
||||
assert "merge" in _KIND_SUGGESTION["snippet"]
|
||||
assert "NOT merge" in _KIND_SUGGESTION["note"]
|
||||
assert "supersedes" in _KIND_SUGGESTION["note"]
|
||||
|
||||
|
||||
# ── the plan gate (milestone 415, step 4) ─────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plan_title_match_short_circuits_the_semantic_arm():
|
||||
ms = MagicMock(id=415, title="Plan gate")
|
||||
sem = AsyncMock()
|
||||
with patch("scribe.services.dedup.can_read_project", AsyncMock(return_value=True)), \
|
||||
patch("scribe.services.dedup.async_session", return_value=_session_returning(ms)), \
|
||||
patch("scribe.services.dedup.embeddings_svc.semantic_search_milestones", sem):
|
||||
dup = await find_matching_plan(7, 2, " plan GATE", "x" * 300)
|
||||
assert (dup.id, dup.reason, dup.similarity) == (415, "title", 1.0)
|
||||
sem.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plan_semantic_arm_asks_for_active_plans_in_the_project_at_the_setting():
|
||||
ms = MagicMock(id=9, title="Metadata")
|
||||
sem = AsyncMock(return_value=[(0.912345, ms)])
|
||||
with patch("scribe.services.dedup.can_read_project", AsyncMock(return_value=True)), \
|
||||
patch("scribe.services.dedup.async_session", return_value=_session_returning(None)), \
|
||||
patch("scribe.services.dedup.embeddings_svc.semantic_search_milestones", sem), \
|
||||
patch("scribe.services.settings.get_setting", AsyncMock(return_value="0.8")):
|
||||
dup = await find_matching_plan(7, 2, "Book metadata", "x" * 300)
|
||||
assert (dup.id, dup.reason, dup.similarity) == (9, "semantic", 0.912)
|
||||
kw = sem.call_args.kwargs
|
||||
assert (kw["project_id"], kw["status"], kw["threshold"]) == (2, "active", 0.8)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plan_gate_fails_open():
|
||||
boom = MagicMock(side_effect=RuntimeError("db down"))
|
||||
with patch("scribe.services.dedup.can_read_project", AsyncMock(return_value=True)), \
|
||||
patch("scribe.services.dedup.async_session", boom):
|
||||
assert await find_matching_plan(7, 2, "Anything", "x" * 300) is None
|
||||
with patch("scribe.services.dedup.can_read_project", AsyncMock(side_effect=RuntimeError)):
|
||||
assert await find_matching_plan(7, 2, "Anything", "x" * 300) is None
|
||||
assert await find_matching_plan(7, 0, "No project") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plan_gate_says_nothing_about_a_project_the_caller_cannot_read():
|
||||
ms = MagicMock(id=415, title="Their plan")
|
||||
session = MagicMock(return_value=_session_returning(ms))
|
||||
with patch("scribe.services.dedup.can_read_project", AsyncMock(return_value=False)), \
|
||||
patch("scribe.services.dedup.async_session", session):
|
||||
assert await find_matching_plan(8, 2, "Their plan") is None
|
||||
session.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_bad_threshold_setting_falls_back_to_the_default():
|
||||
with patch("scribe.services.settings.get_setting", AsyncMock(return_value="lots")):
|
||||
assert await get_plan_match_threshold(7) == PLAN_MATCH_DEFAULT_THRESHOLD
|
||||
with patch("scribe.services.settings.get_setting", AsyncMock(return_value="7")):
|
||||
assert await get_plan_match_threshold(7) == 1.0
|
||||
|
||||
|
||||
def test_plan_candidate_text_carries_the_steps():
|
||||
text = plan_candidate_text(description=None, body=" design ", step_texts=["Step one\n", ""])
|
||||
assert text == "design\n\nStep one"
|
||||
|
||||
|
||||
def test_plan_match_response_points_at_adding_steps_not_a_second_plan():
|
||||
out = plan_match_response(DuplicateMatch(12, "Metadata", 0.93, "semantic"),
|
||||
{"total": 5, "completed": 2, "description": "providers"})
|
||||
assert out["duplicate"] is True and out["existing_id"] == 12
|
||||
assert out["existing_milestone"] == {
|
||||
"id": 12, "title": "Metadata", "description": "providers", "total": 5, "completed": 2,
|
||||
}
|
||||
for phrase in ("create_records(milestone_id=12", "get_milestone(12)", "force=true",
|
||||
"2 of 5 steps"):
|
||||
assert phrase in out["message"]
|
||||
|
||||
Reference in New Issue
Block a user