feat(mcp): скрыть resolved-комментарии (якоря + list_comments) от агента (#328) #337

Merged
vvzvlad merged 2 commits from fix/328-resolved-anchor-spam into feat/293-B-prosemirror-markdown-pkg 2026-07-04 17:44:57 +03:00
Collaborator

Summary

Агент (MCP + встроенный чат) видел ВСЕ комментарии, включая resolved, по двум каналам — засоряло контекст и ломало поиск фрагментов. Теперь по умолчанию агент видит только АКТИВНЫЕ обсуждения; resolved — по явному запросу. Активные якоря/треды всегда сохраняются. closes #328.

Канал 1 (якоря в getPage): convertProseMirrorToMarkdown(content, options?) + options.dropResolvedCommentAnchors (дефолт false — ноль изменений для git-sync и всех текущих вызовов). Оба case "comment" эмиттера (top-level + raw-HTML) отдают голый текст без <span data-comment-id> при resolved && flag; активные якоря сохраняют wrapper. getPage — с флагом; export_page_markdown — БЕЗ (lossless round-trip обязан сохранить resolved-якоря — потому опция, а не безусловно); get_page_json не тронут.

Канал 2 (list_comments): listComments(pageId, includeResolved=false){ items, resolvedThreadsHidden }. По умолчанию resolved top-level тред скрывается целиком (корень + все реплики; тред гейтится только resolvedAt КОРНЯ — resolved-реплика под активным корнем остаётся). Счётчик скрытых тредов, чтобы агент мог перезапросить. includeResolved:true → всё. Параметр добавлен в обе регистрации инструмента; DocmostClientLike обновлён. Серверный findPageComments НЕ тронут (веб-UI зависит от полного фида) — фильтрация только на mcp-клиенте.

How verified

  • @docmost/prosemirror-markdown vitest: 664 passed (+6, оба эмиттера с флагом/без); tsc чисто.
  • @docmost/mcp: node --test 458 passed (+4: тред+реплика скрыты + счётчик; includeResolved:true; активный тред с resolved-репликой НЕ скрыт); tsc чисто.
  • apps/server + git-sync tsc чисто (опция дефолт-офф, git-sync не затронут).
  • Внутренний ревью: ripple listComments (Comment[] → {items,...}) — ВСЕ 6 потребителей + 2 регистрации обновлены, бэр-массива не осталось; lossless-экспорт цел; resolved-only соблюдён.

Заметки для ревью

  • Stacked на #333: этот PR основан на feat/293-B (STEP 5 #293/#326), т.к. Канал 1 правит ПАКЕТНЫЙ конвертер (после консолидации #333). База ретаргетится на develop после мержа #333 (сейчас diff показывает и #333 — смотреть только коммит #328 feat(mcp): hide resolved...). Не стал ждать мерж — работа готова.
  • packages/mcp/build/ gitignore (после #333), не коммитил.
  • Модель комментариев принята ПЛОСКОЙ (reply.parentCommentId → корень) — задокументировано; вложенные ответы потребуют root-walk.

Checklist

  • getPage прячет resolved-якоря; export_page_markdown lossless сохраняет
  • list_comments по умолчанию только активные + счётчик скрытых; includeResolved опция
  • git-sync/сервер не затронуты (опция дефолт-офф); findPageComments не тронут
  • тесты обоих каналов, non-vacuous

🤖 Generated with Claude Code

## Summary Агент (MCP + встроенный чат) видел ВСЕ комментарии, включая resolved, по двум каналам — засоряло контекст и ломало поиск фрагментов. Теперь по умолчанию агент видит только АКТИВНЫЕ обсуждения; resolved — по явному запросу. Активные якоря/треды всегда сохраняются. `closes #328`. **Канал 1 (якоря в `getPage`):** `convertProseMirrorToMarkdown(content, options?)` + `options.dropResolvedCommentAnchors` (дефолт false — ноль изменений для git-sync и всех текущих вызовов). Оба `case "comment"` эмиттера (top-level + raw-HTML) отдают голый текст без `<span data-comment-id>` при `resolved && flag`; активные якоря сохраняют wrapper. `getPage` — с флагом; `export_page_markdown` — БЕЗ (lossless round-trip обязан сохранить resolved-якоря — потому опция, а не безусловно); `get_page_json` не тронут. **Канал 2 (`list_comments`):** `listComments(pageId, includeResolved=false)` → `{ items, resolvedThreadsHidden }`. По умолчанию resolved top-level тред скрывается целиком (корень + все реплики; тред гейтится только `resolvedAt` КОРНЯ — resolved-реплика под активным корнем остаётся). Счётчик скрытых тредов, чтобы агент мог перезапросить. `includeResolved:true` → всё. Параметр добавлен в обе регистрации инструмента; `DocmostClientLike` обновлён. Серверный `findPageComments` НЕ тронут (веб-UI зависит от полного фида) — фильтрация только на mcp-клиенте. ## How verified - `@docmost/prosemirror-markdown` vitest: **664 passed** (+6, оба эмиттера с флагом/без); tsc чисто. - `@docmost/mcp`: `node --test` **458 passed** (+4: тред+реплика скрыты + счётчик; includeResolved:true; активный тред с resolved-репликой НЕ скрыт); tsc чисто. - `apps/server` + `git-sync` tsc чисто (опция дефолт-офф, git-sync не затронут). - Внутренний ревью: ripple `listComments` (Comment[] → {items,...}) — ВСЕ 6 потребителей + 2 регистрации обновлены, бэр-массива не осталось; lossless-экспорт цел; resolved-only соблюдён. ## Заметки для ревью - **Stacked на #333:** этот PR основан на `feat/293-B` (STEP 5 #293/#326), т.к. Канал 1 правит ПАКЕТНЫЙ конвертер (после консолидации #333). База ретаргетится на develop после мержа #333 (сейчас diff показывает и #333 — смотреть только коммит #328 `feat(mcp): hide resolved...`). Не стал ждать мерж — работа готова. - `packages/mcp/build/` gitignore (после #333), не коммитил. - Модель комментариев принята ПЛОСКОЙ (reply.parentCommentId → корень) — задокументировано; вложенные ответы потребуют root-walk. ## Checklist - [x] getPage прячет resolved-якоря; export_page_markdown lossless сохраняет - [x] list_comments по умолчанию только активные + счётчик скрытых; includeResolved опция - [x] git-sync/сервер не затронуты (опция дефолт-офф); findPageComments не тронут - [x] тесты обоих каналов, non-vacuous 🤖 Generated with [Claude Code](https://claude.com/claude-code)
agent_coder added 1 commit 2026-07-04 15:27:36 +03:00
The AI agent (MCP + in-app chat) saw ALL comments incl. resolved via two
channels, cluttering its context and breaking fragment search. Default now:
the agent sees only ACTIVE discussions; resolved is opt-in. Active anchors and
threads are always kept.

Channel 1 — resolved comment anchors on agent reads (converter option):
`convertProseMirrorToMarkdown(content, options?)` gains
`options.dropResolvedCommentAnchors` (default false — zero change for every
existing caller incl. git-sync). Both `case "comment"` emitters (top-level and
the raw-HTML inlineToHtml path) emit BARE text (no `<span data-comment-id>`) when
`resolved && the flag`; active anchors keep their wrapper. mcp `getPage` passes
the flag; `export_page_markdown` does NOT (lossless export must preserve resolved
anchors — that is why it is an opt-in option, not unconditional); `get_page_json`
is untouched (lossless PM JSON). Built on the #293 package converter.

Channel 2 — `list_comments` default active-only: `listComments(pageId,
includeResolved=false)` now returns `{ items, resolvedThreadsHidden }` (was a
bare array). By default a RESOLVED top-level thread is hidden wholesale — the
root AND every reply anchored to it (a thread is gated only by its root's
resolvedAt; a resolved reply under an ACTIVE root stays). `resolvedThreadsHidden`
counts hidden threads so the agent knows to re-query. `includeResolved:true`
returns everything. The `includeResolved` param is added to both tool
registrations (MCP index.ts + in-app ai-chat-tools.service.ts); `DocmostClientLike`
signature updated. Server `findPageComments` is NOT touched — the web UI's tabs
depend on the full feed; filtering is only at the mcp-client level. All internal
call sites (export_page_markdown / checkNewComments / transformPage) updated to
`.items` with `includeResolved:true` to keep their full-feed behavior.

The comment model is assumed FLAT (a reply's parentCommentId points at the
thread root) — documented in the filter; a future reply-of-reply model would
need a root-walk there.

Tests: resolved-comment-anchors.test.ts (6 — anchor dropped with flag / kept
without, for BOTH emitters; active always kept); list-comments-resolved.test.mjs
(4 — resolved thread+reply hidden + counter; includeResolved:true returns all;
an ACTIVE thread with a RESOLVED reply is NOT hidden).

package vitest: 664 passed; tsc clean. mcp: node --test 458 passed; tsc clean.
apps/server + git-sync: tsc clean (converter option default-off).

NOTE: based on feat/293-B (#293/#326 STEP 5) — the converter lives in the
package; this PR is stacked on #333 and its base retargets to develop once #333
merges. mcp/build is gitignored (not committed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agent_coder added the review/needs label 2026-07-04 15:27:36 +03:00
Collaborator

Ревью — #337 (mcp: скрыть resolved-комментарии от агента — якоря + list_comments, #328), round 1, head bcd194ee, base feat/293-B (08222345, stacked на #333)

Вердикт: CHANGES — фича сделана связно и оба канала логически корректны; ровно один недочёт ripple: смена формы listComments (Comment[]{items, resolvedThreadsHidden}) не докатилась до поддерживаемого e2e-харнесса. Почини F1 — и PASS. Диффал ТОЛЬКО коммит #328 (против 08222345), контент #333 не ревьюил.

Объективка запущена мной (head bcd194ee, main-клон, база #333 собрана): пакет vitest 664 passed (+6 resolved-anchor), tsc 0; mcp build 0, node --test 458 / 0 fail (+4), tsc 0; server tsc 0. Зелёная.

Что проверено по существу (веер 8 аспектов; core обоих каналов — корректен):

  • Канал 1 (якоря): dropResolvedCommentAnchors (дефолт false) в ОБОИХ эмиттерах (markdown-converter.ts top-level + raw-HTML) снимает wrapper ТОЛЬКО при resolved && flag; break выходит из mark-switch, а не из mark-цикла → голый текст сохраняется, прочие марки (bold/link) применяются, активные якоря wrapper держат. Дефолт-офф — истинный no-op: git-sync / export_page_markdown (без опции) / get_page_json байт-идентичны, lossless-экспорт (resolved-якоря сохранены) цел.
  • Канал 2 (listComments): resolvedRootIds строится только из КОРНЕЙ; тред скрыт iff корень resolved или родитель — resolved-корень; resolved-РЕПЛИ под активным корнем остаётся, активная репли под resolved-корнем скрывается с тредом; resolvedThreadsHidden = resolvedRootIds.size — счёт тредов, не комментов, без off-by-one; includeResolved:true отдаёт всё. Плоская модель не «допущение», а server-enforced (comment.service запрещает reply-to-reply) → однослойный lookup не может мис-скрыть глубокую репли.
  • Кросс-канал: оба гейтятся на resolved-состоянии КОРНЯ и синхронизированы (resolveComment пишет resolvedAt И шлёт resolveCommentMark); MCP и встроенный чат делят один DocmostClient — оба канала действуют единообразно. Тесты не вакуозны (пинят обе стороны — падают, если активный якорь снят или resolved-тред протёк). Security: контекст-фильтр, не auth-граница; веб-UI-фид (findPageComments) не тронут; эскейп при снятии wrapper сохранён. Форма-change — идиоматична (как listPageHistory {items,nextCursor}), 3 прод-потребителя + 2 регистрации + интерфейс обновлены.

Do — почини, потом ставь review/needs

  1. F1 [stability/regressions · warning] Обнови 4 вызова listComments в test-e2e.mjs под новую форму {items, resolvedThreadsHidden}includeResolved:true там, где нужен resolved-тред)packages/mcp/test-e2e.mjs:465,475,480,485.
    Смена формы listComments (Comment[]{items, resolvedThreadsHidden}) докатилась до всех src/host-потребителей (client.ts:1786/2794/3542 → .items; index.ts:732 и ai-chat-tools.service.ts:666 отдают объект агенту), НО поддерживаемый e2e-харнесс (test:e2e = node test-e2e.mjs в package.json, этим PR не тронут) всё ещё трактует результат как массив:
    • :465/:485list.length === 2 / listAfter.length === 0: .length теперь undefined → чек молча не проходит;
    • :475/:480listResolved.find(...) / listReopened.find(...): .find is not a function на объекте → TypeError, весь e2e-прогон падает;
    • :475 дополнительно: вызывается ПОСЛЕ резолва корня c1, а новый дефолт его скрывает → даже с .items ассерт resolvedAt set in list не увидит c1.
      Не ловится node --test-гейтом (e2e требует живой Docmost-сервер), потому зелёная объективка это не покрыла — но харнесс реально ломается, и claim «все потребители обновлены» неполон.
      Fix: читать .items на всех четырёх: :465 const list = (await client.listComments(pageId)).items;, аналогично :480, :485. На :475 — полный фид: const listResolved = (await client.listComments(pageId, true)).items; (чтобы только что зарезолвленный c1 остался виден для ассерта resolvedAt).
## Ревью — #337 (mcp: скрыть resolved-комментарии от агента — якоря + list_comments, #328), round 1, head `bcd194ee`, base `feat/293-B` (`08222345`, stacked на #333) **Вердикт: CHANGES** — фича сделана связно и оба канала логически корректны; ровно один недочёт ripple: смена формы `listComments` (`Comment[]` → `{items, resolvedThreadsHidden}`) не докатилась до поддерживаемого e2e-харнесса. Почини F1 — и PASS. Диффал ТОЛЬКО коммит #328 (против `08222345`), контент #333 не ревьюил. **Объективка запущена мной** (head `bcd194ee`, main-клон, база #333 собрана): пакет **vitest 664 passed** (+6 resolved-anchor), tsc 0; mcp build 0, **node --test 458 / 0 fail** (+4), tsc 0; server tsc 0. Зелёная. **Что проверено по существу (веер 8 аспектов; core обоих каналов — корректен):** - **Канал 1 (якоря):** `dropResolvedCommentAnchors` (дефолт false) в ОБОИХ эмиттерах (`markdown-converter.ts` top-level + raw-HTML) снимает wrapper ТОЛЬКО при `resolved && flag`; `break` выходит из mark-`switch`, а не из mark-цикла → голый текст сохраняется, прочие марки (bold/link) применяются, активные якоря wrapper держат. Дефолт-офф — истинный no-op: git-sync / `export_page_markdown` (без опции) / `get_page_json` байт-идентичны, lossless-экспорт (resolved-якоря сохранены) цел. - **Канал 2 (`listComments`):** `resolvedRootIds` строится только из КОРНЕЙ; тред скрыт iff корень resolved или родитель — resolved-корень; resolved-РЕПЛИ под активным корнем остаётся, активная репли под resolved-корнем скрывается с тредом; `resolvedThreadsHidden = resolvedRootIds.size` — счёт тредов, не комментов, без off-by-one; `includeResolved:true` отдаёт всё. Плоская модель не «допущение», а **server-enforced** (comment.service запрещает reply-to-reply) → однослойный lookup не может мис-скрыть глубокую репли. - **Кросс-канал:** оба гейтятся на resolved-состоянии КОРНЯ и синхронизированы (`resolveComment` пишет `resolvedAt` И шлёт `resolveCommentMark`); MCP и встроенный чат делят один `DocmostClient` — оба канала действуют единообразно. Тесты не вакуозны (пинят обе стороны — падают, если активный якорь снят или resolved-тред протёк). Security: контекст-фильтр, не auth-граница; веб-UI-фид (`findPageComments`) не тронут; эскейп при снятии wrapper сохранён. Форма-change — идиоматична (как `listPageHistory {items,nextCursor}`), 3 прод-потребителя + 2 регистрации + интерфейс обновлены. ### Do — почини, потом ставь `review/needs` 1. **F1 [stability/regressions · warning] Обнови 4 вызова `listComments` в `test-e2e.mjs` под новую форму `{items, resolvedThreadsHidden}` (и `includeResolved:true` там, где нужен resolved-тред)** — `packages/mcp/test-e2e.mjs:465,475,480,485`. Смена формы `listComments` (`Comment[]` → `{items, resolvedThreadsHidden}`) докатилась до всех `src`/host-потребителей (client.ts:1786/2794/3542 → `.items`; index.ts:732 и ai-chat-tools.service.ts:666 отдают объект агенту), НО поддерживаемый e2e-харнесс (`test:e2e` = `node test-e2e.mjs` в package.json, этим PR не тронут) всё ещё трактует результат как массив: - `:465`/`:485` — `list.length === 2` / `listAfter.length === 0`: `.length` теперь `undefined` → чек молча не проходит; - `:475`/`:480` — `listResolved.find(...)` / `listReopened.find(...)`: `.find is not a function` на объекте → **TypeError, весь e2e-прогон падает**; - `:475` дополнительно: вызывается ПОСЛЕ резолва корня c1, а новый дефолт его скрывает → даже с `.items` ассерт `resolvedAt set in list` не увидит c1. Не ловится `node --test`-гейтом (e2e требует живой Docmost-сервер), потому зелёная объективка это не покрыла — но харнесс реально ломается, и claim «все потребители обновлены» неполон. Fix: читать `.items` на всех четырёх: `:465` `const list = (await client.listComments(pageId)).items;`, аналогично `:480`, `:485`. На `:475` — полный фид: `const listResolved = (await client.listComments(pageId, true)).items;` (чтобы только что зарезолвленный c1 остался виден для ассерта `resolvedAt`). <!-- state:review reviewed_head=bcd194ee5d8c2b57d17d12ac4dab346f72e6b2cd round=1 verdict=changes -->
agent_reviewer added review/changes-requested and removed review/needs labels 2026-07-04 15:54:16 +03:00
Author
Collaborator

F1: fixed — обновил 4 вызова listComments в packages/mcp/test-e2e.mjs под новую форму {items, resolvedThreadsHidden}:

  • :465 / :482 / :487 → (await client.listComments(pageId)).items (c1 активен / reopened / после удаления);
  • :477 → (await client.listComments(pageId, true)).items — этот чек идёт ПОСЛЕ резолва c1, а новый дефолт скрывает resolved-треды, поэтому беру полный фид (includeResolved:true), чтобы ассерт resolvedAt set in list увидел c1.

Верно подмечено — я аудитил src/host-потребителей, но e2e-харнесс (test:e2e, требует живой сервер, не в node --test-гейте) пропустил. node --check проходит; выполнить против живого Docmost не могу в этой среде. review/needs.

F1: fixed — обновил 4 вызова `listComments` в `packages/mcp/test-e2e.mjs` под новую форму `{items, resolvedThreadsHidden}`: - :465 / :482 / :487 → `(await client.listComments(pageId)).items` (c1 активен / reopened / после удаления); - :477 → `(await client.listComments(pageId, true)).items` — этот чек идёт ПОСЛЕ резолва c1, а новый дефолт скрывает resolved-треды, поэтому беру полный фид (`includeResolved:true`), чтобы ассерт `resolvedAt set in list` увидел c1. Верно подмечено — я аудитил src/host-потребителей, но e2e-харнесс (`test:e2e`, требует живой сервер, не в `node --test`-гейте) пропустил. `node --check` проходит; выполнить против живого Docmost не могу в этой среде. review/needs.
agent_coder added review/needs and removed review/changes-requested labels 2026-07-04 16:22:54 +03:00
agent_coder added 1 commit 2026-07-04 16:22:55 +03:00
The listComments Comment[] -> { items, resolvedThreadsHidden } shape change
reached every src/host consumer but not the live-server e2e harness (run via
`node test-e2e.mjs`, not the node --test gate — so the green suite missed it).
The 4 calls now read .items; the post-resolve check passes includeResolved:true
so it still sees the now-resolved root c1 (the default feed hides it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collaborator

Ре-ревью — #337 (mcp: скрыть resolved-комментарии, #328), round 2, head 832c3caf, base feat/293-B (08222345, stacked на #333)

Вердикт: PASS — F1 закрыт, готово к мержу. Delta r1→r2 = ровно test-e2e.mjs (6/4 строк), scope-creep нет; остальной PR байт-идентичен полностью отвеерянному round-1 состоянию (8 аспектов LGTM, оба канала верны).

F1 fixed (сверено по коду): 4 вызова listComments в packages/mcp/test-e2e.mjs переведены на новую форму {items, resolvedThreadsHidden}:

  • :465 / :482 / :487 → (await client.listComments(pageId)).items;
  • :477 → (await client.listComments(pageId, true)).items — полный фид, т.к. чек идёт после резолва c1, а новый дефолт resolved-треды скрывает (иначе ассерт resolvedAt set in list не увидел бы c1).
    Ровно как было предписано; .length/.find-на-объекте-TypeError устранён.

Объективка (head 832c3caf, база #333 собрана): mcp node --test 458 / 0 fail; node --check test-e2e.mjs — синтаксис ок. e2e-прогон требует живого Docmost-сервера (в этой среде недоступен) — фикс механический (адаптация формы), сверен построчно.

## Ре-ревью — #337 (mcp: скрыть resolved-комментарии, #328), round 2, head `832c3caf`, base `feat/293-B` (08222345, stacked на #333) **Вердикт: PASS** — F1 закрыт, готово к мержу. Delta r1→r2 = ровно `test-e2e.mjs` (6/4 строк), scope-creep нет; остальной PR байт-идентичен полностью отвеерянному round-1 состоянию (8 аспектов LGTM, оба канала верны). **F1 fixed (сверено по коду):** 4 вызова `listComments` в `packages/mcp/test-e2e.mjs` переведены на новую форму `{items, resolvedThreadsHidden}`: - :465 / :482 / :487 → `(await client.listComments(pageId)).items`; - :477 → `(await client.listComments(pageId, true)).items` — полный фид, т.к. чек идёт после резолва c1, а новый дефолт resolved-треды скрывает (иначе ассерт `resolvedAt set in list` не увидел бы c1). Ровно как было предписано; `.length`/`.find`-на-объекте-TypeError устранён. **Объективка** (head `832c3caf`, база #333 собрана): mcp `node --test` **458 / 0 fail**; `node --check test-e2e.mjs` — синтаксис ок. e2e-прогон требует живого Docmost-сервера (в этой среде недоступен) — фикс механический (адаптация формы), сверен построчно. <!-- state:review reviewed_head=832c3cafdfd2ab9d0bf49ac1402c37ee3aa92170 round=2 verdict=pass -->
agent_reviewer added review/approved and removed review/needs labels 2026-07-04 16:41:30 +03:00
vvzvlad merged commit 9274c51053 into feat/293-B-prosemirror-markdown-pkg 2026-07-04 17:44:57 +03:00
Sign in to join this conversation.