CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 16s
CI & Build / Python tests (push) Failing after 29s
CI & Build / Build & push image (push) Skipped
First real-world test of the #2562 fixes (Scribe issue #2569): a Forge session ran a whole-codebase audit and created zero Systems — endorsed by the shipped guidance, whose "no particular area takes none" clause read as an exemption for exactly the record type that enumerates the subsystem vocabulary. And the systems_hint was silent for a zero-Systems project, the one state nothing else nudges out of. - systems_hint gains a zero-Systems branch: prompt the FIRST create_system instead of going quiet. - create_system is duplicate-gated like the other creates (normalized name, archived included, fail-open) — liberal creation becomes safe by construction, so the guidance can stop preaching restraint. - Prose inverted on every surface (hint text, create_system docstring, floor bullet, using-scribe step 7): audits/sweeps take several tags and mint the Systems they name; the gate is the guardrail against sprawl, not holding back; only a record genuinely about no particular area goes untagged. - Plugin 0.1.26 -> 0.1.27. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
168 lines
8.2 KiB
Python
168 lines
8.2 KiB
Python
"""MCP system tools + task/note issue wiring (service layer mocked)."""
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
def _fake_system(sid=1, name="Reader", project_id=5):
|
|
s = MagicMock()
|
|
s.to_dict.return_value = {"id": sid, "name": name, "project_id": project_id}
|
|
s.project_id = project_id
|
|
return s
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_system_returns_dict():
|
|
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
|
|
patch("scribe.mcp.tools.systems.systems_svc") as svc:
|
|
svc.create_system = AsyncMock(return_value=_fake_system(name="Reader"))
|
|
from scribe.mcp.tools.systems import create_system
|
|
result = await create_system(project_id=5, name="Reader", description="pdf reader")
|
|
assert result["name"] == "Reader"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_system_no_access_raises():
|
|
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
|
|
patch("scribe.mcp.tools.systems.systems_svc") as svc:
|
|
svc.create_system = AsyncMock(return_value=None)
|
|
from scribe.mcp.tools.systems import create_system
|
|
with pytest.raises(ValueError):
|
|
await create_system(project_id=5, name="Reader")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_system_splits_records_by_kind():
|
|
issue = MagicMock(); issue.to_dict.return_value = {"id": 10}; issue.task_kind = "issue"; issue.status = "todo"
|
|
work = MagicMock(); work.to_dict.return_value = {"id": 11}; work.task_kind = "work"; work.status = "todo"
|
|
note = MagicMock(); note.to_dict.return_value = {"id": 12}; note.task_kind = "work"; note.status = None
|
|
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
|
|
patch("scribe.mcp.tools.systems.systems_svc") as svc:
|
|
svc.get_system = AsyncMock(return_value=_fake_system(sid=3))
|
|
svc.list_records_for_system = AsyncMock(return_value=[issue, work, note])
|
|
from scribe.mcp.tools.systems import get_system
|
|
result = await get_system(system_id=3)
|
|
assert [r["id"] for r in result["issues"]] == [10]
|
|
assert [r["id"] for r in result["tasks"]] == [11]
|
|
assert [r["id"] for r in result["notes"]] == [12]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_system_not_found_raises():
|
|
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
|
|
patch("scribe.mcp.tools.systems.systems_svc") as svc:
|
|
svc.update_system = AsyncMock(return_value=None)
|
|
from scribe.mcp.tools.systems import update_system
|
|
with pytest.raises(ValueError):
|
|
await update_system(system_id=99, name="x")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_task_issue_sets_kind_provenance_and_systems():
|
|
note = MagicMock(); note.id = 50; note.to_dict.return_value = {"id": 50, "task_kind": "issue"}
|
|
with patch("scribe.mcp.tools.tasks.current_user_id", return_value=1), \
|
|
patch("scribe.mcp.tools.tasks.notes_svc") as notes_svc, \
|
|
patch("scribe.mcp.tools.tasks.systems_svc") as systems_svc:
|
|
notes_svc.create_note = AsyncMock(return_value=note)
|
|
systems_svc.set_record_systems = AsyncMock(return_value=[2, 3])
|
|
systems_svc.list_record_systems = AsyncMock(return_value=[])
|
|
from scribe.mcp.tools.tasks import create_task
|
|
result = await create_task(title="bug", kind="issue", system_ids=[2, 3], arose_from_id=9)
|
|
_, kwargs = notes_svc.create_note.call_args
|
|
assert kwargs["task_kind"] == "issue"
|
|
assert kwargs["arose_from_id"] == 9
|
|
systems_svc.set_record_systems.assert_awaited_once_with(1, 50, [2, 3])
|
|
assert result["id"] == 50
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_untagged_hint_names_the_projects_systems():
|
|
sys_a = MagicMock(); sys_a.id = 1; sys_a.name = "workers"
|
|
sys_b = MagicMock(); sys_b.id = 2; sys_b.name = "scrape-pipeline"
|
|
with patch("scribe.mcp.tools.systems.systems_svc") as svc:
|
|
svc.list_systems = AsyncMock(return_value=[sys_a, sys_b])
|
|
from scribe.mcp.tools.systems import untagged_systems_hint
|
|
hint = await untagged_systems_hint(1, 5)
|
|
assert "workers" in hint and "scrape-pipeline" in hint
|
|
assert "system_ids" in hint
|
|
assert "create_system" in hint
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_untagged_hint_zero_systems_prompts_first_create_and_fails_open():
|
|
from scribe.mcp.tools.systems import untagged_systems_hint
|
|
with patch("scribe.mcp.tools.systems.systems_svc") as svc:
|
|
# Zero Systems is the state nothing else nudges out of — the hint must
|
|
# prompt the FIRST create_system, not go silent.
|
|
svc.list_systems = AsyncMock(return_value=[])
|
|
hint = await untagged_systems_hint(1, 5)
|
|
assert "no Systems yet" in hint and "create_system" in hint
|
|
with patch("scribe.mcp.tools.systems.systems_svc") as svc:
|
|
# A hint must never break a create — DB failure degrades to no hint.
|
|
svc.list_systems = AsyncMock(side_effect=RuntimeError("db down"))
|
|
assert await untagged_systems_hint(1, 5) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_system_same_normalized_name_is_duplicate_gated():
|
|
existing = _fake_system(sid=7, name="Scrape Pipeline")
|
|
existing.name = "Scrape Pipeline"
|
|
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
|
|
patch("scribe.mcp.tools.systems.systems_svc") as svc:
|
|
svc.list_systems = AsyncMock(return_value=[existing])
|
|
svc.create_system = AsyncMock()
|
|
from scribe.mcp.tools.systems import create_system
|
|
result = await create_system(project_id=5, name=" scrape pipeline ")
|
|
assert result["duplicate"] is True
|
|
assert result["existing_id"] == 7
|
|
svc.create_system.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_system_distinct_name_passes_the_gate():
|
|
other = _fake_system(sid=7, name="Workers")
|
|
other.name = "Workers"
|
|
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
|
|
patch("scribe.mcp.tools.systems.systems_svc") as svc:
|
|
svc.list_systems = AsyncMock(return_value=[other])
|
|
svc.create_system = AsyncMock(return_value=_fake_system(sid=8, name="Exporter"))
|
|
from scribe.mcp.tools.systems import create_system
|
|
result = await create_system(project_id=5, name="Exporter")
|
|
assert result["name"] == "Exporter"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_task_untagged_in_project_with_systems_carries_hint():
|
|
note = MagicMock(); note.id = 60; note.to_dict.return_value = {"id": 60}
|
|
sys_a = MagicMock(); sys_a.id = 4; sys_a.name = "plugin-hooks"
|
|
with patch("scribe.mcp.tools.tasks.current_user_id", return_value=1), \
|
|
patch("scribe.mcp.tools.tasks.notes_svc") as notes_svc, \
|
|
patch("scribe.mcp.tools.tasks.dedup_svc") as dedup_svc, \
|
|
patch("scribe.mcp.tools.systems.systems_svc") as hint_svc:
|
|
notes_svc.create_note = AsyncMock(return_value=note)
|
|
dedup_svc.find_duplicate_note = AsyncMock(return_value=None)
|
|
hint_svc.list_systems = AsyncMock(return_value=[sys_a])
|
|
from scribe.mcp.tools.tasks import create_task
|
|
result = await create_task(title="untagged", project_id=5)
|
|
assert "plugin-hooks" in result["systems_hint"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_task_tagged_or_projectless_carries_no_hint():
|
|
note = MagicMock(); note.id = 61; note.to_dict.return_value = {"id": 61}
|
|
with patch("scribe.mcp.tools.tasks.current_user_id", return_value=1), \
|
|
patch("scribe.mcp.tools.tasks.notes_svc") as notes_svc, \
|
|
patch("scribe.mcp.tools.tasks.dedup_svc") as dedup_svc, \
|
|
patch("scribe.mcp.tools.tasks.systems_svc") as systems_svc, \
|
|
patch("scribe.mcp.tools.systems.systems_svc") as hint_svc:
|
|
notes_svc.create_note = AsyncMock(return_value=note)
|
|
dedup_svc.find_duplicate_note = AsyncMock(return_value=None)
|
|
systems_svc.set_record_systems = AsyncMock()
|
|
systems_svc.list_record_systems = AsyncMock(return_value=[])
|
|
hint_svc.list_systems = AsyncMock(return_value=[MagicMock()])
|
|
from scribe.mcp.tools.tasks import create_task
|
|
tagged = await create_task(title="tagged", project_id=5, system_ids=[4])
|
|
orphan = await create_task(title="orphan", project_id=0)
|
|
assert "systems_hint" not in tagged
|
|
assert "systems_hint" not in orphan
|