feat(mcp): enter_project becomes a small primer: goal, recent work, open work, vocabulary (#4045)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 46s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m44s
CI & Build / Build & push image (push) Successful in 23s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 46s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m44s
CI & Build / Build & push image (push) Successful in 23s
The handshake carried the whole project record, every milestone's plan, full rule text, the notes most recently edited and ~9k of design guidance. For project 2 that was ~222k characters, past what an MCP client accepts as a tool result. Each category was walked through with the operator and sized to what a session needs on arrival; each names the call that has the rest. - project: id, title, status and the full goal (session start's "full goal" pointer still lands here). get_project keeps the whole record. - milestone_summary: the 5 most recently touched milestones, any status, most recent first, without plans. Summaries gain last_touched_at: the later of the milestone's own edit and its newest step update, from the query that already counts steps. milestone_summary_omitted counts the rest and points to list_milestones. get_project and list_milestones list every milestone, also without plans. - open_tasks: the 10 most recently touched open tasks, with or without a milestone, each naming its milestone. list_notes gains sort="touched" (the later of updated_at and the newest work-log), because a log doesn't bump updated_at. - recent_notes: dropped. Retrieval surfaces notes by relevance, and get_recent covers recency. - systems: id and name. - design_system: summary plus guidance_call. get_design_system gains resolved_guidance, the chain-merged prose; its own guidance field is only the departures, so session start's old pointer to it led to a fragment. The session start pointer and using-scribe's "Building UI" section now name resolved_guidance. - rules: rules_payload(brief=True) gives project_rules as id and title plus subscribed_rulebooks, and records only what it shows. Retrieval delivers rules in full and ignores subscriptions (#4052). Other callers unchanged. - pattern_coverage, inception and systems_bootstrap: unchanged. Clients: the plugin's using-scribe skill, the compaction notice and session start are updated here; the REST project summary only gains last_touched_at. Plugin version minted. Tests: a size ceiling on the handshake for a large project; milestone and task selection and naming; brief rules; resolved_guidance; the session start pointer; and a real-Postgres test that a work-log touches its task and a step update touches its milestone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
@@ -112,7 +112,7 @@ TOPICS: tuple[Topic, ...] = (
|
||||
Topic("scribe is the system of record; keep one copy", U, ("one copy",),
|
||||
"let any existing local memory shrink", index=("one copy",)),
|
||||
Topic("orient: enter the project, check repo bindings", U, ("enter_project", "list_repo_bindings"),
|
||||
"returns the project plus the rules bound to the areas it works in",
|
||||
"the milestones and open tasks worked on most recently",
|
||||
index=("enter_project",)),
|
||||
Topic("rules are retrieved; ask before a consequential act", U, ('content_type="rule"', "nothing matched"),
|
||||
"an empty session is not evidence of an empty rulebook",
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Real-Postgres tests for "recently touched" in the enter_project handshake (#4045).
|
||||
|
||||
What a mock cannot show: that a work-log moves its task up the list even
|
||||
though logging never bumps the task's updated_at, and that a milestone whose
|
||||
steps changed today reads as touched today even though its own row is old.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.milestone import Milestone
|
||||
from scribe.models.note import Note
|
||||
from scribe.models.project import Project
|
||||
from scribe.models.task_log import TaskLog
|
||||
from scribe.services import milestones as milestones_svc
|
||||
from scribe.services import notes as notes_svc
|
||||
from tests.helpers import ensure_user
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine", "_no_embedding")]
|
||||
|
||||
NOW = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def world():
|
||||
"""Two open tasks and two milestones with deliberately staged timestamps."""
|
||||
async with async_session() as s:
|
||||
owner = await ensure_user(s, "handshake_owner")
|
||||
project = Project(user_id=owner.id, title="Handshake target")
|
||||
s.add(project)
|
||||
await s.flush()
|
||||
|
||||
# Milestone "old plan": its own row is 30 days old, one step changed now.
|
||||
old_plan = Milestone(user_id=owner.id, project_id=project.id, title="old plan",
|
||||
updated_at=NOW - timedelta(days=30))
|
||||
# Milestone "recent edit": its own row changed 2 days ago, no steps.
|
||||
recent_edit = Milestone(user_id=owner.id, project_id=project.id, title="recent edit",
|
||||
updated_at=NOW - timedelta(days=2))
|
||||
s.add_all([old_plan, recent_edit])
|
||||
await s.flush()
|
||||
|
||||
# "logged": last edited 10 days ago, but worked through a log just now.
|
||||
logged = Note(user_id=owner.id, project_id=project.id, title="logged",
|
||||
status="todo", task_kind="work",
|
||||
updated_at=NOW - timedelta(days=10))
|
||||
# "edited": last edited 1 day ago, no logs.
|
||||
edited = Note(user_id=owner.id, project_id=project.id, title="edited",
|
||||
status="todo", task_kind="work",
|
||||
updated_at=NOW - timedelta(days=1))
|
||||
step = Note(user_id=owner.id, project_id=project.id, milestone_id=old_plan.id,
|
||||
title="step", status="done", task_kind="work", updated_at=NOW)
|
||||
s.add_all([logged, edited, step])
|
||||
await s.flush()
|
||||
s.add(TaskLog(task_id=logged.id, user_id=owner.id, content="worked on it",
|
||||
created_at=NOW))
|
||||
ids = {"owner": owner.id, "pid": project.id, "logged": logged.id,
|
||||
"edited": edited.id, "old_plan": old_plan.id, "recent_edit": recent_edit.id}
|
||||
await s.commit()
|
||||
return ids
|
||||
|
||||
|
||||
async def test_a_work_log_counts_as_touching_its_task(world):
|
||||
by_edit, _ = await notes_svc.list_notes(
|
||||
world["owner"], is_task=True, project_id=world["pid"],
|
||||
status=["todo", "in_progress"], sort="updated_at",
|
||||
)
|
||||
by_touch, _ = await notes_svc.list_notes(
|
||||
world["owner"], is_task=True, project_id=world["pid"],
|
||||
status=["todo", "in_progress"], sort="touched",
|
||||
)
|
||||
assert [t.id for t in by_edit] == [world["edited"], world["logged"]]
|
||||
assert [t.id for t in by_touch] == [world["logged"], world["edited"]]
|
||||
|
||||
|
||||
async def test_a_step_changing_counts_as_touching_its_milestone(world):
|
||||
rows = await milestones_svc.get_project_milestone_summary(world["owner"], world["pid"])
|
||||
brief, omitted = milestones_svc.brief_milestone_summary(rows, limit=1)
|
||||
assert omitted == 1
|
||||
assert [m["id"] for m in brief] == [world["old_plan"]]
|
||||
@@ -197,3 +197,25 @@ async def test_update_token_leaves_supersedes_alone_when_omitted():
|
||||
from scribe.mcp.tools.design_systems import update_design_token
|
||||
await update_design_token(token_id=9, purpose="text on action surfaces")
|
||||
assert "supersedes" not in svc.update_token.await_args.kwargs
|
||||
|
||||
|
||||
# --- get: the guidance a UI session builds from -----------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_design_system_carries_the_chain_merged_guidance():
|
||||
"""enter_project carries only a summary and points here (#4045). The
|
||||
system's own `guidance` field is only its departures; a session building
|
||||
UI needs the house style too, so the merged form rides alongside."""
|
||||
from scribe.mcp.tools.design_systems import get_design_system
|
||||
|
||||
merged = [{"design_system_id": 1, "title": "House", "guidance": "house style"},
|
||||
{"design_system_id": 2, "title": "App", "guidance": "departures"}]
|
||||
with patch("scribe.mcp.tools.design_systems.ds_svc.get_design_system",
|
||||
AsyncMock(return_value=_fake_design_system())), \
|
||||
patch("scribe.mcp.tools.design_systems.ds_svc.list_tokens",
|
||||
AsyncMock(return_value=[])), \
|
||||
patch("scribe.mcp.tools.design_systems.ds_svc.design_context",
|
||||
AsyncMock(return_value={"guidance": merged})):
|
||||
out = await get_design_system(2)
|
||||
|
||||
assert out["resolved_guidance"] == merged
|
||||
|
||||
@@ -165,8 +165,8 @@ async def test_update_project_raises_when_not_found():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enter_project_composes_full_context():
|
||||
"""enter_project pulls project + rules + milestone summary + open tasks +
|
||||
recent notes in one composed call."""
|
||||
"""enter_project pulls project + rule titles + milestone summary + open
|
||||
tasks in one composed call, each in its brief handshake form (#4045)."""
|
||||
p = fake_project(id=5, title="P")
|
||||
applicable_payload = {
|
||||
"rules": [{"id": 1, "title": "r1", "statement": "s",
|
||||
@@ -199,14 +199,19 @@ async def test_enter_project_composes_full_context():
|
||||
):
|
||||
out = await enter_project(project_id=5)
|
||||
|
||||
assert out["project"]["id"] == 5
|
||||
assert out["project"] == {"id": 5, "title": "P", "status": "active", "goal": ""}
|
||||
assert out["milestone_summary"] == milestone_summary
|
||||
assert out["applicable_rules"][0]["title"] == "r1"
|
||||
assert out["project_rules"][0]["id"] == 99
|
||||
# Rules arrive in full by retrieval; the handshake lists the project's own
|
||||
# by id and title and drops the subscription bookkeeping (#4045).
|
||||
assert out["project_rules"] == [{"id": 99, "title": "pr1"}]
|
||||
assert out["subscribed_rulebooks"] == [{"id": 2, "title": "rb"}]
|
||||
assert out["open_tasks"][0]["id"] == 100
|
||||
assert out["open_tasks"][0]["status"] == "in_progress"
|
||||
assert out["recent_notes"][0]["id"] == 200
|
||||
for gone in ("applicable_rules", "applicable_rules_truncated",
|
||||
"suppressed_rules", "suppressed_topics", "recent_notes"):
|
||||
assert gone not in out, gone
|
||||
assert out["open_tasks"] == [{
|
||||
"id": 100, "title": "T1", "status": "in_progress",
|
||||
"milestone_id": 10, "milestone_title": "MS",
|
||||
}]
|
||||
# No design system on this project -> the key is present and null, not
|
||||
# absent. A caller that has to distinguish "no key" from "no system" will
|
||||
# eventually get it wrong.
|
||||
@@ -252,10 +257,7 @@ async def test_enter_project_surfaces_the_systems_vocabulary():
|
||||
):
|
||||
out = await enter_project(project_id=5)
|
||||
|
||||
assert out["systems"] == [
|
||||
{"id": 3, "name": "retrieval",
|
||||
"description": "Embeddings, ranking, auto-inject."}
|
||||
]
|
||||
assert out["systems"] == [{"id": 3, "name": "retrieval"}]
|
||||
|
||||
|
||||
def _enter_project_stubs(p):
|
||||
@@ -371,6 +373,9 @@ async def test_enter_project_hands_back_the_design_system_when_the_project_has_o
|
||||
|
||||
assert out["design_system"]["token_count"] == 95
|
||||
assert out["design_system"]["inherits_from"] == ["House"]
|
||||
# Summary only: the guidance is one call away (#4045).
|
||||
assert "guidance" not in out["design_system"]
|
||||
assert out["design_system"]["guidance_call"] == "get_design_system(9) → resolved_guidance"
|
||||
assert ctx.await_args.args == (7, 9) # caller's id, the project's system
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Milestone listings stay brief and bounded (#4045).
|
||||
"""The enter_project handshake stays a small primer (#4045).
|
||||
|
||||
enter_project once carried every milestone's full plan body. On a project with
|
||||
39 milestones that came to ~222k characters, past what an MCP client accepts
|
||||
as a tool result, so the session handshake arrived as a file to page through.
|
||||
enter_project once carried every milestone's full plan body, the whole project
|
||||
record, full rule text and ~9k of design guidance. On a project with 39
|
||||
milestones that came to ~222k characters, past what an MCP client accepts as
|
||||
a tool result, so the session handshake arrived as a file to page through.
|
||||
"""
|
||||
import contextlib
|
||||
import json
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -19,28 +20,32 @@ from tests.helpers import fake_project
|
||||
pytestmark = pytest.mark.usefixtures("_bind_user")
|
||||
|
||||
PLAN = "A plan paragraph long enough to matter. " * 125 # ~5k chars
|
||||
GOAL = "What the project is for. " * 50 # ~1.2k chars
|
||||
|
||||
|
||||
def _row(mid: int, status: str, updated: str) -> dict:
|
||||
def _milestone(mid: int, status: str, touched_day: int) -> dict:
|
||||
"""A summary row as get_project_milestone_summary returns it."""
|
||||
touched = f"2026-08-{touched_day:02d}T00:00:00+00:00"
|
||||
return {
|
||||
"id": mid, "user_id": 7, "project_id": 5, "title": f"M{mid}",
|
||||
"description": f"what M{mid} is for", "body": PLAN, "status": status,
|
||||
"order_index": mid, "created_at": "2026-01-01T00:00:00+00:00",
|
||||
"updated_at": updated, "total": 4, "completed": 2, "pct": 50.0,
|
||||
"updated_at": "2026-01-01T00:00:00+00:00", "last_touched_at": touched,
|
||||
"total": 4, "completed": 2, "pct": 50.0,
|
||||
"status_counts": {"todo": 2, "in_progress": 0, "done": 2, "cancelled": 0},
|
||||
}
|
||||
|
||||
|
||||
def _history(done: int, active: int) -> list[dict]:
|
||||
"""`done` done milestones (higher id = more recently updated), then `active` open ones."""
|
||||
rows = [_row(i, "done", f"2026-08-{i + 1:02d}T00:00:00+00:00") for i in range(done)]
|
||||
rows += [_row(done + i, "active", "2026-01-01T00:00:00+00:00") for i in range(active)]
|
||||
return rows
|
||||
def _history(count: int) -> list[dict]:
|
||||
"""`count` milestones, alternating done/active; higher id = touched later."""
|
||||
return [
|
||||
_milestone(i, "done" if i % 2 else "active", 1 + i % 28)
|
||||
for i in range(count)
|
||||
]
|
||||
|
||||
|
||||
def test_brief_rows_leave_out_the_plan_and_what_the_caller_already_knows():
|
||||
brief, omitted = brief_milestone_summary([_row(1, "active", "2026-09-01")])
|
||||
brief, omitted = brief_milestone_summary([_milestone(1, "active", 3)])
|
||||
assert omitted == 0
|
||||
assert brief == [{
|
||||
"id": 1, "title": "M1", "description": "what M1 is for", "status": "active",
|
||||
@@ -49,93 +54,145 @@ def test_brief_rows_leave_out_the_plan_and_what_the_caller_already_knows():
|
||||
}]
|
||||
|
||||
|
||||
def test_cap_keeps_every_open_milestone_and_the_most_recent_done_ones_in_order():
|
||||
rows = _history(done=8, active=3)
|
||||
brief, omitted = brief_milestone_summary(rows, done_kept=2)
|
||||
assert omitted == 6
|
||||
# The two most recently updated done ones (ids 6, 7) and all open ones,
|
||||
# still in order_index order.
|
||||
assert [r["id"] for r in brief] == [6, 7, 8, 9, 10]
|
||||
def test_limit_keeps_the_most_recently_touched_whatever_their_status():
|
||||
"""A plan can sit "active" for months; recency is what says it's current."""
|
||||
rows = [_milestone(1, "active", 1), _milestone(2, "done", 9),
|
||||
_milestone(3, "active", 5), _milestone(4, "done", 7)]
|
||||
brief, omitted = brief_milestone_summary(rows, limit=2)
|
||||
assert omitted == 2
|
||||
assert [r["id"] for r in brief] == [2, 4] # most recent first
|
||||
|
||||
|
||||
def test_no_cap_keeps_every_row():
|
||||
brief, omitted = brief_milestone_summary(_history(done=8, active=3))
|
||||
def test_no_limit_keeps_every_row_in_order():
|
||||
brief, omitted = brief_milestone_summary(_history(8))
|
||||
assert omitted == 0
|
||||
assert len(brief) == 11
|
||||
assert [r["id"] for r in brief] == list(range(8))
|
||||
|
||||
|
||||
def _enter_stubs(rows: list[dict]):
|
||||
def _task(tid: int, milestone_id: int | None) -> MagicMock:
|
||||
t = MagicMock()
|
||||
t.id = tid
|
||||
t.title = f"T{tid}"
|
||||
t.status = "todo"
|
||||
t.milestone_id = milestone_id
|
||||
return t
|
||||
|
||||
|
||||
def _enter_stubs(project, milestones: list[dict], tasks: list, *, rules=None, systems=None,
|
||||
design=None):
|
||||
applicable = rules or {"rules": [], "project_rules": [], "truncated": False,
|
||||
"subscribed_rulebooks": []}
|
||||
return [
|
||||
patch("scribe.mcp.tools.projects.projects_svc.get_project",
|
||||
AsyncMock(return_value=fake_project(id=5))),
|
||||
AsyncMock(return_value=project)),
|
||||
patch("scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
|
||||
AsyncMock(return_value={"rules": [], "truncated": False,
|
||||
"subscribed_rulebooks": []})),
|
||||
AsyncMock(return_value=applicable)),
|
||||
patch("scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
|
||||
AsyncMock(return_value=rows)),
|
||||
AsyncMock(return_value=milestones)),
|
||||
patch("scribe.mcp.tools.projects.notes_svc.list_notes",
|
||||
AsyncMock(side_effect=[([], 0), ([], 0)])),
|
||||
AsyncMock(return_value=(tasks, len(tasks)))),
|
||||
patch("scribe.mcp.tools.projects.systems_svc.list_systems",
|
||||
AsyncMock(return_value=[])),
|
||||
AsyncMock(return_value=systems or [])),
|
||||
patch("scribe.mcp.tools.projects.systems_tools.bootstrap_systems_ask",
|
||||
AsyncMock(return_value=None)),
|
||||
patch("scribe.mcp.tools.projects.design_systems_svc.design_context",
|
||||
AsyncMock(return_value=design)),
|
||||
patch("scribe.mcp.tools.projects.coverage_svc.cached_coverage",
|
||||
AsyncMock(return_value=None)),
|
||||
patch("scribe.mcp.tools.projects.spawn"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enter_project_stays_small_however_long_the_history():
|
||||
"""The ceiling is the point: a long-lived project's handshake must not
|
||||
grow with its history. 200 milestones with 5k-character plans would be
|
||||
~1M characters if bodies rode along."""
|
||||
async def _enter(*stubs):
|
||||
with contextlib.ExitStack() as stack:
|
||||
for cm in _enter_stubs(_history(done=190, active=10)):
|
||||
stack.enter_context(cm)
|
||||
mocks = [stack.enter_context(cm) for cm in stubs]
|
||||
out = await enter_project(project_id=5)
|
||||
return out, mocks
|
||||
|
||||
summary = out["milestone_summary"]
|
||||
assert all("body" not in m for m in summary)
|
||||
assert sum(m["status"] == "done" for m in summary) == 5
|
||||
assert sum(m["status"] == "active" for m in summary) == 10
|
||||
assert out["milestone_summary_omitted"].startswith("185 older done milestone(s)")
|
||||
assert "list_milestones(5)" in out["milestone_summary_omitted"]
|
||||
assert len(json.dumps(out["milestone_summary"], indent=2)) < 10_000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enter_project_stays_small_however_large_the_project():
|
||||
"""The ceiling is the point: a long-lived project's handshake must not
|
||||
grow with its history. 200 milestones with 5k-character plans, 60 project
|
||||
rules and 40 Systems would be well over 1M characters in the old shape."""
|
||||
project = fake_project(id=5, design_system_id=9, goal=GOAL,
|
||||
description="Background. " * 300)
|
||||
rules = {
|
||||
"rules": [{"id": i, "title": f"r{i}", "statement": PLAN} for i in range(50)],
|
||||
"project_rules": [{"id": 100 + i, "title": f"pr{i}", "statement": PLAN,
|
||||
"when_to_apply": PLAN} for i in range(60)],
|
||||
"truncated": True, "subscribed_rulebooks": [{"id": 1, "title": "Family"}],
|
||||
"suppressed_rules": [], "suppressed_topics": [],
|
||||
}
|
||||
systems = []
|
||||
for i in range(40):
|
||||
s = MagicMock()
|
||||
s.id, s.name, s.description = i, f"Area {i}", PLAN
|
||||
systems.append(s)
|
||||
design = {"id": 9, "title": "Kit", "description": "", "inherits_from": ["House"],
|
||||
"guidance": [{"design_system_id": 9, "title": "Kit", "guidance": PLAN * 2}],
|
||||
"token_count": 111, "token_groups": ["accent", "surface"]}
|
||||
tasks = [_task(1000 + i, i % 200) for i in range(10)]
|
||||
|
||||
out, _ = await _enter(*_enter_stubs(project, _history(200), tasks, rules=rules,
|
||||
systems=systems, design=design))
|
||||
|
||||
assert len(out["milestone_summary"]) == 5
|
||||
assert out["milestone_summary_omitted"].startswith("195 other milestone(s)")
|
||||
assert len(out["open_tasks"]) == 10
|
||||
assert "applicable_rules" not in out and "recent_notes" not in out
|
||||
assert "guidance" not in out["design_system"]
|
||||
# The fixed parts are bounded by their caps; what's left to grow is the
|
||||
# goal, the rule and System titles, and the ask keys when they apply.
|
||||
size = len(json.dumps(out, indent=2))
|
||||
assert size < 16_000, size
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_tasks_name_their_milestone_even_when_it_is_not_listed():
|
||||
"""The milestone list is capped at 5; a task's milestone can fall outside
|
||||
it, and its id must not arrive without a name."""
|
||||
milestones = _history(20)
|
||||
tasks = [_task(1, 0), _task(2, None)] # milestone 0 is the least recent
|
||||
out, mocks = await _enter(*_enter_stubs(fake_project(id=5), milestones, tasks))
|
||||
|
||||
assert 0 not in [m["id"] for m in out["milestone_summary"]]
|
||||
assert out["open_tasks"] == [
|
||||
{"id": 1, "title": "T1", "status": "todo", "milestone_id": 0, "milestone_title": "M0"},
|
||||
{"id": 2, "title": "T2", "status": "todo", "milestone_id": None, "milestone_title": None},
|
||||
]
|
||||
list_notes = mocks[3]
|
||||
assert list_notes.await_args.kwargs["sort"] == "touched"
|
||||
assert list_notes.await_args.kwargs["limit"] == 10
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_omitted_key_is_absent_when_nothing_was_left_out():
|
||||
"""Attached only when it applies (#2483)."""
|
||||
with contextlib.ExitStack() as stack:
|
||||
for cm in _enter_stubs(_history(done=3, active=2)):
|
||||
stack.enter_context(cm)
|
||||
out = await enter_project(project_id=5)
|
||||
assert len(out["milestone_summary"]) == 5
|
||||
out, _ = await _enter(*_enter_stubs(fake_project(id=5), _history(3), []))
|
||||
assert len(out["milestone_summary"]) == 3
|
||||
assert "milestone_summary_omitted" not in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_uses_the_same_brief_block():
|
||||
async def test_get_project_lists_every_milestone_without_plans():
|
||||
with patch("scribe.mcp.tools.projects.projects_svc.get_project",
|
||||
AsyncMock(return_value=fake_project(id=5))), \
|
||||
patch("scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
|
||||
AsyncMock(return_value=_history(done=9, active=1))), \
|
||||
AsyncMock(return_value=_history(10))), \
|
||||
patch("scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
|
||||
AsyncMock(return_value={"rules": [], "truncated": False,
|
||||
"subscribed_rulebooks": []})):
|
||||
out = await get_project(project_id=5)
|
||||
assert len(out["milestone_summary"]) == 6
|
||||
assert len(out["milestone_summary"]) == 10
|
||||
assert all("body" not in m for m in out["milestone_summary"])
|
||||
assert out["milestone_summary_omitted"].startswith("4 older done milestone(s)")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_milestones_lists_every_milestone_without_plans():
|
||||
"""The call milestone_summary_omitted points to: every milestone, done
|
||||
ones included, and no bodies (get_milestone has the plan)."""
|
||||
"""The call milestone_summary_omitted points to."""
|
||||
with patch("scribe.mcp.tools.milestones.milestones_svc.get_project_milestone_summary",
|
||||
AsyncMock(return_value=_history(done=30, active=2))):
|
||||
AsyncMock(return_value=_history(30))):
|
||||
out = await list_milestones(project_id=5)
|
||||
assert len(out["milestones"]) == 32
|
||||
assert len(out["milestones"]) == 30
|
||||
assert all("body" not in m for m in out["milestones"])
|
||||
|
||||
@@ -341,6 +341,33 @@ def test_rules_payload_records_both_the_family_and_project_halves():
|
||||
assert kw["source"] == "enter_project"
|
||||
|
||||
|
||||
def test_brief_rules_payload_lists_titles_and_records_only_what_it_shows():
|
||||
"""The handshake's form (#4045): project rules by id and title, the
|
||||
subscribed rulebooks, nothing else. A subscription-derived rule it doesn't
|
||||
show must not count as surfaced."""
|
||||
from scribe.services import rulebooks as svc
|
||||
|
||||
rec = MagicMock()
|
||||
with patch.object(svc, "record_rule_surfaced", rec):
|
||||
out = svc.rules_payload(
|
||||
{
|
||||
"rules": [{"id": 10, "title": "family", "statement": "s"}],
|
||||
"project_rules": [{"id": 12, "title": "own", "statement": "s"}],
|
||||
"truncated": False,
|
||||
"subscribed_rulebooks": [{"id": 1, "title": "Family"}],
|
||||
},
|
||||
user_id=1,
|
||||
source="enter_project",
|
||||
brief=True,
|
||||
)
|
||||
|
||||
assert out == {
|
||||
"project_rules": [{"id": 12, "title": "own"}],
|
||||
"subscribed_rulebooks": [{"id": 1, "title": "Family"}],
|
||||
}
|
||||
assert rec.call_args.kwargs["rule_ids"] == [12]
|
||||
|
||||
|
||||
def test_every_rules_payload_caller_names_itself():
|
||||
"""`source` is the CALLER's name, so the readout can still separate the
|
||||
session handshake from a mid-session milestone read. A shared constant here
|
||||
|
||||
@@ -176,6 +176,9 @@ async def test_build_session_context_pushes_the_projects_design_system():
|
||||
# returns those rather than the resolved tokens.
|
||||
assert "resolve_design_system(9)" in ctx
|
||||
assert "get_design_system_stylesheet(9)" in ctx
|
||||
# The prose pointer names the call that has the MERGED guidance, not
|
||||
# enter_project, which carries only the summary now (#4045).
|
||||
assert "`get_design_system(9)` → `resolved_guidance`" in ctx
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -14,6 +14,7 @@ So these tests assert the number of sessions opened, not only the values
|
||||
returned. A version that produced identical output while opening a session per
|
||||
project would pass a correctness test and reproduce the outage.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -99,17 +100,27 @@ async def test_milestone_summaries_for_many_projects_open_ONE_session():
|
||||
a session per MILESTONE, which is what turned 25 into ~250."""
|
||||
from scribe.services import milestones as svc
|
||||
|
||||
m1 = MagicMock(id=10, project_id=1)
|
||||
written = datetime(2026, 9, 1, tzinfo=timezone.utc)
|
||||
m1 = MagicMock(id=10, project_id=1, updated_at=written)
|
||||
m1.to_dict = MagicMock(return_value={"id": 10, "title": "A"})
|
||||
m2 = MagicMock(id=11, project_id=2)
|
||||
m2 = MagicMock(id=11, project_id=2, updated_at=written)
|
||||
m2.to_dict = MagicMock(return_value={"id": 11, "title": "B"})
|
||||
|
||||
step_closed = datetime(2026, 9, 14, tzinfo=timezone.utc)
|
||||
opened = [0]
|
||||
rows = [[m1, m2], [(10, "done", 2), (10, "todo", 1), (11, "cancelled", 1)]]
|
||||
rows = [[m1, m2], [
|
||||
(10, "done", 2, step_closed),
|
||||
(10, "todo", 1, datetime(2026, 9, 2, tzinfo=timezone.utc)),
|
||||
(11, "cancelled", 1, datetime(2026, 8, 1, tzinfo=timezone.utc)),
|
||||
]]
|
||||
with patch.object(svc, "async_session", _session_factory(opened, rows)):
|
||||
out = await svc.get_project_milestone_summaries(1, [1, 2])
|
||||
|
||||
assert opened[0] == 1
|
||||
# Touched is the later of the milestone's own edit and its newest step
|
||||
# update (#4045): steps closing today count, an old step doesn't pull it back.
|
||||
assert out[1][0]["last_touched_at"] == step_closed.isoformat()
|
||||
assert out[2][0]["last_touched_at"] == written.isoformat()
|
||||
assert out[1][0]["completed"] == 2 and out[1][0]["total"] == 3
|
||||
# Cancelled is excluded from the denominator, so a milestone whose only
|
||||
# task was cancelled reads as complete rather than stalled at 0%.
|
||||
|
||||
Reference in New Issue
Block a user