From 41c748e769244dc3084b3b62eeae592454d87e2b Mon Sep 17 00:00:00 2001
From: yuyukosama2004
Date: Mon, 20 Jul 2026 17:55:05 +0800
Subject: [PATCH] feat: merge concurrent changeset edits
---
...720_0004_add_changeset_application_mode.py | 35 +++
backend/app/changesets/engine.py | 94 ++++++-
backend/app/changesets/merge.py | 233 ++++++++++++++++++
backend/app/models/changeset.py | 1 +
backend/app/schemas/changeset.py | 1 +
backend/app/services/changeset_service.py | 36 ++-
backend/tests/test_changeset_api.py | 134 ++++++++++
backend/tests/test_changeset_engine.py | 106 ++++++++
backend/tests/test_changeset_merge.py | 105 ++++++++
.../src/extensions/ChangeSetPreview.test.ts | 1 +
.../workspace/ChangeSetReviewPanel.test.tsx | 3 +
.../workspace/ChangeSetReviewPanel.tsx | 30 ++-
frontend/src/types/entities.ts | 1 +
13 files changed, 764 insertions(+), 16 deletions(-)
create mode 100644 backend/alembic/versions/20260720_0004_add_changeset_application_mode.py
create mode 100644 backend/app/changesets/merge.py
create mode 100644 backend/tests/test_changeset_merge.py
diff --git a/backend/alembic/versions/20260720_0004_add_changeset_application_mode.py b/backend/alembic/versions/20260720_0004_add_changeset_application_mode.py
new file mode 100644
index 0000000..448caf8
--- /dev/null
+++ b/backend/alembic/versions/20260720_0004_add_changeset_application_mode.py
@@ -0,0 +1,35 @@
+"""add change operation application mode
+
+Revision ID: 20260720_0004
+Revises: 20260718_0003
+Create Date: 2026-07-20
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision: str = "20260720_0004"
+down_revision: str | None = "20260718_0003"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "change_operations",
+ sa.Column(
+ "application_mode",
+ sa.String(length=40),
+ nullable=False,
+ server_default="",
+ ),
+ )
+
+
+def downgrade() -> None:
+ op.drop_column("change_operations", "application_mode")
diff --git a/backend/app/changesets/engine.py b/backend/app/changesets/engine.py
index 4bae3fd..6ec0954 100644
--- a/backend/app/changesets/engine.py
+++ b/backend/app/changesets/engine.py
@@ -4,6 +4,7 @@
from dataclasses import dataclass
from typing import Any, Literal
+from app.changesets.merge import ThreeWayMergeConflict, merge_block_changes
from app.documents.codec import (
SceneDocument,
build_scene_document,
@@ -14,6 +15,7 @@
OperationType = Literal["insert_before", "insert_after", "replace_block", "delete_block"]
OperationStatus = Literal["accepted", "skipped", "orphaned", "conflicted"]
+ApplicationMode = Literal["", "direct", "rebased", "three_way"]
class ChangeApplicationError(ValueError):
@@ -40,6 +42,8 @@ class OperationOutcome:
operation_id: str
status: OperationStatus
reason: str = ""
+ application_mode: ApplicationMode = ""
+ changed: bool = False
@dataclass(frozen=True)
@@ -58,8 +62,10 @@ def apply_change_operations(
) -> ChangeApplicationResult:
current = deepcopy(document)
actual_hash = scene_document_hash(current, tiptap_to_markdown(current))
- if actual_hash != base_document_hash and not allow_rebase:
+ baseline_changed = actual_hash != base_document_hash
+ if baseline_changed and not allow_rebase:
raise ChangeApplicationError("BASE_DOCUMENT_HASH_MISMATCH")
+ application_mode: ApplicationMode = "rebased" if baseline_changed else "direct"
selected = accepted_operation_ids
outcomes: list[OperationOutcome] = []
@@ -67,7 +73,12 @@ def apply_change_operations(
if selected is not None and operation.id not in selected:
outcomes.append(OperationOutcome(operation.id, "skipped"))
continue
- outcome = _apply_operation(current, operation)
+ outcome = _apply_operation(
+ current,
+ operation,
+ application_mode=application_mode,
+ allow_three_way=allow_rebase,
+ )
outcomes.append(outcome)
return ChangeApplicationResult(
@@ -79,6 +90,9 @@ def apply_change_operations(
def _apply_operation(
document: dict[str, Any],
operation: ChangeOperationInput,
+ *,
+ application_mode: ApplicationMode,
+ allow_three_way: bool,
) -> OperationOutcome:
blocks = document.get("content")
if not isinstance(blocks, list):
@@ -91,37 +105,91 @@ def _apply_operation(
target = blocks[index]
if not isinstance(target, dict):
raise ChangeApplicationError("DOCUMENT_BLOCK_INVALID")
- if operation.original_hash and scene_node_hash(target) != operation.original_hash:
- return OperationOutcome(operation.id, "conflicted", "ORIGINAL_HASH_MISMATCH")
- if operation.original_json is not None and scene_node_hash(
- operation.original_json
- ) != scene_node_hash(target):
- return OperationOutcome(operation.id, "conflicted", "ORIGINAL_CONTENT_MISMATCH")
+ hash_changed = bool(operation.original_hash and scene_node_hash(target) != operation.original_hash)
+ content_changed = bool(
+ operation.original_json is not None
+ and scene_node_hash(operation.original_json) != scene_node_hash(target)
+ )
+ if hash_changed or content_changed:
+ if (
+ allow_three_way
+ and operation.operation_type == "replace_block"
+ and operation.original_json is not None
+ and operation.proposed_json is not None
+ ):
+ try:
+ merged = merge_block_changes(
+ operation.original_json,
+ target,
+ operation.proposed_json,
+ )
+ except ThreeWayMergeConflict as exc:
+ return OperationOutcome(
+ operation.id,
+ "conflicted",
+ exc.reason,
+ )
+ merged_attrs = merged.get("attrs") or {}
+ if not isinstance(merged_attrs, dict):
+ raise ChangeApplicationError("PROPOSED_BLOCK_ATTRS_INVALID")
+ merged_attrs["nodeId"] = operation.target_node_id
+ merged["attrs"] = merged_attrs
+ changed = scene_node_hash(target) != scene_node_hash(merged)
+ blocks[index] = merged
+ return OperationOutcome(
+ operation.id,
+ "accepted",
+ application_mode="three_way",
+ changed=changed,
+ )
+ reason = "ORIGINAL_HASH_MISMATCH" if hash_changed else "ORIGINAL_CONTENT_MISMATCH"
+ return OperationOutcome(operation.id, "conflicted", reason)
if operation.operation_type == "delete_block":
blocks.pop(index)
- return OperationOutcome(operation.id, "accepted")
+ return OperationOutcome(
+ operation.id,
+ "accepted",
+ application_mode=application_mode,
+ changed=True,
+ )
proposed = _proposed_block(operation)
proposed_attrs = proposed.get("attrs") or {}
if not isinstance(proposed_attrs, dict):
raise ChangeApplicationError("PROPOSED_BLOCK_ATTRS_INVALID")
- proposed_attrs.setdefault("nodeId", operation.target_node_id)
+ proposed_attrs["nodeId"] = operation.target_node_id
proposed["attrs"] = proposed_attrs
+ changed = scene_node_hash(target) != scene_node_hash(proposed)
blocks[index] = proposed
- return OperationOutcome(operation.id, "accepted")
+ return OperationOutcome(
+ operation.id,
+ "accepted",
+ application_mode=application_mode,
+ changed=changed,
+ )
if operation.operation_type == "insert_before":
index = _find_top_level_block(blocks, operation.anchor_after_node_id)
if index is None:
return OperationOutcome(operation.id, "orphaned", "ANCHOR_NODE_NOT_FOUND")
blocks.insert(index, _proposed_block(operation))
- return OperationOutcome(operation.id, "accepted")
+ return OperationOutcome(
+ operation.id,
+ "accepted",
+ application_mode=application_mode,
+ changed=True,
+ )
if operation.operation_type == "insert_after":
index = _find_top_level_block(blocks, operation.anchor_before_node_id)
if index is None:
return OperationOutcome(operation.id, "orphaned", "ANCHOR_NODE_NOT_FOUND")
blocks.insert(index + 1, _proposed_block(operation))
- return OperationOutcome(operation.id, "accepted")
+ return OperationOutcome(
+ operation.id,
+ "accepted",
+ application_mode=application_mode,
+ changed=True,
+ )
raise ChangeApplicationError("OPERATION_TYPE_UNSUPPORTED")
diff --git a/backend/app/changesets/merge.py b/backend/app/changesets/merge.py
new file mode 100644
index 0000000..935ca28
--- /dev/null
+++ b/backend/app/changesets/merge.py
@@ -0,0 +1,233 @@
+from __future__ import annotations
+
+import json
+from copy import deepcopy
+from dataclasses import dataclass
+from difflib import SequenceMatcher
+from typing import Any
+
+
+class ThreeWayMergeConflict(ValueError):
+ def __init__(self, reason: str) -> None:
+ super().__init__(reason)
+ self.reason = reason
+
+
+@dataclass(frozen=True)
+class _SequenceEdit:
+ start: int
+ end: int
+ replacement: tuple[dict[str, Any], ...]
+
+
+_MISSING = object()
+
+
+def merge_block_changes(
+ base: dict[str, Any],
+ current: dict[str, Any],
+ proposed: dict[str, Any],
+) -> dict[str, Any]:
+ """Merge independent author and proposal edits to one Tiptap block.
+
+ Text is compared as character tokens carrying their marks. Non-text inline
+ nodes and nested block nodes remain atomic JSON tokens. Ambiguous overlap is
+ rejected instead of guessing an order.
+ """
+
+ merged = _merge_mapping(base, current, proposed, path="block")
+ current_attrs = current.get("attrs")
+ if isinstance(current_attrs, dict) and current_attrs.get("nodeId"):
+ attrs = merged.setdefault("attrs", {})
+ if not isinstance(attrs, dict):
+ raise ThreeWayMergeConflict("THREE_WAY_BLOCK_ATTRIBUTE_CONFLICT")
+ attrs["nodeId"] = current_attrs["nodeId"]
+ return merged
+
+
+def _merge_mapping(
+ base: dict[str, Any],
+ current: dict[str, Any],
+ proposed: dict[str, Any],
+ *,
+ path: str,
+) -> dict[str, Any]:
+ merged: dict[str, Any] = {}
+ for key in sorted(base.keys() | current.keys() | proposed.keys()):
+ base_value = base.get(key, _MISSING)
+ current_value = current.get(key, _MISSING)
+ proposed_value = proposed.get(key, _MISSING)
+ child_path = f"{path}.{key}"
+ value: Any
+ if key == "content" and all(
+ value is _MISSING or isinstance(value, list)
+ for value in (base_value, current_value, proposed_value)
+ ):
+ value = _merge_content(
+ [] if base_value is _MISSING else base_value,
+ [] if current_value is _MISSING else current_value,
+ [] if proposed_value is _MISSING else proposed_value,
+ )
+ elif all(
+ value is _MISSING or isinstance(value, dict)
+ for value in (base_value, current_value, proposed_value)
+ ):
+ value = _merge_mapping(
+ {} if base_value is _MISSING else base_value,
+ {} if current_value is _MISSING else current_value,
+ {} if proposed_value is _MISSING else proposed_value,
+ path=child_path,
+ )
+ else:
+ value = _merge_scalar(
+ base_value,
+ current_value,
+ proposed_value,
+ reason=_conflict_reason(child_path),
+ )
+ if value is not _MISSING:
+ merged[key] = value
+ return merged
+
+
+def _merge_scalar(
+ base: Any,
+ current: Any,
+ proposed: Any,
+ *,
+ reason: str,
+) -> Any:
+ if current == proposed:
+ return deepcopy(current)
+ if current == base:
+ return deepcopy(proposed)
+ if proposed == base:
+ return deepcopy(current)
+ raise ThreeWayMergeConflict(reason)
+
+
+def _merge_content(
+ base: list[Any],
+ current: list[Any],
+ proposed: list[Any],
+) -> list[dict[str, Any]]:
+ base_tokens = _tokenize_content(base)
+ current_tokens = _tokenize_content(current)
+ proposed_tokens = _tokenize_content(proposed)
+ current_edits = _sequence_edits(base_tokens, current_tokens)
+ proposed_edits = _sequence_edits(base_tokens, proposed_tokens)
+ edits = _combine_edits(current_edits, proposed_edits)
+ merged_tokens = _apply_edits(base_tokens, edits)
+ return _collapse_tokens(merged_tokens)
+
+
+def _tokenize_content(content: list[Any]) -> list[dict[str, Any]]:
+ tokens: list[dict[str, Any]] = []
+ for node in content:
+ if not isinstance(node, dict):
+ raise ThreeWayMergeConflict("THREE_WAY_BLOCK_STRUCTURE_CONFLICT")
+ if node.get("type") == "text" and isinstance(node.get("text"), str):
+ text = node["text"]
+ shell = {key: deepcopy(value) for key, value in node.items() if key != "text"}
+ tokens.extend({**deepcopy(shell), "text": character} for character in text)
+ else:
+ tokens.append(deepcopy(node))
+ return tokens
+
+
+def _token_key(token: dict[str, Any]) -> str:
+ return json.dumps(token, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
+
+
+def _sequence_edits(
+ base: list[dict[str, Any]],
+ changed: list[dict[str, Any]],
+) -> list[_SequenceEdit]:
+ matcher = SequenceMatcher(
+ None,
+ [_token_key(item) for item in base],
+ [_token_key(item) for item in changed],
+ autojunk=False,
+ )
+ return [
+ _SequenceEdit(start, end, tuple(deepcopy(changed[changed_start:changed_end])))
+ for tag, start, end, changed_start, changed_end in matcher.get_opcodes()
+ if tag != "equal"
+ ]
+
+
+def _combine_edits(
+ current_edits: list[_SequenceEdit],
+ proposed_edits: list[_SequenceEdit],
+) -> list[_SequenceEdit]:
+ combined = list(current_edits)
+ for proposed in proposed_edits:
+ duplicate = False
+ for current in current_edits:
+ if current == proposed:
+ duplicate = True
+ break
+ if _edits_conflict(current, proposed):
+ raise ThreeWayMergeConflict("THREE_WAY_BLOCK_CONTENT_CONFLICT")
+ if not duplicate:
+ combined.append(proposed)
+ return sorted(
+ combined,
+ key=lambda edit: (
+ edit.start,
+ 0 if edit.start == edit.end else 1,
+ edit.end,
+ ),
+ )
+
+
+def _edits_conflict(left: _SequenceEdit, right: _SequenceEdit) -> bool:
+ left_insert = left.start == left.end
+ right_insert = right.start == right.end
+ if left_insert and right_insert:
+ return left.start == right.start and left.replacement != right.replacement
+ if left_insert:
+ return right.start < left.start < right.end
+ if right_insert:
+ return left.start < right.start < left.end
+ return max(left.start, right.start) < min(left.end, right.end)
+
+
+def _apply_edits(
+ base: list[dict[str, Any]],
+ edits: list[_SequenceEdit],
+) -> list[dict[str, Any]]:
+ output: list[dict[str, Any]] = []
+ cursor = 0
+ for edit in edits:
+ if edit.start < cursor:
+ raise ThreeWayMergeConflict("THREE_WAY_BLOCK_CONTENT_CONFLICT")
+ output.extend(deepcopy(base[cursor : edit.start]))
+ output.extend(deepcopy(edit.replacement))
+ cursor = max(cursor, edit.end)
+ output.extend(deepcopy(base[cursor:]))
+ return output
+
+
+def _collapse_tokens(tokens: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ collapsed: list[dict[str, Any]] = []
+ for token in tokens:
+ if token.get("type") != "text":
+ collapsed.append(deepcopy(token))
+ continue
+ shell = {key: value for key, value in token.items() if key != "text"}
+ if collapsed and collapsed[-1].get("type") == "text":
+ previous_shell = {key: value for key, value in collapsed[-1].items() if key != "text"}
+ if previous_shell == shell:
+ collapsed[-1]["text"] = str(collapsed[-1].get("text", "")) + str(token.get("text", ""))
+ continue
+ collapsed.append(deepcopy(token))
+ return collapsed
+
+
+def _conflict_reason(path: str) -> str:
+ if path.endswith(".type"):
+ return "THREE_WAY_BLOCK_TYPE_CONFLICT"
+ if ".attrs" in path:
+ return "THREE_WAY_BLOCK_ATTRIBUTE_CONFLICT"
+ return "THREE_WAY_BLOCK_STRUCTURE_CONFLICT"
diff --git a/backend/app/models/changeset.py b/backend/app/models/changeset.py
index fd9e9b3..d9e4625 100644
--- a/backend/app/models/changeset.py
+++ b/backend/app/models/changeset.py
@@ -71,5 +71,6 @@ class ChangeOperation(UUIDMixin, TimestampMixin, Base):
status: Mapped[str] = mapped_column(String(40), default="pending")
accepted_draft_revision: Mapped[int | None] = mapped_column(Integer, nullable=True)
conflict_reason: Mapped[str] = mapped_column(Text, default="")
+ application_mode: Mapped[str] = mapped_column(String(40), default="")
change_set = relationship("ChangeSet", back_populates="operations")
diff --git a/backend/app/schemas/changeset.py b/backend/app/schemas/changeset.py
index 078c60d..207939e 100644
--- a/backend/app/schemas/changeset.py
+++ b/backend/app/schemas/changeset.py
@@ -49,6 +49,7 @@ class ChangeOperationRead(EntityBase):
status: str
accepted_draft_revision: int | None
conflict_reason: str
+ application_mode: str
class ChangeSetRead(EntityBase):
diff --git a/backend/app/services/changeset_service.py b/backend/app/services/changeset_service.py
index e738c47..ccc2493 100644
--- a/backend/app/services/changeset_service.py
+++ b/backend/app/services/changeset_service.py
@@ -155,7 +155,8 @@ async def apply(
)
outcomes = {item.operation_id: item for item in result.outcomes}
has_document_change = any(
- outcomes[operation_id].status == "accepted" for operation_id in payload.accept_operation_ids
+ outcomes[operation_id].status == "accepted" and outcomes[operation_id].changed
+ for operation_id in payload.accept_operation_ids
)
next_revision = current_revision + 1 if has_document_change else current_revision
if has_document_change:
@@ -168,13 +169,21 @@ async def apply(
)
for operation_id in payload.reject_operation_ids:
operation_by_id[operation_id].status = "rejected"
+ operation_by_id[operation_id].application_mode = ""
for operation_id in payload.accept_operation_ids:
outcome = outcomes[operation_id]
operation = operation_by_id[operation_id]
operation.status = outcome.status
- operation.conflict_reason = outcome.reason
+ operation.conflict_reason = outcome.reason if outcome.status in {"conflicted", "orphaned"} else ""
+ operation.application_mode = outcome.application_mode
if outcome.status == "accepted":
operation.accepted_draft_revision = next_revision
+ if has_document_change:
+ self._reevaluate_pending(
+ change_set,
+ result.document,
+ excluded_operation_ids=requested_ids,
+ )
self._update_status(change_set)
try:
await self.session.commit()
@@ -187,6 +196,29 @@ async def apply(
persisted_draft = await self.manuscript.get_working_draft(change_set.scene_id)
return await self.get(change_set.id), persisted_draft
+ def _reevaluate_pending(
+ self,
+ change_set: ChangeSet,
+ current: SceneDocument,
+ *,
+ excluded_operation_ids: set[str],
+ ) -> None:
+ for operation in change_set.operations:
+ if operation.status != "pending" or operation.id in excluded_operation_ids:
+ continue
+ evaluation = apply_change_operations(
+ current.content_json,
+ base_document_hash=change_set.base_document_hash,
+ operations=[self._engine_input(operation)],
+ accepted_operation_ids={operation.id},
+ allow_rebase=True,
+ )
+ outcome = evaluation.outcomes[0]
+ if outcome.status in {"conflicted", "orphaned"}:
+ operation.status = outcome.status
+ operation.conflict_reason = outcome.reason
+ operation.application_mode = ""
+
async def _base_document(
self,
scene_id: str,
diff --git a/backend/tests/test_changeset_api.py b/backend/tests/test_changeset_api.py
index 6b767b5..fb48ac9 100644
--- a/backend/tests/test_changeset_api.py
+++ b/backend/tests/test_changeset_api.py
@@ -101,6 +101,8 @@ def test_create_and_partially_apply_change_set_to_working_draft(
"rejected",
]
assert applied["change_set"]["operations"][0]["accepted_draft_revision"] == 1
+ assert applied["change_set"]["operations"][0]["application_mode"] == "direct"
+ assert applied["change_set"]["operations"][1]["application_mode"] == ""
assert len(versions) == 1
@@ -243,3 +245,135 @@ def test_pending_operation_rebases_after_an_earlier_partial_accept(
assert rebased["draft"]["revision"] == 2
assert rebased["draft"]["content_markdown"] == "first changed\n\nsecond changed"
assert rebased["change_set"]["status"] == "accepted"
+ assert rebased["change_set"]["operations"][0]["application_mode"] == "direct"
+ assert rebased["change_set"]["operations"][1]["application_mode"] == "rebased"
+
+
+def test_three_way_merges_non_overlapping_author_edit(client: TestClient) -> None:
+ scene = create_scene(client)
+ version = data(
+ client.post(
+ f"/api/scenes/{scene['id']}/versions",
+ json={"content_markdown": "The lantern went dark."},
+ )
+ )
+ block = version["content_json"]["content"][0]
+ change_set = data(
+ client.post(
+ f"/api/scenes/{scene['id']}/change-sets",
+ json={
+ "base_working_revision": 0,
+ "base_version_id": version["id"],
+ "purpose": "rewrite",
+ "operations": [
+ {
+ "sequence_no": 1,
+ "operation_type": "replace_block",
+ "target_node_id": block["attrs"]["nodeId"],
+ "original_json": block,
+ "original_hash": scene_node_hash(block),
+ "proposed_json": {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "The lantern went completely dark.",
+ }
+ ],
+ },
+ }
+ ],
+ },
+ )
+ )
+ edited_block = {
+ **block,
+ "content": [
+ {
+ "type": "text",
+ "text": "Suddenly, the lantern went dark.",
+ }
+ ],
+ }
+ draft = data(
+ client.put(
+ f"/api/scenes/{scene['id']}/working-draft",
+ json={
+ "revision": 0,
+ "content_json": {"type": "doc", "content": [edited_block]},
+ "content_markdown": "Suddenly, the lantern went dark.",
+ },
+ )
+ )
+
+ applied = data(
+ client.post(
+ f"/api/change-sets/{change_set['id']}/apply",
+ json={
+ "expected_draft_revision": draft["revision"],
+ "accept_operation_ids": [change_set["operations"][0]["id"]],
+ },
+ )
+ )
+
+ assert applied["draft"]["revision"] == 2
+ assert applied["draft"]["content_markdown"] == "Suddenly, the lantern went completely dark."
+ operation = applied["change_set"]["operations"][0]
+ assert operation["status"] == "accepted"
+ assert operation["application_mode"] == "three_way"
+ assert operation["conflict_reason"] == ""
+
+
+def test_accept_reevaluates_remaining_operations(client: TestClient) -> None:
+ scene = create_scene(client)
+ version = data(
+ client.post(
+ f"/api/scenes/{scene['id']}/versions",
+ json={"content_markdown": "first\n\nsecond"},
+ )
+ )
+ first = version["content_json"]["content"][0]
+ change_set = data(
+ client.post(
+ f"/api/scenes/{scene['id']}/change-sets",
+ json={
+ "base_working_revision": 0,
+ "base_version_id": version["id"],
+ "purpose": "rewrite",
+ "operations": [
+ {
+ "sequence_no": 1,
+ "operation_type": "insert_after",
+ "anchor_before_node_id": first["attrs"]["nodeId"],
+ "proposed_json": {
+ "type": "paragraph",
+ "content": [{"type": "text", "text": "after first"}],
+ },
+ },
+ {
+ "sequence_no": 2,
+ "operation_type": "delete_block",
+ "target_node_id": first["attrs"]["nodeId"],
+ "original_json": first,
+ "original_hash": scene_node_hash(first),
+ },
+ ],
+ },
+ )
+ )
+
+ applied = data(
+ client.post(
+ f"/api/change-sets/{change_set['id']}/apply",
+ json={
+ "expected_draft_revision": 0,
+ "accept_operation_ids": [change_set["operations"][1]["id"]],
+ },
+ )
+ )
+
+ assert applied["change_set"]["status"] == "conflicted"
+ assert [(item["status"], item["conflict_reason"]) for item in applied["change_set"]["operations"]] == [
+ ("orphaned", "ANCHOR_NODE_NOT_FOUND"),
+ ("accepted", ""),
+ ]
diff --git a/backend/tests/test_changeset_engine.py b/backend/tests/test_changeset_engine.py
index 7139bcf..532753e 100644
--- a/backend/tests/test_changeset_engine.py
+++ b/backend/tests/test_changeset_engine.py
@@ -83,6 +83,11 @@ def test_applies_selected_block_operations_in_sequence() -> None:
"skipped",
"accepted",
]
+ assert [outcome.application_mode for outcome in result.outcomes] == [
+ "direct",
+ "",
+ "direct",
+ ]
blocks = result.document.content_json["content"]
assert blocks[0]["attrs"]["nodeId"] == FIRST_ID
assert len({block["attrs"]["nodeId"] for block in blocks}) == 4
@@ -148,6 +153,107 @@ def test_rebases_unchanged_target_when_working_revision_has_other_edits() -> Non
assert result.document.content_markdown == "rebased\n\nsecond\n\nthird\n\nauthor edit"
assert result.outcomes[0].status == "accepted"
+ assert result.outcomes[0].application_mode == "rebased"
+
+
+def test_three_way_merges_non_overlapping_changes_to_same_block() -> None:
+ base = base_document()
+ edited = {
+ **base.content_json,
+ "content": [
+ paragraph(FIRST_ID, "author: first"),
+ *base.content_json["content"][1:],
+ ],
+ }
+ result = apply_change_operations(
+ edited,
+ base_document_hash=base.document_hash,
+ allow_rebase=True,
+ operations=[
+ ChangeOperationInput(
+ id="merge",
+ sequence_no=1,
+ operation_type="replace_block",
+ target_node_id=FIRST_ID,
+ original_json=paragraph(FIRST_ID, "first"),
+ original_hash=scene_node_hash(paragraph(FIRST_ID, "first")),
+ proposed_json=paragraph(None, "first revised"),
+ )
+ ],
+ )
+
+ assert result.document.content_markdown == "author: first revised\n\nsecond\n\nthird"
+ assert result.outcomes[0].status == "accepted"
+ assert result.outcomes[0].application_mode == "three_way"
+ assert result.outcomes[0].changed is True
+ assert result.document.content_json["content"][0]["attrs"]["nodeId"] == FIRST_ID
+
+
+def test_three_way_rejects_overlapping_changes_and_changed_delete() -> None:
+ base = base_document()
+ edited = {
+ **base.content_json,
+ "content": [
+ paragraph(FIRST_ID, "author replacement"),
+ *base.content_json["content"][1:],
+ ],
+ }
+ operations = [
+ ChangeOperationInput(
+ id="replace",
+ sequence_no=1,
+ operation_type="replace_block",
+ target_node_id=FIRST_ID,
+ original_json=paragraph(FIRST_ID, "first"),
+ original_hash=scene_node_hash(paragraph(FIRST_ID, "first")),
+ proposed_json=paragraph(None, "model replacement"),
+ ),
+ ChangeOperationInput(
+ id="delete",
+ sequence_no=2,
+ operation_type="delete_block",
+ target_node_id=FIRST_ID,
+ original_json=paragraph(FIRST_ID, "first"),
+ original_hash=scene_node_hash(paragraph(FIRST_ID, "first")),
+ ),
+ ]
+
+ result = apply_change_operations(
+ edited,
+ base_document_hash=base.document_hash,
+ allow_rebase=True,
+ operations=operations,
+ )
+
+ assert result.document.content_markdown == "author replacement\n\nsecond\n\nthird"
+ assert [(item.status, item.reason) for item in result.outcomes] == [
+ ("conflicted", "THREE_WAY_BLOCK_CONTENT_CONFLICT"),
+ ("conflicted", "ORIGINAL_HASH_MISMATCH"),
+ ]
+
+
+def test_replace_preserves_target_identity_and_reports_noop() -> None:
+ base = base_document()
+ proposed = paragraph("99999999-9999-4999-8999-999999999999", "first")
+ result = apply_change_operations(
+ base.content_json,
+ base_document_hash=base.document_hash,
+ operations=[
+ ChangeOperationInput(
+ id="noop",
+ sequence_no=1,
+ operation_type="replace_block",
+ target_node_id=FIRST_ID,
+ original_json=paragraph(FIRST_ID, "first"),
+ original_hash=scene_node_hash(paragraph(FIRST_ID, "first")),
+ proposed_json=proposed,
+ )
+ ],
+ )
+
+ assert result.document.content_json["content"][0]["attrs"]["nodeId"] == FIRST_ID
+ assert result.outcomes[0].status == "accepted"
+ assert result.outcomes[0].changed is False
def test_rejects_stale_baseline_and_duplicate_target_identity() -> None:
diff --git a/backend/tests/test_changeset_merge.py b/backend/tests/test_changeset_merge.py
new file mode 100644
index 0000000..af0ce28
--- /dev/null
+++ b/backend/tests/test_changeset_merge.py
@@ -0,0 +1,105 @@
+from __future__ import annotations
+
+import pytest
+
+from app.changesets.merge import ThreeWayMergeConflict, merge_block_changes
+
+NODE_ID = "13a4b02a-4a2e-4a7b-a88e-6e47d6c8184f"
+
+
+def paragraph(text: str, *, marks: list[dict] | None = None) -> dict:
+ text_node: dict = {"type": "text", "text": text}
+ if marks is not None:
+ text_node["marks"] = marks
+ return {
+ "type": "paragraph",
+ "attrs": {"nodeId": NODE_ID},
+ "content": [text_node],
+ }
+
+
+def text(block: dict) -> str:
+ return "".join(item.get("text", "") for item in block.get("content", []))
+
+
+def test_merges_non_overlapping_author_and_proposal_text_edits() -> None:
+ base = paragraph("The lantern went dark.")
+ current = paragraph("Suddenly, the lantern went dark.")
+ proposed = paragraph("The lantern went completely dark.")
+
+ merged = merge_block_changes(base, current, proposed)
+
+ assert text(merged) == "Suddenly, the lantern went completely dark."
+ assert merged["attrs"]["nodeId"] == NODE_ID
+
+
+def test_preserves_independent_rich_text_marks() -> None:
+ base = paragraph("alpha beta")
+ current = {
+ **paragraph("alpha beta"),
+ "content": [
+ {"type": "text", "text": "alpha", "marks": [{"type": "bold"}]},
+ {"type": "text", "text": " beta"},
+ ],
+ }
+ proposed = paragraph("alpha beta!")
+
+ merged = merge_block_changes(base, current, proposed)
+
+ assert merged["content"] == [
+ {"type": "text", "text": "alpha", "marks": [{"type": "bold"}]},
+ {"type": "text", "text": " beta!"},
+ ]
+
+
+def test_rejects_overlapping_text_edits_and_same_point_insertions() -> None:
+ base = paragraph("the old door")
+
+ with pytest.raises(
+ ThreeWayMergeConflict,
+ match="THREE_WAY_BLOCK_CONTENT_CONFLICT",
+ ):
+ merge_block_changes(
+ base,
+ paragraph("the red door"),
+ paragraph("the iron door"),
+ )
+
+ with pytest.raises(
+ ThreeWayMergeConflict,
+ match="THREE_WAY_BLOCK_CONTENT_CONFLICT",
+ ):
+ merge_block_changes(
+ paragraph("door"),
+ paragraph("red door"),
+ paragraph("old door"),
+ )
+
+
+def test_merges_identical_edits_once_and_keeps_current_node_identity() -> None:
+ base = paragraph("door")
+ current = paragraph("old door")
+ proposed = {
+ **paragraph("old door"),
+ "attrs": {"nodeId": "99999999-9999-4999-8999-999999999999"},
+ }
+
+ merged = merge_block_changes(base, current, proposed)
+
+ assert text(merged) == "old door"
+ assert merged["attrs"]["nodeId"] == NODE_ID
+
+
+def test_rejects_conflicting_block_type_or_attribute_changes() -> None:
+ base = paragraph("heading")
+ current = {**paragraph("heading"), "type": "heading", "attrs": {"nodeId": NODE_ID, "level": 2}}
+ proposed = {
+ **paragraph("heading"),
+ "type": "blockquote",
+ }
+
+ with pytest.raises(
+ ThreeWayMergeConflict,
+ match="THREE_WAY_BLOCK_TYPE_CONFLICT",
+ ):
+ merge_block_changes(base, current, proposed)
diff --git a/frontend/src/extensions/ChangeSetPreview.test.ts b/frontend/src/extensions/ChangeSetPreview.test.ts
index 3b01fdc..ec8f102 100644
--- a/frontend/src/extensions/ChangeSetPreview.test.ts
+++ b/frontend/src/extensions/ChangeSetPreview.test.ts
@@ -31,6 +31,7 @@ function operation(
status: "pending",
accepted_draft_revision: null,
conflict_reason: "",
+ application_mode: "",
created_at: now,
updated_at: now,
...fields,
diff --git a/frontend/src/features/workspace/ChangeSetReviewPanel.test.tsx b/frontend/src/features/workspace/ChangeSetReviewPanel.test.tsx
index 845716c..b5afbc1 100644
--- a/frontend/src/features/workspace/ChangeSetReviewPanel.test.tsx
+++ b/frontend/src/features/workspace/ChangeSetReviewPanel.test.tsx
@@ -59,6 +59,7 @@ const operation: ChangeOperation = {
status: "pending",
accepted_draft_revision: null,
conflict_reason: "",
+ application_mode: "",
created_at: now,
updated_at: now,
};
@@ -114,6 +115,7 @@ describe("ChangeSetReviewPanel", () => {
...operation,
status: "accepted",
accepted_draft_revision: 4,
+ application_mode: "direct",
},
],
},
@@ -149,6 +151,7 @@ describe("ChangeSetReviewPanel", () => {
expect(
await screen.findByText("改动已应用到工作草稿。"),
).toBeInTheDocument();
+ expect(screen.getByText("已接受 · 直接应用")).toBeInTheDocument();
});
it("blocks all decisions while the editor has unsaved content", async () => {
diff --git a/frontend/src/features/workspace/ChangeSetReviewPanel.tsx b/frontend/src/features/workspace/ChangeSetReviewPanel.tsx
index 13bb5bd..335c1d5 100644
--- a/frontend/src/features/workspace/ChangeSetReviewPanel.tsx
+++ b/frontend/src/features/workspace/ChangeSetReviewPanel.tsx
@@ -57,6 +57,30 @@ const OPERATION_STATUS_LABELS: Record = {
orphaned: "目标丢失",
};
+const APPLICATION_MODE_LABELS: Record<
+ Exclude,
+ string
+> = {
+ direct: "直接应用",
+ rebased: "安全重放",
+ three_way: "三方合并",
+};
+
+const CONFLICT_REASON_LABELS: Record = {
+ TARGET_NODE_NOT_FOUND: "目标段落已不存在",
+ ANCHOR_NODE_NOT_FOUND: "定位锚点已不存在",
+ ORIGINAL_HASH_MISMATCH: "目标段落已被修改",
+ ORIGINAL_CONTENT_MISMATCH: "目标段落内容与提议基线不一致",
+ THREE_WAY_BLOCK_CONTENT_CONFLICT: "作者和 AI 修改了同一内容区域",
+ THREE_WAY_BLOCK_TYPE_CONFLICT: "作者和 AI 对段落类型的修改冲突",
+ THREE_WAY_BLOCK_ATTRIBUTE_CONFLICT: "作者和 AI 对段落格式的修改冲突",
+ THREE_WAY_BLOCK_STRUCTURE_CONFLICT: "段落结构变化无法安全自动合并",
+};
+
+function conflictReasonLabel(reason: string): string {
+ return CONFLICT_REASON_LABELS[reason] ?? reason;
+}
+
function renderBlock(
value: Record,
emptyLabel: string,
@@ -350,6 +374,9 @@ export function ChangeSetReviewPanel({
{OPERATION_STATUS_LABELS[operation.status]}
+ {operation.application_mode
+ ? ` · ${APPLICATION_MODE_LABELS[operation.application_mode]}`
+ : ""}
@@ -368,7 +395,8 @@ export function ChangeSetReviewPanel({
{operation.conflict_reason ? (
- 无法自动应用:{operation.conflict_reason}
+ 无法自动应用:
+ {conflictReasonLabel(operation.conflict_reason)}
) : null}
{isPending ? (
diff --git a/frontend/src/types/entities.ts b/frontend/src/types/entities.ts
index 4b1c058..a623f0e 100644
--- a/frontend/src/types/entities.ts
+++ b/frontend/src/types/entities.ts
@@ -160,6 +160,7 @@ export interface ChangeOperation extends EntityBase {
status: ChangeOperationStatus;
accepted_draft_revision: number | null;
conflict_reason: string;
+ application_mode: "" | "direct" | "rebased" | "three_way";
}
export interface ChangeSet extends EntityBase {