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 {