Add versioned task context capsules
This commit is contained in:
parent
34cd5f74c1
commit
4cc6277054
18 changed files with 2834 additions and 10 deletions
|
|
@ -12,6 +12,7 @@ from contextlib import contextmanager
|
|||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
from mcp.shared.memory import create_connected_server_and_client_session
|
||||
|
|
@ -34,6 +35,9 @@ from docforge.viewer_manager import ViewerManager
|
|||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FIXTURES = ROOT / "tests" / "fixtures"
|
||||
CAPSULE_SCHEMA = json.loads(
|
||||
(ROOT / "schemas" / "context-capsule.schema.json").read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
|
||||
class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
|
@ -85,6 +89,7 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertNotIn("limit", tools[name].inputSchema.get("required", []))
|
||||
for name in (
|
||||
"docforge_get_context",
|
||||
"docforge_get_task_context",
|
||||
"docforge_list_changesets",
|
||||
"docforge_get_changeset",
|
||||
"docforge_validate_changeset",
|
||||
|
|
@ -93,6 +98,22 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
for field in ("limit", "cursor"):
|
||||
self.assertIn(field, tools[name].inputSchema["properties"])
|
||||
self.assertNotIn(field, tools[name].inputSchema.get("required", []))
|
||||
self.assertEqual(
|
||||
{"task_kind", "task"},
|
||||
set(tools["docforge_get_task_context"].inputSchema["required"]),
|
||||
)
|
||||
self.assertEqual(
|
||||
[
|
||||
"change",
|
||||
"implementation",
|
||||
"failure",
|
||||
"ownership",
|
||||
"test",
|
||||
"operation",
|
||||
"release",
|
||||
],
|
||||
tools["docforge_get_task_context"].inputSchema["properties"]["task_kind"]["enum"],
|
||||
)
|
||||
self.assertIn(
|
||||
"deep",
|
||||
tools["docforge_render_status"].inputSchema["properties"],
|
||||
|
|
@ -160,6 +181,316 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(0, diagnostics["counters"]["project_loads"])
|
||||
self.assertEqual(0, diagnostics["counters"]["source_files_parsed"])
|
||||
|
||||
async def test_task_context_is_hash_stable_paged_and_generation_bound(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
ProjectIndex(Project.open(root)).build()
|
||||
async with create_connected_server_and_client_session(
|
||||
create_server(root, capability_mode="read", diagnostics=True),
|
||||
raise_exceptions=True,
|
||||
) as session:
|
||||
first = await session.call_tool(
|
||||
"docforge_get_task_context",
|
||||
{
|
||||
"task_kind": "change",
|
||||
"task": "Change the editing workflow",
|
||||
"focus_node_id": "guide.workflow",
|
||||
"limit": 1,
|
||||
},
|
||||
)
|
||||
first_capsule = first.structuredContent["capsule"]
|
||||
Draft202012Validator(CAPSULE_SCHEMA).validate(first_capsule)
|
||||
cursor = first_capsule["pagination"]["next_cursor"]
|
||||
hashes = {
|
||||
first_capsule["capsule_hash"],
|
||||
first_capsule["collection_hash"],
|
||||
first_capsule["plan"]["plan_hash"],
|
||||
}
|
||||
evidence_ids = [item["node_id"] for item in first_capsule["evidence"]]
|
||||
while cursor is not None:
|
||||
page = await session.call_tool(
|
||||
"docforge_get_task_context",
|
||||
{
|
||||
"task_kind": "change",
|
||||
"task": "Change the editing workflow",
|
||||
"focus_node_id": "guide.workflow",
|
||||
"limit": 2,
|
||||
"cursor": cursor,
|
||||
},
|
||||
)
|
||||
capsule = page.structuredContent["capsule"]
|
||||
self.assertEqual(first_capsule["capsule_hash"], capsule["capsule_hash"])
|
||||
self.assertEqual(
|
||||
first_capsule["collection_hash"],
|
||||
capsule["collection_hash"],
|
||||
)
|
||||
self.assertEqual(
|
||||
first_capsule["plan"]["plan_hash"],
|
||||
capsule["plan"]["plan_hash"],
|
||||
)
|
||||
evidence_ids.extend(item["node_id"] for item in capsule["evidence"])
|
||||
cursor = capsule["pagination"]["next_cursor"]
|
||||
|
||||
self.assertEqual(
|
||||
["guide.workflow", "guide.foundation", "proof.validation"],
|
||||
evidence_ids,
|
||||
)
|
||||
self.assertEqual(3, len(hashes))
|
||||
self.assertEqual(
|
||||
"mcp.task_context",
|
||||
first.structuredContent["diagnostics"]["operation"],
|
||||
)
|
||||
counters = first.structuredContent["diagnostics"]["counters"]
|
||||
self.assertEqual(0, counters["project_loads"])
|
||||
self.assertEqual(0, counters["source_files_parsed"])
|
||||
self.assertEqual(0, counters["adapter_projection_loads"])
|
||||
self.assertEqual(0, counters["adapter_source_extractions"])
|
||||
self.assertEqual(0, counters["index_builds"])
|
||||
self.assertLessEqual(
|
||||
len(json.dumps(first.structuredContent, separators=(",", ":"))),
|
||||
Project.open(root).descriptor.limits.max_tool_output_chars,
|
||||
)
|
||||
|
||||
for changed_arguments in (
|
||||
{"task": "A different task"},
|
||||
{"task_kind": "failure"},
|
||||
{"focus_node_id": "guide.foundation"},
|
||||
{"budget": 100},
|
||||
):
|
||||
arguments = {
|
||||
"task_kind": "change",
|
||||
"task": "Change the editing workflow",
|
||||
"focus_node_id": "guide.workflow",
|
||||
"limit": 1,
|
||||
"cursor": first_capsule["pagination"]["next_cursor"],
|
||||
**changed_arguments,
|
||||
}
|
||||
changed_cursor = await session.call_tool(
|
||||
"docforge_get_task_context",
|
||||
arguments,
|
||||
)
|
||||
self.assertEqual(
|
||||
"stale_cursor",
|
||||
changed_cursor.structuredContent["error"]["code"],
|
||||
)
|
||||
|
||||
different_policy = DocForgeService(
|
||||
Project.open(root),
|
||||
capability_mode_name="proposal",
|
||||
).task_context(
|
||||
"change",
|
||||
"Change the editing workflow",
|
||||
focus_node_id="guide.workflow",
|
||||
limit=1,
|
||||
cursor=first_capsule["pagination"]["next_cursor"],
|
||||
)
|
||||
self.assertEqual("stale_cursor", different_policy["error"]["code"])
|
||||
|
||||
changed = root / "docs/content/foundation.md"
|
||||
changed.write_text(
|
||||
changed.read_text(encoding="utf-8") + "\nNew generation.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
stale = await session.call_tool(
|
||||
"docforge_get_task_context",
|
||||
{
|
||||
"task_kind": "change",
|
||||
"task": "Change the editing workflow",
|
||||
"focus_node_id": "guide.workflow",
|
||||
"limit": 1,
|
||||
"cursor": first_capsule["pagination"]["next_cursor"],
|
||||
},
|
||||
)
|
||||
self.assertEqual("error", stale.structuredContent["status"])
|
||||
self.assertEqual("stale_cursor", stale.structuredContent["error"]["code"])
|
||||
|
||||
async def test_custom_context_policy_does_not_silently_gain_task_planning(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
project = Project.open(root)
|
||||
ProjectIndex(project).build()
|
||||
service = DocForgeService(
|
||||
project,
|
||||
context_provider=lambda index, profile, budget: {
|
||||
"profile": profile,
|
||||
"budget": budget,
|
||||
"entries": [],
|
||||
"omissions": [],
|
||||
},
|
||||
capability_mode_name="read",
|
||||
)
|
||||
async with create_connected_server_and_client_session(
|
||||
_create_bound_server(service, read_only=True),
|
||||
raise_exceptions=True,
|
||||
) as session:
|
||||
bootstrap = await session.call_tool("docforge_bootstrap", {})
|
||||
with (
|
||||
mock.patch.object(
|
||||
service.project,
|
||||
"load",
|
||||
side_effect=AssertionError("capability errors must not load"),
|
||||
),
|
||||
mock.patch.object(
|
||||
service.index,
|
||||
"check",
|
||||
side_effect=AssertionError("capability errors must not check"),
|
||||
),
|
||||
mock.patch.object(
|
||||
service.index,
|
||||
"build",
|
||||
side_effect=AssertionError("capability errors must not build"),
|
||||
),
|
||||
mock.patch.object(
|
||||
service.index,
|
||||
"synchronize",
|
||||
side_effect=AssertionError("capability errors must not synchronize"),
|
||||
),
|
||||
):
|
||||
result = await session.call_tool(
|
||||
"docforge_get_task_context",
|
||||
{
|
||||
"task_kind": "change",
|
||||
"task": "Do not widen the adapter context policy",
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
"docforge_get_context",
|
||||
bootstrap.structuredContent["session_contract"]["recommended_first_operation"][
|
||||
"tool"
|
||||
],
|
||||
)
|
||||
self.assertFalse(bootstrap.structuredContent["capabilities"]["task_context"]["enabled"])
|
||||
self.assertEqual("error", result.structuredContent["status"])
|
||||
self.assertEqual(
|
||||
"task_context_unavailable",
|
||||
result.structuredContent["error"]["code"],
|
||||
)
|
||||
|
||||
def test_task_context_page_hash_binds_final_page_envelope(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
descriptor = root / ".docforge" / "project.toml"
|
||||
descriptor.write_text(
|
||||
descriptor.read_text(encoding="utf-8").replace(
|
||||
"max_context_tokens = 2000",
|
||||
"max_context_tokens = 2000\nmax_tool_output_chars = 8000",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
project = Project.open(root)
|
||||
ProjectIndex(project).build()
|
||||
service = DocForgeService(project, capability_mode_name="read")
|
||||
one = service.task_context(
|
||||
"change",
|
||||
"Change the editing workflow",
|
||||
focus_node_id="guide.workflow",
|
||||
limit=1,
|
||||
)
|
||||
two = service.task_context(
|
||||
"change",
|
||||
"Change the editing workflow",
|
||||
focus_node_id="guide.workflow",
|
||||
limit=2,
|
||||
)
|
||||
one_capsule = one["capsule"]
|
||||
two_capsule = two["capsule"]
|
||||
self.assertEqual(
|
||||
["guide.workflow"],
|
||||
[item["node_id"] for item in one_capsule["evidence"]],
|
||||
)
|
||||
self.assertEqual(
|
||||
["guide.workflow"],
|
||||
[item["node_id"] for item in two_capsule["evidence"]],
|
||||
)
|
||||
self.assertEqual("complete", one_capsule["page_state"])
|
||||
self.assertEqual("incomplete", two_capsule["page_state"])
|
||||
self.assertNotEqual(one_capsule["page_hash"], two_capsule["page_hash"])
|
||||
self.assertNotEqual(one_capsule["pagination"], two_capsule["pagination"])
|
||||
self.assertLessEqual(
|
||||
len(json.dumps(one, sort_keys=True, separators=(",", ":"))),
|
||||
8_000,
|
||||
)
|
||||
self.assertLessEqual(
|
||||
len(json.dumps(two, sort_keys=True, separators=(",", ":"))),
|
||||
8_000,
|
||||
)
|
||||
|
||||
def test_task_context_default_page_clamps_to_small_project_limit(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
descriptor = root / ".docforge" / "project.toml"
|
||||
descriptor.write_text(
|
||||
descriptor.read_text(encoding="utf-8").replace(
|
||||
"max_results = 20",
|
||||
"max_results = 2",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
project = Project.open(root)
|
||||
ProjectIndex(project).build()
|
||||
result = DocForgeService(
|
||||
project,
|
||||
capability_mode_name="read",
|
||||
).task_context(
|
||||
"change",
|
||||
"Change the editing workflow",
|
||||
focus_node_id="guide.workflow",
|
||||
)
|
||||
self.assertEqual(2, result["pagination"]["limit"])
|
||||
self.assertLessEqual(result["pagination"]["returned_count"], 2)
|
||||
|
||||
def test_oversized_task_evidence_advances_once_as_an_omission(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
descriptor = root / ".docforge" / "project.toml"
|
||||
descriptor.write_text(
|
||||
descriptor.read_text(encoding="utf-8").replace(
|
||||
"max_context_tokens = 2000",
|
||||
"max_context_tokens = 50000\nmax_tool_output_chars = 8000",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
workflow = root / "docs" / "content" / "workflow.md"
|
||||
workflow.write_text(
|
||||
workflow.read_text(encoding="utf-8") + "\n" + ("large evidence " * 2_000),
|
||||
encoding="utf-8",
|
||||
)
|
||||
project = Project.open(root)
|
||||
ProjectIndex(project).build()
|
||||
service = DocForgeService(project, capability_mode_name="read")
|
||||
first = service.task_context(
|
||||
"change",
|
||||
"Change the editing workflow",
|
||||
focus_node_id="guide.workflow",
|
||||
budget=50_000,
|
||||
limit=1,
|
||||
)
|
||||
first_capsule = first["capsule"]
|
||||
self.assertEqual([], first_capsule["evidence"])
|
||||
self.assertEqual("response_limit", first_capsule["omissions"][0]["code"])
|
||||
self.assertEqual("guide.workflow", first_capsule["omissions"][0]["subject"])
|
||||
self.assertEqual(1, first_capsule["pagination"]["returned_count"])
|
||||
self.assertTrue(first_capsule["pagination"]["has_more"])
|
||||
second = service.task_context(
|
||||
"change",
|
||||
"Change the editing workflow",
|
||||
focus_node_id="guide.workflow",
|
||||
budget=50_000,
|
||||
limit=1,
|
||||
cursor=first_capsule["pagination"]["next_cursor"],
|
||||
)
|
||||
self.assertNotEqual(first_capsule["page_hash"], second["capsule"]["page_hash"])
|
||||
self.assertNotIn(
|
||||
"guide.workflow",
|
||||
[
|
||||
item.get("node_id", item.get("subject"))
|
||||
for item in (
|
||||
*second["capsule"]["evidence"],
|
||||
*second["capsule"]["omissions"],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
async def test_context_pagination_is_complete_and_stale_cursors_fail_closed(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
|
|
@ -304,6 +635,14 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
logic = await session.call_tool(
|
||||
"docforge_get_logic", {"owner_node_id": "guide.workflow"}
|
||||
)
|
||||
task_context = await session.call_tool(
|
||||
"docforge_get_task_context",
|
||||
{
|
||||
"task_kind": "change",
|
||||
"task": "Change the editing workflow without AST analysis",
|
||||
"focus_node_id": "guide.workflow",
|
||||
},
|
||||
)
|
||||
|
||||
policy = bootstrap.structuredContent["adapter_policy"]
|
||||
self.assertEqual("preserve-no-ast", policy["mode"])
|
||||
|
|
@ -329,6 +668,14 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
"adapter_policy_forbids_logic",
|
||||
logic.structuredContent["error"]["code"],
|
||||
)
|
||||
self.assertEqual("ok", task_context.structuredContent["status"])
|
||||
self.assertNotIn(
|
||||
"logic",
|
||||
{
|
||||
step["operation"]
|
||||
for step in task_context.structuredContent["capsule"]["plan"]["steps"]
|
||||
},
|
||||
)
|
||||
|
||||
async def test_every_read_tool_returns_scoped_structured_results(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
|
|
@ -358,6 +705,14 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
("docforge_visualization_status", {}),
|
||||
("docforge_bootstrap", {}),
|
||||
("docforge_sync", {}),
|
||||
(
|
||||
"docforge_get_task_context",
|
||||
{
|
||||
"task_kind": "change",
|
||||
"task": "Change the editing workflow",
|
||||
"focus_node_id": "guide.workflow",
|
||||
},
|
||||
),
|
||||
)
|
||||
with self.running_manager(Path(directory) / "viewer-manager.json"):
|
||||
service = DocForgeService(Project.open(root))
|
||||
|
|
@ -413,6 +768,7 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
context = results[9].structuredContent
|
||||
self.assertLessEqual(context["estimated_tokens"], 180)
|
||||
self.assertTrue(context["omissions"])
|
||||
self.assertEqual("complete", results[17].structuredContent["capsule"]["state"])
|
||||
|
||||
async def test_invalid_traversal_limit_is_a_structured_domain_error(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue