Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ All notable changes to this project will be documented in this file.
`navigateToPageCitation` expose materialization and preview navigation. The MCP inline
preview remains explicitly continuous pending #434. See
[`docs/architecture/page_map.md`](docs/architecture/page_map.md).
- **Atomic multi-step mutation batches** (issue #445). `DocxSession.ExecuteBatch`
and the reusable nested-safe `BeginTransaction` primitive checkpoint the complete
OPC package, relationship topology, anchor/revision generators, mutable session
configuration, version, and both undo/redo cursors. Atomic mode is the default:
all available preflights run before step zero; success advances the version once
and creates one undo unit; any failed or thrown step restores the exact package
and history state and returns its index/tool/action/error with `rolledBack: true`.
Explicit `best_effort` retains sequential partial-success behavior. The contract
is available through .NET/Ops/JSON, WASM/npm, stdio/Python, and MCP;
MCP's legacy `apply` spelling is now a deprecated alias for `best_effort`.
- **Optimistic mutation preconditions and a monotonic document version** (issue
#447). Every `DocxSession` starts at version `0` and advances exactly once for
each committed mutation, undo, or redo; failures and successful no-ops leave it
Expand Down
608 changes: 608 additions & 0 deletions Docxodus.Tests/DocxSessionAtomicBatchTests.cs

Large diffs are not rendered by default.

160 changes: 160 additions & 0 deletions Docxodus.Tests/McpServerDispatcherTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1029,6 +1029,166 @@ public void MCP092_Mutations_RejectsUndoRedoAsSteps()
Assert.Contains("undo", ex.Message, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public void MCP094_Mutations_AtomicFailureIsStructuredAndLeavesNoVersionOrHistory()
{
var sessionId = OpenSession();
var sessionArg = JsonSerializer.Serialize(sessionId);
var anchor = FirstBodyAnchorId(sessionId, _store);
var before = Parse(Dispatcher.Call(_store, "docxodus_get_content", J(
$$"""{"sessionId":{{sessionArg}},"format":"markdown"}""")))
.GetProperty("markdown").GetString();

var batch = Parse(Dispatcher.Call(_store, "docxodus_mutations", J(
$$"""
{
"sessionId": {{sessionArg}},
"mode": "atomic",
"steps": [
{ "tool": "docxodus_edit", "args": { "action": "replace_text", "anchorId": "{{anchor}}", "markdown": "speculative" } },
{ "tool": "docxodus_edit", "args": { "action": "replace_text", "anchorId": "p:body:missing", "markdown": "failure" } }
]
}
""")));

Assert.Equal("failed", batch.GetProperty("status").GetString());
Assert.False(batch.GetProperty("success").GetBoolean());
Assert.True(batch.GetProperty("rolledBack").GetBoolean());
var failure = batch.GetProperty("failure");
Assert.Equal(1, failure.GetProperty("index").GetInt32());
Assert.Equal("docxodus_edit", failure.GetProperty("tool").GetString());
Assert.Equal("replace_text", failure.GetProperty("action").GetString());
Assert.Equal("anchor_not_found", failure.GetProperty("error").GetProperty("code").GetString());
Assert.True(failure.GetProperty("rolledBack").GetBoolean());

var after = Parse(Dispatcher.Call(_store, "docxodus_get_content", J(
$$"""{"sessionId":{{sessionArg}},"format":"markdown"}""")))
.GetProperty("markdown").GetString();
Assert.Equal(before, after);
var version = Parse(Dispatcher.Call(_store, "docxodus_get_content", J(
$$"""{"sessionId":{{sessionArg}},"format":"version"}""")))
.GetProperty("version").GetInt64();
Assert.Equal(0, version);
var undo = Parse(Dispatcher.Call(_store, "docxodus_edit", J(
$$"""{"sessionId":{{sessionArg}},"action":"undo"}""")));
Assert.False(undo.GetProperty("success").GetBoolean());
}

[Fact]
public void MCP095_Mutations_AtomicSuccessIsOneUndoAndInvalidStepHasCallerErrorCode()
{
var sessionId = OpenSession();
var sessionArg = JsonSerializer.Serialize(sessionId);
var anchor = FirstBodyAnchorId(sessionId, _store);

var invalid = Parse(Dispatcher.Call(_store, "docxodus_mutations", J(
$$"""
{
"sessionId": {{sessionArg}},
"mode": "atomic",
"steps": [
{ "tool": "docxodus_comment", "args": { "action": "list" } }
]
}
""")));
Assert.Equal("invalid_batch_step",
invalid.GetProperty("failure").GetProperty("error").GetProperty("code").GetString());

var batch = Parse(Dispatcher.Call(_store, "docxodus_mutations", J(
$$"""
{
"sessionId": {{sessionArg}},
"mode": "atomic",
"steps": [
{ "tool": "docxodus_edit", "args": { "action": "replace_text", "anchorId": "{{anchor}}", "markdown": "atomic MCP" } },
{ "tool": "docxodus_format", "args": { "action": "apply_format", "anchorId": "{{anchor}}", "format": { "bold": true } } }
]
}
""")));
Assert.Equal("ok", batch.GetProperty("status").GetString());
Assert.Equal(1, Docxodus.Internal.DocxSessionOps.GetVersion(_store.Get(sessionId).Handle));

var undo = Parse(Dispatcher.Call(_store, "docxodus_edit", J(
$$"""{"sessionId":{{sessionArg}},"action":"undo"}""")));
Assert.True(undo.GetProperty("success").GetBoolean());
var markdown = Parse(Dispatcher.Call(_store, "docxodus_get_content", J(
$$"""{"sessionId":{{sessionArg}},"format":"markdown"}""")))
.GetProperty("markdown").GetString();
Assert.DoesNotContain("atomic MCP", markdown);
}

[Fact]
public void MCP096_AtomicPreflightsLaterArgumentErrorsBeforeStepZeroMutates()
{
var sessionId = OpenSession();
var sessionArg = JsonSerializer.Serialize(sessionId);
var anchor = FirstBodyAnchorId(sessionId, _store);
var before = Parse(Dispatcher.Call(_store, "docxodus_get_content", J(
$$"""{"sessionId":{{sessionArg}},"format":"markdown"}""")))
.GetProperty("markdown").GetString();

var batch = Parse(Dispatcher.Call(_store, "docxodus_mutations", J(
$$"""
{
"sessionId": {{sessionArg}},
"mode": "atomic",
"steps": [
{ "tool": "docxodus_edit", "args": { "action": "replace_text", "anchorId": "{{anchor}}", "markdown": "must never run" } },
{ "tool": "docxodus_create", "args": { "action": "set_header_text", "bodyAnchorId": "{{anchor}}", "kind": "sideways", "markdown": "invalid header" } }
]
}
""")));

Assert.False(batch.GetProperty("success").GetBoolean());
Assert.True(batch.GetProperty("rolledBack").GetBoolean());
var failure = batch.GetProperty("failure");
Assert.Equal(1, failure.GetProperty("index").GetInt32());
Assert.Equal("docxodus_create", failure.GetProperty("tool").GetString());
Assert.Equal("set_header_text", failure.GetProperty("action").GetString());
Assert.Equal("invalid_batch_step", failure.GetProperty("error").GetProperty("code").GetString());
Assert.Contains("kind", failure.GetProperty("error").GetProperty("message").GetString());

var after = Parse(Dispatcher.Call(_store, "docxodus_get_content", J(
$$"""{"sessionId":{{sessionArg}},"format":"markdown"}""")))
.GetProperty("markdown").GetString();
Assert.Equal(before, after);
Assert.Equal(0, Docxodus.Internal.DocxSessionOps.GetVersion(_store.Get(sessionId).Handle));
var undo = Parse(Dispatcher.Call(_store, "docxodus_edit", J(
$$"""{"sessionId":{{sessionArg}},"action":"undo"}""")));
Assert.False(undo.GetProperty("success").GetBoolean());
}

[Fact]
public void MCP097_AtomicStepPreconditionsUseBatchStartState()
{
var sessionId = OpenSession();
var sessionArg = JsonSerializer.Serialize(sessionId);
var anchor = FirstBodyAnchorId(sessionId, _store);
var info = Parse(Docxodus.Internal.DocxSessionOps.GetAnchorInfo(
_store.Get(sessionId).Handle, anchor));
var originalText = JsonSerializer.Serialize(info.GetProperty("visibleText").GetString());

var batch = Parse(Dispatcher.Call(_store, "docxodus_mutations", J(
$$"""
{
"sessionId": {{sessionArg}},
"steps": [
{ "tool": "docxodus_edit", "args": { "action": "replace_text", "anchorId": "{{anchor}}", "markdown": "first atomic state" } },
{ "tool": "docxodus_edit", "args": { "action": "replace_text", "anchorId": "{{anchor}}", "markdown": "second atomic state", "preconditions": { "expectedText": {{originalText}} } } }
]
}
""")));

Assert.True(batch.GetProperty("success").GetBoolean());
Assert.Equal("atomic", batch.GetProperty("mode").GetString());
Assert.Equal(1, Docxodus.Internal.DocxSessionOps.GetVersion(_store.Get(sessionId).Handle));
var markdown = Parse(Dispatcher.Call(_store, "docxodus_get_content", J(
$$"""{"sessionId":{{sessionArg}},"format":"markdown"}""")))
.GetProperty("markdown").GetString();
Assert.Contains("second atomic state", markdown);
Assert.DoesNotContain("first atomic state", markdown);
}

// ─── Tool catalog ───────────────────────────────────────────────────

[Fact]
Expand Down
Loading