Guarantee bounded mutation receipts
This commit is contained in:
parent
4ae9b31db5
commit
21c4992f9c
7 changed files with 485 additions and 30 deletions
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
|
@ -380,6 +381,122 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual("result_too_large", payload["error"]["code"])
|
||||
self.assertNotIn("canonical_paths", payload)
|
||||
|
||||
async def test_mutation_overflow_returns_exact_compact_success_receipts(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 = 1600",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
project = Project.open(root)
|
||||
ProjectIndex(project).build()
|
||||
node_hashes = {node.node_id: node.content_hash for node in project.load().nodes}
|
||||
async with create_connected_server_and_client_session(
|
||||
create_server(
|
||||
root,
|
||||
"alpha-editor",
|
||||
canonical_applier_id="alpha-editor",
|
||||
),
|
||||
raise_exceptions=True,
|
||||
) as session:
|
||||
created = await session.call_tool(
|
||||
"docforge_create_changeset",
|
||||
{"changeset_id": "compact-mutation"},
|
||||
)
|
||||
first = await session.call_tool(
|
||||
"docforge_propose_node_update",
|
||||
{
|
||||
"changeset_id": "compact-mutation",
|
||||
"expected_changeset_hash": created.structuredContent["changeset_hash"],
|
||||
"node_id": "guide.workflow",
|
||||
"expected_content_hash": node_hashes["guide.workflow"],
|
||||
"metadata": None,
|
||||
"content": "Updated workflow.\n\n" + ("bounded receipt evidence " * 200),
|
||||
"relationship_changes": [],
|
||||
"rationale": "Exercise exact compact append receipts.",
|
||||
},
|
||||
)
|
||||
second = await session.call_tool(
|
||||
"docforge_propose_node_update",
|
||||
{
|
||||
"changeset_id": "compact-mutation",
|
||||
"expected_changeset_hash": first.structuredContent["changeset_hash"],
|
||||
"node_id": "guide.foundation",
|
||||
"expected_content_hash": node_hashes["guide.foundation"],
|
||||
"metadata": None,
|
||||
"content": "Updated foundation.\n\n" + ("second exact receipt " * 200),
|
||||
"relationship_changes": [],
|
||||
"rationale": "Prove the returned hash supports the next append.",
|
||||
},
|
||||
)
|
||||
applied = await session.call_tool(
|
||||
"docforge_apply_changeset",
|
||||
{
|
||||
"changeset_id": "compact-mutation",
|
||||
"expected_changeset_hash": second.structuredContent["changeset_hash"],
|
||||
},
|
||||
)
|
||||
|
||||
for result in (first, second, applied):
|
||||
payload = result.structuredContent
|
||||
self.assertEqual("ok", payload["status"])
|
||||
self.assertTrue(payload["mutation_committed"])
|
||||
self.assertEqual("receipt", payload["result_mode"])
|
||||
self.assertLessEqual(
|
||||
len(json.dumps(payload, sort_keys=True, separators=(",", ":"))),
|
||||
1600,
|
||||
)
|
||||
self.assertNotEqual(
|
||||
first.structuredContent["changeset_hash"],
|
||||
second.structuredContent["changeset_hash"],
|
||||
)
|
||||
self.assertTrue(applied.structuredContent["applied"])
|
||||
self.assertEqual(
|
||||
"applied",
|
||||
applied.structuredContent["lifecycle"]["status"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"ok",
|
||||
applied.structuredContent["derived_refresh"]["status"],
|
||||
)
|
||||
self.assertIn(
|
||||
"Updated workflow.",
|
||||
(root / "docs/content/workflow.md").read_text(encoding="utf-8"),
|
||||
)
|
||||
|
||||
async def test_mutation_preflight_rejects_before_writing(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 = 700",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
ProjectIndex(Project.open(root)).build()
|
||||
changeset_id = "must-not-exist-" + ("x" * 100)
|
||||
async with create_connected_server_and_client_session(
|
||||
create_server(root, "alpha-editor"),
|
||||
raise_exceptions=True,
|
||||
) as session:
|
||||
result = await session.call_tool(
|
||||
"docforge_create_changeset",
|
||||
{"changeset_id": changeset_id},
|
||||
)
|
||||
|
||||
payload = result.structuredContent
|
||||
self.assertEqual("error", payload["status"])
|
||||
self.assertEqual("result_too_large", payload["error"]["code"])
|
||||
self.assertEqual("preflight", payload["error"]["details"]["stage"])
|
||||
self.assertFalse(payload["error"]["details"]["mutation_committed"])
|
||||
self.assertFalse((root / f".docforge/changesets/{changeset_id}.json").exists())
|
||||
|
||||
async def test_proposal_tools_use_fixed_writer_and_never_change_canonical_content(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