fix(mcp): structural-diff write-back so agent edits don't jump the cursor (#152) #154
Reference in New Issue
Block a user
Delete Branch "fix/mcp-comment-cursor-jump"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Закрывает #152.
Проблема
mutatePageContent(packages/mcp/src/lib/collaboration.ts) писал правки агента обратно через полную замену живого Yjs-документа:fragment.delete(0, len)+applyUpdate(новый Y.Doc). Yjs — CRDT, курсор редактора приклеен к id узлов через y-prosemirror. Полное удаление стирает все id → открытый редактор не может восстановить позицию → курсор прыгает в конец статьи. Заметнее всего на комментариях (текст не меняется, а курсор всё равно улетает). Регрессия с4201f0a3(до него привязка комментария молча проваливалась).Решение
Запись через
updateYFragmentизy-prosemirror— ровно тот механизм, которым сам редактор синхронит правки в Yjs: структурный диф нового документа против живого фрагмента, трогаются только изменённые узлы, id неизменных сохраняются → курсор на месте.Новый
applyDocToFragment:sanitizeForYjs→PMNode.fromJSONпротив memoized docmost-схемы →updateYFragmentв одномtransact, с сохранением диагностикиfindUnstorableAttr.Строгое улучшение для всех write-инструментов агента (правка текста, узлы, комментарии, replace): минимальный диф вместо полной замены — меньше шума в коллаборации, стабильные block-id, не сбрасываются чужие курсоры.
Тесты
comment-cursor-stability.test.mjs: YjsRelativePosition(якорь курсора) переживает и правку соседнего узла, и привязку comment-mark (старая полная замена давалаnull).node --test).y-prosemirrorподнят в прямые зависимости (был транзитивный), пакет пересобран (build/обновлён).🤖 Generated with Claude Code
Code review (multi-aspect) — структурный write-back против прыжка курсора (#152)
Вердикт: ✅ Approve with comments. Правка корректна, обоснована и покрыта тестами, которые реально фиксируют фикс (на до-фиксовом коде новые тесты падают). Все 280 unit-тестов пакета зелёные; персистентность подтверждена байт-в-байт идентичной старому пути. Все замечания ниже — уровня suggestion, ни одно не блокирует merge.
Ревью — 8 параллельных специализированных проходов (security, stability, conventions, documentation, regressions, test-coverage, simplification, architecture) по диапазону
acf6d85b..c7c0c28e. Шум (закоммиченныйbuild/, pnpm-симлинкnode_modules/y-prosemirror, lock) — в рамках принятых в пакете конвенций.Проверено и подтверждено
updateYFragment» корректен на граничных формах: пустой документ, документ→пусто, смена типа узла верхнего уровня, перестановка узлов, смена типа вложенного узла.updateYFragment(ydoc, fragment, pmNode, { mapping, isOMark })верна — четвёртый аргументmetaименно так конструируется внутри самой y-prosemirror 1.3.7.ydoc.transact, синхронное окно read→write безawait.RelativePositionрезолвятся вnull.y-prosemirrorи его транзитивные пакеты уже были в дереве через@hocuspocus/transformer.Замечания (suggestions, не блокеры)
Диагностический label врёт про этап сбоя —
packages/mcp/src/lib/collaboration.ts:15-22, 540-554.catchвapplyDocToFragmentоборачивает иPMNode.fromJSON(гидрация), иupdateYFragmentединым ярлыком(updateYFragment), хотя комментарийunstorableYjsErrorутверждает, чтоlabel«называет упавший этап». Новый тест сам это демонстрирует: неизвестный тип узла падает внутриPMNode.fromJSON— доupdateYFragment, — а проверяется(updateYFragment). Влияния на поведение нет, только на точность диагностики.Фикс: либо смягчить комментарий (label = «путь кодирования», а не точный этап), либо разнести
try, помечая сбойfromJSONярлыком"fromJSON".Не покрыты структурные граничные случаи дифа —
packages/mcp/test/unit/comment-cursor-stability.test.mjs.Тесты 1–2 покрывают правку текста сос��днего узла и добавление comment-mark, но не: очистку документа (
content: []), удаление/добавление узла, смену типа узла верхнего уровня. Сейчас всё работает (проверено), но регресс именно в дифф-пути это не поймает.Фикс: добавить (a) неизменный абзац сохраняет id/курсор при удалении соседнего; (b) запись
doc→пустоочищает фрагмент без исключения.Ветка «Offending attribute:» в
unstorableYjsErrorне прогоняется —collaboration.ts:15-28.Тест 3 бьёт только в ветку
bad == null. ЧерезapplyDocToFragmentветку с непустымfindUnstorableAttrпрактически не достать (sanitizeForYjsсрезаетundefinedраньше). Минорно —findUnstorableAttrотдельно протестирован вnode-ops-table.test.mjs. Можно принять как есть.Фикс не распространён на путь замены изображения —
packages/mcp/src/client.ts:489-491.mutateLiveContentUnlocked(используетсяreplaceImage, см.client.ts:2659и:2713) всё ещё пишет по-старому:fragment.delete(0, length)+Y.applyUpdate(encodeStateAsUpdate(tempDoc)). То есть для инструмента замены изображения прыжок курсора #152 сохраняется. Это предсуществующий код вне диффа PR — не регресс, но фикс неполон.Фикс: при желании перевести и этот путь на
applyDocToFragmentотдельным шагом, либо явно отметить в issue, что image-replace покрывается отдельно.Архитектура (forward-looking, не блокирует merge)
A. Зависимость от приватного
updateYFragmentна основном write-пути.updateYFragmentпомечен в y-prosemirror как@private/@unstable; публичные обёртки документированы только для пустого фрагмента, а здесь диф по живому — публичной альтернативы под задачу нет. Сигнал именно из этого репозитория: сервер (apps/server/src/collaboration/yjs.util.ts:51) намеренно обходитupdateYFragmentс комментарием «which has compatibility issues». Версия запинена точно (1.3.7), на этом пути нет ни одного теста с реальным Y.Doc.fromYdoc → applyDocToFragment → fromYdocна живом фрагменте с проверкой сохранения id) + однострочный комментарий, почему API внутренний и версия запинена.pnpm upот тихого регресса без теста-стража.B. Расхождение preview/apply (три маршрута кодирования).
assertYjsEncodable(dry-run) валидирует черезtoYdoc, а реальный apply — черезupdateYFragment: превью использует другой кодер. Сам PR это честно отмечает («encodability gate, не репетиция apply»). Риск узкий (общийsanitizeForYjs+ схема ловят доминирующий класс ошибок), реален лишь для документа, падающего только наPMNode.fromJSONили на живом дифе.applyDocToFragmentпротив клона живого Y.Doc.PMNode.fromJSON(docmostSchema, sanitizeForYjs(doc))в гейт.🤖 Multi-aspect review через code-review-orchestrator (8 проходов + judge-pass: дедуп, ре-категоризация, фильтр шума, сверка с исходниками).
после исправления можно мержить