Bound paged retrieval responses
This commit is contained in:
parent
176b2d2784
commit
529accf858
15 changed files with 1567 additions and 30 deletions
|
|
@ -16,6 +16,7 @@ from mcp import ClientSession, StdioServerParameters
|
|||
from mcp.client.stdio import stdio_client
|
||||
from mcp.shared.memory import create_connected_server_and_client_session
|
||||
|
||||
from docforge.changesets import ChangesetStore
|
||||
from docforge.index import ProjectIndex
|
||||
from docforge.mcp_server import (
|
||||
ALL_TOOLS,
|
||||
|
|
@ -80,6 +81,16 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
):
|
||||
self.assertIn("limit", tools[name].inputSchema["properties"])
|
||||
self.assertNotIn("limit", tools[name].inputSchema.get("required", []))
|
||||
for name in (
|
||||
"docforge_get_context",
|
||||
"docforge_list_changesets",
|
||||
"docforge_get_changeset",
|
||||
"docforge_validate_changeset",
|
||||
"docforge_get_changeset_diff",
|
||||
):
|
||||
for field in ("limit", "cursor"):
|
||||
self.assertIn(field, tools[name].inputSchema["properties"])
|
||||
self.assertNotIn(field, tools[name].inputSchema.get("required", []))
|
||||
self.assertIn(
|
||||
"deep",
|
||||
tools["docforge_render_status"].inputSchema["properties"],
|
||||
|
|
@ -110,6 +121,134 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(0, diagnostics["counters"]["project_loads"])
|
||||
self.assertEqual(0, diagnostics["counters"]["source_files_parsed"])
|
||||
|
||||
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))
|
||||
ProjectIndex(Project.open(root)).build()
|
||||
async with create_connected_server_and_client_session(
|
||||
create_server(root), raise_exceptions=True
|
||||
) as session:
|
||||
first = await session.call_tool(
|
||||
"docforge_get_context",
|
||||
{"profile": "active", "limit": 1},
|
||||
)
|
||||
cursor = first.structuredContent["pagination"]["next_cursor"]
|
||||
pages = [first.structuredContent]
|
||||
while cursor is not None:
|
||||
page = await session.call_tool(
|
||||
"docforge_get_context",
|
||||
{
|
||||
"profile": "active",
|
||||
"limit": 2,
|
||||
"cursor": cursor,
|
||||
},
|
||||
)
|
||||
pages.append(page.structuredContent)
|
||||
cursor = page.structuredContent["pagination"]["next_cursor"]
|
||||
|
||||
stale_cursor = first.structuredContent["pagination"]["next_cursor"]
|
||||
workflow = root / "docs" / "content" / "workflow.md"
|
||||
workflow.write_text(
|
||||
workflow.read_text(encoding="utf-8") + "\nChanged between pages.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
stale = await session.call_tool(
|
||||
"docforge_get_context",
|
||||
{
|
||||
"profile": "active",
|
||||
"limit": 1,
|
||||
"cursor": stale_cursor,
|
||||
},
|
||||
)
|
||||
|
||||
evidence = [
|
||||
*(("entry", item["node_id"]) for page in pages for item in page["entries"]),
|
||||
*(("omission", item["node_id"]) for page in pages for item in page["omissions"]),
|
||||
]
|
||||
self.assertEqual(len(evidence), pages[0]["summary"]["evidence_count"])
|
||||
self.assertEqual(len(evidence), len(set(evidence)))
|
||||
self.assertEqual("stale_cursor", stale.structuredContent["error"]["code"])
|
||||
self.assertEqual("stale", stale.structuredContent["staleness"])
|
||||
self.assertEqual(
|
||||
"restart_pagination",
|
||||
stale.structuredContent["error"]["remediation"]["action"],
|
||||
)
|
||||
|
||||
async def test_oversized_changeset_reads_return_exact_pages_and_chunks(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_changeset_bytes = 100000",
|
||||
"max_changeset_bytes = 100000\nmax_tool_output_chars = 20000",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
project = Project.open(root)
|
||||
ProjectIndex(project).build()
|
||||
store = ChangesetStore(project, "alpha-editor")
|
||||
created = store.create("mcp-chunked-diff")
|
||||
foundation = next(
|
||||
node for node in project.load().nodes if node.node_id == "guide.foundation"
|
||||
)
|
||||
proposed = store.propose_update(
|
||||
changeset_id="mcp-chunked-diff",
|
||||
expected_changeset_hash=str(created["changeset_hash"]),
|
||||
node_id=foundation.node_id,
|
||||
expected_content_hash=foundation.content_hash,
|
||||
metadata=None,
|
||||
content="replacement " * 4_000,
|
||||
relationship_changes=[],
|
||||
rationale="Exercise bounded MCP diff reconstruction.",
|
||||
)
|
||||
direct = store.diff("mcp-chunked-diff")
|
||||
|
||||
async with create_connected_server_and_client_session(
|
||||
create_server(root), raise_exceptions=True
|
||||
) as session:
|
||||
inspected = await session.call_tool(
|
||||
"docforge_get_changeset",
|
||||
{"changeset_id": "mcp-chunked-diff"},
|
||||
)
|
||||
validated = await session.call_tool(
|
||||
"docforge_validate_changeset",
|
||||
{"changeset_id": "mcp-chunked-diff"},
|
||||
)
|
||||
cursor: str | None = None
|
||||
chunks: list[str] = []
|
||||
while True:
|
||||
page = await session.call_tool(
|
||||
"docforge_get_changeset_diff",
|
||||
{
|
||||
"changeset_id": "mcp-chunked-diff",
|
||||
"cursor": cursor,
|
||||
},
|
||||
)
|
||||
self.assertLessEqual(
|
||||
len(json.dumps(page.structuredContent, separators=(",", ":"))),
|
||||
20_000,
|
||||
)
|
||||
chunks.append(page.structuredContent["chunk"]["content"])
|
||||
cursor = page.structuredContent["pagination"]["next_cursor"]
|
||||
if cursor is None:
|
||||
break
|
||||
|
||||
self.assertEqual("operation_summaries", inspected.structuredContent["result_mode"])
|
||||
self.assertEqual("operation_summaries", validated.structuredContent["result_mode"])
|
||||
self.assertTrue(validated.structuredContent["valid"])
|
||||
self.assertEqual(
|
||||
proposed["changeset_hash"],
|
||||
validated.structuredContent["changeset_hash"],
|
||||
)
|
||||
self.assertEqual(
|
||||
{
|
||||
"changes": direct["changes"],
|
||||
"operations": direct["operations"],
|
||||
},
|
||||
json.loads("".join(chunks)),
|
||||
)
|
||||
|
||||
async def test_no_ast_binding_preserves_adapter_and_blocks_logic(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue