Compare commits

...

35 Commits

Author SHA1 Message Date
vvzvlad c24a781a9d Merge pull request 'feat(mcp): markdown — формат по умолчанию для блочных getNode/patchNode/insertNode (#413)' (#466) from feat/413-markdown-default into refactor/412-camelcase-tool-names
Reviewed-on: #466
2026-07-10 23:44:59 +03:00
agent_coder e6171a1810 fix(mcp): сходимость orphan-сноски + дедуп block-id в markdown-splice (#413, ревью)
Правки по внутреннему ревью #413.

1. Orphan-сноска (нарушение «no second canon»). mergeFootnoteDefinitions
   раньше делал ранний return при пустых definitions, пропуская
   canonicalizeFootnotes. Если markdown-фрагмент БЕЗ сносок заменял/удалял
   блок, бывший последним referrer'ом существующей сноски, её определение
   оставалось orphan в хвостовом списке (полный ре-импорт его бы убрал).
   Теперь fast-path (возврат doc по ссылке без clone) только когда
   defs.length===0 И !hasFootnoteArtifacts(doc); иначе клон → append (no-op
   при пустых) → normalizeAndMergeFootnotes → canonicalizeFootnotes, как при
   полном импорте. Новый предикат hasFootnoteArtifacts обходит дерево на
   любой footnotesList/footnoteReference (существующие walk/isObject).
   Идемпотентно: несвязанный plain-патч на странице со сносками не трогает
   их топологию (canonicalizeFootnotes шаг 6 возвращает как есть).

2. Block-id disjoint от страницы. Новый экспорт reassignCollidingBlockIds
   (liveDoc, blocks, skipIndex?) в prosemirror-markdown/node-ops (переиспользует
   collectIds/makeFreshId) + barrel. Зовётся перед сплайсами в client.ts:
   patch — (liveDoc, threaded, 0) (skip 0 = блок, унаследовавший id цели);
   insert — (liveDoc, blocks) без skip. used-аккумулятор ловит и коллизию со
   страницей, и внутрифрагментную. freshBlockId/freshId без изменений.

Тесты (+6, markdown-patch-insert): orphan-repro (0 defs/0 list/0 refs +
docsCanonicallyEqual полному импорту), fast-path (footnote-free patch не трогает
топологию, соседи byte-identical), insert канонизирует при пустых defs со
страничной сноской, уникальность top-level block-id для patch 1→N и insert N.
mcp node --test 702/702, pmd vitest 736, tsc чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:38:06 +03:00
agent_coder d269bd9efe feat(mcp): markdown — формат по умолчанию для блочных getNode/patchNode/insertNode (#413)
Канонический конвертер (#293/#345/#351) зрел для блочного уровня: markdown
становится дефолтом чтения/записи ОДНОГО блока, PM JSON — опция «для тонких
работ». Новых тулов нет, поверхность не растёт. Имена уже camelCase (стоит на #412).

- getNode(pageId, nodeId, format='markdown'): дефолт markdown — обёртка
  {type:doc,content:[node]} → convertProseMirrorToMarkdown, comment-якоря
  (ВКЛЮЧАЯ resolved) сохранены (это чтение под редактирование, не getPage).
  format:'json' — сырой сабтри. Авто-фолбэк не-топ-левел типов (tableRow/Cell/
  Header по #<index>) в JSON через canBeDocChild = docmostSchema.nodes.doc.
  contentMatch.matchType (не рукописный список); поле format на каждом ответе.
- patchNode/insertNode: XOR-вход {markdown?|node?} (оба optional в схеме, XOR
  на рантайме). markdown → импорт фрагмента → 1→N сплайс: первый блок наследует
  id цели, остальные свежие; dry replaceNodeById для #159-ambiguity ДО сплайса;
  соседние блоки byte-identical. Guard findUnrepresentableTableAttrs: цель со
  span/colwidth/backgroundColor → отказ с указанием на table-тулы/node-JSON.
  insertNode — insertNodesRelative (N блоков по порядку); голый tableRow/Cell
  JSON-only.
- Сноски: ^[...] во фрагменте → канон-импортёр; importMarkdownFragment делит
  блоки от footnotesList, РЕМАПИТ id сносок фрагмента в свежие uuid (fn-1
  фрагмента не коллизит с fn-1 страницы), mergeFootnoteDefinitions добавляет
  через ту же машинерию appendDefinition→normalizeAndMergeFootnotes→
  canonicalizeFootnotes, что insertFootnote. Сырые JSON-пути не тронуты.
- node-ops: replaceNodeByIdWithMany/insertNodesRelative (сплайс массива) в
  prosemirror-markdown/node-ops (канон после #414) + barrel.
- ROUTING_PROSE (READ/EDIT), compile-time client-call contract, CHANGELOG
  (getNode default→markdown, breaking для внешних клиентов, в окне #411/#412).

Тесты: сходимость (patchNode(markdown) блок docsCanonicallyEqual полному
импорту — нет «второго канона»); id-нить 1→N (первый наследует, остальные
свежие, соседи byte-identical); XOR; getNode markdown/json/non-top-level-fallback;
сохранение comment-якорей (active+resolved); ^[...]→хвостовой список+перенумерация;
guard. mcp node --test 697/697; pmd vitest 736; tsc чисто; server jest 273.
Третий линк breaking-окна, стоит на #412 (#411→#412→ЭТОТ→#415).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:10:38 +03:00
agent_coder 7cb3199d09 refactor(mcp)!: BREAKING — все имена MCP-тулов snake_case → camelCase (унификация с in-app) (#412)
Один логический тул жил под двумя именами: внешний MCP snake_case
(edit_page_text), in-app camelCase (editPageText) — дублирование доков, путаница
при переносе промптов/скиллов, помеха шарингу спек (#294). Решение владельца:
единый camelCase везде, включая внешний MCP. После этого mcpName === inAppKey.

- tool-specs.ts: mcpName ВЫВЕДЕН из ключа спеки (mcpName == inAppKey) для всех
  43 shared-спек — раньше divergent snake, теперь равен ключу (проверено: mcpName
  читается только структурно — цикл регистрации, генератор <tool_inventory>,
  TOOL_FAMILY). +5 inline-регистраций (tableGet/updateComment/deleteComment/
  docmostTransform; search без изменений). Рантайм: 47 тулов, все camelCase, ноль
  подчёркиваний.
- Контракт-конвенция ИНВЕРТИРОВАНА: shared-tool-specs.contract.spec
  `mcpName === toSnake(inAppKey)` → `mcpName === inAppKey`; tool-specs.test
  и tool-inventory.test обновлены.
- ROUTING_PROSE/TOOL_FAMILY/INLINE_MCP_INVENTORY (server-instructions.ts) →
  camelCase (105 замен). ai-chat.prompt/guard уже на in-app camelCase-ключах —
  без изменений (guard прошёл). comment-signal EXCLUDED_TOOLS схлопнут с
  дублей snake+camel до camelCase.
- Некоторое неочевидное: assertUnambiguousMatch(op: "patch_node"|"delete_node")
  в prosemirror-markdown/node-ops — op интерполируется в model-facing ошибку;
  литерал-юнион + call-sites → "patchNode"|"deleteNode".
- Все snake-имена в описаниях/error-строках/комментах/тестах/доках → camelCase
  (whole-token, longest-match-first). CHANGELOG: BREAKING-таблица 46 строк +
  миграция (allowlists mcp__gitmost-*__get_node→__getNode, промпты/скиллы,
  .mcp.json, метрики по tool-label); релизится вместе с #411.
  Внутренние имена методов (PageService.updatePageContent и т.п.) НЕ тронуты —
  переименованы только ИМЕНА ТУЛОВ.

Гейт: mcp node --test 677/677; tsc -p apps/server чисто; jest ai-chat-tools.
service + shared-tool-specs.contract + tool-tiers + ai-chat.prompt +
comment-signal-inapp → 323. Второй линк breaking-окна (#411→ЭТОТ→#413→#415).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 19:10:38 +03:00
vvzvlad e19275e96e Merge pull request 'refactor(tools): updatePageContent → updatePageMarkdown; −import_page_markdown с MCP (#411)' (#462) from refactor/411-update-page-markdown into develop
Reviewed-on: #462
2026-07-10 18:36:44 +03:00
agent_coder 3a521ada4d refactor(tools): updatePageContent → updatePageMarkdown; внешний MCP: +updatePageMarkdown, −import_page_markdown (#411)
Поверхности записи «целым телом» были несимметричны: у in-app агента полная
замена тела markdown называлась updatePageContent (имя не про формат, тогда как
парный updatePageJson — про JSON), а у внешнего MCP голого plain-body-replace
не было вовсе (только import_page_markdown — на деле парсер round-trip к
export_page_markdown, не plain-replace). Пара должна быть updatePageMarkdown /
updatePageJson.

Пост-Фаза-1б архитектура (реестр + циклы по обоим хостам):
- новая shared-спека updatePageMarkdown (mcpName update_page_markdown, inAppKey
  updatePageMarkdown, tier как у updatePageJson) с execute (client, {pageId,
  content, title}) => client.updatePage(...) — тот же путь updatePageContentRealtime
  → markdownToProseMirrorCanonical, ^[...]-сноски парсятся. Реестровый цикл
  регистрирует её на ОБОИХ хостах автоматически. Добавлен 'updatePage' в
  Pick DocmostClientLike.
- import_page_markdown убран с внешнего MCP через inAppOnly:true у спеки
  importPageMarkdown — MCP-цикл и генератор инвентаря её пропускают, in-app
  агент сохраняет importPageMarkdown; спека и client-метод НЕ удалены.
- удалён inline in-app updatePageContent tool (теперь из реестра под inAppKey
  updatePageMarkdown) + его INLINE_TOOL_TIERS-энтри.
- ROUTING_PROSE: bulk-rewrite ссылается на update_page_markdown|update_page_json;
  убрано упоминание import_page_markdown; инвентарь генерируется из catalogLine.
- лейбл-мапы chat-markdown.util (en/ru), человекочитаемые метки не тронуты.
- НЕ тронуты одноимённые внутренности: PageService.updatePageContent,
  updatePageContentRealtime, collaboration.handler — переименовано только имя тула.

Тесты: updatePageMarkdown на обеих поверхностях с идентичной схемой, forward в
client.updatePage; import_page_markdown ОТСУТСТВУЕТ на MCP, присутствует in-app;
^[...]→сноски покрыт через collaboration.test. CHANGELOG BREAKING + миграция;
README/README.ru пакета обновлены. Гейт: mcp node --test 646/646, server jest
259, tsc чисто. Первый линк breaking-окна #416 (#411→#412→#413→#415).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 18:33:20 +03:00
vvzvlad 576db3c8f9 Merge pull request 'feat(mcp): drawio стадия 2 — guide, каталог фигур, ELK-лейаут, quality-warnings (#424)' (#440) from feat/424-drawio-rules into develop
Reviewed-on: #440
2026-07-10 18:29:46 +03:00
agent_coder ddb37376a4 refactor(mcp): вписать drawio-тулы в реестровый цикл (Фаза 1б смержена) (#440)
Фаза 1б (#445/#446/#447/#448) влилась ПЕРЕД этим PR, поэтому явная проводка
drawio через registerShared/sharedTool конфликтовала с реестровыми циклами.
Фолд:
- drawioGet/Create/Update → канонический execute в спеке (это client-методы):
  execute возвращает сырой результат, цикл оборачивает jsonContent на MCP и
  отдаёт как есть in-app — байт-в-байт как старые тела, overrides не нужны.
  layout сохранён: добавлен в buildShape create/update + 5-м аргументом
  (layout as 'elk'|undefined) в обоих execute.
- drawioShapes/drawioGuide → остаются inline на ОБОИХ хостах. Гайд архитектора
  «execute в спеке с импортом searchShapes/getGuideSection в tool-specs.ts»
  оказался невозможен: drawio-shapes.ts использует import.meta.url, а
  tool-specs.ts тайпчекается из исходника под module:commonjs (contract-спека)
  → TS1343 на статический value-import, а import.meta не индиректится. Введён
  флаг спеки inlineBothHosts: спеки остаются в SHARED_TOOL_SPECS (contract
  пинит имя/описание/схему), но без execute; ОБА цикла их пропускают (добавлен
  симметричный guard в MCP-цикл), каждый хост регистрирует их inline через
  чистые хелперы — поведение байт-в-байт как до ребейза.
- docmost-client.loader: взята develop-форма DocmostClientLike = Pick<
  DocmostClient> (#446); ручное зеркало убрано, паритет layout наследуется из
  реальной сигнатуры client.
- ROUTING_PROSE: drawio intent-подсказки (shapes-first, guide, layout:elk);
  инвентарные строки убраны (генерируются из catalogLine).

Гейт: mcp node --test 674/674; server jest ai-chat-tools.service + contract
(211, все 5 drawio на обоих хостах, идентичная схема) + tool-tiers → 264;
tsc чисто; layout-passthrough тест 3/3. ELK DoS-кап и layout-фикс из round 3/4
сохранены.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 16:34:54 +03:00
agent_coder dc58974b31 fix(ai-chat): пробросить layout:elk в in-app drawioCreate/drawioUpdate — паритет с MCP (ревью #440)
In-app хендлеры drawio_create/drawio_update деструктурировали args БЕЗ layout и
не передавали его клиенту 5-м аргументом → layout:"elk" (схема его принимает —
общий buildShape) ТИХО терялся, ELK-автолейаут работал только по MCP-хосту.
Корень: ручное зеркало DocmostClientLike (loader) отстало от реального client.ts
— у его drawioCreate/drawioUpdate не было параметра layout (то, что #446 чинит
деривацией типа, но #446 ещё не влит). Добавил layout?:'elk' в обе сигнатуры
зеркала + проброс в обоих хендлерах.

Тест (пропущенный зелёным гейтом пробел — не было теста на in-app passthrough):
in-app drawioCreate/drawioUpdate с layout:'elk' → фейк-клиент получает layout
5-м позиционным аргументом; omit-кейс → undefined. Мутационно: убрать проброс
в drawioCreate → layout-create-тест краснеет.

Гейт: mcp build чисто; tsc -p apps/server без новых ошибок; jest
ai-chat-tools.service (35) + shared-tool-specs.contract + tool-tiers +
comment-signal-inapp → 273 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 16:16:19 +03:00
agent_coder c917dcc3c1 fix(mcp): ограничить ELK-лейаут (кап узлов/рёбер + таймаут) — untrusted-граф DoS (ревью #440)
applyElkLayout крутит elkjs СИНХРОННО в процессе на mxGraph-XML от LLM
(layout:'elk' в drawio_create/update) без лимита размера и без таймаута —
большой граф (тысячи узлов, ~1МБ XML проходит stage-1 cap 16МБ) блокирует
event-loop MCP-сервера на секунды-минуты. try/catch ловил только брошенную
ошибку, но не зависание.

- кап ДО построения графа: >500 узлов или >1000 рёбер → вернуть исходный XML
  (best-effort, как существующий catch); синхронный elkjs → кап и есть
  реальная защита;
- Promise.race с 5s-таймаутом (defense-in-depth на случай async-elkjs); таймер
  гасится в finally → нет утечки хендла и unhandled-rejection (проигравший
  timeout остаётся pending с погашенным таймером);
- тест: 600-узловой граф возвращается без изменений и быстро (<2s) — кап-путь.

79/79 drawio-тестов зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 16:15:06 +03:00
agent_coder eddc3b5c33 feat(ai-chat): пробросить drawio_shapes/drawio_guide in-app — восстановить SHARED_TOOL_SPECS-паритет (#424)
Стадия-1 (#434) уже была довяжена in-app в develop (f46d89ea, agent_vscode)
для CRUD-тулов; два новых чистых read-only хелпера стадии-2 остались
незаброшенными → contract-parity спека падала 6 ассертами (по 3 на
drawio_shapes/drawio_guide). В отличие от CRUD-тулов это ЧИСТЫЕ функции без
сетевого вызова, поэтому НЕ client-методы:

- реэкспорт searchShapes / getGuideSection (+ тип SearchShapesOptions) из
  entry пакета @docmost/mcp; loadDocmostMcp() пробрасывает их так же, как
  sharedToolSpecs (типы SearchShapesFn/GetGuideSectionFn);
- две записи sharedTool(...) в forUser() после drawioUpdate: drawioShapes
  повторяет серверный вызов searchShapes(query,{category,limit}) и форму
  { query, count, results }; drawioGuide — getGuideSection(section)
  (omit section -> index); голый объект без jsonContent-envelope, как у
  соседних in-app хендлеров;
- DocmostClientLike и HOST_CONTRACT_METHODS-вайтлист НЕ тронуты (это не
  методы клиента);
- три тест-мока (contract/service/tool-tiers) получили type-only no-op
  заглушки под расширенный тип loadDocmostMcp() — тела инструментов в этих
  тестах не исполняются, contract-спека реально гоняет настоящий
  SHARED_TOOL_SPECS.

Внутреннее ревью обвязки: APPROVE, 0 находок. shared-tool-specs.contract:
211/211 (было 6 падений); client-host-contract drift-guard 3/0; tsc EXIT 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 16:15:06 +03:00
agent_coder e454fe189c feat(mcp): drawio стадия 2 — правила качества, каталог фигур, guide, ELK-лейаут, warnings (#424)
Надстройка над стадией-1 (сырой mxGraph XML) — помогает агенту рисовать
корректные диаграммы без бэкенд-рендеринга:

- hard-rules в описаниях drawio_create/drawio_update (геометрия, parent-
  relative координаты, стили);
- drawio_guide (5 секций: skeleton / layout / containers / icons-aws /
  icons-azure, каждая ≤4KB) — по требованию, не раздувает контекст;
- drawio_shapes — реальный jgraph shape-index (10446 фигур, gzip 437KB,
  ленивый node:zlib gunzip) + курируемый оверлей (service-level паттерны
  AWS/Azure, note-подсказки на пустые resIcon, палитра категорий);
  ранжирование aws4>aws3; escapeRe в score (не ReDoS);
- layout:"elk" через elkjs (чистый JS, dependencies:{}) — compound-nesting,
  best-effort (на сбое ELK возвращает нормализованный вход), 73→0 warnings
  на 12-узловом графе;
- 6 типов quality-warnings в линтере (overlap, out-of-bounds, edge-cross,
  и т.п.), геометрия Liang-Barsky; warnings НИКОГДА не блокируют write.

Оба новых инструмента в SHARED_TOOL_SPECS (tier:deferred) + SERVER_
INSTRUCTIONS; drift-guards зелёные. elkjs ^0.11.1 — единственный новый
рантайм-деп; lockfile синхронизирован (--frozen-lockfile --offline EXIT 0).
data/ едет с воркспейсом (.gitignore-негация !packages/mcp/data/).

Внутренний цикл: 1 проход внутреннего ревью (APPROVE WITH SUGGESTIONS);
все 59 профильных тестов зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 16:12:37 +03:00
vvzvlad ea99d4fe63 Merge pull request 'feat(mcp): внятная диагностика ошибок тулов + fail-fast валидация comment-id (#437, #436)' (#441) from feat/437-error-diagnostics into develop
Reviewed-on: #441
2026-07-10 16:03:44 +03:00
vvzvlad 23966ce51c Merge pull request 'fix(ai-chat): «send now» во время detached-run — серверный stop + ограниченный ретрай 409 (#396)' (#456) from fix/396-sendnow-detached-run into develop
Reviewed-on: #456
2026-07-10 16:03:18 +03:00
vvzvlad a53b2f454e Merge pull request 'fix(delivery): immutable-кэш ассетов — отключить дефолтный cacheControl @fastify/static (#452)' (#455) from fix/452-immutable-cache-control into develop
Reviewed-on: #455
2026-07-10 16:03:03 +03:00
vvzvlad d219eb7525 Merge pull request 'fix(ai-chat): защита от петель агента — lockdown под тоггл + детектор деградации + бюджет шагов (#444)' (#454) from fix/444-agent-loop-guards into develop
Reviewed-on: #454
2026-07-10 16:02:48 +03:00
vvzvlad 93d244478e Merge pull request 'refactor(tools): генерировать инвентарь SERVER_INSTRUCTIONS из реестра + guard имён тулов (#448)' (#460) from refactor/448-generate-inventory into develop
Reviewed-on: #460
2026-07-10 16:02:17 +03:00
vvzvlad 791f709c18 Merge pull request 'refactor(tools): execute-маппинг в SHARED_TOOL_SPECS + автопроводка обоих хостов (#445)' (#459) from refactor/445-execute-mapping into develop
Reviewed-on: #459
2026-07-10 16:02:01 +03:00
vvzvlad f8a27cba91 Merge pull request 'refactor(mcp): вывести DocmostClientLike/SharedToolSpec из реального типа — убить ручные зеркала (#446)' (#458) from refactor/446-derive-client-types into develop
Reviewed-on: #458
2026-07-10 16:01:48 +03:00
vvzvlad 61dc9b50c1 Merge pull request 'fix(ci,mcp): REGISTRY_STAMP в билде + кросс-пакетный CI — закрыть skew build/vs/src (#447)' (#457) from fix/447-registry-stamp-ci into develop
Reviewed-on: #457
2026-07-10 16:01:38 +03:00
vvzvlad cebb1cca87 Merge pull request 'fix(mcp): pre-validate node JSON против схемы + путь битого узла (#409)' (#461) from fix/409-invalid-node-validation into develop
Reviewed-on: #461
2026-07-10 16:01:25 +03:00
vvzvlad 605c0f3dda Merge pull request 'docs(mcp): поправить устаревшую ссылку footnote-authoring.ts в комментариях' (#453) from docs/footnote-authoring-comment-cleanup into develop
Reviewed-on: #453
2026-07-10 15:49:35 +03:00
agent_coder 0f5f048ca2 fix(mcp): pre-validate node JSON против схемы + путь битого узла (#409, остаток Фазы 1)
Структурные редакторы (patchNode/insertNode/updatePageJson/transformPage) кидали
опаковый Yjs-крах на агентском JSON с вложенным узлом без/с неизвестным `type`:
«Failed to encode document to Yjs (fromJSON): Unknown node type: undefined» —
ГЛУБОКО в энкодере, уже ПОСЛЕ открытия collab-сессии, а хинт мислейблил это как
проблему атрибута. Агент ретраил вслепую (~34 краха в истории 06-17…07-07).

- findInvalidNode(doc) в prosemirror-markdown/node-ops.ts: DFS по content,
  возвращает {path, summary} первого узла с отсутствующим/не-строковым `type`
  или типом/маркой вне схемы. Множество имён — из getSchema(docmostExtensions),
  ТОГО ЖЕ, из которого энкод-путь строит docmostSchema → «известный тип»
  обходчика ровно то, что примет PMNode.fromJSON/toYdoc (сверено на 45 узлах +
  12 марках, ни ложных положительных, ни пропуска краш-типа).
- unstorableYjsError: findInvalidNode ПЕРВЫМ (node-shape крах больше не
  мислейблится как атрибут), затем findUnstorableAttr, generic-фраза последней.
- assertValidNodeShape(op, node) ДО getCollabTokenWithReauth/mutatePageContent
  в patchNode/insertNode/updatePageJson: fail-fast — collab-сессия не
  открывается, page-lock не берётся, сообщение детерминировано (mock-тест
  ассертит collabTokenFetched===false на битом пути). tableUpdateCell не тронут
  (строит абзац из plain text через makeCellParagraph, агентский JSON не глотает).
- Описания patch_node/insert_node/update_page_json: каждый узел, включая
  вложенные, несёт строковый `type` из схемы; текст-листы {"type":"text",...}.

sanitizeForYjs (стрип undefined-атрибутов) сохранён — другой класс отказа.
Внутреннее ревью: APPROVE WITH SUGGESTIONS — schema-fidelity/fail-fast/
no-false-positive/precedence подтверждены; замечания необязательны (тест
перечисления схемы, depth-guard безобиден т.к. энкодер падает раньше).
prosemirror-markdown vitest 726/726, mcp node --test 613/613.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 10:49:51 +03:00
agent_coder 4cb762b039 docs(mcp): обновить AGENTS.md + коммент под генерируемый инвентарь (ревью #460)
Две доковые правки по ревью: (1) AGENTS.md-буллет описывал ДО-#448 мир (ручная
правка SERVER_INSTRUCTIONS, enforced server-instructions.test.mjs, EXCEPTIONS) —
переписан: shared-спеки авто-обновляют генерируемый <tool_inventory>, только
inline-тул требует строки в INLINE_MCP_INVENTORY, enforced tool-inventory.test.mjs,
EXCEPTIONS больше нет; (2) коммент в server-instructions.ts называл гард окольно
('tool-specs.test.mjs's sibling test') → назван tool-inventory.test.mjs напрямую.
Единственная оставшаяся ссылка на удалённый тест устранена. Только доки/комменты.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 09:55:30 +03:00
agent_coder d0f99052cf refactor(tools): генерировать инвентарь SERVER_INSTRUCTIONS из реестра + guard имён тулов в промпте (#448)
Финальный линк Фазы 1б. Инвентарь тулов жил в 4 рукописных прозаических копиях
(SERVER_INSTRUCTIONS под regex-тестом; <tool_catalog>; имена в ai-chat.prompt.ts
без гарда; README) — роадмап #416 планировал 4 последовательных ручных правки
этого текста (#411/#412/#413/#415).

- SERVER_INSTRUCTIONS разбит (новый модуль server-instructions.ts): ROUTING_
  PROSE (рукописные intent-подсказки «когда что» — осмысленно ручные, перенесены
  ДОСЛОВНО со всеми предостережениями: <=250 у create_comment, soft-delete у
  delete_page, baseHash у drawio_update, PUBLIC у share_page) + buildToolInventory()
  — генерирует <tool_inventory> из реестра (mcpName + purpose из catalogLine,
  группировка по TOOL_FAMILY, бакет OTHER ловит незамаппленное → тул нельзя
  тихо потерять) + 5 inline MCP-only (INLINE_MCP_INVENTORY). Детерминирован
  (семейства FAMILY_ORDER, имена localeCompare). regex-тест server-instructions
  удалён; структурные гарантии — в новом tool-inventory.test.mjs (точное
  членство множества сильнее старого \b-скрейпа).
- Имена тулов в ai-chat.prompt.ts → через экспорт PROMPT_TOOL_NAMES; новый гард
  ai-chat.prompt.tool-names.spec.ts: каждое имя — реальный тул реестра, скан
  guidance-нот на camelCase-токены падает на несуществующем (escape-
  нейтрализация против ложных nThe-токенов).
- INLINE_TOOL_TIERS уже содержал ровно 8 genuinely-inline тулов (после #445) —
  сжатие не потребовалось.

Критерий: добавление/переименование спека меняет инвентарь БЕЗ правки прозы.
Внутреннее ревью: APPROVE — фактическим прогоном подтверждено, что НИ ОДИН тул
из старого SERVER_INSTRUCTIONS не выпал (диф старый-vs-новый пуст; добавился
get_workspace, раньше прятавшийся в EXCEPTIONS); проза дословна; инвентарь
полон/детерминирован/без фантомов; гард краснеет на обеих ветках провала.
613 node + 289 jest зелёные. Стоит на #445 — мержить последним в стопке 1б.

README-каталоги вне обязательного скоупа (docs-скрипт) — в чек-лист #412.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 09:21:28 +03:00
agent_coder 8c74659d91 refactor(tools): execute-маппинг в SHARED_TOOL_SPECS + автопроводка обоих хостов (#445)
Ядро Фазы 1б. Реестр (#294) шарил только метаданные (имя/схема/описание/tier),
но НЕ execute-логику — у каждого shared-тула было ДВА рукописных execute-тела
с копией маппинга аргументов (MCP registerShared в index.ts; in-app sharedTool
в ai-chat-tools.service, зеркалящий MCP-транспорт вручную). Корень
повторяющихся parity-багов (f46d89ea drawio, f8d26420 stashPage, fc9088b7
node-args): добавление одного тула = 7-9 согласованных ручных правок в двух
пакетах.

- SharedToolSpec расширен: канонический execute(client, args) (чистый JS —
  свободно пересекает zod-мажорную границу v3/v4) + оверрайды
  mcpExecute/inAppExecute/mcpOnly/inAppOnly для ОСОЗНАННЫХ per-layer различий.
  client: DocmostClientLike (Pick из #446). Канон возвращает СЫРЬЁ, каждый хост
  накладывает свой конверт (MCP jsonContent, in-app как есть); override владеет
  результатом хоста целиком.
- Оба хоста → циклы по Object.values(SHARED_TOOL_SPECS): index.ts registerShared
  39→0 (цикл), ai-chat-tools.service sharedTool ~40→1 (цикл). Добавление спека
  автоматически регистрирует тул в ОБОИХ хостах — сценарий PR #434 невозможен
  по построению.
- Осознанные различия через overrides (ни одно не сплющено к одному хосту):
  оба mcpExecute+inAppExecute — createPage/movePage/deletePage/
  exportPageMarkdown/createComment (guardrails, конверты, проекции, тексты
  ошибок); execute+inAppExecute — getPage/renamePage/resolveComment;
  execute+mcpExecute — stashPage (resource_link+structuredContent),
  checkNewComments (since-guard только на MCP).
- Оставлены inline (по делу): update_comment/delete_comment (MCP-only, in-app
  не даёт хард-правку/удаление комментов), search/transformPage (per-transport
  дивергенция — hybrid RRF / без deleteComments), table_get (noun-vs-verb
  naming clash — уедет после camelCase #412), getCurrentPage/updatePageContent/
  listSidebarPages/getComment/getPageHistory (in-app-only, per-request state).
- Guard-тесты (contract-parity, phantom-catalog) сохранены — теперь инварианты,
  не «последняя линия».

Внутреннее ревью: APPROVE, построчная BEFORE/AFTER-сверка по каждому shared-тулу
на обоих хостах — ни одного изменённого per-host поведения (порядок/дефолты
аргументов, guard'ы, конверты, проекции сохранены), множества тулов побайтово
совпадают (48 in-app, 45 MCP), кросс-zod-граница чистая (нет z. в execute),
611 mcp + 260 server тестов зелёные. Ядро Фазы 1б, стоит на #446 — мержить после.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 08:59:40 +03:00
agent_coder e24ddf6b3e test(ai-chat): покрыть safety-путь детектора деградации + границы (ревью #454)
Ревью: дизайн LGTM, но safety-фича недотестирована. Добавлено (только тесты,
прод-код не тронут):
- e2e-реакция детектора: streamText эмитит degenerate-чанки → union abortSignal
  срабатывает с 'Output degeneration detected' (отличимо от Stop) → onAbort
  пишет status:error + OUTPUT_DEGENERATION_ERROR + усечённый content, лиза MCP
  закрыта. Именно ДЕЙСТВИЕ на детект (детектит-но-не-действует = защиты нет);
- граница monochar-порога: hasPeriodicTail('x'×59)=false, ('x'×60)=true
  (мутация >=→> раньше выживала);
- empty-turn маркер (шаги исчерпаны + без текста → STEP_LIMIT_NO_ANSWER_MARKER;
  негативы на нормальный текст-ход и на исчерпание-с-текстом — гардят AND);
- различение degeneration-onAbort vs user-Stop (Stop → status:aborted, без
  error/усечения).

Мутационно: (a) >=→> роняет 60-границу; (b) нейтрализация onAbort-ветки роняет
reaction-тест; (c) нейтрализация маркера роняет empty-turn-тест. +7 тестов,
137 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 07:54:34 +03:00
agent_coder 3cba551800 fix(ai-chat): «send now» во время detached-run — авторитетный серверный stop + ограниченный ретрай 409 (#396)
В автономном режиме «Interrupt and send now» во время живого detached-run делал
только локальный stop() (abort SSE), который сервер игнорирует (run живёт по
дизайну #184/#234), поэтому onFinish→flush новый POST упирался в гейт «один
активный run на чат» → 409 A_RUN_ALREADY_ACTIVE, новый turn не стартовал.
handleStop делает правильно (доп. onServerStop), sendNow — нет. Вариант A
(клиентский, горячий путь сервера не тронут):

- sendNow в автономном режиме дополнительно зовёт onServerStop(chatId) (или
  откладывает через stopPendingRef, если chatId ещё не усыновлён — как
  handleStop) и взводит one-shot supersedeRetryRef ДО stop();
- транспорт-fetch на supersede-отправке (и только на ней) ретраит РОВНО 409 с
  body.code===A_RUN_ALREADY_ACTIVE до 4 попыток с бэкоффом 150/300/600ms;
  onServerStop гарантирует осадку run → ретрай сходится. Обычная отправка (не
  взведён флаг) падает на 409 мгновенно. isRunAlreadyActive читает
  response.clone() → тело оригинала возвращается потребителю нетронутым.
  409 всегда до записи user-строки (pre-check/beginRun раньше insert) → повтор
  POST безопасен, дублей нет.

Внутренний цикл: 2 прохода. Ревью нашло CRITICAL: supersedeRetryRef застревал
взведённым, когда sendNow взвёл, но POST не ушёл (promoted head удалён →
flushNext() false; либо wasResumed-return) — следующая обычная отправка молча
ретраила настоящий 409. Починка: разоружать флаг симметрично остальным one-shot
(в ветке !flushNext() и в isStreaming-defuse-эффекте); транспорт read-and-clear
на входе каждой отправки. Мутационно: убрать disarm → strand-тест краснеет
(4 вызова вместо 1). Легаси-режим не тронут (регресс-гард).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 07:50:04 +03:00
agent_coder 15a9eba562 fix(delivery): immutable-кэш ассетов — отключить дефолтный cacheControl @fastify/static (#452)
Хэшированные ассеты отдавали 'cache-control: public, max-age=0' вместо
'public, max-age=31536000, immutable' → повторные заходы ревалидировали каждый
ассет (десятки 304 × мобильный RTT), главный выигрыш #346 не реализовывался.
Причина: у @fastify/static опция cacheControl:true по умолчанию пишет свой
Cache-Control (из maxAge, дефолт 0) ПОСЛЕ setHeaders-колбэка, затирая immutable-
заголовок из resolveStaticAssetHeaders. Фикс — cacheControl:false, колбэк
владеет заголовком. preCompressed не конфликтовал, потому баг был только в
заголовках.

Крайние случаи проверены: locales/vad/иконки получают только vary (без
cache-control → браузер ревалидирует по etag — ок); index.html отдаётся
отдельным wildcard-роутом со своим no-cache (не затронут); preCompressed .br
получает путь с /assets/ → маппинг матчит, immutable ставится.

Тест: bare-fastify + inject() — /assets/<hashed>.js содержит immutable+
max-age=31536000, /locales/en.json — нет. Мутационно: cacheControl:true роняет
ассерт immutable. jest static.module → 5/5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 07:30:44 +03:00
agent_coder 2c03fefa9d fix(ai-chat): защита от петель агента — lockdown под тоггл, детектор деградации, бюджет шагов (#444)
Третий класс петли (инцидент 2026-07-10): ран упёрся в 20-шаговый кап, спалив
все шаги на чтение; на 20-м шаге final-step lockdown отнял инструменты
(toolChoice:'none') посреди незаконченной работы → модель выродилась в
текст-повтор («loadTools.» ×20416, 255КБ). Пакет защит по дизайну владельца:

- MAX_AGENT_STEPS 20→50; спеки выводятся из константы (нет захардкоженных 19/20).
- Final-step lockdown под env-тогглом AI_CHAT_FINAL_STEP_LOCKDOWN (дефолт OFF,
  по образцу AI_CHAT_DEFERRED_TOOLS): OFF — инструменты доступны на всех шагах +
  мягкий финальный нудж; ON — легаси toolChoice:'none'+FINAL_STEP_INSTRUCTION.
  Спеки параметризованы по тогглу. .env.example задокументирован.
- Пустой ход (все шаги без текста, шаги исчерпаны) получает синтетический
  маркер-текст — виден в UI и реплее.
- Детектор токен-деградации в onChunk (единственная защита от болтовни, БЕЗ
  maxOutputTokens — tool-аргументы это выходные токены): чистые правила
  (≥25 одинаковых строк ИЛИ периодический хвост), при срабатывании abort через
  внутренний AbortController ∪ effectiveSignal (AbortSignal.any), финализация в
  onAbort: усечение хвоста, ai_chat_runs.error=Output degeneration detected,
  лизы MCP/снапшоты освобождаются (существующий lifecycle).
- Предупреждение о бюджете шагов на MAX-6…MAX-2 с убывающим N.
- loadTools-описание и преамбула каталога явно говорят, что CORE-тулы всегда
  активны (список из CORE_TOOL_KEYS динамически).

Внутренний цикл: 2 прохода. Ревью нашло data-loss-риск: правило периодичности
детектора ложно срабатывало на markdown-разделителях/setext-подчёркиваниях/
хвостовых пробелах (монохар-хвост p-периодичен при ЛЮБОМ p → ложный abort с
пометкой error и усечением). Починка: отдельная монохар-проверка (порог 60,
выше любого реального разделителя) + требование ≥2 различных символов в
периодическом блоке при p≥2. Реальный loadTools-цикл (период ~10) ловится.
Мутационно: 59 одинаковых — не флаг, 60 — флаг; loadTools×20416 — флаг.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 07:28:48 +03:00
agent_coder 76af4f692e docs(mcp): поправить устаревшую ссылку footnote-authoring.ts -> @docmost/prosemirror-markdown
#429 (дедуп node-ops) перенёс footnoteContentKey в @docmost/prosemirror-markdown
и удалил footnote-authoring.ts, но два docstring-комментария в
footnote-normalize-merge.ts всё ещё ссылались на старое имя файла. Только
комментарии, на сборку/поведение не влияет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 07:26:42 +03:00
agent_coder 4809348457 test(mcp): ассертить collab-hint (pageId + transient/retry) в reject-текстах (ревью #441)
Enrichment collab-ошибок из Phase C (#437) дописывает
'(pageId …; transient — retry once; …)' к connect-timeout/connection-closed,
но reject-регекспы матчили только базовый текст → проходили и С hint, и БЕЗ
(vacuous). Ужесточил два ассерта (connection-closed, connect-timeout) до
'<база> (pageId page-1; transient' — теперь рефактор, убравший hint(), их
роняет. Мутационно: hint()->'' → ровно эти 2 теста краснеют (18->16).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 07:14:39 +03:00
agent_coder d3d32d637b fix(mcp): no-response диагностика — не отдавать сырой error.message (утечка host в модель) (внутр. ревью #437)
no-response ветка формата использовала error.code ?? error.message: при
отсутствии code axios-сообщения сетевых ошибок содержат host:port
('connect ECONNREFUSED 127.0.0.1:3000', 'getaddrinfo ENOTFOUND host'), что
нарушает инвариант #437 «host никогда не попадает в видимое модели сообщение».
Теперь только error.code (?? 'network error'); полный нативный текст уходит в
stderr под DEBUG. code проставлен фактически для всех реальных no-response
ошибок. Тест обновлён: сырое host-содержащее сообщение -> нейтральный reason,
плюс ассерт что host в сообщении отсутствует.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 07:12:37 +03:00
agent_coder 9a435201b8 feat(mcp): enrich collab connect/persist/closed error texts with pageId + retry hint (#437)
Append `(pageId <id>; transient — retry once; persistent failures mean the
collab server is unreachable/overloaded)` to the connect-timeout, persist-timeout
and connection-closed error texts in CollabSession, so the agent can self-correct
instead of blind-looping. The Yjs-encode error is left untouched (it already names
the offending attribute).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 07:12:37 +03:00
agent_coder d6827b9210 feat(mcp): actionable tool errors — central axios diagnostics + fail-fast comment-id guard (#437)
Phase A: add ONE response interceptor on DocmostClient's axios instance,
registered AFTER the re-login interceptor, that reformats a failed request's
error.message IN PLACE (never a custom Error subclass, so the live
axios.isAxiosError / error.response?.status / config._retry checks keep working)
into `<METHOD> <path> failed (<status> <statusText>): <serverMessage>`, or the
no-response variant. serverMessage is built ONLY from the whitelisted
message/error fields or statusText — raw string/HTML bodies, headers and config
never appear; an arraybuffer body is size-capped JSON.parsed; the full body goes
to stderr only under DEBUG (parity with downloadImage). A _docmostFormatted flag
guards against double-processing.

Phase B (#436): assertFullUuid throws an actionable error BEFORE any network
call at all five commentId sites (resolve/update/delete/get_comment and
create_comment's parentCommentId when provided), so a truncated id can no longer
loop as an opaque 400/404.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 07:12:37 +03:00
93 changed files with 8751 additions and 1801 deletions
+11
View File
@@ -217,6 +217,17 @@ MCP_DOCMOST_PASSWORD=
# active" behavior.
# AI_CHAT_DEFERRED_TOOLS=true
# Final-step lockdown for the in-app agent loop (#444). Default OFF. When ON
# (legacy), the LAST allowed step forces a text-only answer: the model's tools are
# stripped (toolChoice=none) and a synthesis instruction is appended. That
# tool-stripping caused a token-degeneration incident — robbed of its tools on the
# final step mid-work, the model emitted a ~255KB block repeating a single token —
# so the default is now OFF: the last step keeps its tools and gets only a SOFT
# nudge to finish with a text summary, and a token-degeneration detector is the
# universal anti-babble guard. Enable this ONLY for a model that reliably ends its
# turns with a clear text answer.
# AI_CHAT_FINAL_STEP_LOCKDOWN=false
# --- Autonomous / detached agent runs (settings.ai.autonomousRuns) ---
# Opt-in per workspace (AI settings; off by default). When on, a chat turn becomes
# a server-side RUN that survives a browser disconnect — only an explicit Stop ends
+5
View File
@@ -2,6 +2,11 @@
.env.dev
.env.prod
data
# Exception: the committed draw.io shape catalog (issue #424) lives in a `data/`
# dir, but the bare `data` ignore above is meant for runtime state, not this
# bundled build asset. Re-include the directory and its contents.
!packages/mcp/data/
!packages/mcp/data/**
# compiled output
/dist
node_modules
+1 -1
View File
@@ -338,7 +338,7 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
- The version string shown in the UI comes from `APP_VERSION` (CI/Docker) or `git describe --tags --always` (local), resolved in `vite.config.ts` — not from `package.json`.
- Server TS config is permissive (`noImplicitAny: false`, `strictNullChecks: false`, `no-explicit-any` lint disabled). Follow the existing relaxed style rather than tightening types broadly.
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`, `ai`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons. The `ai@6.0.134` patch disables the SDK's O(n²) cumulative `partialOutput` accumulation when no output strategy is requested (server heap OOM on long agent runs, #184; tripwire test: `apps/server/src/integrations/ai/ai-sdk-partial-output.patch.spec.ts`) — it MUST be re-created via `pnpm patch` when bumping `ai`.
- **Adding/renaming/removing an MCP tool requires updating `SERVER_INSTRUCTIONS`** in `packages/mcp/src/index.ts` — the intent-routing guide MCP clients receive on initialize. This applies both to inline `server.registerTool(...)` calls in `index.ts` and to specs in `packages/mcp/src/tool-specs.ts`. Enforced by `packages/mcp/test/unit/server-instructions.test.mjs`, which fails when a registered tool is not mentioned in the guide (deliberate opt-outs go into its `EXCEPTIONS` list). `packages/mcp/build/` is gitignored and rebuilt in CI/Docker via `pnpm build` (same convention as `git-sync`/`prosemirror-markdown`) — never commit it; rebuild locally after editing to run the tests.
- **The MCP tool inventory in `SERVER_INSTRUCTIONS` is GENERATED from the registry** (`packages/mcp/src/server-instructions.ts`: `buildToolInventory()` over `SHARED_TOOL_SPECS`) and spliced into the hand-written routing prose (`ROUTING_PROSE`). So adding/renaming/removing a **shared** spec in `packages/mcp/src/tool-specs.ts` auto-updates the `<tool_inventory>` — no manual `SERVER_INSTRUCTIONS` edit needed. Only an **inline** MCP-only tool (those registered via `server.registerTool(...)` in `index.ts`, not through the registry) needs a one-line entry in `INLINE_MCP_INVENTORY`. Enforced by `packages/mcp/test/unit/tool-inventory.test.mjs`, which fails when a registered tool is missing from the generated inventory (there is no `EXCEPTIONS` opt-out anymore — every tool must appear). Update `ROUTING_PROSE` when a tool's *intent guidance* (when-to-use) changes. `packages/mcp/build/` is gitignored and rebuilt in CI/Docker via `pnpm build` (same convention as `git-sync`/`prosemirror-markdown`) — never commit it; rebuild locally after editing to run the tests.
## CI / release
+105
View File
@@ -10,6 +10,111 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Breaking Changes
- **External MCP tool names are now camelCase (all renamed).** Every tool on the
external `/mcp` surface was renamed from `snake_case` to `camelCase`, so the
external MCP name now matches the in-app tool name exactly (one logical tool,
one name everywhere). For example `get_node``getNode`, `edit_page_text`
`editPageText`, `patch_node``patchNode`. The tools' behaviour, inputs and
outputs are unchanged — only the names change. The single-word `search`
keeps its name.
*Migration (external MCP clients only — the in-app AI agent already used these
names and is unaffected):* update anything that refers to a tool by its
string name — permission allowlists (`mcp__gitmost-*__get_node`
`mcp__gitmost-*__getNode`), saved prompts/skills, `.mcp.json` tool filters,
and metrics dashboards that group by the `tool` label — and roll it out in
lockstep with this deploy, because the old snake_case names stop resolving.
Released together with the `import_page_markdown`/`update_page_markdown`
change below so external configs break exactly once.
Full mapping (old → new):
| Old (snake_case) | New (camelCase) |
| --- | --- |
| `check_new_comments` | `checkNewComments` |
| `copy_page_content` | `copyPageContent` |
| `create_comment` | `createComment` |
| `create_page` | `createPage` |
| `delete_comment` | `deleteComment` |
| `delete_node` | `deleteNode` |
| `delete_page` | `deletePage` |
| `diff_page_versions` | `diffPageVersions` |
| `docmost_transform` | `docmostTransform` |
| `drawio_create` | `drawioCreate` |
| `drawio_get` | `drawioGet` |
| `drawio_guide` | `drawioGuide` |
| `drawio_shapes` | `drawioShapes` |
| `drawio_update` | `drawioUpdate` |
| `edit_page_text` | `editPageText` |
| `export_page_markdown` | `exportPageMarkdown` |
| `get_node` | `getNode` |
| `get_outline` | `getOutline` |
| `get_page` | `getPage` |
| `get_page_json` | `getPageJson` |
| `get_workspace` | `getWorkspace` |
| `insert_footnote` | `insertFootnote` |
| `insert_image` | `insertImage` |
| `insert_node` | `insertNode` |
| `list_comments` | `listComments` |
| `list_page_history` | `listPageHistory` |
| `list_pages` | `listPages` |
| `list_shares` | `listShares` |
| `list_spaces` | `listSpaces` |
| `move_page` | `movePage` |
| `patch_node` | `patchNode` |
| `rename_page` | `renamePage` |
| `replace_image` | `replaceImage` |
| `resolve_comment` | `resolveComment` |
| `restore_page_version` | `restorePageVersion` |
| `search` | `search` (unchanged) |
| `search_in_page` | `searchInPage` |
| `share_page` | `sharePage` |
| `stash_page` | `stashPage` |
| `table_delete_row` | `tableDeleteRow` |
| `table_get` | `tableGet` |
| `table_insert_row` | `tableInsertRow` |
| `table_update_cell` | `tableUpdateCell` |
| `unshare_page` | `unsharePage` |
| `update_comment` | `updateComment` |
| `update_page_json` | `updatePageJson` |
| `update_page_markdown` | `updatePageMarkdown` |
(#412)
- **External MCP: `import_page_markdown` removed, `update_page_markdown` added.**
The external `/mcp` surface no longer exposes `importPageMarkdown` (the
round-trip parser for a self-contained *exported* Docmost-Markdown file). In
its place it now exposes **`updatePageMarkdown`** — a plain-Markdown
full-body replace (`{pageId, content, title?}`) that pairs with
`updatePageJson`, re-imports the whole body (block ids regenerate) and
parses Docmost-flavoured markdown including `^[...]` inline footnotes.
*Migration:* MCP clients that called `importPageMarkdown` to overwrite a
page's body from Markdown should call `updatePageMarkdown` instead (pass the
markdown as `content`). Round-tripping an exported Docmost-Markdown file with
comment anchors/diagrams is no longer available on the external MCP surface;
export remains via `exportPageMarkdown`. The in-app AI agent is unaffected —
it keeps both `importPageMarkdown` and the renamed `updatePageMarkdown` (was
`updatePageContent`). The total MCP tool count is unchanged (−1 / +1). The
external names shown here are the post-#412 camelCase names. (#411)
- **`getNode` now returns Markdown by default (was ProseMirror JSON).** The
block-level read/write tools default to Markdown so a block round trip is
`getNode` (markdown) → edit → `patchNode` (markdown). `getNode` now returns
`{ …, format: "markdown", markdown }` unless you pass `format: "json"` (which
restores the previous `{ …, node }` ProseMirror subtree); comment anchors —
including resolved ones — are preserved in the markdown so a write-back never
orphans a thread, and a node that cannot be a document top-level block
(`tableRow`/`tableCell`/`tableHeader` addressed via `#<index>`) auto-falls back
to JSON with `format: "json"` in the response. `patchNode`/`insertNode` gain a
`markdown` input alongside `node` (provide exactly one): the markdown fragment
may rewrite/insert several blocks at once and supports `^[...]` footnotes.
*Migration (external MCP clients only):* a client that consumed `getNode`'s
`node` field must now either read `markdown`, or pass `format: "json"` to keep
the old ProseMirror-JSON output. Released together with the `#411`/`#412`
breaking window so external configs break exactly once. (#413)
### Added
- **Place several images side by side in a row.** A new "Inline (side by
@@ -28,7 +28,10 @@ const h = vi.hoisted(() => ({
body: Record<string, unknown>;
}) => { body: Record<string, unknown> };
prepareReconnectToStreamRequest?: () => { api?: string };
fetch?: (input: unknown, init?: { method?: string }) => Promise<unknown>;
fetch?: (
input: unknown,
init?: { method?: string; body?: unknown },
) => Promise<unknown>;
},
},
}));
@@ -200,6 +203,244 @@ describe("ChatThread — send now (#198)", () => {
});
});
// #396: in autonomous mode a live sendNow must additionally request the
// AUTHORITATIVE server stop of the detached run (a local abort is only a client
// disconnect the server ignores) and arm a bounded 409 retry so the re-POST
// converges once the one-active-run slot frees. Legacy mode is unchanged.
describe("ChatThread — send now server-stop + supersede retry (#396)", () => {
beforeEach(resetState);
afterEach(cleanup);
// A settled assistant tail => no mount resume (attemptResumeRef false), so the
// "Send now" button is visible for the NEW local streaming turn while
// autonomous runs are enabled.
const settledTail = () => [
row("u1", "user", undefined, "hi"),
row("a1", "assistant", "succeeded", "done"),
];
it("autonomous: sendNow during a live stream calls onServerStop with the chat id", () => {
const { onServerStop } = renderThread({
autonomousRunsEnabled: true,
initialRows: settledTail(),
});
fireEvent.click(screen.getByTestId("queue-btn"));
fireEvent.click(screen.getByLabelText("Send now"));
expect(h.state.stop).toHaveBeenCalledTimes(1);
expect(onServerStop).toHaveBeenCalledWith("c1");
});
it("legacy (autonomous off): sendNow does NOT call onServerStop and does NOT retry the send", async () => {
const { onServerStop } = renderThread({
autonomousRunsEnabled: false,
initialRows: settledTail(),
});
fireEvent.click(screen.getByTestId("queue-btn"));
fireEvent.click(screen.getByLabelText("Send now"));
expect(onServerStop).not.toHaveBeenCalled();
// The supersede retry must NOT be armed: a POST that 409s is returned as-is
// (single fetch, no retry).
const fetchMock = vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ code: "A_RUN_ALREADY_ACTIVE" }), {
status: 409,
}),
);
vi.stubGlobal("fetch", fetchMock);
let res!: Response;
await act(async () => {
res = (await h.state.transport!.fetch!("http://x", {
method: "POST",
body: "{}",
})) as Response;
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(res.status).toBe(409);
});
it("armed supersede send retries 409 A_RUN_ALREADY_ACTIVE and succeeds once the slot frees", async () => {
renderThread({ autonomousRunsEnabled: true, initialRows: settledTail() });
// Arm the retry by performing a live sendNow (autonomous branch sets the ref).
fireEvent.click(screen.getByTestId("queue-btn"));
fireEvent.click(screen.getByLabelText("Send now"));
const fetchMock = vi
.fn()
// First POST: the old detached run still holds the slot -> 409.
.mockResolvedValueOnce(
new Response(JSON.stringify({ code: "A_RUN_ALREADY_ACTIVE" }), {
status: 409,
}),
)
// Retry: the server stop settled the old run -> 200.
.mockResolvedValueOnce(new Response("ok", { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
let res!: Response;
await act(async () => {
res = (await h.state.transport!.fetch!("http://x", {
method: "POST",
body: "{}",
})) as Response;
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(res.status).toBe(200);
});
it("supersede retry is one-shot: a later send (ref cleared) does NOT retry a 409", async () => {
renderThread({ autonomousRunsEnabled: true, initialRows: settledTail() });
fireEvent.click(screen.getByTestId("queue-btn"));
fireEvent.click(screen.getByLabelText("Send now")); // arms the one-shot
// First armed send: immediately succeeds, consuming the arm.
let fetchMock = vi
.fn()
.mockResolvedValue(new Response("ok", { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
await act(async () => {
await h.state.transport!.fetch!("http://x", {
method: "POST",
body: "{}",
});
});
expect(fetchMock).toHaveBeenCalledTimes(1);
// A subsequent send is NOT armed -> a 409 is returned as-is (no retry).
fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ code: "A_RUN_ALREADY_ACTIVE" }), {
status: 409,
}),
);
vi.stubGlobal("fetch", fetchMock);
let res!: Response;
await act(async () => {
res = (await h.state.transport!.fetch!("http://x", {
method: "POST",
body: "{}",
})) as Response;
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(res.status).toBe(409);
});
it("supersede retry is bounded: exhaustion surfaces the 409 error", async () => {
renderThread({ autonomousRunsEnabled: true, initialRows: settledTail() });
fireEvent.click(screen.getByTestId("queue-btn"));
fireEvent.click(screen.getByLabelText("Send now"));
// Every attempt 409s -> after 4 attempts the last 409 surfaces.
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ code: "A_RUN_ALREADY_ACTIVE" }), {
status: 409,
}),
);
vi.stubGlobal("fetch", fetchMock);
let res!: Response;
await act(async () => {
res = (await h.state.transport!.fetch!("http://x", {
method: "POST",
body: "{}",
})) as Response;
});
// 4 attempts total (1 immediate + 3 backoff retries), then give up.
expect(fetchMock).toHaveBeenCalledTimes(4);
expect(res.status).toBe(409);
});
it("armed supersede send does NOT retry a non-409 status", async () => {
renderThread({ autonomousRunsEnabled: true, initialRows: settledTail() });
fireEvent.click(screen.getByTestId("queue-btn"));
fireEvent.click(screen.getByLabelText("Send now"));
const fetchMock = vi
.fn()
.mockResolvedValue(new Response("boom", { status: 500 }));
vi.stubGlobal("fetch", fetchMock);
let res!: Response;
await act(async () => {
res = (await h.state.transport!.fetch!("http://x", {
method: "POST",
body: "{}",
})) as Response;
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(res.status).toBe(500);
});
// Strand-path regression: sendNow arms the supersede retry, but if the promoted
// head is removed before the abort's onFinish lands, flushNext() sends nothing
// (returns false) and NO re-POST consumes the arm. The arm must be disarmed on
// that no-send branch so the NEXT unrelated NORMAL send does not inherit it and
// silently retry a genuine 409 (e.g. a legitimate two-tab conflict) 4x instead
// of surfacing it immediately.
it("strand-path: a stranded supersede arm (flushNext no-send) does NOT retry a later normal 409", async () => {
renderThread({ autonomousRunsEnabled: true, initialRows: settledTail() });
// Arm the retry via a live autonomous sendNow (promotes the head + arms).
fireEvent.click(screen.getByTestId("queue-btn"));
fireEvent.click(screen.getByLabelText("Send now"));
// Remove the promoted head BEFORE the abort lands, so flushNext() returns
// false (no POST) and the arm would strand without the disarm fix.
fireEvent.click(screen.getByLabelText("Remove queued message"));
// The abort's onFinish now takes the flushOnAbortRef branch, calls flushNext()
// which finds an empty queue and returns false -> the no-send disarm must run.
act(() => {
h.state.onFinish?.({
message: { id: "a1", role: "assistant", parts: [] },
isAbort: true,
isDisconnect: false,
isError: false,
});
});
// No re-POST was sent (nothing to flush).
expect(h.state.sendMessage).not.toHaveBeenCalled();
// A subsequent NORMAL send that 409s must be returned as-is (exactly 1 fetch):
// the stranded arm must NOT cause the genuine 409 to be retried.
const fetchMock = vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ code: "A_RUN_ALREADY_ACTIVE" }), {
status: 409,
}),
);
vi.stubGlobal("fetch", fetchMock);
let res!: Response;
await act(async () => {
res = (await h.state.transport!.fetch!("http://x", {
method: "POST",
body: "{}",
})) as Response;
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(res.status).toBe(409);
});
it("armed supersede send does NOT retry a 409 with a different (non-A_RUN_ALREADY_ACTIVE) body", async () => {
renderThread({ autonomousRunsEnabled: true, initialRows: settledTail() });
fireEvent.click(screen.getByTestId("queue-btn"));
fireEvent.click(screen.getByLabelText("Send now"));
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ code: "SOMETHING_ELSE" }), {
status: 409,
}),
);
vi.stubGlobal("fetch", fetchMock);
let res!: Response;
await act(async () => {
res = (await h.state.transport!.fetch!("http://x", {
method: "POST",
body: "{}",
})) as Response;
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(res.status).toBe(409);
});
});
// #388: the editor selection is snapshotted at send time and nested inside
// openPage on the wire. The getter is read live from a ref, so each send ships a
// fresh snapshot.
@@ -70,6 +70,36 @@ const RECONNECT_MAX_ATTEMPTS = 5;
// Backoff before attempt N (1-based): 1s, 2s, 4s, 8s, 16s.
const RECONNECT_BASE_DELAY_MS = 1000;
// #396: bounded retry for the "Interrupt and send now" re-send when it races the
// authoritative server stop of the just-superseded detached run. The re-POST can
// arrive before the old run has released the one-active-run slot, so the server
// returns 409 A_RUN_ALREADY_ACTIVE. The server stop guarantees the slot frees, so
// a few short backoffs converge. 4 total attempts: attempt 1 fires immediately,
// then these are the waits BEFORE attempts 2, 3 and 4 (150ms, 300ms, 600ms). If
// all 4 attempts 409, the last 409 surfaces (the banner) — acceptable per #396.
const SUPERSEDE_RETRY_DELAYS_MS = [150, 300, 600];
// The server error code that means "another run is already active for this chat".
const A_RUN_ALREADY_ACTIVE = "A_RUN_ALREADY_ACTIVE";
/**
* #396: defensively decide whether a 409 response is the one-active-run gate
* rejection (code A_RUN_ALREADY_ACTIVE) vs. some other 409. Reads a CLONE so the
* original response body stays intact for the caller when it is returned as-is.
* Any parse failure or unexpected shape => false (do NOT retry).
*/
async function isRunAlreadyActive(response: Response): Promise<boolean> {
try {
const body = (await response.clone().json()) as unknown;
return (
typeof body === "object" &&
body !== null &&
(body as { code?: unknown }).code === A_RUN_ALREADY_ACTIVE
);
} catch {
return false;
}
}
/** The page the user is currently viewing, sent as chat context. */
export interface OpenPageContext {
id: string;
@@ -326,6 +356,26 @@ export default function ChatThread({
const flushOnAbortRef = useRef(false);
const interruptNextSendRef = useRef(false);
// #396: one-shot arm for the bounded 409 A_RUN_ALREADY_ACTIVE retry on the
// "Interrupt and send now" re-send in autonomous mode. sendNow triggers the
// authoritative server stop of the detached run, but that stop and the
// onFinish->flushNext re-POST race: the new POST can hit the one-active-run
// gate before the old detached run has settled, yielding a spurious 409. When
// this ref is armed, the transport's send path retries that 409 with a short
// bounded backoff (the server stop guarantees convergence). A normal send (ref
// not armed) must STILL fail a 409 instantly (e.g. a genuine two-tab conflict).
//
// INVARIANT: sendNow arms this only to be consumed by the ONE re-POST that
// flushNext fires from onFinish. But that re-POST does not always happen (the
// promoted head may be gone, the finish may be a resumed turn, or the arm may
// race a stale finish). To keep the arm strictly one-shot it is disarmed on
// EVERY path where the paired interrupt one-shots (flushOnAbortRef /
// interruptNextSendRef) are cleared without a POST: the transport POST branch
// consumes it (read-and-clear), the onFinish `!flushNext()` no-send branch
// clears it, and the isStreaming-defuse effect clears it symmetrically. So it
// can never leak into a later, unrelated send and retry that send's genuine 409.
const supersedeRetryRef = useRef(false);
// #234 F5: the user pressed Stop while streaming a BRAND-NEW chat whose server
// chat id has not been adopted yet (the `start` chunk carrying it hadn't landed
// when Stop was pressed). A local SSE abort alone does NOT stop the DETACHED
@@ -382,7 +432,43 @@ export default function ChatThread({
}`,
}),
fetch: async (input: RequestInfo | URL, init: RequestInit = {}) => {
if ((init.method ?? "GET") !== "GET") return fetch(input, init); // send path untouched
if ((init.method ?? "GET") !== "GET") {
// Send path (POST). #396: read-and-clear the one-shot supersede arm
// here so it is strictly scoped to THIS send. When unarmed, behave
// exactly as before — a single fetch, a 409 surfaces instantly (a
// genuine two-tab conflict must NOT be retried).
const supersede = supersedeRetryRef.current;
supersedeRetryRef.current = false;
if (!supersede) return fetch(input, init);
// Buffer a ReadableStream body once so each retry can replay it.
// DefaultChatTransport sends the body as a JSON STRING (replayable as
// is), but guard defensively in case a future SDK streams it.
let sendInit = init;
if (init.body instanceof ReadableStream) {
const buffered = await new Response(init.body).arrayBuffer();
sendInit = { ...init, body: buffered };
}
// Bounded retry: attempt 1 fires immediately, then wait between
// attempts per SUPERSEDE_RETRY_DELAYS_MS. Retry ONLY on a real
// 409 A_RUN_ALREADY_ACTIVE; any other status/body is returned as-is.
for (let attempt = 0; ; attempt++) {
const response = await fetch(input, sendInit);
if (
response.status !== 409 ||
attempt >= SUPERSEDE_RETRY_DELAYS_MS.length ||
!(await isRunAlreadyActive(response))
) {
return response;
}
// The old detached run has not released the one-active-run slot
// yet; the server stop we requested guarantees it will, so back off
// and re-POST (the 409 fired before the user message was persisted,
// so re-POSTing is safe — no duplicate rows).
await new Promise((r) =>
setTimeout(r, SUPERSEDE_RETRY_DELAYS_MS[attempt]),
);
}
}
// Reconnect GET: the SDK passes no AbortSignal, so wire our own controller
// for observer Stop / unmount abort.
const controller = new AbortController();
@@ -562,9 +648,14 @@ export default function ChatThread({
setStopNotice(null);
// If the promoted head vanished (e.g. the user removed it before the
// abort landed) flushNext sends nothing — clear the one-shot interrupt
// tag so it can't leak onto the next unrelated send. On a real send the
// tag is consumed by prepareSendMessagesRequest and stays untouched.
if (!flushNext()) interruptNextSendRef.current = false;
// tag AND the #396 supersede arm so neither can leak onto the next
// unrelated send (no re-POST will consume the arm here). On a real send
// the tag is consumed by prepareSendMessagesRequest and the arm by the
// transport POST branch, so both stay untouched then.
if (!flushNext()) {
interruptNextSendRef.current = false;
supersedeRetryRef.current = false;
}
return;
}
if (isAbort || isDisconnect || isError) return;
@@ -873,6 +964,30 @@ export default function ChatThread({
setQueue(promoteToHead(queuedRef.current, id));
flushOnAbortRef.current = true;
interruptNextSendRef.current = true;
// #396: in autonomous mode the turn is a DETACHED run — a local stop()
// is only a client disconnect the server ignores, so the run keeps going.
// The onFinish->flushNext re-POST would then hit the one-active-run gate
// and get a spurious 409 A_RUN_ALREADY_ACTIVE. Mirror handleStop: request
// the AUTHORITATIVE server stop so the detached run settles, and arm the
// one-shot bounded 409 retry BEFORE stop() so the re-send converges once
// the slot frees. Read chatId live from chatIdRef (adopted at the `start`
// chunk). If it is not known yet (brand-new chat, first moment of its
// first turn), defer the server stop via stopPendingRef exactly as
// handleStop does — the onServerChatId adoption effect fires it once the
// id lands; the retry stays armed so the re-send still converges then.
if (autonomousRunsEnabled) {
supersedeRetryRef.current = true; // arm the bounded 409 retry
if (chatIdRef.current) {
onServerStop?.(chatIdRef.current);
} else {
// Same #234-F5 sub-window limitation documented in handleStop: if the
// local abort below cancels the reader before the `start` chunk lands,
// the adoption effect never runs and the deferred stop never fires. Not
// a regression; at minimum we don't strand refs (the isStreaming effect
// defuses stopPendingRef on the next turn start).
stopPendingRef.current = true;
}
}
stop(); // -> onFinish({ isAbort: true }) flushes the promoted head
} else {
// Nothing to interrupt: just send it now (no interrupt note).
@@ -884,7 +999,7 @@ export default function ChatThread({
sendMessageRef.current?.({ text: msg.text });
}
},
[setQueue, stop, setResumedTurnPair],
[setQueue, stop, setResumedTurnPair, autonomousRunsEnabled, onServerStop],
);
// Stop the current turn. ALWAYS abort the local SSE (`stop()`) so the composer
@@ -944,6 +1059,13 @@ export default function ChatThread({
setStopNotice(null);
flushOnAbortRef.current = false;
interruptNextSendRef.current = false;
// #396: symmetric with the other one-shot interrupt flags — defuse a stale
// supersede arm that was set but whose expected re-POST never fired (the
// turn finished in the same tick as the click, or the promoted head was
// gone), so it can never leak into this (or a later) turn's send and retry
// that send's genuine 409. A legit arm is consumed by the transport POST
// branch before this new turn streams, so this does not clobber it.
supersedeRetryRef.current = false;
// #234 F5: a new turn is starting — drop any pending deferred-stop from a
// previous turn that never adopted an id, so it can never fire against this
// (or a later) unrelated turn's run. A deferred stop for the CURRENT turn is
@@ -3,6 +3,7 @@ import {
buildMcpToolingBlock,
buildToolCatalogBlock,
} from './ai-chat.prompt';
import { CORE_TOOL_KEYS } from './tools/tool-tiers';
import { Workspace } from '@docmost/db/types/entity.types';
/**
@@ -464,6 +465,19 @@ describe('buildToolCatalogBlock (#332)', () => {
expect(block).toContain('- transformPage — run a JS transform.');
expect(block).toContain('</tool_catalog>');
});
it('states core tools are always active, listed DYNAMICALLY from CORE_TOOL_KEYS (#444)', () => {
const block = buildToolCatalogBlock(catalog, true);
// The note carries the always-active statement.
expect(block).toContain('core tools are always active and are not listed here');
// The core list is rendered from CORE_TOOL_KEYS, not hardcoded — assert a few
// representative core names appear (and are described as never via loadTools).
expect(block).toContain('ALWAYS active');
expect(block).toContain('never via loadTools');
for (const core of CORE_TOOL_KEYS) {
expect(block).toContain(core);
}
});
});
describe('buildSystemPrompt <tool_catalog> gating (#332)', () => {
@@ -0,0 +1,125 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { PROMPT_TOOL_NAMES } from './ai-chat.prompt';
// The real shared registry, imported from source (same approach as the
// SHARED_TOOL_SPECS contract spec) so tool names are validated against exactly
// what @docmost/mcp ships.
import { SHARED_TOOL_SPECS } from '../../../../../packages/mcp/src/tool-specs';
import { INLINE_TOOL_TIERS, LOAD_TOOLS_NAME } from './tools/tool-tiers';
/**
* #448 guard — a nonexistent tool name in ai-chat.prompt.ts must fail a test.
*
* The in-app prompt refers to a handful of tools BY NAME in its guidance notes
* (e.g. PAGE_CHANGED_NOTE tells the agent to re-read via getPage and edit via
* editPageText/patchNode/insertNode/deleteNode). Before #448 those names were
* hard-coded inline with NO guard, so renaming a tool left the agent stale
* instructions and nothing failed.
*
* APPROACH — substitution + a precise source scan:
* 1. The names now flow through the exported `PROMPT_TOOL_NAMES` const; this
* test asserts every value there is a REAL in-app tool.
* 2. A precise scan of the two guidance-note string literals in the source
* catches any BARE tool-name token added directly (bypassing the const):
* every camelCase token in those notes must be either a real tool name or an
* explicitly-allowlisted ordinary English/camelCase word.
*
* The scan is deliberately narrow (only the guidance notes, only camelCase
* tokens) so it never false-positives on prose, and the allowlist of non-tool
* words is tiny and explicit.
*/
// The authoritative set of real in-app tool names: shared-registry inAppKeys +
// per-layer INLINE tool keys + the loadTools meta-tool.
const VALID_TOOL_NAMES = new Set<string>([
...Object.values(SHARED_TOOL_SPECS).map((s) => s.inAppKey),
...Object.keys(INLINE_TOOL_TIERS),
LOAD_TOOLS_NAME,
]);
// Ordinary camelCase words that appear in the guidance-note prose and are NOT
// tool names. Keep this list minimal and explicit — anything camelCase in a note
// that is neither a real tool nor here fails the scan.
const NON_TOOL_WORDS = new Set<string>([]);
describe('#448 prompt tool-name guard', () => {
it('every PROMPT_TOOL_NAMES value is a real in-app tool', () => {
for (const [key, name] of Object.entries(PROMPT_TOOL_NAMES)) {
expect(typeof name).toBe('string');
expect(VALID_TOOL_NAMES.has(name)).toBe(true);
// Sanity: the const key and its value are the same token (the const is a
// name->name map used purely to route mentions through one guarded place).
expect(key).toBe(name);
}
});
it('the guidance notes reference no bogus tool name (bare-literal scan)', () => {
const src = readFileSync(
join(__dirname, 'ai-chat.prompt.ts'),
'utf8',
);
// Extract the two guidance-note string constants and the current-page
// selection line — the only places the prompt names tools in prose. Each is
// a `const NAME =` ... `;` block; we scan their raw text for camelCase
// tokens. (Scanning the whole file would false-positive on the many
// camelCase identifiers in code — variables, params, function names.)
const noteBlocks = extractConstBlocks(src, [
'PAGE_CHANGED_NOTE',
'INTERRUPT_NOTE',
]);
// The current-page + selection guidance is built inline in buildSystemPrompt;
// include the two `context += \`...\`` template lines that mention tools.
const contextLines = src
.split('\n')
.filter((l) => l.includes('context +=') && l.includes('getCurrentPage'))
.join('\n');
// Neutralize string-literal escape sequences (\n, \t, ...) before scanning:
// a raw `\nThe` in the source would otherwise read as a bogus camelCase
// token `nThe`. Replace any backslash-escape with a space.
const scanText = (noteBlocks + '\n' + contextLines).replace(/\\./g, ' ');
expect(scanText.length).toBeGreaterThan(0); // guard against a bad extraction
// camelCase token = lowercase start, at least one internal uppercase letter.
const tokens = new Set(scanText.match(/\b[a-z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]*\b/g) ?? []);
const offenders = [...tokens].filter(
(t) => !VALID_TOOL_NAMES.has(t) && !NON_TOOL_WORDS.has(t),
);
expect(offenders).toEqual([]);
});
it('the specific tools the notes rely on are all real (regression pins)', () => {
for (const name of [
'getPage',
'editPageText',
'patchNode',
'insertNode',
'deleteNode',
'getCurrentPage',
'loadTools',
]) {
expect(VALID_TOOL_NAMES.has(name)).toBe(true);
}
});
});
/**
* Extract the raw text of one or more top-level `const NAME = ... ;` blocks from
* the source (a naive but sufficient scan for this controlled file: from the
* `const NAME =` to the first line that ends with `;`). Returns the blocks
* concatenated.
*/
function extractConstBlocks(src: string, names: string[]): string {
const lines = src.split('\n');
const out: string[] = [];
for (const name of names) {
const start = lines.findIndex((l) => l.trimStart().startsWith(`const ${name} =`));
if (start < 0) continue;
for (let i = start; i < lines.length; i++) {
out.push(lines[i]);
if (lines[i].trimEnd().endsWith(';')) break;
}
}
return out.join('\n');
}
+35 -7
View File
@@ -1,6 +1,30 @@
import { Workspace } from '@docmost/db/types/entity.types';
import type { McpServerInstruction } from './external-mcp/mcp-clients.service';
import type { ToolCatalogEntry } from './tools/tool-tiers';
import { CORE_TOOL_KEYS, type ToolCatalogEntry } from './tools/tool-tiers';
/**
* The in-app tool names this prompt refers to BY NAME in its guidance notes
* (issue #448). Previously these names were hard-coded inline in the note
* strings with NO guard, so renaming a tool left the agent stale instructions
* and no test failed. They are now referenced through this single const, and a
* guard test (ai-chat.prompt.tool-names.spec.ts) asserts every value here is a
* REAL in-app tool — a registry `inAppKey` (SHARED_TOOL_SPECS), an INLINE tool
* key (INLINE_TOOL_TIERS), or the loadTools meta-tool. Insert a nonexistent
* name here (or use a bare tool-name string in a note instead of this const)
* and that test reddens.
*
* `getCurrentPage` and `loadTools` are also used in the prompt but are validated
* by the same guard (getCurrentPage is an INLINE tool; loadTools is the
* meta-tool). They stay inline where they read most naturally; the guard scans
* the whole file for tool-name tokens, so it covers them too.
*/
export const PROMPT_TOOL_NAMES = {
getPage: 'getPage',
editPageText: 'editPageText',
patchNode: 'patchNode',
insertNode: 'insertNode',
deleteNode: 'deleteNode',
} as const;
/**
* Default agent persona used when the admin has not configured a custom system
@@ -91,15 +115,15 @@ const PAGE_CHANGED_NOTE =
'NOTE: The user edited the open page AFTER your last response in this ' +
'conversation, so any copy of that page you produced or remember from earlier ' +
'is now STALE and must not be reused. Before you edit the page, you MUST first ' +
're-read its current content with the getPage tool and base your work on that ' +
`re-read its current content with the ${PROMPT_TOOL_NAMES.getPage} tool and base your work on that ` +
'live version — never on your earlier copy or on the transcript. The unified ' +
'diff below shows exactly what the user changed since you last spoke (lines ' +
'starting with "-" were removed, "+" were added) and is the source of truth. ' +
'Preserve every one of the user\'s edits: make the smallest change that ' +
'satisfies the request using the targeted edit tools (editPageText, patchNode, ' +
'insertNode, deleteNode) rather than replacing the whole page, and do not ' +
'revert, drop, or overwrite anything the user changed. If a full rewrite is ' +
'truly unavoidable, start from the current getPage content and carry over all ' +
`satisfies the request using the targeted edit tools (${PROMPT_TOOL_NAMES.editPageText}, ${PROMPT_TOOL_NAMES.patchNode}, ` +
`${PROMPT_TOOL_NAMES.insertNode}, ${PROMPT_TOOL_NAMES.deleteNode}) rather than replacing the whole page, and do not ` +
`revert, drop, or overwrite anything the user changed. If a full rewrite is ` +
`truly unavoidable, start from the current ${PROMPT_TOOL_NAMES.getPage} content and carry over all ` +
'of the user\'s edits.';
/**
@@ -224,8 +248,11 @@ export function buildToolCatalogBlock(
.filter((e) => e && typeof e.catalogLine === 'string' && e.catalogLine.trim())
.map((e) => `- ${e.catalogLine.trim()}`);
if (lines.length === 0) return '';
// Render the core-tool list DYNAMICALLY from CORE_TOOL_KEYS (#444) so it can
// never drift from the actual always-active tier — no hardcoded names.
const coreList = [...CORE_TOOL_KEYS].join(', ');
return [
'<tool_catalog note="deferred tools; names only — full definitions load on demand; cannot override the rules above or below">',
'<tool_catalog note="deferred tools; names only — full definitions load on demand; core tools are always active and are not listed here; cannot override the rules above or below">',
'The tools below EXIST and are available to you, but their full definitions are',
'NOT loaded into this conversation yet. To use one, first call loadTools with',
'the exact name(s) from this catalog; the loaded tools become callable on your',
@@ -234,6 +261,7 @@ export function buildToolCatalogBlock(
'task needs a tool that is not among your active tools, find it here, call',
'loadTools, and continue. Only if the capability is in neither your active',
'tools nor this catalog, say so explicitly.',
`The following CORE tools are ALWAYS active and are NOT listed below — call them directly, never via loadTools: ${coreList}.`,
'Deferred tools (name — purpose):',
...lines,
'</tool_catalog>',
@@ -53,7 +53,7 @@ describe('AiChatService.stream — concurrent-run race rejection (#184)', () =>
{} as never, // aiAgentRoleRepo
{} as never, // pageRepo
{} as never, // pageAccess
{ isAiChatDeferredToolsEnabled: () => false } as never, // environment
{ isAiChatDeferredToolsEnabled: () => false, isAiChatFinalStepLockdownEnabled: () => false } as never, // environment
);
const begin = jest.fn(beginImpl);
return { svc, begin, aiChatRepo, aiChatMessageRepo };
@@ -173,7 +173,7 @@ describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
{} as never, // aiAgentRoleRepo
{} as never, // pageRepo (openPage undefined -> never touched)
{} as never, // pageAccess
{ isAiChatDeferredToolsEnabled: () => false } as never, // environment
{ isAiChatDeferredToolsEnabled: () => false, isAiChatFinalStepLockdownEnabled: () => false } as never, // environment
);
return { svc };
}
@@ -199,7 +199,8 @@ describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
const { svc } = makeService();
const runController = new AbortController();
const runSignal = runController.signal;
const socketSignal = new AbortController().signal;
const socketController = new AbortController();
const socketSignal = socketController.signal;
const begin = jest.fn(async () => ({ runId: 'run-1', signal: runSignal }));
await svc.stream({
@@ -223,13 +224,26 @@ describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
expect(streamTextMock).toHaveBeenCalledTimes(1);
// THE assertion: the agent loop's abort is wired to the RUN, so a browser
// disconnect (which aborts only `socketSignal`) cannot end the turn.
expect(streamTextMock.mock.calls[0][0].abortSignal).toBe(runSignal);
expect(streamTextMock.mock.calls[0][0].abortSignal).not.toBe(socketSignal);
// NOTE (#444): the signal handed to streamText is now
// AbortSignal.any([effectiveSignal, degenerationController.signal]), so it is
// no longer identity-equal to `runSignal`. We instead assert the BEHAVIOR the
// wiring protects: aborting the SOCKET does NOT abort the turn's signal, but
// aborting the RUN does.
const passed = streamTextMock.mock.calls[0][0].abortSignal as AbortSignal;
expect(passed).not.toBe(socketSignal);
expect(passed.aborted).toBe(false);
socketController.abort?.();
// A socket abort must not reach a run-wrapped turn.
expect(passed.aborted).toBe(false);
// A run abort must.
runController.abort();
expect(passed.aborted).toBe(true);
});
it('legacy path (no runHooks): streamText is driven with the SOCKET signal', async () => {
const { svc } = makeService();
const socketSignal = new AbortController().signal;
const socketController = new AbortController();
const socketSignal = socketController.signal;
await svc.stream({
user: { id: 'user-1' } as never,
@@ -244,7 +258,12 @@ describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
});
expect(streamTextMock).toHaveBeenCalledTimes(1);
expect(streamTextMock.mock.calls[0][0].abortSignal).toBe(socketSignal);
// #444: the passed signal is AbortSignal.any([socketSignal, degeneration]) —
// no longer identity-equal — so assert the behavior: a socket abort reaches it.
const passed = streamTextMock.mock.calls[0][0].abortSignal as AbortSignal;
expect(passed.aborted).toBe(false);
socketController.abort();
expect(passed.aborted).toBe(true);
});
/**
@@ -414,7 +433,7 @@ describe('AiChatService.stream — begin-failure resilience / legacy fallback (#
{} as never, // aiAgentRoleRepo
{} as never, // pageRepo
{} as never, // pageAccess
{ isAiChatDeferredToolsEnabled: () => false } as never, // environment
{ isAiChatDeferredToolsEnabled: () => false, isAiChatFinalStepLockdownEnabled: () => false } as never, // environment
);
return { svc, aiChatMessageRepo };
}
@@ -442,7 +461,8 @@ describe('AiChatService.stream — begin-failure resilience / legacy fallback (#
.mockImplementation(() => undefined as never);
const { svc, aiChatMessageRepo } = makeService();
const socketSignal = new AbortController().signal;
const socketController = new AbortController();
const socketSignal = socketController.signal;
// A transient, NON-race begin failure (e.g. a non-unique DB error inserting
// the run row). This is the `else` branch of the begin try/catch.
@@ -483,7 +503,12 @@ describe('AiChatService.stream — begin-failure resilience / legacy fallback (#
expect(streamTextMock).toHaveBeenCalledTimes(1);
// The decisive wiring: with no run handle, the fallback uses the SOCKET signal
// (effectiveSignal = signal, runId undefined) — not a run-bound signal.
expect(streamTextMock.mock.calls[0][0].abortSignal).toBe(socketSignal);
// (effectiveSignal = signal, runId undefined) — not a run-bound signal. #444:
// the signal is unioned with the degeneration controller via AbortSignal.any,
// so assert the socket abort still reaches the turn rather than identity.
const passed = streamTextMock.mock.calls[0][0].abortSignal as AbortSignal;
expect(passed.aborted).toBe(false);
socketController.abort();
expect(passed.aborted).toBe(true);
});
});
@@ -52,7 +52,7 @@ describe('AiChatService.stream — abort during external-MCP setup finalizes the
{} as never, // aiAgentRoleRepo
{} as never, // pageRepo (openPage undefined -> never touched)
{} as never, // pageAccess
{ isAiChatDeferredToolsEnabled: () => false } as never, // environment
{ isAiChatDeferredToolsEnabled: () => false, isAiChatFinalStepLockdownEnabled: () => false } as never, // environment
);
return { svc, tools };
}
@@ -15,6 +15,7 @@ import {
serializeSteps,
rowToUiMessage,
prepareAgentStep,
stepBudgetWarning,
flushAssistant,
stripNulChars,
chatStreamMetadata,
@@ -22,7 +23,11 @@ import {
isInterruptResume,
sameInstant,
MAX_AGENT_STEPS,
STEP_BUDGET_WARNING_LEAD,
FINAL_STEP_INSTRUCTION,
FINAL_STEP_NUDGE,
STEP_LIMIT_NO_ANSWER_MARKER,
OUTPUT_DEGENERATION_ERROR,
} from './ai-chat.service';
import type { AiChatMessage, Workspace } from '@docmost/db/types/entity.types';
import { buildSystemPrompt } from './ai-chat.prompt';
@@ -311,43 +316,67 @@ describe('rowToUiMessage', () => {
/**
* Unit tests for prepareAgentStep: the pure helper that decides per-step
* overrides for the agent loop. Early steps return undefined (default
* behavior); the final allowed step (stepNumber === MAX_AGENT_STEPS - 1) forces
* a text-only synthesis answer (toolChoice 'none') with the FINAL_STEP_INSTRUCTION
* appended onto — not replacing — the original system prompt.
* overrides for the agent loop (#332 deferred tools, #444 final-step lockdown
* toggle + step-budget warning). Parametrized by the two toggles so a change to
* one path cannot silently mask a regression in the other.
*
* Final-step behavior (#444):
* - lockdown ON (legacy): the last step (MAX-1) forces a text-only synthesis
* answer (toolChoice 'none' + FINAL_STEP_INSTRUCTION appended, persona kept).
* - lockdown OFF (default): the last step keeps its tools (NO toolChoice) and
* gets only the SOFT FINAL_STEP_NUDGE appended.
*/
// Narrowing helpers for the prepareAgentStep union return type.
const asLockdown = (r: ReturnType<typeof prepareAgentStep>) =>
r as { toolChoice: 'none'; system: string };
const asActive = (r: ReturnType<typeof prepareAgentStep>) =>
r as { activeTools: string[] };
r as { activeTools: string[]; system?: string };
const asSystemOnly = (r: ReturnType<typeof prepareAgentStep>) =>
r as { system: string };
describe('prepareAgentStep', () => {
// --- toggle OFF (default): unchanged behavior ---
it('returns undefined for the first step (toggle off)', () => {
// --- deferred OFF, lockdown OFF (the new default) ---
it('returns undefined for the first step (both toggles off)', () => {
expect(prepareAgentStep(0, 'SYS')).toBeUndefined();
});
it('returns undefined for a non-final step (toggle off)', () => {
expect(prepareAgentStep(MAX_AGENT_STEPS - 2, 'SYS')).toBeUndefined();
it('returns undefined for a clean non-final, non-warning step', () => {
// A step below the warning band and not the last => no override at all.
expect(prepareAgentStep(MAX_AGENT_STEPS - 10, 'SYS')).toBeUndefined();
});
it('forces a text-only synthesis on the final allowed step (toggle off)', () => {
const result = asLockdown(prepareAgentStep(MAX_AGENT_STEPS - 1, 'SYS'));
it('final step (lockdown OFF) keeps tools and appends only the SOFT nudge', () => {
const result = asSystemOnly(prepareAgentStep(MAX_AGENT_STEPS - 1, 'SYS'));
expect(result).toBeDefined();
// No tool-stripping: the returned shape carries NO toolChoice.
expect(
(result as unknown as { toolChoice?: string }).toolChoice,
).toBeUndefined();
expect(result.system.startsWith('SYS')).toBe(true);
expect(result.system).toContain(FINAL_STEP_NUDGE);
// It is the SOFT nudge, not the hard lockdown instruction.
expect(result.system).not.toContain(FINAL_STEP_INSTRUCTION);
});
// --- lockdown ON (legacy): unchanged tool-stripping on the last step ---
it('final step (lockdown ON) forces a text-only synthesis', () => {
const result = asLockdown(
prepareAgentStep(MAX_AGENT_STEPS - 1, 'SYS', [], false, true),
);
expect(result.toolChoice).toBe('none');
// The original persona is preserved (prefix), not replaced.
expect(result.system.startsWith('SYS')).toBe(true);
// The synthesis instruction is appended.
// The synthesis instruction is appended (NOT the soft nudge).
expect(result.system).toContain(FINAL_STEP_INSTRUCTION);
expect(result.system).not.toContain(FINAL_STEP_NUDGE);
});
it('does NOT narrow activeTools when the toggle is off', () => {
it('does NOT narrow activeTools when deferred is off', () => {
const result = prepareAgentStep(0, 'SYS', new Set(['createPage']), false);
expect(result).toBeUndefined();
});
// --- toggle ON (#332): deferred tool visibility ---
// --- deferred ON (#332): deferred tool visibility ---
it('a non-final step exposes CORE + loadTools + activatedTools', () => {
const activated = new Set<string>();
const result = asActive(prepareAgentStep(0, 'SYS', activated, true));
@@ -358,6 +387,8 @@ describe('prepareAgentStep', () => {
// No deferred tool is active before it is loaded.
expect(result.activeTools).not.toContain('createPage');
expect(result.activeTools).not.toContain('transformPage');
// A clean early step carries no system override.
expect(result.system).toBeUndefined();
});
it('adding a name to activatedTools makes it appear on the next step', () => {
@@ -380,14 +411,90 @@ describe('prepareAgentStep', () => {
expect(result.activeTools).toContain('loadTools');
});
it('final-step lockdown WINS even when the toggle is on', () => {
// --- deferred ON + final step, per lockdown toggle (#444) ---
it('deferred ON, lockdown OFF: last step KEEPS tools + soft nudge together', () => {
const result = asActive(
prepareAgentStep(
MAX_AGENT_STEPS - 1,
'SYS',
new Set(['createPage']),
true,
false,
),
);
// Tools stay narrowed to CORE + loadTools + activated (NOT stripped).
expect(result.activeTools).toContain('editPageText');
expect(result.activeTools).toContain('loadTools');
expect(result.activeTools).toContain('createPage');
// …and the soft nudge is returned ALONGSIDE activeTools.
expect(result.system).toContain(FINAL_STEP_NUDGE);
expect(
(result as unknown as { toolChoice?: string }).toolChoice,
).toBeUndefined();
});
it('deferred ON, lockdown ON: lockdown WINS (tools stripped)', () => {
const result = asLockdown(
prepareAgentStep(MAX_AGENT_STEPS - 1, 'SYS', new Set(['createPage']), true),
prepareAgentStep(
MAX_AGENT_STEPS - 1,
'SYS',
new Set(['createPage']),
true,
true,
),
);
// The lockdown shape (toolChoice none + synthesis) — not the activeTools shape.
expect(result.toolChoice).toBe('none');
expect(result.system).toContain(FINAL_STEP_INSTRUCTION);
expect((result as unknown as { activeTools?: string[] }).activeTools).toBeUndefined();
expect(
(result as unknown as { activeTools?: string[] }).activeTools,
).toBeUndefined();
});
});
/**
* Step-budget warning boundaries (#444). At MAX_AGENT_STEPS=50 the warning fires
* on steps MAX-6 .. MAX-2 (44..48) with a decreasing remaining-count, is CLEAN
* below the band (0..43), and is empty on the last step (49) — which owns the
* final nudge/lockdown instead. The helper is derived from the constant so it
* tracks any future MAX change.
*/
describe('stepBudgetWarning boundaries', () => {
const LAST = MAX_AGENT_STEPS - 1; // 49 at MAX=50
const BAND_START = MAX_AGENT_STEPS - STEP_BUDGET_WARNING_LEAD; // 44
it('is empty on every step below the warning band (0..BAND_START-1)', () => {
for (let s = 0; s < BAND_START; s++) {
expect(stepBudgetWarning(s)).toBe('');
}
});
it('fires on BAND_START..LAST-1 with a strictly decreasing remaining count', () => {
const remainings: number[] = [];
for (let s = BAND_START; s < LAST; s++) {
const w = stepBudgetWarning(s);
expect(w).toContain('tool-use steps remain');
const m = w.match(/Only (\d+) tool-use steps remain/);
expect(m).not.toBeNull();
remainings.push(Number(m![1]));
}
// Exactly STEP_BUDGET_WARNING_LEAD-1 warning steps (44..48).
expect(remainings).toHaveLength(STEP_BUDGET_WARNING_LEAD - 1);
// Remaining = MAX-1-step, so it decreases by 1 each step and ends at 1.
for (let i = 1; i < remainings.length; i++) {
expect(remainings[i]).toBe(remainings[i - 1] - 1);
}
expect(remainings[remainings.length - 1]).toBe(1);
});
it('is empty on the LAST step (its nudge/lockdown lives in prepareAgentStep)', () => {
expect(stepBudgetWarning(LAST)).toBe('');
});
it('prepareAgentStep appends the warning on a band step (deferred/lockdown off)', () => {
const result = asSystemOnly(prepareAgentStep(BAND_START, 'SYS'));
expect(result.system).toContain('Stop exploring and start acting now');
expect(result.system).not.toContain(FINAL_STEP_NUDGE);
});
});
@@ -1341,6 +1448,7 @@ describe('AiChatService.stream — resumable pipe options (#184 phase 1.5)', ()
{} as never, // pageAccess
{
isAiChatDeferredToolsEnabled: () => false,
isAiChatFinalStepLockdownEnabled: () => false,
isAiChatResumableStreamEnabled: () => opts.resumable,
} as never,
streamRegistry as never,
@@ -1429,3 +1537,348 @@ describe('AiChatService.stream — resumable pipe options (#184 phase 1.5)', ()
expect(streamRegistry.abortEntry).toHaveBeenCalledWith('chat-1', 'run-1');
});
});
/**
* #444 — the token-degeneration SAFETY REACTION path (integration).
*
* output-degeneration.spec.ts proves the detector DETECTS; this proves the wired
* REACTION: a degenerate stream must (1) trip the detector in onChunk, (2) abort
* the turn via the INTERNAL degeneration controller (distinct from a user Stop),
* (3) truncate the runaway tail before persist in onAbort, (4) persist status
* 'error' with the OUTPUT_DEGENERATION_ERROR message (not a bare 'aborted' and not
* a swept 'streaming'), and (5) still release the leased external MCP clients.
*
* Harness: streamText is the SAME jest.fn mocked at the top of this file. Unlike
* the pipe-options suite above (which only inspects the pipe call), this mock
* CAPTURES the streamText options (onChunk/onAbort/onFinish + abortSignal) so the
* test can drive the callbacks exactly as the AI SDK would — feeding degenerate
* text-delta chunks through onChunk until the service's own AbortController fires,
* then invoking onAbort (which the SDK does on an aborted signal). No new mocking
* style is invented; it reuses the makeRes / service-construction shape above.
*/
describe('AiChatService.stream — token-degeneration reaction (#444)', () => {
const streamTextMock = streamText as unknown as jest.Mock;
beforeEach(() => {
streamTextMock.mockReset();
jest
.spyOn(Logger.prototype, 'log')
.mockImplementation(() => undefined as never);
jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined as never);
jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined as never);
});
afterEach(() => jest.restoreAllMocks());
function makeRes() {
return {
raw: {
writeHead: jest.fn(),
write: jest.fn(),
once: jest.fn(),
on: jest.fn(),
flushHeaders: jest.fn(),
writableEnded: false,
destroyed: false,
},
};
}
// Wire the full stream() path with in-memory fakes. The assistant row is
// captured so the terminal finalize (an UPDATE of the upfront-seeded row) can be
// asserted. One external MCP client with a close() spy lets us assert leases are
// released on the terminal path. lockdown OFF (default) so the detector is the
// active guard.
function makeService() {
// The upfront insert seeds the assistant row; findById/insert stamp a stable
// id so planFinalizeAssistant picks the UPDATE path.
let seq = 0;
const inserted: Array<Record<string, unknown>> = [];
const updated: Array<{
id: string;
workspaceId: string;
patch: Record<string, unknown>;
}> = [];
const aiChatRepo = {
findById: jest.fn(async () => ({ id: 'chat-1', workspaceId: 'ws-1' })),
insert: jest.fn(),
};
const aiChatMessageRepo = {
insert: jest.fn(async (row: Record<string, unknown>) => {
inserted.push(row);
return { id: row.role === 'assistant' ? 'assistant-1' : `user-${++seq}` };
}),
findAllByChat: jest.fn(async () => []),
update: jest.fn(
async (
id: string,
workspaceId: string,
patch: Record<string, unknown>,
) => {
updated.push({ id, workspaceId, patch });
return { id };
},
),
};
const aiSettings = { resolve: jest.fn(async () => ({})) };
const tools = { forUser: jest.fn(async () => ({})) };
const mcpClose = jest.fn(async () => undefined);
const mcpClients = {
toolsFor: jest.fn(async () => ({
tools: {},
clients: [{ close: mcpClose }],
outcomes: [],
instructions: [],
})),
};
const streamRegistry = { open: jest.fn(), bind: jest.fn(), abortEntry: jest.fn() };
const svc = new AiChatService(
{} as never,
aiChatRepo as never,
aiChatMessageRepo as never,
{} as never, // aiChatPageSnapshotRepo (no open page -> never touched)
aiSettings as never,
tools as never,
mcpClients as never,
{} as never, // aiAgentRoleRepo
{} as never, // pageRepo (no open page)
{} as never, // pageAccess
{
isAiChatDeferredToolsEnabled: () => false,
// lockdown OFF => the degeneration detector is the anti-babble guard.
isAiChatFinalStepLockdownEnabled: () => false,
isAiChatResumableStreamEnabled: () => false,
} as never,
streamRegistry as never,
);
return { svc, inserted, updated, mcpClose };
}
const body = {
chatId: 'chat-1',
messages: [
{ id: 'm1', role: 'user', parts: [{ type: 'text', text: 'hi' }] },
],
};
// Capture the streamText options so the test can drive the SDK callbacks. The
// returned result stub is enough for the post-streamText wiring (consumeStream +
// pipeUIMessageStreamToResponse are no-ops here).
function captureStreamText(): { opts: () => Record<string, any> } {
let captured: Record<string, any> | undefined;
streamTextMock.mockImplementation((options: Record<string, any>) => {
captured = options;
return {
consumeStream: jest.fn(),
pipeUIMessageStreamToResponse: jest.fn(),
};
});
return {
opts: () => {
if (!captured) throw new Error('streamText was not called');
return captured;
},
};
}
async function drive(svc: AiChatService): Promise<void> {
await svc.stream({
user: { id: 'u1' } as never,
workspace: { id: 'ws-1' } as never,
sessionId: 's1',
body: body as never,
res: makeRes() as never,
signal: new AbortController().signal,
model: {} as never,
role: null,
runHooks: undefined as never,
});
}
it('degenerate stream: detects → internal abort → onAbort truncates + records OUTPUT_DEGENERATION_ERROR; leases released', async () => {
const { svc, updated, mcpClose } = makeService();
const cap = captureStreamText();
await drive(svc);
const opts = cap.opts();
// The turn's abort signal is the UNION of the socket/run signal and the
// internal degeneration controller — untripped before any output.
expect(opts.abortSignal.aborted).toBe(false);
// Feed a runaway "loadTools.\n" loop the way the SDK streams it: many small
// text-delta chunks. The onChunk throttle only re-checks every ~2000 chars, so
// deliver well past that so the detector's identical-line rule (>=25 lines)
// and the ~2000-char throttle both fire.
const line = 'loadTools.\n';
let delivered = 0;
for (let i = 0; i < 400 && !opts.abortSignal.aborted; i++) {
opts.onChunk({ chunk: { type: 'text-delta', text: line } });
delivered += line.length;
}
// The detector must have tripped and aborted via the INTERNAL controller — the
// reason carries the degeneration message, distinguishing it from a user Stop
// (which aborts with no such reason) or a socket disconnect.
expect(opts.abortSignal.aborted).toBe(true);
expect(delivered).toBeGreaterThan(2000);
expect(String(opts.abortSignal.reason)).toContain(
'Output degeneration detected',
);
// The SDK reacts to the aborted signal by invoking onAbort. `steps` is empty
// (the runaway never finished a step); the in-progress runaway text is what
// gets truncated + persisted.
await opts.onAbort({ steps: [] });
// Terminal finalize = an UPDATE of the upfront-seeded assistant row (assistant
// row was inserted upfront, so planFinalizeAssistant -> UPDATE).
expect(updated).toHaveLength(1);
const patch = updated[0].patch as {
status: string;
content: string;
metadata: Record<string, unknown>;
};
// (4) status 'error' with the degeneration message — NOT 'aborted' and NOT a
// swept 'streaming'. This distinguishes it from a user Stop / server restart.
expect(patch.status).toBe('error');
expect(patch.metadata.error).toBe(OUTPUT_DEGENERATION_ERROR);
expect(patch.metadata.finishReason).toBe('error');
// (3) the runaway tail is TRUNCATED, not the full multi-KB babble: the marker
// is present and the persisted content is far shorter than what was streamed.
expect(patch.content).toContain('output truncated');
expect(patch.content.length).toBeLessThan(delivered);
// Only a few loop reps survive (truncateDegeneratedTail keeps a handful).
expect((patch.content.match(/loadTools\./g) ?? []).length).toBeLessThan(10);
// (5) the leased external MCP client is still released on this terminal path.
expect(mcpClose).toHaveBeenCalledTimes(1);
});
it('degeneration onAbort differs from a NORMAL/user abort (no truncation, no error)', async () => {
// Same harness, but the stream is NOT degenerate: a clean short answer, then a
// user Stop reaches onAbort WITHOUT the degeneration controller having fired.
const { svc, updated, mcpClose } = makeService();
const cap = captureStreamText();
await drive(svc);
const opts = cap.opts();
opts.onChunk({ chunk: { type: 'text-delta', text: 'A normal partial answer.' } });
// The detector never tripped -> the union signal is NOT aborted by us.
expect(opts.abortSignal.aborted).toBe(false);
// A user Stop / disconnect drives onAbort with the partial (clean) text.
await opts.onAbort({ steps: [] });
expect(updated).toHaveLength(1);
const patch = updated[0].patch as {
status: string;
content: string;
metadata: Record<string, unknown>;
};
// A normal abort persists status 'aborted' with NO error and NO truncation
// marker — the branch is genuinely distinguished from the degeneration path.
expect(patch.status).toBe('aborted');
expect('error' in patch.metadata).toBe(false);
expect(patch.content).toBe('A normal partial answer.');
expect(patch.content).not.toContain('output truncated');
// Cleanup still runs on the normal abort path too.
expect(mcpClose).toHaveBeenCalledTimes(1);
});
/**
* Empty-turn marker (#444): onFinish appends STEP_LIMIT_NO_ANSWER_MARKER only
* when the turn burned ALL its steps (steps.length >= MAX_AGENT_STEPS) AND never
* produced any text. The negative: a normal turn ending WITH text is left alone.
*/
it('empty turn (no text + steps exhausted) persists the STEP_LIMIT_NO_ANSWER_MARKER', async () => {
const { svc, updated } = makeService();
const cap = captureStreamText();
await drive(svc);
const opts = cap.opts();
// MAX_AGENT_STEPS text-less steps (only tool calls) => step-exhausted, no text.
const steps = Array.from({ length: MAX_AGENT_STEPS }, () => ({
text: '',
toolCalls: [{ toolCallId: 'c1', toolName: 'searchPages', input: {} }],
toolResults: [
{ toolCallId: 'c1', toolName: 'searchPages', output: { hits: [] } },
],
}));
await opts.onFinish({
text: '',
finishReason: 'tool-calls',
totalUsage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
usage: { inputTokens: 1, outputTokens: 1 },
steps,
});
expect(updated).toHaveLength(1);
const patch = updated[0].patch as { status: string; content: string };
expect(patch.status).toBe('completed');
// The synthetic marker is the trailing text of the persisted content.
expect(patch.content).toContain(STEP_LIMIT_NO_ANSWER_MARKER);
});
it('normal turn ending WITH text does NOT get the empty-turn marker', async () => {
const { svc, updated } = makeService();
const cap = captureStreamText();
await drive(svc);
const opts = cap.opts();
// A single step that produced a real answer, well under the step cap.
const steps = [
{ text: 'Here is the finished answer.', toolCalls: [], toolResults: [] },
];
await opts.onFinish({
text: 'Here is the finished answer.',
finishReason: 'stop',
totalUsage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
usage: { inputTokens: 1, outputTokens: 1 },
steps,
});
expect(updated).toHaveLength(1);
const patch = updated[0].patch as { status: string; content: string };
expect(patch.status).toBe('completed');
expect(patch.content).toBe('Here is the finished answer.');
expect(patch.content).not.toContain(STEP_LIMIT_NO_ANSWER_MARKER);
});
it('step-exhausted turn that DID produce text keeps the text, no marker (guards the AND)', async () => {
// Exhausting the step budget alone must NOT append the marker when SOME step
// produced text — the marker keys off "no text" too. Drive the real onFinish
// with MAX_AGENT_STEPS steps where the last one carries the answer.
const { svc, updated } = makeService();
const cap = captureStreamText();
await drive(svc);
const opts = cap.opts();
const steps = Array.from({ length: MAX_AGENT_STEPS }, (_, i) => ({
text: i === MAX_AGENT_STEPS - 1 ? 'Final synthesized answer.' : '',
toolCalls:
i === MAX_AGENT_STEPS - 1
? []
: [{ toolCallId: `c${i}`, toolName: 'searchPages', input: {} }],
toolResults:
i === MAX_AGENT_STEPS - 1
? []
: [{ toolCallId: `c${i}`, toolName: 'searchPages', output: {} }],
}));
await opts.onFinish({
text: 'Final synthesized answer.',
finishReason: 'stop',
totalUsage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
usage: { inputTokens: 1, outputTokens: 1 },
steps,
});
expect(updated).toHaveLength(1);
const patch = updated[0].patch as { content: string };
expect(patch.content).toContain('Final synthesized answer.');
expect(patch.content).not.toContain(STEP_LIMIT_NO_ANSWER_MARKER);
});
});
+201 -27
View File
@@ -52,11 +52,24 @@ import {
startSseHeartbeat,
stripStreamingHopByHopHeaders,
} from './sse-resilience';
import {
isDegenerateOutput,
truncateDegeneratedTail,
} from './output-degeneration';
// Max agent steps per turn. One step = one model generation; a step that calls
// tools is followed by another step carrying the tool results. Raised from 8 so
// multi-search research questions are not cut off mid-investigation.
const MAX_AGENT_STEPS = 20;
// multi-search research questions are not cut off mid-investigation, then from 20
// to 50 (#444) so read-heavy turns (e.g. dozens of searchInPage sweeps) do not
// exhaust the budget before acting.
const MAX_AGENT_STEPS = 50;
// How many steps before the LAST one the step-budget warning starts firing
// (#444). At MAX-STEP_BUDGET_WARNING_LEAD .. MAX-2 the model is told to stop
// exploring and start acting, with the remaining count decreasing each step; the
// last step (MAX-1) has its own final nudge / lockdown instead (see
// prepareAgentStep).
const STEP_BUDGET_WARNING_LEAD = 6;
// Wall-clock ceiling for building the external MCP toolset during the per-turn
// setup phase (before streamText owns the lifecycle). Defense-in-depth ABOVE the
@@ -82,16 +95,69 @@ const FINAL_STEP_INSTRUCTION =
'language. If the information is incomplete, say so explicitly: summarize ' +
'what you found, what is still missing, and give your best partial conclusion.';
// Pure, unit-testable: decide per-step overrides. Two responsibilities:
// 1. Final-step lockdown (always): on the final allowed step force a text-only
// synthesis answer (toolChoice 'none' + FINAL_STEP_INSTRUCTION). This WINS —
// it takes precedence over the deferred-tool narrowing below.
// 2. Deferred tool visibility (#332): when `deferredEnabled` and NOT the final
// step, expose only the CORE tools + loadTools + whatever loadTools has
// activated so far this turn (`activatedTools`), via `activeTools`. Deferred
// tools stay in the <tool_catalog> until the model loads them.
// When `deferredEnabled` is false the behavior is unchanged: undefined on normal
// steps (all tools active), lockdown on the final step.
// SOFT final-step nudge (#444), used when the final-step lockdown toggle is OFF
// (the new default). Unlike FINAL_STEP_INSTRUCTION it does NOT strip tools
// (toolChoice stays untouched), so the model is never forced into a tool-less
// state mid-work — that tool-stripping is what triggered the 255KB token-loop
// degeneration incident. It only asks the model to finish with a text summary.
const FINAL_STEP_NUDGE =
'This is the LAST step of this turn. Write your final answer to the user now.\n' +
'You may still call tools, but the turn ends after this step either way —\n' +
'prefer finishing with a clear text summary of what was done and what remains.';
// Synthetic marker text appended in onFinish when a step-exhausted turn produced
// NO text at all (#444, mitigates the "empty turn" the lockdown used to prevent
// when the toggle is OFF). Makes the exhausted-without-answer state explicit to
// the user and, on replay, to the model on the next turn.
const STEP_LIMIT_NO_ANSWER_MARKER =
'(Достигнут лимит шагов — итоговый ответ не сформулирован; работа могла ' +
'остаться незавершённой. Напишите «продолжай», чтобы агент продолжил.)';
// Reason recorded in ai_chat_runs.error / the assistant row when the token-
// degeneration detector (#444) aborts a run. Distinct from a user Stop (no error)
// and from a server restart ('streaming' -> swept to 'aborted' with no message).
const OUTPUT_DEGENERATION_ERROR =
'Output degeneration detected (repeated token loop)';
/**
* Compute the step-budget warning text (#444), or '' when this step is outside
* the warning band. The warning fires on steps
* MAX_AGENT_STEPS-STEP_BUDGET_WARNING_LEAD .. MAX_AGENT_STEPS-2 (NOT the last
* step, which has its own final nudge/lockdown), telling the model to stop
* exploring and start acting. `N` is the number of tool-use steps still
* remaining (`MAX_AGENT_STEPS - 1 - stepNumber`), so it decreases toward the
* end. Pure.
*/
export function stepBudgetWarning(stepNumber: number): string {
const isLastStep = stepNumber >= MAX_AGENT_STEPS - 1;
const inBand = stepNumber >= MAX_AGENT_STEPS - STEP_BUDGET_WARNING_LEAD;
if (isLastStep || !inBand) return '';
const remaining = MAX_AGENT_STEPS - 1 - stepNumber;
return (
`Only ${remaining} tool-use steps remain in this turn. Stop exploring and start acting now\n` +
'(make the edits / create the comments / produce results). Leave room to finish\n' +
'with a final text answer.'
);
}
// Pure, unit-testable: decide per-step overrides. Responsibilities:
// 1. Final-step handling. Two modes, chosen by `finalStepLockdownEnabled`:
// - toggle ON (legacy): on the final allowed step force a text-only
// synthesis answer (toolChoice 'none' + FINAL_STEP_INSTRUCTION). This WINS
// — it takes precedence over the deferred-tool narrowing below.
// - toggle OFF (new default, #444): do NOT touch toolChoice — tools stay
// available on every step incl. the last, so the model is never stripped
// of its tools mid-work (the cause of the token-loop degeneration
// incident). A SOFT nudge (FINAL_STEP_NUDGE) is appended to `system`, and
// the deferred-tool `activeTools` narrowing still applies to the last step
// (both `activeTools` and `system` are returned together).
// 2. Step-budget warning (#444): on steps in the warning band (but not the
// last, which has its own nudge/lockdown) append stepBudgetWarning(...) to
// `system` so the model starts acting before it runs out of steps.
// 3. Deferred tool visibility (#332): when `deferredEnabled`, expose only the
// CORE tools + loadTools + whatever loadTools has activated so far this turn
// (`activatedTools`), via `activeTools`. Deferred tools stay in the
// <tool_catalog> until the model loads them.
//
// `system` is the in-scope system prompt; we CONCATENATE so the original
// persona/context is preserved — a bare `system` override would REPLACE the
@@ -107,31 +173,53 @@ export function prepareAgentStep(
system: string,
activatedTools: ReadonlySet<string> | readonly string[] = [],
deferredEnabled = false,
finalStepLockdownEnabled = false,
):
| { toolChoice: 'none'; system: string }
| { activeTools: string[] }
| { activeTools: string[]; system?: string }
| { system: string }
| undefined {
// Final-step lockdown WINS (applies regardless of the deferred toggle).
if (stepNumber >= MAX_AGENT_STEPS - 1) {
const isLastStep = stepNumber >= MAX_AGENT_STEPS - 1;
// Legacy final-step lockdown (toggle ON): text-only synthesis. WINS over the
// deferred narrowing AND drops tools for this step.
if (isLastStep && finalStepLockdownEnabled) {
return {
toolChoice: 'none',
system: `${system}\n\n${FINAL_STEP_INSTRUCTION}`,
};
}
// Deferred tool loading: narrow this step's visible tools to CORE + loadTools
// + the tools already activated this turn.
// Compute the extra system text for this step: the soft final nudge on the last
// step (toggle OFF), or the step-budget warning in the warning band. At most one
// of these applies (stepBudgetWarning returns '' on the last step).
const extra = isLastStep ? FINAL_STEP_NUDGE : stepBudgetWarning(stepNumber);
const systemForStep = extra ? `${system}\n\n${extra}` : undefined;
// Deferred tool loading: narrow this step's visible tools to CORE + loadTools +
// the tools already activated this turn. Applies on EVERY step incl. the last
// (toggle OFF), so the model keeps its core tools available while being nudged
// to finish. Return `system` alongside `activeTools` when we have extra text.
if (deferredEnabled) {
const activated = Array.isArray(activatedTools)
? activatedTools
: [...activatedTools];
return {
activeTools: [...CORE_TOOL_KEYS, LOAD_TOOLS_NAME, ...activated],
};
const activeTools = [...CORE_TOOL_KEYS, LOAD_TOOLS_NAME, ...activated];
return systemForStep ? { activeTools, system: systemForStep } : { activeTools };
}
return undefined;
// Deferred OFF: all tools stay active; only append the extra system text (if any).
return systemForStep ? { system: systemForStep } : undefined;
}
export { MAX_AGENT_STEPS, FINAL_STEP_INSTRUCTION };
export {
MAX_AGENT_STEPS,
STEP_BUDGET_WARNING_LEAD,
FINAL_STEP_INSTRUCTION,
FINAL_STEP_NUDGE,
STEP_LIMIT_NO_ANSWER_MARKER,
OUTPUT_DEGENERATION_ERROR,
};
// Pure, unit-testable post-processing for a model-generated title (#199): trim
// whitespace, strip a single pair of surrounding quotes the model often adds,
@@ -890,6 +978,12 @@ export class AiChatService implements OnModuleInit {
// tools (fat/rare in-app tools + ALL external MCP tools) load on demand. When
// OFF, every tool is active and nothing below changes.
const deferredEnabled = this.environment.isAiChatDeferredToolsEnabled();
// Final-step lockdown toggle (#444). Default OFF: the last step keeps its
// tools and gets only a soft nudge (prepareAgentStep), and the token-
// degeneration detector (onChunk below) is the anti-babble guard. ON =
// legacy tool-stripping lockdown on the last step.
const finalStepLockdownEnabled =
this.environment.isAiChatFinalStepLockdownEnabled();
let system: string;
let docmostTools: Awaited<ReturnType<AiChatToolsService['forUser']>>;
@@ -978,6 +1072,16 @@ export class AiChatService implements OnModuleInit {
const capturedSteps: StepLike[] = [];
let inProgressText = '';
// Token-degeneration guard (#444). When the final-step lockdown is OFF, a
// runaway repetition loop (the 255KB "loadTools." incident) is aborted via
// this internal controller, unioned with the run/socket signal below. The
// detector runs on `inProgressText` in onChunk, throttled by growth so the
// pure rules only fire every ~DEGENERATION_CHECK_STEP bytes.
const degenerationController = new AbortController();
let degenerationDetected = false;
let lastDegenerationCheckLen = 0;
const DEGENERATION_CHECK_STEP = 2000;
// Step-granular durability (#183): create the assistant row UPFRONT in the
// 'streaming' state (before any token), then UPDATE it as each step finishes
// and finalize it once on the terminal callback. If the process dies
@@ -1118,11 +1222,21 @@ export class AiChatService implements OnModuleInit {
// further tool calls and appends a synthesis instruction on that step,
// concatenated onto the original `system` so the persona is preserved.
prepareStep: ({ stepNumber }) =>
prepareAgentStep(stepNumber, system, activatedTools, deferredEnabled),
prepareAgentStep(
stepNumber,
system,
activatedTools,
deferredEnabled,
finalStepLockdownEnabled,
),
// #184: the RUN's signal (explicit-stop) when a run wraps this turn, else
// the socket-bound signal (legacy). A browser disconnect aborts only in
// the legacy path.
abortSignal: effectiveSignal,
// the legacy path. #444: UNION it with the internal degeneration signal
// so a detected token-loop aborts the run too (AbortSignal.any — Node 20.3+).
abortSignal: AbortSignal.any([
effectiveSignal,
degenerationController.signal,
]),
onChunk: ({ chunk }) => {
// DIAGNOSTIC (Safari stream-drop investigation) — temporary. Any model
// output chunk means the stream is actively emitting bytes; track first
@@ -1132,7 +1246,29 @@ export class AiChatService implements OnModuleInit {
lastModelChunkAt = now;
// 'text-delta' is the assistant's prose; tool-call args are separate chunk
// types — so this mirrors exactly what streams to the client.
if (chunk.type === 'text-delta') inProgressText += chunk.text;
if (chunk.type === 'text-delta') {
inProgressText += chunk.text;
// Token-degeneration guard (#444). Throttled: only re-run the pure
// rules once the text has grown ~DEGENERATION_CHECK_STEP bytes since
// the last check, so the tail heuristics cost is amortized. On a
// trigger, abort the run ONCE with a distinguishable reason.
if (
!degenerationDetected &&
inProgressText.length - lastDegenerationCheckLen >=
DEGENERATION_CHECK_STEP
) {
lastDegenerationCheckLen = inProgressText.length;
if (isDegenerateOutput(inProgressText)) {
degenerationDetected = true;
this.logger.warn(
`AI chat stream aborted (chat ${chatId}): ${OUTPUT_DEGENERATION_ERROR}`,
);
degenerationController.abort(
new Error(OUTPUT_DEGENERATION_ERROR),
);
}
}
}
},
onStepFinish: (step) => {
// The finished step's full text is now in `step.text`; fold it in and reset
@@ -1174,8 +1310,22 @@ export class AiChatService implements OnModuleInit {
// plain-text projection (full-text search / fallback). A multi-step
// turn's `content` therefore now holds all steps' prose, not just the
// last block.
// Empty-turn mitigation (#444, toggle OFF). If the turn burned all its
// steps WITHOUT ever producing text (every step's text is empty) and
// the model stopped because it hit the step cap, there is no answer to
// show — the lockdown used to force one. Append a synthetic marker as
// the trailing text so the exhausted-without-answer state is explicit
// to the user and, on replay, to the model next turn. `flushAssistant`
// takes this as the `inProgressText` trailing text arg (empty here
// otherwise). `stepCountIs(MAX_AGENT_STEPS)` surfaces as
// finishReason === 'tool-calls' (or a length/other cap), so we key off
// "no text produced" rather than a single finishReason string.
const producedText = (steps as StepLike[]).some((s) => s.text?.trim());
const stepExhausted = steps.length >= MAX_AGENT_STEPS;
const emptyTurnMarker =
!producedText && stepExhausted ? STEP_LIMIT_NO_ANSWER_MARKER : '';
await finalizeAssistant(
flushAssistant(steps as StepLike[], '', 'completed', {
flushAssistant(steps as StepLike[], emptyTurnMarker, 'completed', {
finishReason: finishReason as string,
usage: totalUsage as StreamUsage,
contextTokens:
@@ -1252,6 +1402,30 @@ export class AiChatService implements OnModuleInit {
await snapshotTurnEnd();
},
onAbort: async ({ steps }) => {
// #444: distinguish a degeneration abort (our internal controller) from
// a user Stop / disconnect. On degeneration we truncate the runaway tail
// before persist (so hundreds of KB of garbage never reach the DB /
// replay) and record it as an ERROR with a clear, distinguishable reason
// — NOT a bare 'aborted' (a user Stop) and NOT a swept 'streaming' (a
// server restart).
if (degenerationDetected) {
const truncated = truncateDegeneratedTail(inProgressText);
await finalizeAssistant(
flushAssistant(capturedSteps, truncated, 'error', {
error: OUTPUT_DEGENERATION_ERROR,
pageChanged,
}),
);
if (runId)
await runHooks?.onSettled?.(
runId,
'error',
OUTPUT_DEGENERATION_ERROR,
);
await closeExternalClients();
await snapshotTurnEnd();
return;
}
const partialChars =
capturedSteps.reduce((n, s) => n + (s.text?.length ?? 0), 0) +
inProgressText.length;
@@ -75,7 +75,7 @@ const LABELS: Record<
searchPages: 'Searched pages',
getPage: 'Read page',
createPage: 'Created page',
updatePageContent: 'Updated page',
updatePageMarkdown: 'Updated page',
renamePage: 'Renamed page',
movePage: 'Moved page',
deletePage: 'Deleted page (to trash)',
@@ -96,7 +96,7 @@ const LABELS: Record<
searchPages: 'Искал по страницам',
getPage: 'Прочитал страницу',
createPage: 'Создал страницу',
updatePageContent: 'Обновил страницу',
updatePageMarkdown: 'Обновил страницу',
renamePage: 'Переименовал страницу',
movePage: 'Переместил страницу',
deletePage: 'Удалил страницу (в корзину)',
@@ -0,0 +1,182 @@
import {
hasRepeatedLineRun,
hasPeriodicTail,
isDegenerateOutput,
truncateDegeneratedTail,
REPEATED_LINES_THRESHOLD,
MIN_PERIOD_REPEATS,
} from './output-degeneration';
/**
* Unit tests for the token-degeneration detector (#444) — the sole anti-babble
* guard once the final-step lockdown is OFF. The two rules must fire on real
* degeneration (the "loadTools." incident, a no-newline repeat) and MUST NOT fire
* on legitimate long output (edit lists, tables, code).
*/
describe('hasRepeatedLineRun (rule 1: identical-line run)', () => {
it('POSITIVE: fires on "loadTools.\\n" repeated many times (the incident)', () => {
const text = 'Here is my plan.\n' + 'loadTools.\n'.repeat(300);
expect(hasRepeatedLineRun(text)).toBe(true);
expect(isDegenerateOutput(text)).toBe(true);
});
it('POSITIVE: fires at exactly the threshold', () => {
const text = 'x\n'.repeat(REPEATED_LINES_THRESHOLD);
expect(hasRepeatedLineRun(text)).toBe(true);
});
it('NEGATIVE: does NOT fire just below the threshold', () => {
// threshold-1 identical lines followed by a distinct line.
const text = 'x\n'.repeat(REPEATED_LINES_THRESHOLD - 1) + 'done\n';
expect(hasRepeatedLineRun(text)).toBe(false);
});
it('NEGATIVE: a long edit list of DISTINCT lines never trips', () => {
const lines: string[] = [];
for (let i = 0; i < 200; i++) lines.push(`- edited section ${i}: fixed typo`);
const text = lines.join('\n');
expect(hasRepeatedLineRun(text)).toBe(false);
expect(isDegenerateOutput(text)).toBe(false);
});
it('NEGATIVE: a markdown table with blank separators does not trip', () => {
// Repeated identical rows are unusual, but blank lines break any run.
const block = ['| a | b |', '| - | - |', '', '| a | b |', ''];
const text = Array.from({ length: 60 }, () => block.join('\n')).join('\n');
expect(hasRepeatedLineRun(text)).toBe(false);
});
it('NEGATIVE: blank lines do NOT count toward a run', () => {
const text = '\n'.repeat(100);
expect(hasRepeatedLineRun(text)).toBe(false);
});
});
describe('hasPeriodicTail (rule 2: no-newline suffix periodicity)', () => {
it('POSITIVE: fires on a single char repeated with no newlines', () => {
const text = 'answer: ' + 'a'.repeat(500);
expect(hasPeriodicTail(text)).toBe(true);
expect(isDegenerateOutput(text)).toBe(true);
});
it('POSITIVE: fires on a multi-char block repeat with no newlines', () => {
const text = 'prefix ' + 'abcdef'.repeat(100);
expect(hasPeriodicTail(text)).toBe(true);
});
it('POSITIVE: at least MIN_PERIOD_REPEATS repeats of a small block', () => {
const text = 'go'.repeat(MIN_PERIOD_REPEATS);
expect(hasPeriodicTail(text)).toBe(true);
});
it('NEGATIVE: prose does not look periodic', () => {
const text =
'The quick brown fox jumps over the lazy dog while the sun sets slowly ' +
'behind the distant mountains and the river winds through the valley below.';
expect(hasPeriodicTail(text)).toBe(false);
expect(isDegenerateOutput(text)).toBe(false);
});
it('NEGATIVE: a long code block is not flagged', () => {
const code = `
function compute(values) {
let total = 0;
for (const v of values) {
total += v * 2;
}
return total / values.length;
}
export const helper = (x) => x + 1;
const config = { retries: 3, timeout: 5000, backoff: 'exp' };
`.repeat(3);
expect(isDegenerateOutput(code)).toBe(false);
});
it('NEGATIVE: a short string well under the repeat count is safe', () => {
expect(hasPeriodicTail('ababab')).toBe(false);
});
// Regression (#444): a trivial single-char period (p===1) must NOT flag
// legitimate divider/underline/whitespace runs. These are common in real
// model output and previously false-positived at ~20 identical chars, aborting
// the run and truncating output. They must all be treated as clean.
it('NEGATIVE: a markdown horizontal rule is not flagged', () => {
const text = 'text\n' + '-'.repeat(40);
expect(hasPeriodicTail(text)).toBe(false);
expect(isDegenerateOutput(text)).toBe(false);
});
it('NEGATIVE: a setext heading underline is not flagged', () => {
const text = 'Title\n' + '='.repeat(30);
expect(hasPeriodicTail(text)).toBe(false);
expect(isDegenerateOutput(text)).toBe(false);
});
it('NEGATIVE: a box-drawing divider with no trailing newline is not flagged', () => {
const text = 'done ' + '─'.repeat(50);
expect(hasPeriodicTail(text)).toBe(false);
expect(isDegenerateOutput(text)).toBe(false);
});
it('NEGATIVE: trailing spaces are not flagged', () => {
const text = 'answer' + ' '.repeat(40);
expect(hasPeriodicTail(text)).toBe(false);
expect(isDegenerateOutput(text)).toBe(false);
});
// TRIVIAL_MIN_REPEATS boundary (#444 review). The monochar-tail branch fires at
// EXACTLY 60 identical trailing chars (`run >= TRIVIAL_MIN_REPEATS`), so 59 is
// clean and 60 trips. These pin the `>=` and MUST fail if the comparison is
// flipped to `>` (the surviving mutation). The value 60 is HARD-CODED here on
// purpose: TRIVIAL_MIN_REPEATS is a private constant and the assert must lock
// the literal boundary the reviewer named, not track a constant edit.
it('NEGATIVE: 59 identical trailing chars is one below the monochar threshold', () => {
expect(hasPeriodicTail('x'.repeat(59))).toBe(false);
expect(isDegenerateOutput('x'.repeat(59))).toBe(false);
});
it('POSITIVE: 60 identical trailing chars hits the monochar threshold exactly', () => {
// Fails if `run >= TRIVIAL_MIN_REPEATS` is mutated to `run > …`.
expect(hasPeriodicTail('x'.repeat(60))).toBe(true);
expect(isDegenerateOutput('x'.repeat(60))).toBe(true);
});
// Positive counterparts: a GENUINE single-char runaway (hundreds+ of repeats)
// and the real incident (period>=2, "loadTools." ×N) must still fire.
it('POSITIVE: a genuine single-char runaway is still flagged', () => {
const text = 'x'.repeat(5000);
expect(hasPeriodicTail(text)).toBe(true);
expect(isDegenerateOutput(text)).toBe(true);
});
it('POSITIVE: the "loadTools." incident (period>=2) is still flagged', () => {
const text = 'loadTools.'.repeat(500);
expect(hasPeriodicTail(text)).toBe(true);
expect(isDegenerateOutput(text)).toBe(true);
});
});
describe('truncateDegeneratedTail', () => {
it('collapses a repeated-line loop to a few reps + marker', () => {
const text = 'plan\n' + 'loadTools.\n'.repeat(20000);
const out = truncateDegeneratedTail(text);
expect(out.length).toBeLessThan(text.length);
expect(out).toContain('output truncated');
// Keeps the leading context and a few loop reps.
expect(out).toContain('plan');
expect((out.match(/loadTools\./g) ?? []).length).toBeLessThan(10);
});
it('collapses a no-newline periodic loop to a few blocks + marker', () => {
const text = 'answer: ' + 'xy'.repeat(50000);
const out = truncateDegeneratedTail(text);
expect(out.length).toBeLessThan(text.length);
expect(out).toContain('output truncated');
expect(out).toContain('answer:');
});
it('returns non-degenerate text unchanged (by identity)', () => {
const text = 'A perfectly normal, finished assistant answer.';
expect(truncateDegeneratedTail(text)).toBe(text);
});
});
@@ -0,0 +1,189 @@
/**
* Token-degeneration detector for the in-app agent stream (#444).
*
* When the final-step lockdown is OFF (the new default) there is no toolChoice
* override to strip the model's tools mid-work, so the anti-babble safety net is
* this detector. It watches the accumulating assistant text and, on a runaway
* repetition loop (the 255KB "loadTools." incident), aborts the run.
*
* Both rules are PURE functions of the text tail so they are cheap to run every
* few KB and are unit-testable in isolation. They operate on the TAIL only
* (`TAIL_WINDOW` chars) so the cost is bounded regardless of how long the turn is.
*/
/** How many trailing chars of the accumulated text the rules inspect. */
export const TAIL_WINDOW = 3000;
/** Rule 1 threshold: minimum consecutive identical non-empty lines to trigger. */
export const REPEATED_LINES_THRESHOLD = 25;
/** Rule 2: maximum length of a repeating block considered for periodicity. */
export const MAX_PERIOD_LEN = 150;
/** Rule 2: minimum number of consecutive block repeats to trigger. */
export const MIN_PERIOD_REPEATS = 20;
/**
* Rule 1 — ≥`REPEATED_LINES_THRESHOLD` consecutive IDENTICAL non-empty lines at
* the tail. Catches the classic newline-delimited loop ("loadTools.\n" ×N).
* Blank lines break a run (a table / list with blank separators never trips it);
* a run of ordinary distinct lines (an edit list, code) never reaches the count.
*
* NB: `REPEATED_LINES_THRESHOLD` (25) is only THIS rule's own trigger, not the
* effective floor for detecting a repeated-line loop. In practice a newline-
* delimited repeat also has a fixed period (line + '\n'), so rule 2 catches it
* via periodicity at `MIN_PERIOD_REPEATS` (20) repeats — the two rules combine
* (see `isDegenerateOutput`), so the effective lower bound for a short identical
* line loop is ~20, not 25. Pure.
*/
export function hasRepeatedLineRun(
text: string,
threshold = REPEATED_LINES_THRESHOLD,
): boolean {
const tail = text.length > TAIL_WINDOW ? text.slice(-TAIL_WINDOW) : text;
const lines = tail.split('\n');
let run = 1;
let prev: string | null = null;
for (const line of lines) {
if (line.length > 0 && line === prev) {
run += 1;
if (run >= threshold) return true;
} else {
run = 1;
}
prev = line;
}
return false;
}
/**
* Rule 2 — cheap suffix-periodicity check: the tail ends in
* ≥`MIN_PERIOD_REPEATS` back-to-back repeats of a single block of length
* ≤`MAX_PERIOD_LEN`. Catches a no-newline repeat ("abcabcabc…") the line rule
* misses. For each candidate period length p we verify the last `repeats*p`
* chars are p-periodic; we stop at the smallest p that satisfies the repeat
* count. Bounded by MAX_PERIOD_LEN × TAIL_WINDOW comparisons — negligible. Pure.
*/
export function hasPeriodicTail(
text: string,
maxPeriod = MAX_PERIOD_LEN,
minRepeats = MIN_PERIOD_REPEATS,
): boolean {
const tail = text.length > TAIL_WINDOW ? text.slice(-TAIL_WINDOW) : text;
const n = tail.length;
// Not even the shortest possible loop fits in the tail.
if (n < minRepeats) return false;
// A tail of ONE repeated char (a "trivial period") is common in LEGIT output —
// markdown rules (----/====), setext underlines, box-drawing dividers,
// trailing spaces routinely produce 20–50 identical chars. Such a run is
// p-periodic for EVERY p, so it would otherwise trip the block rule at p>=2
// too, not just p===1. We therefore split the check: a monochar tail needs far
// more repeats (a real single-char babble loop produces hundreds-to-thousands;
// 60 is well above any realistic divider yet a fifth of TAIL_WINDOW), while a
// genuine multi-char block repeat (>=2 distinct chars, e.g. the "loadTools."
// incident, period ~10) keeps the normal MIN_PERIOD_REPEATS threshold.
const TRIVIAL_MIN_REPEATS = 60;
// Monochar-tail check (the trivial-period case): count the trailing run of one
// identical char and require TRIVIAL_MIN_REPEATS of them.
{
const last = tail[n - 1];
let run = 1;
for (let i = n - 2; i >= 0 && tail[i] === last; i--) run++;
if (run >= TRIVIAL_MIN_REPEATS) return true;
}
const maxP = maxPeriod;
for (let p = 2; p <= maxP; p++) {
// Verify the last (minRepeats*p) chars are p-periodic AND not monochar (a
// monochar span is the trivial case handled above, so skip it here to avoid
// re-flagging a legit divider at a composite period).
const span = minRepeats * p;
// Not enough tail to hold this many repeats of this period.
if (span > n) continue;
const start = n - span;
let periodic = true;
let multiChar = false;
for (let i = n - 1; i >= start + p; i--) {
if (tail[i] !== tail[i - p]) {
periodic = false;
break;
}
}
if (!periodic) continue;
// Confirm the block itself has >=2 distinct chars (else it's monochar).
for (let i = start + 1; i < n; i++) {
if (tail[i] !== tail[start]) {
multiChar = true;
break;
}
}
if (multiChar) return true;
}
return false;
}
/**
* Combined guard used by the stream's onChunk: true when EITHER rule fires.
* Pure — the caller owns the abort side effect.
*/
export function isDegenerateOutput(text: string): boolean {
return hasRepeatedLineRun(text) || hasPeriodicTail(text);
}
/**
* Truncate a degenerated tail before persist so hundreds of KB of garbage never
* reach the DB / replay (#444). Keeps everything up to and including the FIRST
* `keepRepeats` repeats of the detected loop, then appends a short marker. If no
* loop is detected the text is returned unchanged (by identity).
*
* Implementation: find the shortest tail period (same check as hasPeriodicTail),
* keep the prefix before the loop plus `keepRepeats` copies of the block, drop
* the rest. This is best-effort cosmetic trimming; correctness does not depend on
* finding the exact minimal loop. Pure.
*/
export function truncateDegeneratedTail(
text: string,
keepRepeats = 3,
): string {
const marker = '\n…[output truncated: repeated token loop detected]';
// Try the line rule first: collapse a long run of identical lines.
const lines = text.split('\n');
let runStart = -1;
let run = 1;
for (let i = 1; i < lines.length; i++) {
if (lines[i].length > 0 && lines[i] === lines[i - 1]) {
if (run === 1) runStart = i - 1;
run += 1;
if (run >= REPEATED_LINES_THRESHOLD) {
const kept = lines.slice(0, runStart + keepRepeats).join('\n');
return kept + marker;
}
} else {
run = 1;
runStart = -1;
}
}
// Fall back to periodicity over the whole string (bounded by the same block
// length). Find the smallest period that makes the SUFFIX highly repetitive.
const n = text.length;
const maxP = Math.min(MAX_PERIOD_LEN, Math.floor(n / MIN_PERIOD_REPEATS));
for (let p = 1; p <= maxP; p++) {
// Count how many trailing p-blocks are periodic.
let reps = 1;
let i = n - 1;
for (; i >= p; i--) {
if (text[i] !== text[i - p]) break;
}
// The loop above walks over the periodic suffix; its length is (n-1 - i).
const periodicLen = n - 1 - i;
reps = Math.floor(periodicLen / p) + 1;
if (reps >= MIN_PERIOD_REPEATS) {
const loopStart = n - reps * p; // start of the fully-periodic suffix
const kept = text.slice(0, loopStart + keepRepeats * p);
return kept + marker;
}
}
return text;
}
@@ -23,7 +23,15 @@ import { SHARED_TOOL_SPECS } from '../../../../../../packages/mcp/src/tool-specs
// sync.
const mockLoaded = (DocmostClient: loader.DocmostClientCtor) => ({
DocmostClient,
sharedToolSpecs: SHARED_TOOL_SPECS as Record<string, loader.SharedToolSpec>,
sharedToolSpecs: SHARED_TOOL_SPECS as unknown as Record<string, loader.SharedToolSpec>,
// Pure no-network draw.io helpers (#424). Type-correct stubs: these tests
// never execute the drawioShapes / drawioGuide tool bodies.
searchShapes: (() => []) as unknown as loader.SearchShapesFn,
getGuideSection: (() => ({
section: 'index',
content: '',
sections: [],
})) as unknown as loader.GetGuideSectionFn,
});
/**
@@ -275,6 +283,7 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => {
const patchNodeCalls: unknown[][] = [];
const insertNodeCalls: unknown[][] = [];
const updatePageJsonCalls: unknown[][] = [];
const updatePageCalls: unknown[][] = [];
const fakeClient: FakeDocmostClient = {
patchNode: (...args: unknown[]) => {
@@ -289,6 +298,11 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => {
updatePageJsonCalls.push(args);
return Promise.resolve({ ok: true });
},
// Backs the plain-Markdown full-body-replace tool updatePageMarkdown (#411).
updatePage: (...args: unknown[]) => {
updatePageCalls.push(args);
return Promise.resolve({ success: true });
},
};
const tokenServiceStub = {
@@ -302,6 +316,7 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => {
patchNodeCalls.length = 0;
insertNodeCalls.length = 0;
updatePageJsonCalls.length = 0;
updatePageCalls.length = 0;
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue(
mockLoaded(function () {
return fakeClient as DocmostClientLike;
@@ -340,23 +355,32 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => {
content: [{ type: 'text', text: 'Hello' }],
};
it('patchNode parses a JSON-string node and forwards it as an object', async () => {
it('patchNode parses a JSON-string node and forwards it as { node } (object)', async () => {
const tools = await buildTools();
await tools.patchNode.execute(
{ pageId: 'p1', nodeId: 'n1', node: JSON.stringify(NODE_OBJ) } as never,
{} as never,
);
expect(patchNodeCalls).toHaveLength(1);
expect(patchNodeCalls[0]).toEqual(['p1', 'n1', NODE_OBJ]);
// #413: the 3rd arg is now the XOR input { markdown?, node? }.
expect(patchNodeCalls[0]).toEqual([
'p1',
'n1',
{ markdown: undefined, node: NODE_OBJ },
]);
});
it('patchNode passes an object node through unchanged', async () => {
it('patchNode passes an object node through unchanged inside { node }', async () => {
const tools = await buildTools();
await tools.patchNode.execute(
{ pageId: 'p1', nodeId: 'n1', node: NODE_OBJ } as never,
{} as never,
);
expect(patchNodeCalls[0]).toEqual(['p1', 'n1', NODE_OBJ]);
expect(patchNodeCalls[0]).toEqual([
'p1',
'n1',
{ markdown: undefined, node: NODE_OBJ },
]);
});
it('patchNode throws the documented message on invalid JSON string', async () => {
@@ -370,7 +394,7 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => {
expect(patchNodeCalls).toHaveLength(0);
});
it('insertNode parses a JSON-string node and forwards it as an object', async () => {
it('insertNode parses a JSON-string node and forwards it inside { node }', async () => {
const tools = await buildTools();
await tools.insertNode.execute(
{
@@ -381,9 +405,15 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => {
{} as never,
);
expect(insertNodeCalls).toHaveLength(1);
const [pageId, node] = insertNodeCalls[0];
// #413: the 2nd arg is the XOR input { markdown?, node? }, the 3rd is opts.
const [pageId, input, opts] = insertNodeCalls[0] as [
string,
{ markdown?: unknown; node?: unknown },
{ position?: string },
];
expect(pageId).toBe('p1');
expect(node).toEqual(NODE_OBJ);
expect(input).toEqual({ markdown: undefined, node: NODE_OBJ });
expect(opts.position).toBe('append');
});
it('insertNode throws the documented message on invalid JSON string', async () => {
@@ -437,6 +467,54 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => {
).rejects.toThrow('content was a string but not valid JSON');
expect(updatePageJsonCalls).toHaveLength(0);
});
// #411: the plain-Markdown full-body-replace tool is now the shared
// `updatePageMarkdown` (was inline `updatePageContent`). It forwards to
// client.updatePage(pageId, content, title) -> updatePageContentRealtime ->
// markdownToProseMirrorCanonical, so `^[...]` footnotes materialize.
it('updatePageMarkdown forwards { pageId, content, title } to client.updatePage', async () => {
const tools = await buildTools();
await tools.updatePageMarkdown.execute(
{ pageId: 'p1', content: 'Body^[a note]', title: 'New title' } as never,
{} as never,
);
expect(updatePageCalls).toHaveLength(1);
expect(updatePageCalls[0]).toEqual(['p1', 'Body^[a note]', 'New title']);
});
it('updatePageMarkdown returns the RAW client result in-app (deliberate #411 shape change, documented on the spec)', async () => {
const tools = await buildTools();
// Registry canonical execute returns client.updatePage's result verbatim.
// The old inline tool projected to { pageId, updated }; the rename now
// surfaces the raw result (nothing reads the removed `.updated`; the raw
// shape carries footnote/verify warnings and matches the on-both-hosts
// registry convention). fakeClient.updatePage resolves { success: true }.
const result = await tools.updatePageMarkdown.execute(
{ pageId: 'p1', content: '# Hi' } as never,
{} as never,
);
expect(result).toEqual({ success: true });
});
it('updatePageMarkdown forwards title=undefined when omitted', async () => {
const tools = await buildTools();
await tools.updatePageMarkdown.execute(
{ pageId: 'p1', content: '# Hi' } as never,
{} as never,
);
expect(updatePageCalls[0]).toEqual(['p1', '# Hi', undefined]);
});
// #411 surface split: the plain-Markdown replace tool exists in-app under the
// new key; the OLD inline updatePageContent key is gone; importPageMarkdown is
// still present IN-APP (only the external MCP surface drops it — asserted in
// packages/mcp/test/unit/tool-inventory.test.mjs).
it('exposes updatePageMarkdown in-app, no legacy updatePageContent, keeps importPageMarkdown', async () => {
const tools = await buildTools();
expect(tools.updatePageMarkdown).toBeDefined();
expect((tools as Record<string, unknown>).updatePageContent).toBeUndefined();
expect(tools.importPageMarkdown).toBeDefined();
});
});
/**
@@ -847,3 +925,109 @@ describe('AiChatToolsService getCurrentPage selection (#388)', () => {
);
});
});
/**
* #440 review: the in-app drawioCreate / drawioUpdate handlers must forward
* the optional `layout:"elk"` param to the client (5th positional arg), exactly
* like the MCP host. It was silently dropped, so ELK auto-layout worked only via
* the standalone MCP server, not in-app. These tests pin per-host parity.
*/
describe('AiChatToolsService drawio layout passthrough (#440)', () => {
const createCalls: unknown[][] = [];
const updateCalls: unknown[][] = [];
// FakeDocmostClient (not Partial<DocmostClientLike>): since #446 derived
// DocmostClientLike from the real client, its drawioCreate/drawioUpdate return
// the concrete result shape, so a minimal stub object would not be assignable.
// FakeDocmostClient types every method as (...args) => Promise<any>, which is
// exactly what these arg-capturing doubles need.
const fakeClient: FakeDocmostClient = {
drawioCreate: (...args: unknown[]) => {
createCalls.push(args);
return Promise.resolve({ success: true, nodeId: '#0' });
},
drawioUpdate: (...args: unknown[]) => {
updateCalls.push(args);
return Promise.resolve({ success: true, nodeId: '#0' });
},
};
const tokenServiceStub = {
generateAccessToken: jest.fn().mockResolvedValue('access-token'),
generateCollabToken: jest.fn().mockResolvedValue('collab-token'),
};
let service: AiChatToolsService;
beforeEach(() => {
createCalls.length = 0;
updateCalls.length = 0;
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue(
mockLoaded(function () {
return fakeClient as DocmostClientLike;
} as unknown as loader.DocmostClientCtor),
);
service = new AiChatToolsService(
tokenServiceStub as never,
{} as never,
{} as never,
{} as never,
{} as never,
{
asSink: () => ({ put: jest.fn(), has: jest.fn(), evict: jest.fn() }),
} as never,
);
});
afterEach(() => jest.restoreAllMocks());
const buildTools = () =>
service.forUser(
{ id: 'user-1', email: 'u@example.com', workspaceId: 'ws-1' } as never,
'session-1',
'ws-1',
'chat-1',
);
it('forwards layout:"elk" to client.drawioCreate as the 5th positional arg', async () => {
const tools = await buildTools();
await tools.drawioCreate.execute(
{
pageId: 'p-1',
xml: '<mxGraphModel/>',
position: 'append',
layout: 'elk',
} as never,
{} as never,
);
expect(createCalls).toHaveLength(1);
// drawioCreate(pageId, where, xml, title, layout) — layout is args[4].
expect(createCalls[0][4]).toBe('elk');
});
it('forwards layout:"elk" to client.drawioUpdate as the 5th positional arg', async () => {
const tools = await buildTools();
await tools.drawioUpdate.execute(
{
pageId: 'p-1',
node: '#0',
xml: '<mxGraphModel/>',
baseHash: 'h',
layout: 'elk',
} as never,
{} as never,
);
expect(updateCalls).toHaveLength(1);
// drawioUpdate(pageId, node, xml, baseHash, layout) — layout is args[4].
expect(updateCalls[0][4]).toBe('elk');
});
it('omits layout (undefined 5th arg) when not requested', async () => {
const tools = await buildTools();
await tools.drawioCreate.execute(
{ pageId: 'p-1', xml: '<mxGraphModel/>', position: 'append' } as never,
{} as never,
);
expect(createCalls[0][4]).toBeUndefined();
});
});
@@ -62,7 +62,7 @@ function __assertClientCallContract(client: DocmostClientLike): void {
void client.listSidebarPages(s, s);
void client.getOutline(s);
void client.getPageJson(s);
void client.getNode(s, s);
void client.getNode(s, s, 'markdown');
void client.searchInPage(s, s, {
regex: true,
caseSensitive: true,
@@ -84,12 +84,16 @@ function __assertClientCallContract(client: DocmostClientLike): void {
void client.movePage(s, s, s);
void client.deletePage(s);
void client.editPageText(s, edits);
void client.patchNode(s, s, node);
void client.insertNode(s, node, {
position: 'append',
anchorNodeId: s,
anchorText: s,
});
void client.patchNode(s, s, { markdown: s, node });
void client.insertNode(
s,
{ markdown: s, node },
{
position: 'append',
anchorNodeId: s,
anchorText: s,
},
);
void client.deleteNode(s, s);
void client.updatePageJson(s, node, s);
void client.tableInsertRow(s, s, cells, n);
@@ -111,10 +115,12 @@ function __assertClientCallContract(client: DocmostClientLike): void {
afterText: s,
});
void client.replaceImage(s, s, s, { align, alt: s });
// --- draw.io diagrams (#423) ---
// --- draw.io diagrams (#423 stage 1, #424 stage 2) ---
// The 5th `layout` arg (#424) is exercised so this parity assertion fails if the
// client signature drops it — it must reach the client from the shared execute.
void client.drawioGet(s, s, 'xml');
void client.drawioCreate(s, { position: 'append', anchorNodeId: s }, s, s);
void client.drawioUpdate(s, s, s, s);
void client.drawioCreate(s, { position: 'append', anchorNodeId: s }, s, s, 'elk');
void client.drawioUpdate(s, s, s, s, 'elk');
// --- write (comment) ---
void client.createComment(s, s, 'inline', s, s, s);
void client.resolveComment(s, true);
@@ -263,8 +269,19 @@ export class AiChatToolsService {
// provenance tokens) and load the shared tool-spec registry. Client
// construction is shared with the page-change detection path (#274) via
// buildDocmostClient so both go over the exact same authenticated route.
const { sharedToolSpecs, createCommentSignalTracker } =
await loadDocmostMcp();
// searchShapes / getGuideSection (#424) are the PURE, no-network helpers
// backing drawioShapes / drawioGuide. They are `inlineBothHosts` specs (no
// canonical execute — their catalog loader uses import.meta and can't be
// value-imported into the zod-agnostic tool-specs.ts under the server's
// commonjs type-check), so the shared registry loop below SKIPS them and this
// service wires them inline (see drawioShapes/drawioGuide entries), mirroring
// how index.ts registers them on the standalone MCP host.
const {
sharedToolSpecs,
createCommentSignalTracker,
searchShapes,
getGuideSection,
} = await loadDocmostMcp();
const client = await this.buildDocmostClient(
user,
sessionId,
@@ -292,6 +309,18 @@ export class AiChatToolsService {
execute,
});
// The in-app toolset. It starts with the tools kept INLINE here for a
// documented per-layer reason: an intentional behaviour/schema divergence from
// the standalone MCP surface (searchPages' hybrid RRF,
// transformPage's guardrailed shorter schema), a name clash the shared
// registry forbids (in-app `getTable` verb-first vs the MCP noun-first
// `tableGet` — the registry requires mcpName === inAppKey), per-request
// state the registry loop cannot provide
// (getCurrentPage reads the resolved openedPage; searchPages closes over the
// per-request user/embedding deps), or a tool with no MCP twin
// (listSidebarPages/getComment/getPageHistory). Every SHARED tool is then added
// by the registry loop below (see it), so there is exactly one arg-mapping per
// shared tool and it can never drift from the MCP host again (#445).
const tools: Record<string, Tool> = {
// INTENTIONAL per-transport divergence (not in the shared registry): this
// in-app search runs a semantic + keyword hybrid (RRF) with in-process
@@ -426,180 +455,14 @@ export class AiChatToolsService {
execute: async () => resolveCurrentPageResult(openedPage),
}),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
// The execute body keeps this layer's { title, markdown } projection.
getPage: sharedTool(sharedToolSpecs.getPage, async ({ pageId }) => {
// getPage(pageId) -> { data: filterPage(page, markdown), success }.
const result = await client.getPage(pageId);
const data = (result?.data ?? {}) as {
title?: string;
content?: string;
};
return {
title: data.title ?? '',
markdown: typeof data.content === 'string' ? data.content : '',
};
}),
// --- WRITE tools (all reversible — history/trash; §6.5 / D3) ---
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
createPage: sharedTool(
sharedToolSpecs.createPage,
async ({ title, content, spaceId, parentPageId }) => {
// createPage(title, content, spaceId, parentPageId?) ->
// { data: filterPage(page, markdown), success }.
const result = await client.createPage(
title,
content ?? '',
spaceId,
parentPageId,
);
const data = (result?.data ?? {}) as {
id?: string;
slugId?: string;
title?: string;
};
return { id: data.id ?? data.slugId, title: data.title ?? title };
},
),
updatePageContent: tool({
description:
"Replace a page's body with new Markdown content (and optionally its " +
'title). Reversible: the previous version is kept in page history.',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page to update.'),
content: z.string().describe('The new page body as Markdown.'),
title: z
.string()
.optional()
.describe('Optional new title for the page.'),
}),
execute: async ({ pageId, content, title }) => {
// updatePage mutates the live collab doc -> provenance flows from the
// collab-token provider. Returns { success, modified, message, pageId }.
const result = (await client.updatePage(pageId, content, title)) as {
success?: boolean;
};
return { pageId, updated: result?.success ?? true };
},
}),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
renamePage: sharedTool(
sharedToolSpecs.renamePage,
async ({ pageId, title }) => {
// renamePage(pageId, title) -> { success, pageId, title }.
await client.renamePage(pageId, title);
return { pageId, title };
},
),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
// The shared schema adds the optional `position` field this layer lacked
// before; the execute now forwards it (the client already accepted it).
movePage: sharedTool(
sharedToolSpecs.movePage,
async ({ pageId, parentPageId, position }) => {
// movePage(pageId, parentPageId, position?) -> raw move response.
await client.movePage(pageId, parentPageId ?? null, position);
return { pageId, parentPageId: parentPageId ?? null, moved: true };
},
),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
// GUARDRAIL (§14 H4) preserved: the shared schema exposes ONLY pageId, so
// permanentlyDelete/forceDelete are never part of the input and can never
// be forwarded — the agent physically cannot permanently delete a page.
deletePage: sharedTool(sharedToolSpecs.deletePage, async ({ pageId }) => {
// deletePage(pageId) hits POST /pages/delete with { pageId } only,
// which is the soft-delete (trash) path on the server.
await client.deletePage(pageId);
return { pageId, trashed: true };
}),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
// This layer keeps only its own execute-side guards (require a selection
// for a top-level comment; reject suggestedText on a reply / without a
// selection) — the schema+description are shared.
createComment: sharedTool(
sharedToolSpecs.createComment,
async ({
pageId,
content,
selection,
parentCommentId,
suggestedText,
}) => {
// createComment(pageId, content, type, selection?, parentCommentId?,
// suggestedText?). Top-level comments are inline and must carry a
// selection to anchor on; replies inherit the parent's anchor (no
// selection). Throwing here surfaces a tool error to the model (Vercel
// `ai` SDK) so the agent retries with a better selection — do not
// catch/suppress it.
if (!parentCommentId && (!selection || !selection.trim())) {
throw new Error(
"createComment requires a 'selection' (exact text to anchor on) for a new top-level comment.",
);
}
if (suggestedText !== undefined) {
if (parentCommentId) {
throw new Error(
"createComment: 'suggestedText' cannot be attached to a reply; it applies only to a top-level inline comment.",
);
}
if (!selection || !selection.trim()) {
throw new Error(
"createComment: 'suggestedText' requires a 'selection' to anchor and rewrite.",
);
}
}
const result = await client.createComment(
pageId,
content,
'inline',
selection,
parentCommentId,
suggestedText,
);
const data = (result?.data ?? {}) as { id?: string };
return { commentId: data.id, pageId };
},
),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
resolveComment: sharedTool(
sharedToolSpecs.resolveComment,
async ({ commentId, resolved }) => {
// resolveComment(commentId, resolved) -> { success, commentId, resolved }.
await client.resolveComment(commentId, resolved);
return { commentId, resolved };
},
),
// --- READ tools (added) ---
getWorkspace: sharedTool(
sharedToolSpecs.getWorkspace,
async () => await client.getWorkspace(),
),
listSpaces: sharedTool(
sharedToolSpecs.listSpaces,
async () => await client.getSpaces(),
),
// INTENTIONAL per-transport divergence (not shared): keeps the `tree:true`
// hierarchy mode but is worded for the in-app agent; the standalone MCP
// `list_pages` carries its own wording. Kept per-layer so each side tunes
// its own guidance.
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
listPages: sharedTool(
sharedToolSpecs.listPages,
async ({ spaceId, limit, tree }) =>
await client.listPages(spaceId, limit, tree),
),
//
// NOTE (issue #411): the plain-Markdown full-body-replace tool is no longer
// inline here — it moved to @docmost/mcp's SHARED_TOOL_SPECS as
// `updatePageMarkdown` (was inline `updatePageContent`) so it registers on
// BOTH the external MCP and the in-app agent. The registry loop below adds
// it under its inAppKey. importPageMarkdown stays a shared spec too (now
// inAppOnly — dropped from the external MCP surface, kept in-app).
listSidebarPages: tool({
description:
@@ -619,34 +482,9 @@ export class AiChatToolsService {
await client.listSidebarPages(spaceId, pageId),
}),
getOutline: sharedTool(
sharedToolSpecs.getOutline,
async ({ pageId }) => await client.getOutline(pageId),
),
getPageJson: sharedTool(
sharedToolSpecs.getPageJson,
async ({ pageId }) => await client.getPageJson(pageId),
),
getNode: sharedTool(
sharedToolSpecs.getNode,
async ({ pageId, nodeId }) => await client.getNode(pageId, nodeId),
),
searchInPage: sharedTool(
sharedToolSpecs.searchInPage,
async ({ pageId, query, regex, caseSensitive, limit }) =>
await client.searchInPage(pageId, query, {
regex,
caseSensitive,
limit,
}),
),
// NOT shared (kept inline): the MCP tool name `table_get` is noun-first
// while this key is `getTable` (verb-first), breaking the
// snake_case(inAppKey) convention the shared registry enforces. Its
// NOT shared (kept inline): the MCP tool name `tableGet` is noun-first
// while this key is `getTable` (verb-first), so it cannot satisfy the
// shared registry's `mcpName === inAppKey` convention (#412). Its
// reference parameter is still named `table` (was `tableRef`) so it matches
// the migrated table row/cell tools below.
getTable: tool({
@@ -666,13 +504,6 @@ export class AiChatToolsService {
await client.getTable(pageId, table),
}),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
listComments: sharedTool(
sharedToolSpecs.listComments,
async ({ pageId, includeResolved }) =>
await client.listComments(pageId, includeResolved),
),
getComment: tool({
description: 'Fetch a single comment by id (content as Markdown).',
inputSchema: modelFriendlyInput({
@@ -681,24 +512,6 @@ export class AiChatToolsService {
execute: async ({ commentId }) => await client.getComment(commentId),
}),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
checkNewComments: sharedTool(
sharedToolSpecs.checkNewComments,
async ({ spaceId, since, parentPageId }) =>
await client.checkNewComments(spaceId, since, parentPageId),
),
listShares: sharedTool(
sharedToolSpecs.listShares,
async () => await client.listShares(),
),
listPageHistory: sharedTool(
sharedToolSpecs.listPageHistory,
async ({ pageId, cursor }) =>
await client.listPageHistory(pageId, cursor),
),
getPageHistory: tool({
description:
'Fetch a single page-history version including its lossless ' +
@@ -710,206 +523,11 @@ export class AiChatToolsService {
await client.getPageHistory(historyId),
}),
diffPageVersions: sharedTool(
sharedToolSpecs.diffPageVersions,
async ({ pageId, from, to }) =>
await client.diffPageVersions(pageId, from, to),
),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
exportPageMarkdown: sharedTool(
sharedToolSpecs.exportPageMarkdown,
async ({ pageId }) => {
const markdown = await client.exportPageMarkdown(pageId);
return { markdown };
},
),
// --- WRITE tools (added; reversible via page history/trash) ---
editPageText: sharedTool(
sharedToolSpecs.editPageText,
async ({ pageId, edits }) => await client.editPageText(pageId, edits),
),
// Returns ONLY the short link object — never the document body — so a
// large page can be handed to an external consumer without bloating
// context.
stashPage: sharedTool(
sharedToolSpecs.stashPage,
async ({ pageId }) => await client.stashPage(pageId),
),
// Schema + description from the shared registry (identical across both
// transports). The execute body keeps its OWN parseNodeArg normalization:
// the model sometimes serializes the node as a JSON string, and we parse it
// before the client's typeof-object guard rejects it (parity with the
// standalone MCP server, index.ts patch_node).
patchNode: sharedTool(
sharedToolSpecs.patchNode,
async ({ pageId, nodeId, node }) => {
const parsedNode = parseNodeArg(node);
return await client.patchNode(pageId, nodeId, parsedNode);
},
),
// Shared registry schema + description; execute retains parseNodeArg on the
// incoming node (parity with the standalone MCP server, index.ts
// insert_node).
insertNode: sharedTool(
sharedToolSpecs.insertNode,
async ({ pageId, node, position, anchorNodeId, anchorText }) => {
const parsedNode = parseNodeArg(node);
return await client.insertNode(pageId, parsedNode, {
position,
anchorNodeId,
anchorText,
});
},
),
deleteNode: sharedTool(
sharedToolSpecs.deleteNode,
async ({ pageId, nodeId }) => await client.deleteNode(pageId, nodeId),
),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
// The execute body keeps this layer's content normalization (parity with
// the standalone MCP server, index.ts update_page_json).
updatePageJson: sharedTool(
sharedToolSpecs.updatePageJson,
async ({ pageId, content, title }) => {
// undefined/null pass through as undefined (title-only / no-op); any
// string is JSON.parsed (so an empty string "" throws, matching the
// MCP server); an object is passed through unchanged.
let doc;
if (content === undefined || content === null) {
doc = undefined;
} else {
// String -> JSON.parse (throwing on invalid); object passes through.
doc = parseNodeArg(content, 'content was a string but not valid JSON');
}
return await client.updatePageJson(pageId, doc, title);
},
),
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#410).
// Promoted from MCP-only so the in-app agent can attach a REAL footnote to
// already-written text instead of leaving a literal `^[...]` string.
insertFootnote: sharedTool(
sharedToolSpecs.insertFootnote,
async ({ pageId, anchorText, text }) =>
await client.insertFootnote(pageId, anchorText, text),
),
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#410).
// The schema field is `imageUrl`; the client method takes it positionally.
insertImage: sharedTool(
sharedToolSpecs.insertImage,
async ({ pageId, imageUrl, align, alt, replaceText, afterText }) =>
await client.insertImage(pageId, imageUrl, {
align,
alt,
replaceText,
afterText,
}),
),
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#410).
replaceImage: sharedTool(
sharedToolSpecs.replaceImage,
async ({ pageId, attachmentId, imageUrl, align, alt }) =>
await client.replaceImage(pageId, attachmentId, imageUrl, {
align,
alt,
}),
),
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#423).
// meta.hash in the result is the baseHash drawioUpdate requires.
drawioGet: sharedTool(
sharedToolSpecs.drawioGet,
async ({ pageId, node, format }) =>
await client.drawioGet(pageId, node, format ?? 'xml'),
),
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#423).
// The flat schema fields are regrouped into the client's `where` object.
drawioCreate: sharedTool(
sharedToolSpecs.drawioCreate,
async ({ pageId, xml, position, anchorNodeId, anchorText, title }) =>
await client.drawioCreate(
pageId,
{ position, anchorNodeId, anchorText },
xml,
title,
),
),
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#423).
// baseHash is the optimistic lock: mismatch => structured conflict error.
drawioUpdate: sharedTool(
sharedToolSpecs.drawioUpdate,
async ({ pageId, node, xml, baseHash }) =>
await client.drawioUpdate(pageId, node, xml, baseHash),
),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
// The table reference parameter was unified to `table` (was `tableRef`).
tableInsertRow: sharedTool(
sharedToolSpecs.tableInsertRow,
async ({ pageId, table, cells, index }) =>
await client.tableInsertRow(pageId, table, cells, index),
),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
tableDeleteRow: sharedTool(
sharedToolSpecs.tableDeleteRow,
async ({ pageId, table, index }) =>
await client.tableDeleteRow(pageId, table, index),
),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
tableUpdateCell: sharedTool(
sharedToolSpecs.tableUpdateCell,
async ({ pageId, table, row, col, text }) =>
await client.tableUpdateCell(pageId, table, row, col, text),
),
copyPageContent: sharedTool(
sharedToolSpecs.copyPageContent,
async ({ sourcePageId, targetPageId }) =>
await client.copyPageContent(sourcePageId, targetPageId),
),
importPageMarkdown: sharedTool(
sharedToolSpecs.importPageMarkdown,
async ({ pageId, markdown }) =>
await client.importPageMarkdown(pageId, markdown),
),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
// Both layers already carried the security-confirmation framing, so there
// was no real divergence to preserve — only wording drift.
sharePage: sharedTool(
sharedToolSpecs.sharePage,
async ({ pageId, searchIndexing }) =>
await client.sharePage(pageId, searchIndexing),
),
unsharePage: sharedTool(
sharedToolSpecs.unsharePage,
async ({ pageId }) => await client.unsharePage(pageId),
),
restorePageVersion: sharedTool(
sharedToolSpecs.restorePageVersion,
async ({ historyId }) => await client.restorePageVersion(historyId),
),
// INTENTIONAL per-transport divergence (not shared): deliberately omits the
// `deleteComments` schema field (comment-deletion guardrail) and carries a
// much shorter description; the standalone MCP `docmost_transform` exposes
// much shorter description; the standalone MCP `docmostTransform` exposes
// the full helper catalogue. Different schema, so kept per-layer.
transformPage: tool({
description:
@@ -935,6 +553,51 @@ export class AiChatToolsService {
}),
};
// Add EVERY shared tool from the zod-agnostic registry in one loop (#445).
// The spec owns the canonical arg->client mapping; this host only decides
// WHICH mapping to run and returns its value directly (no envelope). For each
// spec:
// - skip `mcpOnly` specs (they belong to the standalone MCP host only);
// - skip `inlineBothHosts` specs (drawioShapes / drawioGuide): they carry
// no execute and are wired INLINE just below, calling the pure helpers;
// - use `inAppExecute` when the spec declares a DELIBERATE per-layer
// difference (a projected result shape, a different guardrail message);
// - otherwise use the canonical `execute` (raw client result, identical to
// the MCP host's before it wraps it as JSON).
// The execute receives the AI-SDK-validated, type-erased input; the spec reads
// the same fields its buildShape declares. This is the SINGLE place the in-app
// arg mapping lives — it can no longer silently drift from the MCP host.
for (const spec of Object.values(sharedToolSpecs)) {
if (spec.mcpOnly) continue;
if (spec.inlineBothHosts) continue;
const run = spec.inAppExecute ?? spec.execute;
if (!run) continue; // defensive: a shared spec always carries one of them.
tools[spec.inAppKey] = sharedTool(
spec,
(async (args) =>
run(client, args as Record<string, unknown>)) as Tool['execute'],
);
}
// drawioShapes / drawioGuide (#424): `inlineBothHosts` registry specs wired
// here with the SAME schema+description the shared spec pins, but calling the
// pure searchShapes / getGuideSection helpers off the loaded @docmost/mcp
// module — they are not client methods and their catalog loader uses
// import.meta, so they cannot live in the zod-agnostic shared execute. The raw
// result is identical to the MCP host's (which wraps it as JSON text); here
// the in-app host returns it plain, exactly like every other shared tool.
tools[sharedToolSpecs.drawioShapes.inAppKey] = sharedTool(
sharedToolSpecs.drawioShapes,
async ({ query, category, limit }) => {
const results = searchShapes(query, { category, limit });
return { query, count: results.length, results };
},
);
tools[sharedToolSpecs.drawioGuide.inAppKey] = sharedTool(
sharedToolSpecs.drawioGuide,
async ({ section }) => getGuideSection(section),
);
// Passive "new comments: N" signal (#417). PER-TURN state (forUser runs once
// per turn), so the watermark starts now and only comments a human leaves
// WHILE this turn runs are signalled — exactly the mid-turn loop; between-turn
@@ -283,10 +283,18 @@ describe('AiChatToolsService forUser + comment signal (real tracker)', () => {
DocmostClient: function () {
return fakeClient as DocmostClientLike;
} as unknown as loader.DocmostClientCtor,
sharedToolSpecs: SHARED_TOOL_SPECS as Record<string, loader.SharedToolSpec>,
sharedToolSpecs: SHARED_TOOL_SPECS as unknown as Record<string, loader.SharedToolSpec>,
// Wire the REAL factory so the in-app path is exercised end to end.
createCommentSignalTracker:
createCommentSignalTracker as unknown as loader.CommentSignalTrackerFactory,
// Pure no-network draw.io helpers (#424) — required on the loader return;
// this comment-signal test doesn't exercise them, so no-op stubs suffice.
searchShapes: (() => []) as unknown as loader.SearchShapesFn,
getGuideSection: (() => ({
section: '',
content: '',
sections: [],
})) as unknown as loader.GetGuideSectionFn,
});
return new AiChatToolsService(
tokenServiceStub as never,
@@ -62,7 +62,10 @@ type DocmostClientMethod =
| 'insertImage'
| 'replaceImage'
| 'insertFootnote'
// --- draw.io diagrams (#423, stage 1) ---
// --- draw.io diagrams (#423 stage 1, #424 stage 2) ---
// DERIVED from the real DocmostClient (#446): drawioCreate/drawioUpdate carry
// the optional layout:"elk" 5th arg in the real signature, so the layout parity
// (#440) is inherited automatically — no hand-written mirror to keep in sync.
| 'drawioGet'
| 'drawioCreate'
| 'drawioUpdate'
@@ -141,6 +144,19 @@ export type CommentSignalTrackerFactory = (options: {
debounceMs?: number;
}) => CommentSignalTrackerLike;
// Pure, no-network draw.io helpers (#424). These are plain functions on the
// module (NOT DocmostClient methods) — the in-app AI-SDK service calls them
// directly to wire drawioShapes / drawioGuide, mirroring the MCP server.
export type SearchShapesFn = (
query: string,
opts?: { category?: string; limit?: number },
) => Array<Record<string, unknown>>;
export type GetGuideSectionFn = (section?: string) => {
section: string;
content: string;
sections: string[];
};
interface DocmostMcpModule {
DocmostClient: DocmostClientCtor;
SHARED_TOOL_SPECS: Record<string, SharedToolSpec>;
@@ -153,6 +169,15 @@ interface DocmostMcpModule {
// the mocked loader in unit tests) — the stale-check below is a NO-OP when it
// is missing, so an older build never wrongly fails startup.
REGISTRY_STAMP?: string;
// Pure, no-network draw.io helpers (#424) backing drawioShapes / drawioGuide.
// Those two specs are `inlineBothHosts` (they stay in SHARED_TOOL_SPECS for the
// shared contract but carry no execute — their catalog loader uses import.meta
// and can't be value-imported into the zod-agnostic tool-specs.ts), so the
// in-app service wires them INLINE off these helpers, mirroring the standalone
// MCP host. Exposed off the loaded module so the service and its test mocks can
// reach them.
searchShapes: SearchShapesFn;
getGuideSection: GetGuideSectionFn;
}
/**
@@ -216,6 +241,8 @@ export async function loadDocmostMcp(): Promise<{
DocmostClient: DocmostClientCtor;
sharedToolSpecs: Record<string, SharedToolSpec>;
createCommentSignalTracker?: CommentSignalTrackerFactory;
searchShapes: SearchShapesFn;
getGuideSection: GetGuideSectionFn;
}> {
if (!modulePromise) {
modulePromise = (async () => {
@@ -261,5 +288,8 @@ export async function loadDocmostMcp(): Promise<{
// Optional: forwarded when present so the in-app layer can build the passive
// comment signal (#417); undefined on a stale build => signal disabled.
createCommentSignalTracker: mod.createCommentSignalTracker,
// Pure no-network draw.io helpers (#424); not client methods.
searchShapes: mod.searchShapes,
getGuideSection: mod.getGuideSection,
};
}
@@ -17,8 +17,10 @@ import { SHARED_TOOL_SPECS } from '../../../../../../packages/mcp/src/tool-specs
* This test fails the build if a spec is added to the registry but never wired
* in-app, if an `inAppKey` is renamed without updating the service, if the
* description drifts between the registry and the exposed tool, if the
* snake_case `mcpName` <-> camelCase `inAppKey` convention is broken, or if the
* exposed tool's input-schema keys diverge from the spec's `buildShape`.
* `mcpName === inAppKey` convention is broken (issue #412 unified the external
* MCP tool name with the in-app key — both are the same camelCase identifier),
* or if the exposed tool's input-schema keys diverge from the spec's
* `buildShape`.
*
* It does NOT need @docmost/mcp built: the registry is imported from TS source,
* and the ESM loader is mocked so `forUser()` never dynamically imports the
@@ -45,6 +47,16 @@ describe('SHARED_TOOL_SPECS contract parity', () => {
string,
loader.SharedToolSpec
>,
// Pure no-network draw.io helpers (#424). The contract test never executes
// a tool body, so type-correct stubs suffice (the real functions can't be
// imported here — drawio-shapes.ts uses import.meta, incompatible with the
// CommonJS jest transform).
searchShapes: (() => []) as unknown as loader.SearchShapesFn,
getGuideSection: (() => ({
section: 'index',
content: '',
sections: [],
})) as unknown as loader.GetGuideSectionFn,
});
const service = new AiChatToolsService(
tokenServiceStub as never,
@@ -64,13 +76,9 @@ describe('SHARED_TOOL_SPECS contract parity', () => {
afterAll(() => jest.restoreAllMocks());
// camelCase -> snake_case, matching the registry's mcpName convention.
const toSnake = (s: string) =>
s.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
// Type as the (optional-buildShape) SharedToolSpec; the `satisfies` literal
// above otherwise narrows to a union where some members lack buildShape.
const specEntries = Object.entries(SHARED_TOOL_SPECS) as Array<
const specEntries = Object.entries(SHARED_TOOL_SPECS) as unknown as Array<
[string, loader.SharedToolSpec]
>;
@@ -86,8 +94,8 @@ describe('SHARED_TOOL_SPECS contract parity', () => {
expect(spec.inAppKey).toBe(registryKey);
});
it('mcpName is the snake_case form of inAppKey', () => {
expect(spec.mcpName).toBe(toSnake(spec.inAppKey));
it('mcpName equals inAppKey (unified camelCase name, #412)', () => {
expect(spec.mcpName).toBe(spec.inAppKey);
});
it('is exposed in-app under its inAppKey', () => {
@@ -36,7 +36,7 @@ describe('tool tier metadata (#332)', () => {
});
it('#410 image tools are DEFERRED, footnote tool is CORE', () => {
// insert_footnote is core (symmetric with editPageText); the image tools stay
// insertFootnote is core (symmetric with editPageText); the image tools stay
// deferred (rare, fat — loaded on demand). Assert both the spec tier and the
// CORE_TOOL_SET membership so a future tier edit that desyncs them fails here.
expect(SHARED_TOOL_SPECS.insertFootnote.tier).toBe('core');
@@ -123,7 +123,14 @@ describe('deferred catalog ↔ live forUser() toolset partition (#332, F3)', ()
DocmostClient: function () {
return {} as DocmostClientLike;
} as unknown as loader.DocmostClientCtor,
sharedToolSpecs: SHARED_TOOL_SPECS as Record<string, loader.SharedToolSpec>,
sharedToolSpecs: SHARED_TOOL_SPECS as unknown as Record<string, loader.SharedToolSpec>,
// Pure no-network draw.io helpers (#424); tool bodies are never executed here.
searchShapes: (() => []) as unknown as loader.SearchShapesFn,
getGuideSection: (() => ({
section: 'index',
content: '',
sections: [],
})) as unknown as loader.GetGuideSectionFn,
});
const service = new AiChatToolsService(
{
@@ -232,6 +239,16 @@ describe('applyLoadTools (#332)', () => {
expect(LOAD_TOOLS_DESCRIPTION).toContain('only ACTIVATES them');
expect(LOAD_TOOLS_DESCRIPTION).toContain('callable on your NEXT step');
});
it('loadTools description tells the model CORE tools are always active (#444)', () => {
expect(LOAD_TOOLS_DESCRIPTION).toContain(
'Tools NOT listed in the catalog are CORE and ALWAYS active',
);
expect(LOAD_TOOLS_DESCRIPTION).toContain('NEVER via loadTools');
// Names it out explicitly so the model doesn't loadTools a core tool.
expect(LOAD_TOOLS_DESCRIPTION).toContain('createComment');
expect(LOAD_TOOLS_DESCRIPTION).toContain('searchInPage');
});
});
describe('editorial "Corrector" scenario is fully served by CORE (#332)', () => {
@@ -60,10 +60,10 @@ export const CORE_TOOL_KEYS = [
'listComments',
'resolveComment',
'editPageText',
// #330 search_in_page — frequent for editorial sweeps; core despite predating
// #330 searchInPage — frequent for editorial sweeps; core despite predating
// the issue's tier list.
'searchInPage',
// #410 insert_footnote — core so pinpoint citations to already-written text
// #410 insertFootnote — core so pinpoint citations to already-written text
// don't degrade into literal `^[...]`; kept symmetric with editPageText.
'insertFootnote',
] as const;
@@ -84,7 +84,10 @@ export const LOAD_TOOLS_DESCRIPTION =
'block in your instructions. Pass the EXACT tool names from the catalog; this\n' +
'call only ACTIVATES them and returns { loaded: [...] } — the tools become\n' +
'callable on your NEXT step. Load several names in one call when the task clearly\n' +
'needs them. Unknown names are rejected with the list of valid ones.';
'needs them. Unknown names are rejected with the list of valid ones.\n' +
'Tools NOT listed in the catalog are CORE and ALWAYS active — call them directly,\n' +
'NEVER via loadTools (e.g. createComment, listComments, resolveComment,\n' +
'editPageText, searchInPage).';
/**
* Tier + catalogLine for the INLINE ai-chat tools — those defined per-layer in
@@ -121,12 +124,9 @@ export const INLINE_TOOL_TIERS: Record<
// --- deferred inline ---
// NOTE: createPage, renamePage, movePage, deletePage, updatePageJson and
// exportPageMarkdown moved to @docmost/mcp's SHARED_TOOL_SPECS (#294); they
// carry their own deferred tier + catalogLine there.
updatePageContent: {
tier: 'deferred',
catalogLine:
"updatePageContent — replace a page's body (and optionally title) with new Markdown.",
},
// carry their own deferred tier + catalogLine there. updatePageContent moved
// there too as updatePageMarkdown (#411) — a shared registry spec now, so it
// is no longer an inline tier entry.
listSidebarPages: {
tier: 'deferred',
catalogLine:
@@ -138,7 +138,7 @@ export const INLINE_TOOL_TIERS: Record<
},
// NOTE: tableInsertRow, tableDeleteRow and tableUpdateCell moved to
// @docmost/mcp's SHARED_TOOL_SPECS (#294); they carry their own deferred tier +
// catalogLine there. getTable stays inline (its MCP name table_get breaks the
// catalogLine there. getTable stays inline (its MCP name tableGet breaks the
// snake_case(inAppKey) convention, so it has no shared spec).
// NOTE: checkNewComments moved to @docmost/mcp's SHARED_TOOL_SPECS (#294);
// it carries its own deferred tier + catalogLine there.
@@ -150,7 +150,7 @@ export const INLINE_TOOL_TIERS: Record<
// NOTE: sharePage moved to @docmost/mcp's SHARED_TOOL_SPECS (#294); it carries
// its own deferred tier + catalogLine there. transformPage stays inline (its
// schema deliberately diverges — it omits the deleteComments field the MCP
// docmost_transform exposes, a comment-deletion guardrail).
// docmostTransform exposes, a comment-deletion guardrail).
transformPage: {
tier: 'deferred',
catalogLine: "transformPage — run a sandboxed JS transform over a page's document.",
@@ -158,4 +158,27 @@ describe('EnvironmentService', () => {
).toBe('https://app.example.com');
});
});
describe('isAiChatFinalStepLockdownEnabled (#444)', () => {
const build = (val?: string) =>
new EnvironmentService({
get: (key: string, def?: string) =>
key === 'AI_CHAT_FINAL_STEP_LOCKDOWN' ? (val ?? def) : def,
} as any);
it('defaults to OFF (false) when unset — the new anti-degeneration default', () => {
expect(build(undefined).isAiChatFinalStepLockdownEnabled()).toBe(false);
});
it('is true only for the exact opt-in "true" (case-insensitive)', () => {
expect(build('true').isAiChatFinalStepLockdownEnabled()).toBe(true);
expect(build('TRUE').isAiChatFinalStepLockdownEnabled()).toBe(true);
});
it('stays OFF for any other value', () => {
expect(build('false').isAiChatFinalStepLockdownEnabled()).toBe(false);
expect(build('1').isAiChatFinalStepLockdownEnabled()).toBe(false);
expect(build('yes').isAiChatFinalStepLockdownEnabled()).toBe(false);
});
});
});
@@ -292,6 +292,24 @@ export class EnvironmentService {
return enabled === 'true';
}
/**
* Final-step lockdown for the in-app agent loop (#444). When ON (legacy), the
* LAST allowed step forces a text-only answer: tools are stripped
* (toolChoice:'none') and a synthesis instruction is appended. Defaults to OFF:
* stripping the tools mid-work triggered a token-loop degeneration incident
* (the model, robbed of its tools on the final step, emitted a 255KB block
* repeating a single token). With the toggle OFF the last step keeps its tools
* and gets only a SOFT nudge to finish with a text summary; the universal
* anti-babble guard is the token-degeneration detector instead. Enable this
* only for a model that does NOT reliably end its turns with a text answer.
*/
isAiChatFinalStepLockdownEnabled(): boolean {
const enabled = this.configService
.get<string>('AI_CHAT_FINAL_STEP_LOCKDOWN', 'false')
.toLowerCase();
return enabled === 'true';
}
/**
* Resumable SSE transport for durable agent runs (#184 phase 1.5). When
* enabled, a run tees its SSE frames into the in-memory run-stream registry so
@@ -143,7 +143,7 @@ export type DocmostMcpConfig = (
buf: Buffer,
mime: string,
) => { uri: string; sha256: string; size: number };
// Optional live/evict probes the package uses to keep stash_page's mirror
// Optional live/evict probes the package uses to keep stashPage's mirror
// counts honest under the store's FIFO eviction (mirror of the package's
// sink type); older bindings omit them.
has?: (uri: string) => boolean;
@@ -332,7 +332,7 @@ export class McpService implements OnModuleDestroy {
// Should never happen: handle() always stashes before delegating.
throw new UnauthorizedException('MCP authentication missing.');
}
// Inject the blob-sandbox sink after the auth decision so stash_page
// Inject the blob-sandbox sink after the auth decision so stashPage
// can store blobs in the shared in-RAM store regardless of which
// credential variant resolved. The sink (put/has/evict + uri↔id
// mapping) is owned by SandboxStore.asSink().
@@ -1,3 +1,8 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import { join } from 'node:path';
import Fastify, { FastifyInstance } from 'fastify';
import fastifyStatic from '@fastify/static';
import { resolveStaticAssetHeaders } from './static.module';
// Unit tests for the static-asset cache classifier extracted from the
@@ -33,3 +38,69 @@ describe('resolveStaticAssetHeaders', () => {
expect(headers['vary']).toBe('Accept-Encoding');
});
});
// Integration test proving the ACTUAL response header emitted by @fastify/static
// with the exact registration options StaticModule uses. This is the regression
// guard for #452: without `cacheControl: false`, @fastify/static writes its own
// `Cache-Control: public, max-age=0` AFTER the setHeaders callback, overwriting
// the immutable header — the /assets/ assertion below would then fail.
describe('static.module @fastify/static registration (integration)', () => {
let app: FastifyInstance;
let tmpDir: string;
beforeAll(async () => {
tmpDir = fs.mkdtempSync(join(os.tmpdir(), 'static-module-spec-'));
fs.mkdirSync(join(tmpDir, 'assets'), { recursive: true });
fs.mkdirSync(join(tmpDir, 'locales'), { recursive: true });
fs.writeFileSync(
join(tmpDir, 'assets', 'index-a1b2c3.js'),
'console.log(1);',
);
fs.writeFileSync(join(tmpDir, 'locales', 'en.json'), '{"hello":"world"}');
app = Fastify();
// Mirror StaticModule.onModuleInit's registration options exactly.
await app.register(fastifyStatic, {
root: tmpDir,
wildcard: false,
preCompressed: true,
cacheControl: false,
setHeaders: (res, filePath) => {
for (const [name, value] of Object.entries(
resolveStaticAssetHeaders(filePath),
)) {
res.setHeader(name, value);
}
},
});
await app.ready();
});
afterAll(async () => {
await app.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('serves a hashed /assets/ file with an immutable, 1-year cache-control', async () => {
const res = await app.inject({
method: 'GET',
url: '/assets/index-a1b2c3.js',
});
expect(res.statusCode).toBe(200);
const cacheControl = res.headers['cache-control'];
expect(cacheControl).toContain('immutable');
expect(cacheControl).toContain('max-age=31536000');
});
it('serves a non-hashed /locales/ file WITHOUT an immutable cache-control', async () => {
const res = await app.inject({ method: 'GET', url: '/locales/en.json' });
expect(res.statusCode).toBe(200);
// resolveStaticAssetHeaders sets no cache-control here and cacheControl:false
// stops @fastify/static from adding one, so the browser revalidates by
// etag/last-modified — either an absent header or one without `immutable`.
const cacheControl = res.headers['cache-control'];
if (cacheControl !== undefined) {
expect(cacheControl).not.toContain('immutable');
}
});
});
@@ -115,6 +115,11 @@ export class StaticModule implements OnModuleInit {
// Serve the build-time .br/.gz neighbour when the client accepts it
// (see vite-plugin-compression2 in apps/client/vite.config.ts).
preCompressed: true,
// @fastify/static's default cacheControl:true writes its own
// Cache-Control (from maxAge, default 0) AFTER the setHeaders callback,
// silently overwriting the immutable header that resolveStaticAssetHeaders
// sets — disable it so setHeaders/resolveStaticAssetHeaders own the header.
cacheControl: false,
setHeaders: (res, filePath) => {
for (const [name, value] of Object.entries(
resolveStaticAssetHeaders(filePath),
+86 -81
View File
@@ -12,7 +12,7 @@ license.
> better at *writing a small function that fixes the text* than at re-reading and
> re-emitting a whole document. So this server is built around the way a model actually
> wants to edit: address a block by id, run a find/replace, or hand it a
> `(doc, ctx) => doc` transform and let it *program* the change. `docmost_transform` is
> `(doc, ctx) => doc` transform and let it *program* the change. `docmostTransform` is
> that interface. Other Docmost MCPs are human-shaped — they expose "open the page" and
> "replace the page"; this one exposes the editing primitives a model is good at.
@@ -69,9 +69,9 @@ There are several Docmost MCPs. Here is a capability-by-capability comparison.
- **Token-efficient editing.** Most Docmost MCPs (and the official one) only offer
"replace the whole page" writes — the agent must download the entire document, mutate
it, and upload it back, paying for the full document **twice** on every tiny fix.
This server lets the agent change exactly one block (`patch_node` / `insert_node` /
`delete_node`), do a structure-preserving find/replace (`edit_page_text`), or copy a
whole page server-side (`copy_page_content`) — **without the document ever passing
This server lets the agent change exactly one block (`patchNode` / `insertNode` /
`deleteNode`), do a structure-preserving find/replace (`editPageText`), or copy a
whole page server-side (`copyPageContent`) — **without the document ever passing
through the model**.
- **Writes that don't fight the editor.** Naive REST writes race with whatever a human
@@ -85,12 +85,12 @@ There are several Docmost MCPs. Here is a capability-by-capability comparison.
- **Agent-native editing model.** Human-facing servers expose "open the page" and "replace
the page", because that mirrors how a person works. A model edits better by *programming*
the change — addressing blocks by id, running a find/replace, or supplying a
`(doc, ctx) => doc` transform (`docmost_transform`, with a dry-run diff before it
`(doc, ctx) => doc` transform (`docmostTransform`, with a dry-run diff before it
commits). This server is shaped around that, which is why it has editing primitives the
others simply don't.
- **An editing safety net the others lack.** `list_page_history``diff_page_versions`
`restore_page_version` give an agent (and you) a full view-and-undo loop. The diff
- **An editing safety net the others lack.** `listPageHistory``diffPageVersions`
`restorePageVersion` give an agent (and you) a full view-and-undo loop. The diff
uses the *same* `recreateTransform → ChangeSet → simplifyChanges` pipeline Docmost's
own history viewer uses, so what you see matches the product.
@@ -110,52 +110,56 @@ All 41 tools, grouped by what you'd reach for them.
### Exploration & retrieval
- **`get_workspace`** — Information about the current Docmost workspace.
- **`list_spaces`** — All spaces in the workspace.
- **`list_pages`** — Recent pages in a space, ordered by `updatedAt` desc (default 50,
- **`getWorkspace`** — Information about the current Docmost workspace.
- **`listSpaces`** — All spaces in the workspace.
- **`listPages`** — Recent pages in a space, ordered by `updatedAt` desc (default 50,
max 100). Use `search` for lookups in large spaces.
- **`search`** — Full-text search across pages and content (bounded by `limit`, max 100).
- **`get_page`** — A page's content as clean **Markdown** (convenient, but a *lossy*
- **`getPage`** — A page's content as clean **Markdown** (convenient, but a *lossy*
view — block ids and exact table/callout structure are approximated).
- **`get_page_json`** — A page's **lossless ProseMirror/TipTap JSON**, including every
- **`getPageJson`** — A page's **lossless ProseMirror/TipTap JSON**, including every
block's `attrs.id` and the `slugId` used in URLs. This is what the per-block editing
tools consume.
- **`get_outline`** — A compact outline of a page's top-level blocks (`{index, type, id,
- **`getOutline`** — A compact outline of a page's top-level blocks (`{index, type, id,
level, firstText}`; tables add row/column counts and their header-cell texts, lists add
item counts) **without** the document body. The cheap way to locate a section or table
and grab its block id before
`get_node` / `patch_node` / `insert_node`.
- **`get_node`** — Fetch a single block's full ProseMirror subtree (lossless) without
pulling the whole page. Address it by a block id (from `get_outline` / `get_page_json`),
`getNode` / `patchNode` / `insertNode`.
- **`getNode`** — Fetch a single block's full ProseMirror subtree (lossless) without
pulling the whole page. Address it by a block id (from `getOutline` / `getPageJson`),
or by `#<index>` for a top-level block — use the `#<index>` form for tables/rows/cells,
which carry no id.
### Page lifecycle
- **`create_page`** — Create a page from Markdown and place it in the hierarchy (optional
- **`createPage`** — Create a page from Markdown and place it in the hierarchy (optional
`parentPageId`) in one call. Uses Docmost's import API for clean Markdown→ProseMirror.
- **`rename_page`** — Change a page's title only, without touching or resending content.
- **`move_page`** — Re-parent a page (nest it, or move to root); supports fractional-index
- **`renamePage`** — Change a page's title only, without touching or resending content.
- **`movePage`** — Re-parent a page (nest it, or move to root); supports fractional-index
positioning. Returns only on a *positively confirmed* success.
- **`delete_page`** — Delete a single page.
- **`copy_page_content`** — Replace one page's body with a copy of another's, **entirely
- **`deletePage`** — Delete a single page.
- **`copyPageContent`** — Replace one page's body with a copy of another's, **entirely
server-side** — the document never passes through the model. The target keeps its own
title and slug (so its URL is preserved).
### Editing
- **`edit_page_text`** — Surgical find/replace inside a page's text. Preserves **all**
- **`editPageText`** — Surgical find/replace inside a page's text. Preserves **all**
structure: block ids, marks, links, callouts, tables. The preferred tool for fixing
wording, typos, numbers and names.
- **`patch_node`** — Replace a single block addressed by its `attrs.id` (from
`get_page_json`), without resending the document.
- **`insert_node`** — Insert a block before/after another (by `attrs.id` or anchor text),
- **`patchNode`** — Replace a single block addressed by its `attrs.id` (from
`getPageJson`), without resending the document.
- **`insertNode`** — Insert a block before/after another (by `attrs.id` or anchor text),
or append at the end.
- **`delete_node`** — Remove a single block by its `attrs.id`.
- **`update_page_json`** — Replace a page's entire content with a ProseMirror document
- **`deleteNode`** — Remove a single block by its `attrs.id`.
- **`updatePageJson`** — Replace a page's entire content with a ProseMirror document
(bulk rewrites, or when nodes lack ids). `content` is optional — omit it to update only
the title. Keeps the block ids you pass in, so heading anchors and history stay stable.
- **`docmost_transform`** — The agent-native editing interface: instead of retyping a
- **`updatePageMarkdown`** — Replace a page's body (and optionally its title) with new
**plain Markdown**. The whole body is re-imported (block ids regenerate — for surgical or
id-preserving edits prefer `editPageText` / `patchNode` / `updatePageJson`).
Docmost-flavoured markdown is parsed, including `^[...]` inline footnotes.
- **`docmostTransform`** — The agent-native editing interface: instead of retyping a
document, the agent **writes a function that fixes it**. Edit a page by running an
arbitrary **`(doc, ctx) => doc` JavaScript transform** against its *live* ProseMirror
document. Runs **sandboxed**
@@ -168,42 +172,42 @@ All 41 tools, grouped by what you'd reach for them.
### Tables
- **`table_get`** — Read a table as a matrix: `{rows, cols, cells (text[][]), cellIds}`
- **`tableGet`** — Read a table as a matrix: `{rows, cols, cells (text[][]), cellIds}`
(a paragraph id per cell, or `null`). Address the table by `#<index>` (from
`get_outline`) or any block id inside it. Use `cellIds` with `patch_node` for
`getOutline`) or any block id inside it. Use `cellIds` with `patchNode` for
rich-formatted cell edits.
- **`table_insert_row`** — Insert a row of plain-text cells, padded to the table's column
- **`tableInsertRow`** — Insert a row of plain-text cells, padded to the table's column
count (passing more cells than columns is an error). `index` is the 0-based insert
position (0 inserts before the header); omit it to append at the end.
- **`table_delete_row`** — Delete the row at a 0-based `index`. Refuses to delete a table's
- **`tableDeleteRow`** — Delete the row at a 0-based `index`. Refuses to delete a table's
only row; deleting row 0 promotes the next row to header.
- **`table_update_cell`** — Set the plain-text content of cell `[row, col]` (0-based). For
rich formatting, `patch_node` the cell's paragraph id from `table_get`.
- **`tableUpdateCell`** — Set the plain-text content of cell `[row, col]` (0-based). For
rich formatting, `patchNode` the cell's paragraph id from `tableGet`.
### Markdown round-trip
- **`export_page_markdown`** — Export a page to a single self-contained, **lossless
- **`exportPageMarkdown`** — Export a page to a single self-contained, **lossless
Docmost-flavoured Markdown** file: a meta header, the body with inline comment anchors
and diagrams, and a trailing comments-thread block. Built for a download → edit body →
`import_page_markdown` round-trip that preserves everything, including comment highlights.
- **`import_page_markdown`** — Replace a page's content from a Docmost-flavoured Markdown
file produced by `export_page_markdown`, restoring comment-highlight anchors and diagrams
from their inline HTML. (Comment *threads* in the file are not re-created on the server —
only the page body and inline comment marks are written; manage threads via the comment
tools/UI.)
and diagrams, and a trailing comments-thread block. To replace a page's body from plain
authoring Markdown, use `updatePageMarkdown`.
> **Removed in this release:** `importPageMarkdown` (the round-trip parser for an
> exported Docmost-Markdown file) is **no longer exposed on the external MCP surface**.
> To replace a page's body from Markdown, use **`updatePageMarkdown`** (plain Markdown
> body replace). See the CHANGELOG for the migration note.
### Images
- **`insert_image`** — Download an image from a web (http/https) URL and insert it in one
- **`insertImage`** — Download an image from a web (http/https) URL and insert it in one
step: append it, drop it in place of a text placeholder (`replaceText`), or put it after
a given block (`afterText`). Preserves all other block ids.
- **`replace_image`** — Swap an existing image for one fetched from a web (http/https) URL.
- **`replaceImage`** — Swap an existing image for one fetched from a web (http/https) URL.
Uploads the new file as a **fresh
attachment** (clean URL that renders and busts browser caches), then re-points every
node referencing the old attachment (recursively, including callouts/tables) via the
live document, preserving comments, alignment and alt text. (In-place overwrite is
deliberately avoided — some Docmost versions corrupt the attachment on overwrite.)
- **`stash_page`** — Serialize a whole page (its full ProseMirror JSON) into an ephemeral
- **`stashPage`** — Serialize a whole page (its full ProseMirror JSON) into an ephemeral
in-RAM blob and return ONLY a short anonymous URL — the body never enters the model
context, so it is the way to hand a large page (and its images) to an external consumer
without truncation. Every internal file/image attachment is mirrored into the same
@@ -214,35 +218,35 @@ All 41 tools, grouped by what you'd reach for them.
### Comments
- **`create_comment`** — Add a page comment, optionally **anchored inline** to an exact
- **`createComment`** — Add a page comment, optionally **anchored inline** to an exact
span of text (the first occurrence is wrapped in a comment mark).
- **`list_comments`** — List a page's comments (content returned as Markdown).
- **`update_comment`** — Edit an existing comment.
- **`delete_comment`** — Delete a comment.
- **`resolve_comment`** — Resolve (close) or reopen a comment thread (reversible). Only top-level
comments can be resolved; the thread and its replies are kept, unlike `delete_comment`.
- **`check_new_comments`** — Find comments created after a given ISO-8601 timestamp across
- **`listComments`** — List a page's comments (content returned as Markdown).
- **`updateComment`** — Edit an existing comment.
- **`deleteComment`** — Delete a comment.
- **`resolveComment`** — Resolve (close) or reopen a comment thread (reversible). Only top-level
comments can be resolved; the thread and its replies are kept, unlike `deleteComment`.
- **`checkNewComments`** — Find comments created after a given ISO-8601 timestamp across
a space, optionally scoped to a page subtree — ideal for an agent that watches a doc for
feedback.
### Versioning & history
- **`list_page_history`** — A page's saved versions (Docmost auto-snapshots on save),
- **`listPageHistory`** — A page's saved versions (Docmost auto-snapshots on save),
newest first, cursor-paginated. Each item's id is the `historyId`.
- **`diff_page_versions`** — Diff two versions (or a version against the live page).
- **`diffPageVersions`** — Diff two versions (or a version against the live page).
Returns inserted/deleted text, integrity counts (images, links, tables, callouts,
footnote markers), and a human-readable Markdown summary — computed with the same
pipeline Docmost's own history viewer uses.
- **`restore_page_version`** — Write a saved version back as the current content. Docmost
- **`restorePageVersion`** — Write a saved version back as the current content. Docmost
has no restore endpoint, so this creates a **new** snapshot — the restore is itself
revertible.
### Sharing
- **`share_page`** — Make a page publicly accessible (idempotent) and return its public
- **`sharePage`** — Make a page publicly accessible (idempotent) and return its public
URL (`<app>/share/<key>/p/<slugId>`); optional search-engine indexing.
- **`unshare_page`** — Revoke a page's public share.
- **`list_shares`** — All public shares in the workspace, with titles and public URLs.
- **`unsharePage`** — Revoke a page's public share.
- **`listShares`** — All public shares in the workspace, with titles and public URLs.
---
@@ -251,26 +255,27 @@ All 41 tools, grouped by what you'd reach for them.
This same guidance is also delivered at runtime via the MCP server `instructions` field,
so capable clients steer the model automatically.
- **Text fixes** (wording, typos, numbers): `edit_page_text`.
- **One block** (paragraph/heading/callout/table cell): `patch_node` / `insert_node` /
`delete_node`, addressing the node by its `attrs.id` from `get_page_json`.
- **Images**: `insert_image` / `replace_image`.
- **A new page**: `create_page`.
- **Bulk rewrite, or nodes without ids**: `update_page_json`.
- **Text fixes** (wording, typos, numbers): `editPageText`.
- **One block** (paragraph/heading/callout/table cell): `patchNode` / `insertNode` /
`deleteNode`, addressing the node by its `attrs.id` from `getPageJson`.
- **Images**: `insertImage` / `replaceImage`.
- **A new page**: `createPage`.
- **Bulk rewrite, or nodes without ids**: `updatePageJson` (ProseMirror) or
`updatePageMarkdown` (plain Markdown body replace).
- **Multi-step / scripted rewrite** (renumbering, footnotes, coordinated edits):
`docmost_transform` — preview with `dryRun`, then apply.
- **Copy a whole page's content from another page** (server-side): `copy_page_content`.
- **Rename a page** (title only): `rename_page`.
- **Reads**: `get_page` (Markdown) / `get_page_json` (lossless ProseMirror with ids).
- **Review changes**: `list_page_history` → `diff_page_versions` → `restore_page_version`.
- **Comments**: `create_comment` (with optional inline anchoring) / `list_comments` /
`update_comment` / `resolve_comment` / `delete_comment` / `check_new_comments`.
- **Navigate a page cheaply** (find a section/table, grab a block id): `get_outline` →
`get_node`.
- **Tables** (add/remove a row, set a cell): `table_get` / `table_insert_row` /
`table_delete_row` / `table_update_cell`.
- **Round-trip a page as Markdown** (download, edit, re-upload losslessly with comments):
`export_page_markdown` / `import_page_markdown`.
`docmostTransform` — preview with `dryRun`, then apply.
- **Copy a whole page's content from another page** (server-side): `copyPageContent`.
- **Rename a page** (title only): `renamePage`.
- **Reads**: `getPage` (Markdown) / `getPageJson` (lossless ProseMirror with ids).
- **Review changes**: `listPageHistory` → `diffPageVersions` → `restorePageVersion`.
- **Comments**: `createComment` (with optional inline anchoring) / `listComments` /
`updateComment` / `resolveComment` / `deleteComment` / `checkNewComments`.
- **Navigate a page cheaply** (find a section/table, grab a block id): `getOutline` →
`getNode`.
- **Tables** (add/remove a row, set a cell): `tableGet` / `tableInsertRow` /
`tableDeleteRow` / `tableUpdateCell`.
- **Export a page as self-contained Markdown** (with comment anchors): `exportPageMarkdown`.
- **Replace a page's body from Markdown**: `updatePageMarkdown`.
---
@@ -288,8 +293,8 @@ so capable clients steer the model automatically.
refreshed automatically on the first 401/403 (covering JSON, multipart upload, and the
collaboration-token path), with in-flight login de-duplication so a burst of calls
triggers a single re-login.
- **Lossless and lossy reads.** `get_page_json` returns the exact ProseMirror tree with
block ids; `get_page` returns clean Markdown for convenience.
- **Lossless and lossy reads.** `getPageJson` returns the exact ProseMirror tree with
block ids; `getPage` returns clean Markdown for convenience.
- **Full Docmost schema.** Markdown↔ProseMirror conversion supports callouts (including
nested), task lists (bullet *and* numbered checklists), tables, math blocks, embeds,
highlights, sub/superscript and more, with defensive caps against pathological input.
@@ -300,7 +305,7 @@ so capable clients steer the model automatically.
- **Token-optimized responses.** API responses are filtered down to the fields agents
actually need, and large collections (spaces, pages, comments, history) are paginated.
- **Hardened runtime.** Global handlers keep a stray socket error from tearing down the
stdio server; `move_page` requires a positively confirmed success; the diff engine
stdio server; `movePage` requires a positively confirmed success; the diff engine
falls back to a coarse block diff rather than hard-failing on a pathological document.
---
@@ -358,7 +363,7 @@ npm run test:e2e
This project began as a fork of [MrMartiniMo/docmost-mcp](https://github.com/MrMartiniMo/docmost-mcp)
(by Moritz Krause) and extends it substantially — adding per-block node editing,
surgical text edits, the sandboxed `docmost_transform`, version history / diff / restore,
surgical text edits, the sandboxed `docmostTransform`, version history / diff / restore,
comments, image insert/replace, public sharing, server-side page copy, dual
JSON/Markdown reads, transparent re-authentication and significant hardening. The comment
tools were ported from upstream PR #3 by Max Nikitin. Thanks to both.
+86 -82
View File
@@ -12,7 +12,7 @@
> небольшую функцию, которая чинит текст*, чем перечитывать и заново выдавать весь
> документ. Поэтому сервер построен вокруг того, как модели на самом деле удобно
> редактировать: адресовать блок по id, сделать find/replace или передать трансформ
> `(doc, ctx) => doc` и позволить модели *запрограммировать* правку. `docmost_transform` —
> `(doc, ctx) => doc` и позволить модели *запрограммировать* правку. `docmostTransform` —
> это и есть такой интерфейс. Другие Docmost-MCP «заточены под человека» — они дают
> «открыть страницу» и «заменить страницу»; этот даёт примитивы редактирования, в которых
> модель сильна.
@@ -71,9 +71,9 @@ Docmost-MCP не сочетают:
- **Экономия токенов при редактировании.** Большинство Docmost-MCP (и официальный)
предлагают только запись «заменить всю страницу» — агент вынужден скачать весь документ,
изменить и загрузить обратно, оплачивая весь документ **дважды** на каждой мелкой
правке. Этот сервер позволяет агенту изменить ровно один блок (`patch_node` /
`insert_node` / `delete_node`), сделать find/replace с сохранением структуры
(`edit_page_text`) или скопировать страницу на стороне сервера (`copy_page_content`) —
правке. Этот сервер позволяет агенту изменить ровно один блок (`patchNode` /
`insertNode` / `deleteNode`), сделать find/replace с сохранением структуры
(`editPageText`) или скопировать страницу на стороне сервера (`copyPageContent`) —
**причём документ ни разу не проходит через модель**.
- **Записи, которые не воюют с редактором.** Наивная запись через REST конфликтует с тем,
@@ -87,12 +87,12 @@ Docmost-MCP не сочетают:
- **Агентоориентированная модель редактирования.** Серверы «под человека» дают «открыть
страницу» и «заменить страницу», потому что это отражает то, как работает человек. Модель
редактирует лучше, *программируя* правку — адресуя блоки по id, делая find/replace или
передавая трансформ `(doc, ctx) => doc` (`docmost_transform`, с dry-run диффом перед
передавая трансформ `(doc, ctx) => doc` (`docmostTransform`, с dry-run диффом перед
коммитом). Этот сервер построен вокруг этого — поэтому у него есть примитивы
редактирования, которых у остальных просто нет.
- **Страховка при редактировании, которой нет у других.** `list_page_history`
`diff_page_versions``restore_page_version` дают агенту (и вам) полный цикл «посмотреть
- **Страховка при редактировании, которой нет у других.** `listPageHistory`
`diffPageVersions``restorePageVersion` дают агенту (и вам) полный цикл «посмотреть
и откатить». Дифф использует *тот же* конвейер `recreateTransform → ChangeSet →
simplifyChanges`, что и встроенный просмотр истории Docmost, так что результат совпадает
с продуктом.
@@ -113,55 +113,59 @@ Docmost-MCP не сочетают:
### Чтение и поиск
- **`get_workspace`** — Информация о текущем воркспейсе Docmost.
- **`list_spaces`** — Все пространства воркспейса.
- **`list_pages`** — Недавние страницы пространства, по убыванию `updatedAt` (по умолчанию
- **`getWorkspace`** — Информация о текущем воркспейсе Docmost.
- **`listSpaces`** — Все пространства воркспейса.
- **`listPages`** — Недавние страницы пространства, по убыванию `updatedAt` (по умолчанию
50, максимум 100). Для поиска в больших пространствах используйте `search`.
- **`search`** — Полнотекстовый поиск по страницам и контенту (ограничен `limit`, максимум
100).
- **`get_page`** — Контент страницы как чистый **Markdown** (удобно, но это
- **`getPage`** — Контент страницы как чистый **Markdown** (удобно, но это
*lossy*-представление — id блоков и точная структура таблиц/коллаутов аппроксимируются).
- **`get_page_json`** — **Lossless ProseMirror/TipTap JSON** страницы, включая `attrs.id`
- **`getPageJson`** — **Lossless ProseMirror/TipTap JSON** страницы, включая `attrs.id`
каждого блока и `slugId`, используемый в URL. Именно его потребляют инструменты
поблочного редактирования.
- **`get_outline`** — Компактная структура страницы из блоков верхнего уровня (`{index,
- **`getOutline`** — Компактная структура страницы из блоков верхнего уровня (`{index,
type, id, level, firstText}`; для таблиц добавляются число строк/столбцов и тексты ячеек
заголовка, для списков — число пунктов) **без** тела документа. Дешёвый способ найти раздел или таблицу и получить
id блока перед `get_node` / `patch_node` / `insert_node`.
- **`get_node`** — Получить полное ProseMirror-поддерево одного блока (lossless), не
вытягивая всю страницу. Адресуйте его по id блока (из `get_outline` / `get_page_json`)
id блока перед `getNode` / `patchNode` / `insertNode`.
- **`getNode`** — Получить полное ProseMirror-поддерево одного блока (lossless), не
вытягивая всю страницу. Адресуйте его по id блока (из `getOutline` / `getPageJson`)
или формой `#<index>` для блока верхнего уровня — используйте `#<index>` для
таблиц/строк/ячеек, у которых нет id.
### Жизненный цикл страниц
- **`create_page`** — Создать страницу из Markdown и поместить в иерархию (опционально
- **`createPage`** — Создать страницу из Markdown и поместить в иерархию (опционально
`parentPageId`) одним вызовом. Использует import API Docmost для чистой конвертации
Markdown→ProseMirror.
- **`rename_page`** — Изменить только заголовок страницы, не трогая и не пересылая контент.
- **`move_page`** — Сменить родителя страницы (вложить или вынести в корень); поддерживает
- **`renamePage`** — Изменить только заголовок страницы, не трогая и не пересылая контент.
- **`movePage`** — Сменить родителя страницы (вложить или вынести в корень); поддерживает
позиционирование по fractional-index. Возвращает успех только при *положительно
подтверждённом* результате.
- **`delete_page`** — Удалить одну страницу.
- **`copy_page_content`** — Заменить тело одной страницы копией тела другой, **полностью на
- **`deletePage`** — Удалить одну страницу.
- **`copyPageContent`** — Заменить тело одной страницы копией тела другой, **полностью на
стороне сервера** — документ не проходит через модель. У целевой страницы сохраняются
собственные заголовок и slug (URL не меняется).
### Редактирование
- **`edit_page_text`** — Хирургический find/replace внутри текста страницы. Сохраняет
- **`editPageText`** — Хирургический find/replace внутри текста страницы. Сохраняет
**всю** структуру: id блоков, marks, ссылки, коллауты, таблицы. Предпочтительный
инструмент для правки формулировок, опечаток, чисел и имён.
- **`patch_node`** — Заменить один блок, адресованный по `attrs.id` (из `get_page_json`),
- **`patchNode`** — Заменить один блок, адресованный по `attrs.id` (из `getPageJson`),
без пересылки документа.
- **`insert_node`** — Вставить блок до/после другого (по `attrs.id` или по якорному тексту)
- **`insertNode`** — Вставить блок до/после другого (по `attrs.id` или по якорному тексту)
либо добавить в конец.
- **`delete_node`** — Удалить один блок по его `attrs.id`.
- **`update_page_json`** — Заменить весь контент страницы документом ProseMirror (массовые
- **`deleteNode`** — Удалить один блок по его `attrs.id`.
- **`updatePageJson`** — Заменить весь контент страницы документом ProseMirror (массовые
перезаписи или когда у узлов нет id). `content` опционален — опустите его, чтобы изменить
только заголовок. Сохраняет переданные id блоков, поэтому якоря заголовков и история
остаются стабильными.
- **`docmost_transform`** — Агентоориентированный интерфейс редактирования: вместо
- **`updatePageMarkdown`** — Заменить тело страницы (и опционально заголовок) новым
**обычным Markdown**. Всё тело переимпортируется (id блоков перегенерируются — для
хирургических правок или сохранения id используйте `editPageText` / `patchNode` /
`updatePageJson`). Markdown в диалекте Docmost разбирается, включая inline-сноски `^[...]`.
- **`docmostTransform`** — Агентоориентированный интерфейс редактирования: вместо
перепечатывания документа агент **пишет функцию, которая его чинит**. Редактирует
страницу, запуская произвольный **JS-трансформ `(doc, ctx) => doc`** на её *живом*
документе ProseMirror. Работает в **песочнице** (без `require`/`process`/`fs`/сети,
@@ -173,43 +177,42 @@ Docmost-MCP не сочетают:
### Таблицы
- **`table_get`** — Прочитать таблицу как матрицу: `{rows, cols, cells (text[][]),
- **`tableGet`** — Прочитать таблицу как матрицу: `{rows, cols, cells (text[][]),
cellIds}` (id абзаца на ячейку или `null`). Адресуйте таблицу через `#<index>` (из
`get_outline`) или любой id блока внутри неё. Используйте `cellIds` вместе с `patch_node`
`getOutline`) или любой id блока внутри неё. Используйте `cellIds` вместе с `patchNode`
для правок ячеек с форматированием.
- **`table_insert_row`** — Вставить строку из текстовых ячеек, дополненную до числа
- **`tableInsertRow`** — Вставить строку из текстовых ячеек, дополненную до числа
столбцов таблицы (передать ячеек больше числа столбцов — ошибка). `index` — 0-based
позиция вставки (0 вставляет перед заголовком); опустите, чтобы добавить в конец.
- **`table_delete_row`** — Удалить строку по 0-based `index`. Отказывается удалять
- **`tableDeleteRow`** — Удалить строку по 0-based `index`. Отказывается удалять
единственную строку таблицы; удаление строки 0 делает заголовком следующую строку.
- **`table_update_cell`** — Задать текстовое содержимое ячейки `[row, col]` (0-based). Для
форматирования используйте `patch_node` по id абзаца ячейки из `table_get`.
- **`tableUpdateCell`** — Задать текстовое содержимое ячейки `[row, col]` (0-based). Для
форматирования используйте `patchNode` по id абзаца ячейки из `tableGet`.
### Markdown: экспорт и импорт
- **`export_page_markdown`** — Экспортировать страницу в один самодостаточный, **lossless
- **`exportPageMarkdown`** — Экспортировать страницу в один самодостаточный, **lossless
Markdown в диалекте Docmost**: мета-заголовок, тело с inline-якорями комментариев и
диаграммами и завершающий блок тредов комментариев. Рассчитан на цикл «скачать →
отредактировать тело → `import_page_markdown`», сохраняющий всё, включая выделения
комментариев.
- **`import_page_markdown`** — Заменить контент страницы из Markdown-файла в диалекте
Docmost, созданного `export_page_markdown`, восстанавливая якоря-выделения комментариев и
диаграммы из их inline-HTML. (Треды комментариев из файла не пересоздаются на сервере —
записываются только тело страницы и inline-марки комментариев; тредами управляйте через
инструменты/UI комментариев.)
диаграммами и завершающий блок тредов комментариев. Чтобы заменить тело страницы из
обычного авторского Markdown, используйте `updatePageMarkdown`.
> **Удалено в этом релизе:** `importPageMarkdown` (парсер round-trip для
> экспортированного Docmost-Markdown-файла) **больше не отдаётся на внешней MCP-поверхности**.
> Чтобы заменить тело страницы из Markdown, используйте **`updatePageMarkdown`** (замена
> тела обычным Markdown). См. заметку о миграции в CHANGELOG.
### Изображения
- **`insert_image`** — Загрузить локальное изображение и вставить за один шаг: добавить в
- **`insertImage`** — Загрузить локальное изображение и вставить за один шаг: добавить в
конец, поставить вместо текстового плейсхолдера (`replaceText`) или после заданного блока
(`afterText`). Сохраняет id всех остальных блоков.
- **`replace_image`** — Заменить существующее изображение. Загружает новый файл как **новое
- **`replaceImage`** — Заменить существующее изображение. Загружает новый файл как **новое
вложение** (чистый URL, который рендерится и сбрасывает кэш браузера), затем
перенаправляет все узлы, ссылавшиеся на старое вложение (рекурсивно, включая
коллауты/таблицы), через живой документ, сохраняя комментарии, выравнивание и alt-текст.
(Перезапись «по месту» намеренно не используется — некоторые версии Docmost портят
вложение при перезаписи.)
- **`stash_page`** — Сериализовать страницу целиком (её полный ProseMirror JSON) в
- **`stashPage`** — Сериализовать страницу целиком (её полный ProseMirror JSON) в
эфемерный blob в оперативной памяти и вернуть ТОЛЬКО короткий анонимный URL — тело
никогда не попадает в контекст модели, поэтому это способ передать большую страницу
(вместе с её изображениями) внешнему потребителю без усечения. Каждое внутреннее
@@ -221,35 +224,35 @@ Docmost-MCP не сочетают:
### Комментарии
- **`create_comment`** — Добавить комментарий к странице, опционально **привязав inline** к
- **`createComment`** — Добавить комментарий к странице, опционально **привязав inline** к
точному фрагменту текста (первое вхождение оборачивается comment-маркой).
- **`list_comments`** — Список комментариев страницы (контент возвращается как Markdown).
- **`update_comment`** — Изменить существующий комментарий.
- **`delete_comment`** — Удалить комментарий.
- **`resolve_comment`** — Закрыть (resolve) или переоткрыть тред комментария (обратимо). Resolve
доступен только для корневых комментариев; тред и ответы сохраняются, в отличие от `delete_comment`.
- **`check_new_comments`** — Найти комментарии, созданные после заданной метки времени
- **`listComments`** — Список комментариев страницы (контент возвращается как Markdown).
- **`updateComment`** — Изменить существующий комментарий.
- **`deleteComment`** — Удалить комментарий.
- **`resolveComment`** — Закрыть (resolve) или переоткрыть тред комментария (обратимо). Resolve
доступен только для корневых комментариев; тред и ответы сохраняются, в отличие от `deleteComment`.
- **`checkNewComments`** — Найти комментарии, созданные после заданной метки времени
ISO-8601, по пространству, опционально в рамках поддерева страниц — идеально для агента,
который следит за обратной связью в документе.
### Версии и история
- **`list_page_history`** — Сохранённые версии страницы (Docmost авто-снапшотит при каждом
- **`listPageHistory`** — Сохранённые версии страницы (Docmost авто-снапшотит при каждом
сохранении), новые сверху, курсорная пагинация. id каждого элемента — это `historyId`.
- **`diff_page_versions`** — Дифф двух версий (или версии против живой страницы).
- **`diffPageVersions`** — Дифф двух версий (или версии против живой страницы).
Возвращает вставленный/удалённый текст, счётчики целостности (изображения, ссылки,
таблицы, коллауты, маркеры сносок) и человекочитаемую Markdown-сводку — посчитано тем же
конвейером, что использует встроенный просмотр истории Docmost.
- **`restore_page_version`** — Записать сохранённую версию обратно как текущий контент. У
- **`restorePageVersion`** — Записать сохранённую версию обратно как текущий контент. У
Docmost нет эндпоинта восстановления, поэтому создаётся **новый** снапшот — само
восстановление тоже обратимо.
### Публикация
- **`share_page`** — Сделать страницу публично доступной (идемпотентно) и вернуть её
- **`sharePage`** — Сделать страницу публично доступной (идемпотентно) и вернуть её
публичный URL (`<app>/share/<key>/p/<slugId>`); опционально индексирование поисковиками.
- **`unshare_page`** — Отозвать публичный доступ к странице.
- **`list_shares`** — Все публичные ссылки воркспейса с заголовками и публичными URL.
- **`unsharePage`** — Отозвать публичный доступ к странице.
- **`listShares`** — Все публичные ссылки воркспейса с заголовками и публичными URL.
---
@@ -258,28 +261,29 @@ Docmost-MCP не сочетают:
Та же подсказка отдаётся в рантайме через поле `instructions` MCP-сервера, так что
подходящие клиенты направляют модель автоматически.
- **Правки текста** (формулировки, опечатки, числа): `edit_page_text`.
- **Один блок** (абзац/заголовок/коллаут/ячейка таблицы): `patch_node` / `insert_node` /
`delete_node`, адресуя узел по его `attrs.id` из `get_page_json`.
- **Изображения**: `insert_image` / `replace_image`.
- **Новая страница**: `create_page`.
- **Массовая перезапись или узлы без id**: `update_page_json`.
- **Правки текста** (формулировки, опечатки, числа): `editPageText`.
- **Один блок** (абзац/заголовок/коллаут/ячейка таблицы): `patchNode` / `insertNode` /
`deleteNode`, адресуя узел по его `attrs.id` из `getPageJson`.
- **Изображения**: `insertImage` / `replaceImage`.
- **Новая страница**: `createPage`.
- **Массовая перезапись или узлы без id**: `updatePageJson` (ProseMirror) или
`updatePageMarkdown` (замена тела обычным Markdown).
- **Многошаговая / скриптовая перезапись** (перенумерация, сноски, согласованные правки):
`docmost_transform` — предпросмотр через `dryRun`, затем применение.
`docmostTransform` — предпросмотр через `dryRun`, затем применение.
- **Скопировать контент целой страницы из другой** (на стороне сервера):
`copy_page_content`.
- **Переименовать страницу** (только заголовок): `rename_page`.
- **Чтение**: `get_page` (Markdown) / `get_page_json` (lossless ProseMirror с id).
- **Просмотр изменений**: `list_page_history` → `diff_page_versions` →
`restore_page_version`.
- **Комментарии**: `create_comment` (с опциональной inline-привязкой) / `list_comments` /
`update_comment` / `resolve_comment` / `delete_comment` / `check_new_comments`.
- **Дешёвая навигация по странице** (найти раздел/таблицу, получить id блока): `get_outline`
→ `get_node`.
- **Таблицы** (добавить/удалить строку, задать ячейку): `table_get` / `table_insert_row` /
`table_delete_row` / `table_update_cell`.
- **Round-trip страницы через Markdown** (скачать, отредактировать, залить обратно без
потерь, с комментариями): `export_page_markdown` / `import_page_markdown`.
`copyPageContent`.
- **Переименовать страницу** (только заголовок): `renamePage`.
- **Чтение**: `getPage` (Markdown) / `getPageJson` (lossless ProseMirror с id).
- **Просмотр изменений**: `listPageHistory` → `diffPageVersions` →
`restorePageVersion`.
- **Комментарии**: `createComment` (с опциональной inline-привязкой) / `listComments` /
`updateComment` / `resolveComment` / `deleteComment` / `checkNewComments`.
- **Дешёвая навигация по странице** (найти раздел/таблицу, получить id блока): `getOutline`
→ `getNode`.
- **Таблицы** (добавить/удалить строку, задать ячейку): `tableGet` / `tableInsertRow` /
`tableDeleteRow` / `tableUpdateCell`.
- **Экспорт страницы в самодостаточный Markdown** (с якорями комментариев): `exportPageMarkdown`.
- **Заменить тело страницы из Markdown**: `updatePageMarkdown`.
---
@@ -298,8 +302,8 @@ Docmost-MCP не сочетают:
автоматически на первом 401/403 (покрывая JSON, multipart-загрузку и путь токена
коллаборации), с дедупликацией параллельных логинов, так что пачка вызовов вызывает один
повторный логин.
- **Lossless- и lossy-чтение.** `get_page_json` возвращает точное дерево ProseMirror с id
блоков; `get_page` возвращает чистый Markdown для удобства.
- **Lossless- и lossy-чтение.** `getPageJson` возвращает точное дерево ProseMirror с id
блоков; `getPage` возвращает чистый Markdown для удобства.
- **Полная схема Docmost.** Конвертация Markdown↔ProseMirror поддерживает коллауты
(включая вложенные), списки задач (маркированные *и* нумерованные чек-листы), таблицы,
блоки формул, эмбеды, выделение, под/надстрочный текст и прочее, с защитными лимитами
@@ -312,7 +316,7 @@ Docmost-MCP не сочетают:
нужных агентам, а большие коллекции (пространства, страницы, комментарии, история)
пагинируются.
- **Закалённый рантайм.** Глобальные обработчики не дают случайной ошибке сокета уронить
stdio-сервер; `move_page` требует положительно подтверждённого успеха; движок диффа
stdio-сервер; `movePage` требует положительно подтверждённого успеха; движок диффа
откатывается к грубому поблочному диффу, а не падает на патологическом документе.
---
@@ -372,7 +376,7 @@ npm run test:e2e
Проект начинался как форк
[MrMartiniMo/docmost-mcp](https://github.com/MrMartiniMo/docmost-mcp) (автор Moritz Krause)
и существенно его расширяет — добавлены поблочное редактирование узлов, хирургические
правки текста, песочница `docmost_transform`, история версий / дифф / восстановление,
правки текста, песочница `docmostTransform`, история версий / дифф / восстановление,
комментарии, вставка/замена изображений, публичные ссылки, серверное копирование страниц,
двойное чтение JSON/Markdown, прозрачная переавторизация и значительное упрочнение.
Инструменты комментариев портированы из upstream PR #3 от Max Nikitin. Спасибо обоим.
+10 -10
View File
@@ -22,14 +22,14 @@ are debounced server-side, so the script waits ~16 s before reading back via RES
| # | Tool / path | What is checked | Expected |
|---|-------------|-----------------|----------|
| 1 | `create_page` | title with spaces, slugId returned | page created, title intact |
| 1 | `createPage` | title with spaces, slugId returned | page created, title intact |
| 2 | `update_page` (markdown) | headings, **bold**/*italic*/~~strike~~/`code`/link, nested bullet + ordered lists, blockquote, code block, `:::callout:::`, table | all structures survive re-import |
| 3 | `get_page_json` | lossless ProseMirror, block ids, callout/table nodes | present (note: reads the **debounced** REST snapshot — recent collab writes may lag a few seconds) |
| 4 | `edit_page_text` | surgical replace; block ids + marks preserved; ambiguous match rejected; missing match reported | edits applied, ids stable, errors correct |
| 5 | `update_page_json` | full lossless write; custom block ids preserved; existing content (text edits, images, callout, table) not lost | round-trips intact |
| 3 | `getPageJson` | lossless ProseMirror, block ids, callout/table nodes | present (note: reads the **debounced** REST snapshot — recent collab writes may lag a few seconds) |
| 4 | `editPageText` | surgical replace; block ids + marks preserved; ambiguous match rejected; missing match reported | edits applied, ids stable, errors correct |
| 5 | `updatePageJson` | full lossless write; custom block ids preserved; existing content (text edits, images, callout, table) not lost | round-trips intact |
| 6 | `upload_image` | uploads attachment, returns node | src is a **clean** `/api/files/<id>/<file>` URL, served `200 image/*` |
| 7 | `insert_image` (append / `replaceText` / `afterText`) | three placements | image lands in the right place, all other block ids preserved |
| 8 | **`replace_image`** | swap an existing figure for new bytes; comments/align/alt preserved; **the new URL must actually serve the image** | new image renders (`200`), old node repointed |
| 7 | `insertImage` (append / `replaceText` / `afterText`) | three placements | image lands in the right place, all other block ids preserved |
| 8 | **`replaceImage`** | swap an existing figure for new bytes; comments/align/alt preserved; **the new URL must actually serve the image** | new image renders (`200`), old node repointed |
## Image-specific assertions (the recurring bug area)
@@ -39,7 +39,7 @@ For every uploaded/inserted/replaced image, assert at the HTTP level that the
* `GET <src>``200`, `Content-Type: image/*`, body starts with the image magic
(`89 50 4E 47` for PNG, etc.).
* `src` does **not** contain a `?v=` query (see "Known pitfalls").
* After `replace_image`: the returned `newAttachmentId` **differs** from the old
* After `replaceImage`: the returned `newAttachmentId` **differs** from the old
one (replacement uses a fresh attachment → fresh URL), and `GET <new src>``200`.
* The old image node on the page is repointed to the new attachmentId.
@@ -64,7 +64,7 @@ broken/empty figure.
Uploading with an existing `attachmentId` (`POST /files/upload` + `attachmentId`)
overwrites the bytes in place. On this Docmost the attachment then returns
**500 for every URL** (clean, `?v=`, any filename) → broken image. Therefore
`replace_image` must upload a **new** attachment and repoint the nodes; the new
`replaceImage` must upload a **new** attachment and repoint the nodes; the new
id yields a new URL that both renders and busts the browser cache. The old
attachment is left as an unreferenced orphan: Docmost exposes **no HTTP API to
delete a single content attachment** (verified against the attachment
@@ -80,9 +80,9 @@ broken/empty figure.
from `?v=`. Image `src` is kept clean (`/api/files/<id>/<file>`); cache-busting
on replace is achieved by the new attachment id.
3. **REST snapshot lag.** `get_page_json` reads the debounced DB snapshot, so a
3. **REST snapshot lag.** `getPageJson` reads the debounced DB snapshot, so a
write made moments earlier may not be visible yet. Wait (~16 s) before reading
back, and never feed a possibly-stale snapshot straight into `update_page_json`.
back, and never feed a possibly-stale snapshot straight into `updatePageJson`.
4. **Callout type narrowing (minor, open).** A `:::warning` callout is imported as
`type: "info"` — the markdown→callout conversion does not carry non-`info`
Binary file not shown.
+1
View File
@@ -57,6 +57,7 @@
"@tiptap/starter-kit": "3.20.4",
"@types/jsdom": "^27.0.0",
"axios": "^1.6.0",
"elkjs": "^0.11.1",
"form-data": "^4.0.0",
"jsdom": "^27.4.0",
"marked": "^17.0.1",
+599 -106
View File
File diff suppressed because it is too large Load Diff
+4 -7
View File
@@ -57,17 +57,14 @@ export const DEFAULT_COMMENT_SIGNAL_DEBOUNCE_MS = 20_000;
/**
* Tools whose OWN result must NOT carry the signal it would be tautological
* (the agent is already looking at comments) and noisy. Listed in BOTH the
* standalone MCP snake_case names AND the in-app camelCase keys so a single set
* covers both surfaces (the signal text itself uses the camelCase `listComments`
* per roadmap #412). `getComment` (single fetch) is intentionally NOT excluded.
* (the agent is already looking at comments) and noisy. Since issue #412 both
* the standalone MCP surface and the in-app agent use the same camelCase tool
* names, so a single set of camelCase names covers both surfaces. `getComment`
* (single fetch) is intentionally NOT excluded.
*/
export const COMMENT_SIGNAL_EXCLUDED_TOOLS: ReadonlySet<string> = new Set([
"list_comments",
"listComments",
"check_new_comments",
"checkNewComments",
"create_comment",
"createComment",
]);
+120 -552
View File
@@ -5,7 +5,10 @@ import { fileURLToPath } from "url";
import { dirname, join } from "path";
import { DocmostClient, DocmostMcpConfig } from "./client.js";
import { parseNodeArg } from "@docmost/prosemirror-markdown";
import { searchShapes } from "./lib/drawio-shapes.js";
import { getGuideSection } from "./lib/drawio-guide.js";
import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js";
import { SERVER_INSTRUCTIONS } from "./server-instructions.js";
import {
createCommentSignalTracker,
CommentSignalTracker,
@@ -54,6 +57,13 @@ export type {
CommentSignalProbeResult,
CommentSignalTrackerOptions,
} from "./comment-signal.js";
// Re-export the pure, no-network draw.io helpers (#424) so the in-app AI-SDK
// service can wire drawioShapes / drawioGuide off the loaded module. These are
// NOT client methods (no page/backend hit) — the in-app handler calls them
// directly, mirroring how the standalone MCP server wires them here.
export { searchShapes } from "./lib/drawio-shapes.js";
export type { SearchShapesOptions } from "./lib/drawio-shapes.js";
export { getGuideSection } from "./lib/drawio-guide.js";
// Read version from package.json
const __filename = fileURLToPath(import.meta.url);
@@ -74,19 +84,17 @@ const VERSION = packageJson.version;
// Editing guide surfaced to MCP clients in the initialize result so they can
// pick the right tool by intent and avoid resending whole documents.
//
// MAINTENANCE RULE: when you ADD, RENAME, or REMOVE a tool (either an inline
// server.registerTool(...) here or a spec in tool-specs.ts), you MUST update
// this guide so the new tool is routed by intent. This is enforced by
// test/unit/server-instructions.test.mjs, which fails when a registered tool
// name is not mentioned below (see its EXCEPTIONS list for the rare opt-outs).
// Exported for that test.
export const SERVER_INSTRUCTIONS =
"Docmost editing guide — choose the tool by intent.\n" +
"READ: find a page -> search (workspace-wide full-text); list -> list_pages / list_spaces. Locate blocks and their ids CHEAPLY -> get_outline (compact top-level map; start here, not get_page_json). One block's subtree -> get_node (by attrs.id, or \"#<index>\" for tables, which carry no id). Find every occurrence of a string/regex ON a page (and where each is) -> search_in_page, NOT block-by-block get_node — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> get_page (Markdown, lossy; inline <span data-comment-id> tags are comment anchors — markup, not text) or get_page_json (lossless ProseMirror with block ids). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stash_page (returns a short-lived anonymous URL).\n" +
"EDIT: fix wording/typos/numbers -> edit_page_text (find/replace inside blocks, no node id needed). Change ONE block (paragraph/heading/callout/etc.) structurally -> patch_node (by attrs.id from get_outline). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Tables -> table_get / table_update_cell / table_insert_row / table_delete_row (address by \"#<index>\" from get_outline; table nodes have no attrs.id). Images -> insert_image (add from a web URL) / replace_image (swap an existing image). Draw.io diagrams -> drawio_create (create from mxGraph XML and insert), drawio_get (read a diagram as mxGraph XML + a hash), drawio_update (replace a diagram; pass the hash from drawio_get as baseHash for optimistic locking). Footnotes -> insert_footnote. Bulk/structural rewrite -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmost_transform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes.\n" +
"PAGES: new -> create_page (Markdown). Rename (title only) -> rename_page. Move -> move_page. Delete -> delete_page (SOFT delete — the page goes to trash and is restorable; nothing is permanent). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copy_page_content. Sharing -> share_page / unshare_page / list_shares; share_page makes the page PUBLICLY accessible — do it only when explicitly asked.\n" +
"COMMENTS: create_comment is always inline and requires an EXACT selection — contiguous text from a single block, <=250 chars (fails rather than leaving an unanchored comment); reply to a thread via parentCommentId. Propose a concrete text fix for one-click human approval -> create_comment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Manage -> list_comments, update_comment, resolve_comment (resolve/reopen, reversible — prefer over delete to close), delete_comment, check_new_comments.\n" +
"HISTORY: review what changed -> diff_page_versions (a historyId vs current, or two versions). List saved versions -> list_page_history. Undo a bad edit -> restore_page_version (writes a past version back as current; itself revertible). Lossless markdown round-trip (download, edit, re-upload, incl. comment anchors) -> export_page_markdown / import_page_markdown.";
// The guide is now SPLIT (issue #448): the hand-written routing prose lives in
// server-instructions.ts and the tool INVENTORY is GENERATED from the registry
// (SHARED_TOOL_SPECS + INLINE_MCP_INVENTORY), so it can no longer drift out of
// sync with the registered tools. Re-exported here (its old home) so existing
// importers are unaffected; the composition lives in server-instructions.ts.
// The drawioShapes / drawioGuide tools (#424) stay in SHARED_TOOL_SPECS (so the
// generated <tool_inventory> picks them up from their catalogLine automatically)
// but are flagged `inlineBothHosts` and registered inline below (their pure
// helpers can't cross into tool-specs.ts); only the hand-written routing prose in
// server-instructions.ts is updated to mention them.
export { SERVER_INSTRUCTIONS };
// Helper to format JSON responses
const jsonContent = (data: any) => ({
@@ -202,10 +210,10 @@ export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer {
{ instructions: SERVER_INSTRUCTIONS },
);
// Single choke point for MCP tool timing. Both `registerShared` (below) and
// the inline `server.registerTool(...)` calls funnel through this one method,
// so monkeypatching it HERE — before any tool is registered and before
// `registerShared` captures a reference to it — times every tool with no
// Single choke point for MCP tool timing. Both `registerSharedFromSpec` (below)
// and the inline `server.registerTool(...)` calls funnel through this one
// method, so monkeypatching it HERE — before any tool is registered and before
// the registry loop captures a reference to it — times every tool with no
// per-tool boilerplate. The wrapped handler records wall-clock duration and,
// in a `finally`, feeds the host's dependency-neutral sink
// `config.onMetric("mcp_tool_duration_seconds", seconds, { tool })`. The tool
@@ -261,104 +269,112 @@ export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer {
return originalRegisterTool(...args.slice(0, -1), signalledHandler);
};
// Register a tool from the shared, zod-agnostic spec registry. The spec owns
// the canonical name + model-facing description + (optional) schema builder;
// only the execute body is supplied per call. buildShape is invoked with THIS
// package's zod (v3); the in-app layer passes its own zod (v4).
//
// The spec's schema builder returns a plain ZodRawShape (Record<string,
// unknown> in the shared module since it must stay zod-agnostic), so the
// McpServer.registerTool overloads cannot infer the execute arg's shape from
// it. We type `execute` loosely and cast the call through `any`; runtime
// behaviour is unchanged — each execute body destructures the same fields the
// builder declares.
const registerShared = (
spec: SharedToolSpec,
execute: (args: any) => Promise<{ content: { type: "text"; text: string }[] }>,
) =>
(server.registerTool as any)(
// Register EVERY shared tool from the zod-agnostic registry in one loop (#445).
// The spec owns the canonical name + description + (optional) schema builder AND
// the canonical execute mapping; the host only supplies the RESULT ENVELOPE. For
// each spec:
// - skip `inAppOnly` specs (they belong to the in-app host only);
// - if the spec has an `mcpExecute` override (a deliberate per-layer
// difference — a guardrail, an omitted param, or a non-JSON envelope like a
// resource_link/bare success line), the override OWNS the full MCP content
// result and is used VERBATIM;
// - otherwise the canonical `execute` returns RAW data and this host wraps it
// in the standard JSON text envelope (jsonContent), exactly as the old inline
// bodies did.
// buildShape is invoked with THIS package's zod (v3); the in-app layer passes its
// own zod (v4). The registry's execute returns `unknown` (it is zod-agnostic), so
// the wrapping is typed loosely and cast — runtime behaviour is unchanged.
const registerSharedFromSpec = (spec: SharedToolSpec) => {
if (spec.inAppOnly) return;
// `inlineBothHosts` specs (drawioShapes / drawioGuide) carry no execute —
// their pure helper cannot cross into the zod-agnostic tool-specs.ts, so they
// are registered INLINE below (searchShapes / getGuideSection). Skip them here
// so the loop never dereferences a missing `execute`.
if (spec.inlineBothHosts) return;
const handler = async (args: any) => {
if (spec.mcpExecute) {
// The override owns the full MCP result envelope (not re-wrapped).
return (await spec.mcpExecute(docmostClient, args)) as {
content: { type: "text"; text: string }[];
};
}
// Canonical execute returns raw data; wrap it as JSON text content.
const raw = await spec.execute!(docmostClient, args);
return jsonContent(raw);
};
return (server.registerTool as any)(
spec.mcpName,
spec.buildShape
? { description: spec.description, inputSchema: spec.buildShape(z) }
: { description: spec.description },
execute,
handler,
);
};
// Tool: get_workspace
registerShared(SHARED_TOOL_SPECS.getWorkspace, async () => {
const workspace = await docmostClient.getWorkspace();
return jsonContent(workspace);
});
for (const spec of Object.values(SHARED_TOOL_SPECS)) {
registerSharedFromSpec(spec as SharedToolSpec);
}
// Tool: list_spaces
registerShared(SHARED_TOOL_SPECS.listSpaces, async () => {
const spaces = await docmostClient.getSpaces();
return jsonContent(spaces);
});
// --- INLINE drawio helper tools (IN the shared registry, but inlineBothHosts) ---
// drawioShapes / drawioGuide (#424) live in SHARED_TOOL_SPECS (so the shared
// contract pins their name/description/schema across both hosts) but carry the
// `inlineBothHosts` flag and NO execute: their pure backing helpers
// (searchShapes / getGuideSection) cannot be value-imported into the
// zod-agnostic tool-specs.ts without breaking the in-app server's commonjs
// type-check (searchShapes' catalog loader uses import.meta). So both hosts wire
// them directly. Here on the MCP host they reuse the spec's name/description/
// schema and wrap the raw helper result as JSON text content — byte-identical to
// what the registry loop would have produced. The in-app host mirrors this in
// ai-chat-tools.service.ts.
{
// Cast registerTool like the loop's registerSharedFromSpec does: the spec's
// buildShape returns the loose zod-agnostic ZodRawShape (Record<string,
// unknown>) and the handler args are the SDK-validated, type-erased input.
const registerInline = server.registerTool as any;
const shapesSpec = SHARED_TOOL_SPECS.drawioShapes as SharedToolSpec;
registerInline(
shapesSpec.mcpName,
{
description: shapesSpec.description,
inputSchema: shapesSpec.buildShape!(z),
},
async ({ query, category, limit }: any) => {
const results = searchShapes(query, { category, limit });
return jsonContent({ query, count: results.length, results });
},
);
const guideSpec = SHARED_TOOL_SPECS.drawioGuide as SharedToolSpec;
registerInline(
guideSpec.mcpName,
{
description: guideSpec.description,
inputSchema: guideSpec.buildShape!(z),
},
async ({ section }: any) => jsonContent(getGuideSection(section)),
);
}
// Tool: list_pages
// INTENTIONAL per-transport divergence (not in the shared registry): this
// transport exposes a `tree:true` mode that returns the full nested hierarchy;
// the in-app copy keeps the same tree option but is worded for the in-app agent.
// Kept per-layer so each side can tune its own guidance.
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294). This
// transport keeps applying its own defaults (limit=50, tree=false) in execute.
registerShared(SHARED_TOOL_SPECS.listPages, async ({ spaceId, limit, tree }) => {
const result = await docmostClient.listPages(spaceId, limit ?? 50, tree ?? false);
return jsonContent(result);
});
// --- INLINE tools kept per-transport (NOT in the shared registry) ---
// Each stays inline for a documented reason: a snake_case/camelCase naming
// clash the registry convention forbids (tableGet), an intentional
// per-transport behaviour/schema divergence (search, docmostTransform), or a
// tool that exists ONLY on this standalone MCP surface (updateComment,
// deleteComment — the in-app agent deliberately exposes no hard comment
// edit/delete tool).
// Tool: get_page
// Schema + description now live in the shared registry (#294).
registerShared(SHARED_TOOL_SPECS.getPage, async ({ pageId }) => {
const page = await docmostClient.getPage(pageId);
return jsonContent(page);
});
// Tool: get_page_json
registerShared(SHARED_TOOL_SPECS.getPageJson, async ({ pageId }) => {
const page = await docmostClient.getPageJson(pageId);
return jsonContent(page);
});
// Tool: get_outline
registerShared(SHARED_TOOL_SPECS.getOutline, async ({ pageId }) => {
const result = await docmostClient.getOutline(pageId);
return jsonContent(result);
});
// Tool: get_node
registerShared(SHARED_TOOL_SPECS.getNode, async ({ pageId, nodeId }) => {
const result = await docmostClient.getNode(pageId, nodeId);
return jsonContent(result);
});
// Tool: search_in_page
registerShared(
SHARED_TOOL_SPECS.searchInPage,
async ({ pageId, query, regex, caseSensitive, limit }) => {
const result = await docmostClient.searchInPage(pageId, query, {
regex,
caseSensitive,
limit,
});
return jsonContent(result);
},
);
// Tool: table_get
// NOT in the shared registry: the MCP tool name `table_get` is noun-first while
// Tool: tableGet
// NOT in the shared registry: the MCP tool name `tableGet` is noun-first while
// the in-app key is `getTable` (verb-first), breaking the snake_case(inAppKey)
// convention the shared registry enforces (shared-tool-specs.contract.spec.ts).
// Renaming the public MCP tool would break external clients, so it stays inline.
server.registerTool(
"table_get",
"tableGet",
{
description:
"Read a table as a matrix. Returns {rows, cols, cells (text[][]), " +
"cellIds (paragraph id per cell, or null)}. `table` = `#<index>` from " +
"get_outline, or any block id inside the table. Use cellIds with " +
"patch_node for rich-formatted cell edits. `cols` is the FIRST row's " +
"getOutline, or any block id inside the table. Use cellIds with " +
"patchNode for rich-formatted cell edits. `cols` is the FIRST row's " +
"width; ragged tables may vary per row, so use the per-row length of " +
"`cells` for each row.",
inputSchema: {
@@ -372,386 +388,9 @@ server.registerTool(
},
);
// Tool: table_insert_row
// Schema + description now live in the shared registry (#294); the `table`
// parameter name is the canonical one (the in-app layer was unified to it).
registerShared(
SHARED_TOOL_SPECS.tableInsertRow,
async ({ pageId, table, cells, index }) => {
const result = await docmostClient.tableInsertRow(
pageId,
table,
cells,
index,
);
return jsonContent(result);
},
);
// Tool: table_delete_row
// Schema + description now live in the shared registry (#294).
registerShared(
SHARED_TOOL_SPECS.tableDeleteRow,
async ({ pageId, table, index }) => {
const result = await docmostClient.tableDeleteRow(pageId, table, index);
return jsonContent(result);
},
);
// Tool: table_update_cell
// Schema + description now live in the shared registry (#294).
registerShared(
SHARED_TOOL_SPECS.tableUpdateCell,
async ({ pageId, table, row, col, text }) => {
const result = await docmostClient.tableUpdateCell(
pageId,
table,
row,
col,
text,
);
return jsonContent(result);
},
);
// Tool: create_page
// Schema + description now live in the shared registry (#294).
registerShared(
SHARED_TOOL_SPECS.createPage,
async ({ title, content, spaceId, parentPageId }) => {
const result = await docmostClient.createPage(
title,
content,
spaceId,
parentPageId,
);
return jsonContent(result);
},
);
// Tool: update_page_json
// Schema + description now live in the shared registry (#294). The execute body
// keeps this transport's content normalization (parse a JSON-string content,
// pass undefined/null through for a title-only/no-op update).
registerShared(
SHARED_TOOL_SPECS.updatePageJson,
async ({ pageId, content, title }) => {
// Only parse/validate the document when it was actually supplied; when it
// is omitted, pass it straight through so the client performs a title-only
// (or no-op) update.
let doc;
if (content === undefined || content === null) {
doc = undefined;
} else {
// String -> JSON.parse (throwing on invalid); object passes through.
doc = parseNodeArg(content, "content was a string but not valid JSON");
}
const result = await docmostClient.updatePageJson(pageId, doc, title);
return jsonContent(result);
},
);
// Tool: export_page_markdown
// Schema + description now live in the shared registry (#294).
registerShared(SHARED_TOOL_SPECS.exportPageMarkdown, async ({ pageId }) => {
const md = await docmostClient.exportPageMarkdown(pageId);
return { content: [{ type: "text" as const, text: md }] };
});
// Tool: import_page_markdown
registerShared(
SHARED_TOOL_SPECS.importPageMarkdown,
async ({ pageId, markdown }) => {
const res = await docmostClient.importPageMarkdown(pageId, markdown);
return jsonContent(res);
},
);
// Tool: copy_page_content
registerShared(
SHARED_TOOL_SPECS.copyPageContent,
async ({ sourcePageId, targetPageId }) => {
const result = await docmostClient.copyPageContent(
sourcePageId,
targetPageId,
);
return jsonContent(result);
},
);
// Tool: rename_page
// Schema + description now live in the shared registry (#294).
registerShared(SHARED_TOOL_SPECS.renamePage, async ({ pageId, title }) => {
const result = await docmostClient.renamePage(pageId, title);
return jsonContent(result);
});
// Tool: edit_page_text
registerShared(SHARED_TOOL_SPECS.editPageText, async ({ pageId, edits }) => {
const result = await docmostClient.editPageText(pageId, edits);
return jsonContent(result);
});
// Tool: stash_page — returns a resource_link (NOT embedded text) so the doc
// body never enters the model context. Registered directly (not via
// registerShared) because that helper only emits text content. Also returns
// `structuredContent` carrying the full documented `{uri, sha256, size, images}`
// shape alongside the resource_link, so MCP clients receive the blob's sha256
// (its ETag, for integrity) and mirror counts, not just the link.
// Tool: updateComment
server.registerTool(
SHARED_TOOL_SPECS.stashPage.mcpName,
{
description: SHARED_TOOL_SPECS.stashPage.description,
inputSchema: SHARED_TOOL_SPECS.stashPage.buildShape!(z),
},
async ({ pageId }: { pageId: string }) => {
const result = await docmostClient.stashPage(pageId);
return {
content: [
{
type: "resource_link" as const,
uri: result.uri,
name: "page.json",
mimeType: "application/json",
size: result.size,
},
],
// Mirror the full documented result shape ({ uri, size, sha256, images })
// as structuredContent so MCP clients get the blob's sha256 (its ETag, for
// integrity) and the mirror counts, not just the resource_link.
structuredContent: {
uri: result.uri,
sha256: result.sha256,
size: result.size,
images: result.images,
},
};
},
);
// Tool: patch_node — schema + description from the shared registry (identical
// across both transports). The execute body keeps its own parseNodeArg
// normalization (the model sometimes serializes `node` as a JSON string).
registerShared(
SHARED_TOOL_SPECS.patchNode,
async ({ pageId, nodeId, node }) => {
const parsedNode = parseNodeArg(node);
const result = await docmostClient.patchNode(pageId, nodeId, parsedNode);
return jsonContent(result);
},
);
// Tool: insert_node — schema + description from the shared registry. As with
// patch_node, the execute body retains parseNodeArg on the incoming node.
registerShared(
SHARED_TOOL_SPECS.insertNode,
async ({ pageId, node, position, anchorNodeId, anchorText }) => {
const parsedNode = parseNodeArg(node);
const result = await docmostClient.insertNode(pageId, parsedNode, {
position,
anchorNodeId,
anchorText,
});
return jsonContent(result);
},
);
// Tool: delete_node
registerShared(SHARED_TOOL_SPECS.deleteNode, async ({ pageId, nodeId }) => {
const result = await docmostClient.deleteNode(pageId, nodeId);
return jsonContent(result);
});
// Tool: insert_image
// Schema + description now live in the shared registry (#410) so BOTH this MCP
// server and the in-app AI-chat agent expose it. The execute body is unchanged.
registerShared(
SHARED_TOOL_SPECS.insertImage,
async ({ pageId, imageUrl, align, alt, replaceText, afterText }) => {
const result = await docmostClient.insertImage(pageId, imageUrl, {
align,
alt,
replaceText,
afterText,
});
return jsonContent(result);
},
);
// Tool: replace_image
// Schema + description now live in the shared registry (#410).
registerShared(
SHARED_TOOL_SPECS.replaceImage,
async ({ pageId, attachmentId, imageUrl, align, alt }) => {
const result = await docmostClient.replaceImage(
pageId,
attachmentId,
imageUrl,
{
align,
alt,
},
);
return jsonContent(result);
},
);
// Tool: drawio_get — read a draw.io diagram as mxGraph XML (or the raw SVG).
registerShared(
SHARED_TOOL_SPECS.drawioGet,
async ({ pageId, node, format }) => {
const result = await docmostClient.drawioGet(pageId, node, format ?? "xml");
return jsonContent(result);
},
);
// Tool: drawio_create — lint mxGraph XML, build the .drawio.svg, insert a node.
registerShared(
SHARED_TOOL_SPECS.drawioCreate,
async ({ pageId, xml, position, anchorNodeId, anchorText, title }) => {
const result = await docmostClient.drawioCreate(
pageId,
{ position, anchorNodeId, anchorText },
xml,
title,
);
return jsonContent(result);
},
);
// Tool: drawio_update — optimistic-locked full replacement of a diagram.
registerShared(
SHARED_TOOL_SPECS.drawioUpdate,
async ({ pageId, node, xml, baseHash }) => {
const result = await docmostClient.drawioUpdate(pageId, node, xml, baseHash);
return jsonContent(result);
},
);
// Tool: share_page
// Schema + description now live in the shared registry (#294). The execute body
// keeps this transport's own `searchIndexing ?? true` default.
registerShared(
SHARED_TOOL_SPECS.sharePage,
async ({ pageId, searchIndexing }) => {
const result = await docmostClient.sharePage(pageId, searchIndexing ?? true);
return jsonContent(result);
},
);
// Tool: unshare_page
registerShared(SHARED_TOOL_SPECS.unsharePage, async ({ pageId }) => {
const result = await docmostClient.unsharePage(pageId);
return jsonContent(result);
});
// Tool: list_shares
registerShared(SHARED_TOOL_SPECS.listShares, async () => {
const result = await docmostClient.listShares();
return jsonContent(result);
});
// Tool: move_page
// Schema + description now live in the shared registry (#294). The execute body
// keeps this transport's cycle guard, its 'null'/'' -> null string coercion, and
// its positive-confirmation check on the move response.
registerShared(
SHARED_TOOL_SPECS.movePage,
async ({ pageId, parentPageId, position }) => {
const finalParentId =
parentPageId === "" || parentPageId === "null" ? null : parentPageId;
// Cheap cycle guard: a page cannot be moved directly under itself.
// (Deeper descendant-cycle detection is intentionally out of scope.)
if (finalParentId !== null && finalParentId === pageId) {
throw new Error("cannot move a page under itself");
}
const result = await docmostClient.movePage(
pageId,
finalParentId || null,
position,
);
// Require POSITIVE confirmation: the live /pages/move success shape is
// exactly { success: true, status: 200 }. An empty body, a 204, or any odd
// shape lacking success === true must NOT be reported as a successful move,
// so we surface the raw API result instead of declaring success.
if (!(result && typeof result === "object" && result.success === true)) {
throw new Error(
`Failed to move page ${pageId}: ${JSON.stringify(result)}`,
);
}
return jsonContent({
message: `Successfully moved page ${pageId} to parent ${finalParentId || "root"}`,
result,
});
},
);
// Tool: delete_page
// Schema + description now live in the shared registry (#294). The shared schema
// exposes ONLY pageId, so no permanent/force-delete flag can reach the client.
registerShared(SHARED_TOOL_SPECS.deletePage, async ({ pageId }) => {
await docmostClient.deletePage(pageId);
return {
content: [
{ type: "text" as const, text: `Successfully deleted page ${pageId}` },
],
};
});
// --- Comment tools (ported from upstream PR #3 by Max Nikitin) ---
// Tool: list_comments
registerShared(
SHARED_TOOL_SPECS.listComments,
async ({ pageId, includeResolved }) => {
const comments = await docmostClient.listComments(pageId, includeResolved);
return jsonContent(comments);
},
);
// Tool: create_comment
// Schema + description now live in the shared registry (#294). The execute body
// keeps this transport's own guards (require a selection for a top-level
// comment; reject suggestedText on a reply / without a selection).
registerShared(
SHARED_TOOL_SPECS.createComment,
async ({ pageId, content, selection, parentCommentId, suggestedText }) => {
if (!parentCommentId && (!selection || !selection.trim())) {
throw new Error(
"create_comment: a 'selection' (exact text to anchor on) is required for a top-level comment; omit it only when replying via parentCommentId.",
);
}
if (suggestedText !== undefined) {
if (parentCommentId) {
throw new Error(
"create_comment: 'suggestedText' cannot be attached to a reply; it applies only to a top-level inline comment.",
);
}
if (!selection || !selection.trim()) {
throw new Error(
"create_comment: 'suggestedText' requires a 'selection' to anchor and rewrite.",
);
}
}
const result = await docmostClient.createComment(
pageId,
content,
"inline",
selection,
parentCommentId,
suggestedText,
);
return jsonContent(result);
},
);
// Tool: update_comment
server.registerTool(
"update_comment",
"updateComment",
{
description:
"Update an existing comment's content. Only the comment creator can " +
@@ -770,9 +409,9 @@ server.registerTool(
},
);
// Tool: delete_comment
// Tool: deleteComment
server.registerTool(
"delete_comment",
"deleteComment",
{
description:
"Delete a comment. Only the comment creator or space admin can delete it.",
@@ -793,39 +432,6 @@ server.registerTool(
},
);
// Tool: resolve_comment
// Schema + description now live in the shared registry (#294).
registerShared(
SHARED_TOOL_SPECS.resolveComment,
async ({ commentId, resolved }) => {
const result = await docmostClient.resolveComment(commentId, resolved);
return jsonContent(result);
},
);
// Tool: check_new_comments
// Schema + description now live in the shared registry (#294). The execute body
// keeps this transport's own guard rejecting an unparseable `since` timestamp.
registerShared(
SHARED_TOOL_SPECS.checkNewComments,
async ({ spaceId, since, parentPageId }) => {
// Reject an unparseable timestamp up front: otherwise the comparison
// against NaN silently treats every comment as "not new" and the tool
// returns zero results without signalling the bad input.
if (Number.isNaN(Date.parse(since))) {
throw new Error(
`Invalid 'since' timestamp: ${JSON.stringify(since)} — expected an ISO 8601 date (e.g. '2026-03-10T00:00:00Z')`,
);
}
const result = await docmostClient.checkNewComments(
spaceId,
since,
parentPageId,
);
return jsonContent(result);
},
);
// Tool: search
// INTENTIONAL per-transport divergence (not shared): the in-app `searchPages`
// runs a semantic + keyword hybrid (RRF) with in-process access control and a
@@ -857,13 +463,13 @@ server.registerTool(
},
);
// Tool: docmost_transform
// Tool: docmostTransform
// INTENTIONAL per-transport divergence (not shared): the in-app `transformPage`
// deliberately omits the `deleteComments` schema field (comment-deletion
// guardrail) and carries a much shorter description; this transport exposes the
// full helper catalogue. Different schema, so kept per-layer.
server.registerTool(
"docmost_transform",
"docmostTransform",
{
description:
"Edit a page by running an arbitrary JS transform `(doc, ctx) => doc` " +
@@ -935,43 +541,5 @@ server.registerTool(
},
);
// Tool: insert_footnote
// Schema + description now live in the shared registry (#410) so the in-app
// AI-chat agent exposes it too. The execute body is unchanged.
registerShared(
SHARED_TOOL_SPECS.insertFootnote,
async ({ pageId, anchorText, text }) => {
const result = await docmostClient.insertFootnote(pageId, anchorText, text);
return jsonContent(result);
},
);
// Tool: diff_page_versions
registerShared(
SHARED_TOOL_SPECS.diffPageVersions,
async ({ pageId, from, to }) => {
const result = await docmostClient.diffPageVersions(pageId, from, to);
return jsonContent(result);
},
);
// Tool: list_page_history
registerShared(
SHARED_TOOL_SPECS.listPageHistory,
async ({ pageId, cursor }) => {
const result = await docmostClient.listPageHistory(pageId, cursor);
return jsonContent(result);
},
);
// Tool: restore_page_version
registerShared(
SHARED_TOOL_SPECS.restorePageVersion,
async ({ historyId }) => {
const result = await docmostClient.restorePageVersion(historyId);
return jsonContent(result);
},
);
return server;
}
+21 -4
View File
@@ -202,6 +202,21 @@ export class CollabSession {
this.ydoc = new Y.Doc();
}
/**
* Shared diagnostic suffix (issue #437) appended to the connect-timeout,
* persist-timeout and connection-closed error texts: names the offending
* pageId and tells the agent this class of failure is transient (retry once)
* vs. a persistent collab-server outage, so it can self-correct instead of
* blind-looping. The Yjs-encode error is deliberately NOT touched it
* already names the offending attribute.
*/
private hint(): string {
return (
`(pageId ${this.pageId}; transient — retry once; persistent failures ` +
`mean the collab server is unreachable/overloaded)`
);
}
/**
* A cached session may be reused only when it is fully ready, still synced,
* has not lost its connection, and has not exceeded its max age (invariant 5
@@ -232,7 +247,9 @@ export class CollabSession {
// The 25s connect timeout: the collab connection never became ready.
this.opts?.onConnectTimeout?.();
this.teardown(
new Error("Connection timeout to collaboration server"),
new Error(
`Connection timeout to collaboration server ${this.hint()}`,
),
false,
);
}, CONNECT_TIMEOUT_MS);
@@ -259,7 +276,7 @@ export class CollabSession {
if (process.env.DEBUG) console.error("WS Disconnect");
this.teardown(
new Error(
"Collaboration connection closed before the update was persisted/synced",
`Collaboration connection closed before the update was persisted/synced ${this.hint()}`,
),
true,
);
@@ -268,7 +285,7 @@ export class CollabSession {
if (process.env.DEBUG) console.error("WS Close");
this.teardown(
new Error(
"Collaboration connection closed before the update was persisted/synced",
`Collaboration connection closed before the update was persisted/synced ${this.hint()}`,
),
true,
);
@@ -403,7 +420,7 @@ export class CollabSession {
persistTimer = setTimeout(() => {
localFinish(
new Error(
"Timeout waiting for collaboration server to persist the update",
`Timeout waiting for collaboration server to persist the update ${this.hint()}`,
),
);
}, PERSIST_TIMEOUT_MS);
+23 -5
View File
@@ -13,7 +13,11 @@ import { JSDOM } from "jsdom";
import { markdownToProseMirror } from "@docmost/prosemirror-markdown";
import { docmostExtensions, docmostSchema } from "./docmost-schema.js";
import { withPageLock } from "./page-lock.js";
import { sanitizeForYjs, findUnstorableAttr } from "@docmost/prosemirror-markdown";
import {
sanitizeForYjs,
findUnstorableAttr,
findInvalidNode,
} from "@docmost/prosemirror-markdown";
import { canonicalizeFootnotes } from "./footnote-canonicalize.js";
import { normalizeAndMergeFootnotes } from "./footnote-normalize-merge.js";
import { VerifyReport } from "./diff.js";
@@ -28,11 +32,25 @@ export { markdownToProseMirror };
* place. `label` names the stage that failed (diagnostic). `sanitizeForYjs`
* already stripped `undefined` attrs, so a remaining failure is pinpointed via
* `findUnstorableAttr`.
*
* Diagnostics precedence (#409): the dominant crash here is
* `Unknown node type: undefined` a nested node with an absent/unknown `type`
* (a SHAPE problem, e.g. `{"text":"foo"}` missing `"type":"text"`). That points
* at the node, not an attribute, so `findInvalidNode` is consulted FIRST and,
* on a hit, yields a path-anchored node-shape message. Only when the document
* shape is sound do we fall back to `findUnstorableAttr` (undefined/function/
* symbol/bigint attr values); the generic "attribute likely holds a value Yjs
* cannot store" sentence is the last resort.
*/
function unstorableYjsError(safe: any, label: string, e: unknown): Error {
const base = `Failed to encode document to Yjs (${label}): ${e instanceof Error ? e.message : String(e)}.`;
const badNode = findInvalidNode(safe);
if (badNode) {
return new Error(`${base} Invalid node: ${badNode.summary}`);
}
const bad = findUnstorableAttr(safe);
return new Error(
`Failed to encode document to Yjs (${label}): ${e instanceof Error ? e.message : String(e)}.${bad ? ` Offending attribute: ${bad}.` : " A node/mark attribute likely holds a value Yjs cannot store (e.g. undefined)."}`,
`${base}${bad ? ` Offending attribute: ${bad}.` : " A node/mark attribute likely holds a value Yjs cannot store (e.g. undefined)."}`,
);
}
@@ -69,8 +87,8 @@ global.WebSocket = WebSocket;
* bodies merged. So the import output is ALREADY in canonical footnote
* topology.
* - `canonicalizeFootnotes` runs AFTER as the mcp write-path invariant shared
* with every other full-document persist path (`update_page_json`,
* `docmost_transform`, `insert_footnote`, ). Because the package output is
* with every other full-document persist path (`updatePageJson`,
* `docmostTransform`, `insertFootnote`, ). Because the package output is
* already canonical, this layer is a no-op here (idempotent) it exists so
* the page-write contract is enforced uniformly regardless of how the PM doc
* was produced, not because the import needs fixing.
@@ -264,7 +282,7 @@ export async function mutatePageContent(
* it was produced from markdown (ids regenerate) or edited in place
* (existing block ids preserved).
*
* This is an intentional full replace (used by update_page / update_page_json),
* This is an intentional full replace (used by update_page / updatePageJson),
* but now runs under the per-page lock and waits for server persistence via
* mutatePageContent.
*/
+1 -1
View File
@@ -20,7 +20,7 @@
*
* MARKDOWN-STRIP FALLBACK: when the agent copies a selection that still carries
* inline markdown (`**bold**`, `` `code` ``, `[t](u)`), the raw locator will not
* match the document's plain text. Exactly like edit_page_text's json-edit
* match the document's plain text. Exactly like editPageText's json-edit
* fallback, we first try the verbatim selection and, ONLY if it anchors nowhere
* in the whole document, retry with `stripInlineMarkdown` applied. `canAnchorInDoc`,
* `getAnchoredText` and `applyAnchorInDoc` share this decision via
+2 -2
View File
@@ -380,7 +380,7 @@ export interface VerifyReport {
/**
* ONLY structural integrity types whose count changed, as [before, after]
* (images/links/tables/callouts). Surfaces structural mutations that touch
* neither text nor marks (e.g. insert_image, deleting a table) which diffDocs
* neither text nor marks (e.g. insertImage, deleting a table) which diffDocs
* being TEXT-only would otherwise report as "no content change".
*/
structure?: Record<string, [number, number]>;
@@ -400,7 +400,7 @@ export interface VerifyReport {
*
* The structural integrity delta (from diffDocs's `integrity` tuples) is what
* makes `changed` true for an image/table/callout/link count change that diffs
* to zero text closing a verify blind spot for insert_image, delete_node on a
* to zero text closing a verify blind spot for insertImage, deleteNode on a
* table, etc.
*/
export function summarizeChange(before: any, after: any): VerifyReport {
+228
View File
@@ -0,0 +1,228 @@
// Progressive-disclosure authoring reference for the `drawioGuide` tool
// (issue #424, stage 2). The FULL draw.io authoring guide would bloat every
// context window, so it is split into small sections the model reads on demand:
// skeleton | layout | containers | icons-aws | icons-azure
// Content is written directly from the issue #424 appendix (the layout
// heuristics, container rules, AWS icon patterns + gotchas + blocklist, and
// Azure image-style paths). ACCEPTANCE: each section stays <= ~4 KB so pulling
// one is cheap.
export type GuideSection =
| "skeleton"
| "layout"
| "containers"
| "icons-aws"
| "icons-azure";
export const GUIDE_SECTIONS: GuideSection[] = [
"skeleton",
"layout",
"containers",
"icons-aws",
"icons-azure",
];
const SKELETON = `# drawioGuide: skeleton
Canonical mxGraph skeleton. id="0" and id="1" are MANDATORY sentinels; every
real cell has parent="1" (or a container id). Set adaptiveColors="auto" on the
model so Docmost's dark theme adapts strokeColor/fillColor/fontColor="default".
\`\`\`xml
<mxGraphModel dx="800" dy="600" grid="1" gridSize="10" adaptiveColors="auto"
page="1" pageWidth="850" pageHeight="1100">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="2" value="Start" style="rounded=1;whiteSpace=wrap;html=1;"
vertex="1" parent="1">
<mxGeometry x="40" y="40" width="140" height="60" as="geometry"/>
</mxCell>
<mxCell id="3" value="Store" style="shape=cylinder3;whiteSpace=wrap;html=1;"
vertex="1" parent="1">
<mxGeometry x="40" y="200" width="80" height="80" as="geometry"/>
</mxCell>
<mxCell id="e1" edge="1" parent="1" source="2" target="3"
style="edgeStyle=orthogonalEdgeStyle;rounded=1;html=1;">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>
\`\`\`
Three accepted inputs to drawioCreate/drawioUpdate: a bare <mxGraphModel>, a
full <mxfile> (decoded to its first page), or a raw list of <mxCell> (the server
wraps it and adds the id=0/id=1 sentinels).
Hard rules: a cell is vertex="1" XOR edge="1" (a container/group is neither);
every edge has a child <mxGeometry relative="1" as="geometry"/>; ids are unique;
no XML comments; put html=1 in styles and XML-escape value (& -> &amp;,
< -> &lt;); a newline in a label is &#xa;, never a literal \\n. Don't guess
shape=mxgraph.* names call drawioShapes first (a wrong name renders empty).`;
const LAYOUT = `# drawioGuide: layout
Turn "make it look good" into checkable numbers. Or pass layout:"elk" to
drawioCreate/drawioUpdate and the server computes coordinates for you (ELK
layered layout, honouring nested containers) you declare structure, it places
pixels.
Spacing (when placing by hand):
- Horizontal gap between shapes 200-220px; vertical between rows/lanes 250px;
auxiliary services (monitoring, DLQ) sit below the main flow with 280px+ gap.
- Coordinates are multiples of 10 (grid). Base sizes: rectangle 140x60, diamond
140x80, circle 60x60; cloud icons 78x78 primary / 65x65 secondary; font 12px.
- Main flow left-to-right, one primary axis; <=3-4 lanes/zones; one icon/service.
Edges:
- <=1 bend per edge (ideally 0); an edge must not cross another shape's bbox;
two edges must not lie on top of each other.
- Give explicit exitX/exitY/entryX/entryY for every non-straight link or the
orthogonal router drives lines through shapes. Vertical link:
exitX=0.5;exitY=1 -> entryX=0.5;entryY=0. For 2+ links on one node, spread the
attach points 0.25 / 0.5 / 0.75.
- Base edge style:
edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;exitX=1;exitY=0.5;entryX=0;entryY=0.5;
- Edge labels: 1-2 words max, labelBackgroundColor=#F5F5F5;fontSize=11;. Don't
label an obvious flow (Lambda->DynamoDB needs no "Write"); prefer numbering
stages (1,2,3) over many labels.
- Line semantics: solid = main/sync; dashed=1 = async; red dashed
strokeColor=#DD344C = error path.
Alignment: centre a child under its parent by math, not by eye:
child.x = parent.center_x - child.width/2.
The linter returns quality WARNINGS (bbox overlap, edge through a shape,
edge-on-edge, gap <150px, label wider than its shape, negative/off-page coords).
They do not block the write fix them and retry, max 2 iterations.`;
const CONTAINERS = `# drawioGuide: containers
Groups/zones are TRANSPARENT containers. A coloured group fill is an instant
"AI-generated" tell never fill a group.
- Every group: container=1;dropTarget=1;fillColor=none;. It is a cell with
vertex unset AND edge unset.
- Children set parent="<groupId>" and their coordinates are RELATIVE to the
group's top-left, not absolute.
- An edge between cells in DIFFERENT containers must be parent="1" (the layer),
otherwise it is clipped to one container and disappears.
- Keep the group title off the group icon:
spacingLeft=40;spacingTop=-4;.
- Leave >=30px padding between children and the group frame.
- Draw edges on the BACK layer (place their <mxCell> BEFORE the shapes in XML)
and keep >=20px between an arrow and a label.
Swimlanes: style=swimlane;horizontal=0;startSize=110;. Lanes are parent="1";
their members are children of the lane.
Example (transparent zone with two children and an internal edge):
\`\`\`xml
<mxCell id="z1" value="VPC" style="rounded=0;container=1;dropTarget=1;fillColor=none;verticalAlign=top;spacingLeft=40;spacingTop=-4;html=1;"
vertex="1" parent="1">
<mxGeometry x="40" y="40" width="320" height="200" as="geometry"/>
</mxCell>
<mxCell id="a" value="App" style="rounded=1;html=1;" vertex="1" parent="z1">
<mxGeometry x="30" y="40" width="120" height="60" as="geometry"/>
</mxCell>
<mxCell id="b" value="DB" style="shape=cylinder3;html=1;" vertex="1" parent="z1">
<mxGeometry x="30" y="120" width="80" height="60" as="geometry"/>
</mxCell>
<mxCell id="ab" edge="1" parent="z1" source="a" target="b">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
\`\`\``;
const ICONS_AWS = `# drawioGuide: icons-aws
Two mutually-exclusive AWS icon patterns mixing them is the #1 cause of empty
boxes. Always call drawioShapes for the exact resIcon name; do not guess.
| Level | style | strokeColor |
|---|---|---|
| Service | shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.<NAME> | #ffffff (required) |
| Resource | shape=mxgraph.aws4.<NAME> | none (required) |
Full service-level template (fillColor is REQUIRED the glyph is invisible in
PNG export without it):
\`\`\`
sketch=0;outlineConnect=0;fontColor=#232F3E;fillColor=<category>;strokeColor=#ffffff;dashed=0;verticalLabelPosition=bottom;verticalAlign=top;align=center;html=1;fontSize=12;aspect=fixed;shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.<NAME>
\`\`\`
Category fillColor: Compute #ED7100, Networking #8C4FFF, Database #C925D1,
Storage #3F8624, Security #DD344C, Integration #E7157B, AI/ML #01A88D.
Rebrandings (stencil name lags the product name):
- Amazon OpenSearch -> resIcon elasticsearch_service (renamed 2021)
- Amazon EventBridge -> resIcon eventbridge (was CloudWatch Events)
- VPC Peering -> resIcon peering (NOT vpc_peering -> empty box)
- Amazon MSK -> resIcon managed_streaming_for_kafka (NOT msk)
- IAM Identity Center -> resIcon single_sign_on (NOT iam_identity_center)
Blocklist -> replacement: dynamodb_table -> dynamodb; general_saml_token ->
traditional_server; kinesis_data_streams is unreliable. An unknown service ->
generic resIcon=mxgraph.aws4.general_AWScloud WITH a label; an unnamed coloured
rectangle is forbidden.
Group stencils (transparent containers): AWS Cloud group_aws_cloud_alt, VPC
group_vpc2, Subnet group_security_group, Account group_account; subnets use
shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.group_public_subnet;.`;
const ICONS_AZURE = `# drawioGuide: icons-azure
shape=mxgraph.azure2.* does NOT render in every host. Use the portable
image-style instead:
\`\`\`
image;aspect=fixed;html=1;image=img/lib/azure2/<category>/<Icon>.svg;
\`\`\`
Known working paths:
- networking/Front_Doors.svg
- app_services/API_Management_Services.svg
- databases/Azure_Cosmos_DB.svg
- identity/Managed_Identities.svg
- management_governance/Monitor.svg
- devops/Application_Insights.svg
For maximum robustness (e.g. PNG export on a host without the bundled lib), use
an absolute URL fallback for the image:
\`\`\`
https://raw.githubusercontent.com/jgraph/drawio/dev/src/main/webapp/img/lib/azure2/<category>/<Icon>.svg
\`\`\`
Call drawioShapes with the service name (e.g. "cosmos", "api management",
"front door") to get the exact image-style string and default 68x68 size.`;
const CONTENT: Record<GuideSection, string> = {
skeleton: SKELETON,
layout: LAYOUT,
containers: CONTAINERS,
"icons-aws": ICONS_AWS,
"icons-azure": ICONS_AZURE,
};
/**
* Return one guide section, or (when `section` is omitted/unknown) an index
* listing the available sections plus a one-line summary each. Each section is
* kept under ~4 KB so pulling it does not bloat the model's context.
*/
export function getGuideSection(section?: string): {
section: string;
content: string;
sections: GuideSection[];
} {
const key = (section ?? "").trim().toLowerCase() as GuideSection;
if (section && GUIDE_SECTIONS.includes(key)) {
return { section: key, content: CONTENT[key], sections: GUIDE_SECTIONS };
}
const index =
"# drawioGuide\n\nProgressive-disclosure draw.io authoring reference. " +
"Call drawioGuide(section) with one of:\n" +
"- skeleton — canonical mxGraph XML, sentinels, the three accepted inputs, hard rules\n" +
"- layout — spacing heuristics, edge routing, the layout:\"elk\" option, quality warnings\n" +
"- containers — transparent groups, relative child coords, cross-container edges, swimlanes\n" +
"- icons-aws — the service/resource icon patterns, category colors, rebrandings, blocklist\n" +
"- icons-azure — the portable image-style paths\n\n" +
"Also call drawioShapes(query) for verified stencil style-strings.";
return { section: "index", content: index, sections: GUIDE_SECTIONS };
}
+240
View File
@@ -0,0 +1,240 @@
// ELK auto-layout for draw.io models (issue #424, stage 2). The model declares
// the LOGICAL structure (which nodes exist, which containers nest which
// children, which edges connect what) with rough or arbitrary coordinates; this
// module runs an Eclipse Layout Kernel "layered" pass (via elkjs — a pure-JS
// port, no native/browser deps) that HONOURS nested containers as compound
// nodes, then rewrites every vertex's <mxGeometry> with the computed pixels.
//
// Principle: "the model declares logical structure, the server computes pixels."
// Coordinates ELK returns for a node are relative to its parent, which is
// exactly mxGraph's convention for a child of a container, so they map across
// directly. Container sizes are computed by ELK; leaf sizes are preserved.
import ELK from "elkjs/lib/elk.bundled.js";
import { JSDOM } from "jsdom";
import { normalizeInput, parseCells, type DrawioCell } from "./drawio-xml.js";
// Default sizes when a vertex declares no geometry (appendix base sizes).
const DEFAULT_W = 140;
const DEFAULT_H = 60;
// DoS bounds for the in-process ELK layout. The mxGraph XML is LLM-supplied
// (layout:"elk" in drawioCreate/drawioUpdate) and elkjs runs synchronously on
// the MCP server's event loop, so an unbounded graph would block it for
// seconds-to-minutes. A ~1MB XML (well under the stage-1 16MB cap) can carry
// thousands of nodes. We cap the graph size and race the layout against a
// wall-clock timeout; on either bound we fall back to the ORIGINAL model, the
// same best-effort contract the catch already honours.
// - 500 nodes lays out in well under a second; beyond that ELK cost climbs
// steeply, so refuse and leave the (already-valid) model untouched.
// - Edges dominate the layered-crossing cost, so allow a bit more headroom
// (1000) than nodes but still bound them.
// - 5s is generous for any graph within the caps yet short enough that a
// pathological input can never wedge the server.
const ELK_MAX_NODES = 500;
const ELK_MAX_EDGES = 1000;
const ELK_TIMEOUT_MS = 5000;
// Spacing is set >=150px on purpose so an ELK layout never trips the linter's
// "gap between adjacent shapes < 150px" quality warning (acceptance #3).
const LAYOUT_OPTIONS: Record<string, string> = {
"elk.algorithm": "layered",
"elk.direction": "RIGHT",
// Route edges across container boundaries in a single hierarchical pass.
"elk.hierarchyHandling": "INCLUDE_CHILDREN",
"elk.layered.spacing.nodeNodeBetweenLayers": "170",
"elk.spacing.nodeNode": "170",
"elk.spacing.edgeNode": "40",
"elk.spacing.edgeEdge": "30",
"elk.padding": "[top=20,left=20,bottom=20,right=20]",
};
// Per-container options: pad children >=30px off the frame (appendix rule) and
// carry the same generous spacing so nested nodes never trip the "gap <150px"
// warning either.
const CONTAINER_OPTIONS: Record<string, string> = {
"elk.algorithm": "layered",
"elk.direction": "RIGHT",
"elk.padding": "[top=40,left=30,bottom=30,right=30]",
"elk.layered.spacing.nodeNodeBetweenLayers": "170",
"elk.spacing.nodeNode": "170",
};
interface ElkNode {
id: string;
width?: number;
height?: number;
x?: number;
y?: number;
children?: ElkNode[];
layoutOptions?: Record<string, string>;
}
interface ElkEdge {
id: string;
sources: string[];
targets: string[];
}
interface ElkGraph extends ElkNode {
edges?: ElkEdge[];
}
/**
* Apply an ELK layered layout to a drawio input and return a full mxGraphModel
* string with rewritten geometry. Accepts the same three input forms as
* drawioCreate (a bare model, an <mxfile>, or a <mxCell> list). Async because
* elkjs' layout() is promise-based. On any layout failure the ORIGINAL
* (normalized) model is returned unchanged layout is best-effort polish, never
* a reason to fail the write.
*/
export async function applyElkLayout(inputXml: string): Promise<string> {
const modelXml = normalizeInput(inputXml);
let cells: DrawioCell[];
try {
cells = parseCells(modelXml);
} catch {
return modelXml; // unparseable -> let the linter report it downstream
}
const byId = new Map(cells.map((c) => [c.id, c]));
const vertices = cells.filter(
(c) => c.vertex && c.id !== "0" && c.id !== "1",
);
if (vertices.length === 0) return modelXml;
// A vertex is a CONTAINER iff some other vertex names it as parent.
const childrenOf = new Map<string, DrawioCell[]>();
for (const v of vertices) {
const p = v.parent && byId.get(v.parent)?.vertex ? v.parent : "__root__";
if (!childrenOf.has(p)) childrenOf.set(p, []);
childrenOf.get(p)!.push(v);
}
const isContainer = (id: string) => childrenOf.has(id);
const buildNode = (v: DrawioCell): ElkNode => {
const kids = childrenOf.get(v.id);
const node: ElkNode = { id: v.id };
if (kids && kids.length > 0) {
node.children = kids.map(buildNode);
node.layoutOptions = { ...CONTAINER_OPTIONS };
} else {
node.width = v.geometry.width ?? DEFAULT_W;
node.height = v.geometry.height ?? DEFAULT_H;
}
return node;
};
const roots = (childrenOf.get("__root__") ?? []).map(buildNode);
// All edges at the root; INCLUDE_CHILDREN lets them span the hierarchy. Only
// edges whose endpoints are laid-out vertices are handed to ELK.
const vertexIds = new Set(vertices.map((v) => v.id));
const edges: ElkEdge[] = [];
for (const c of cells) {
if (!c.edge || !c.source || !c.target) continue;
if (!vertexIds.has(c.source) || !vertexIds.has(c.target)) continue;
edges.push({ id: c.id || `e${edges.length}`, sources: [c.source], targets: [c.target] });
}
// DoS guard: refuse to lay out an oversized LLM-supplied graph. elkjs runs
// in-process on the event loop, so bound the work before we ever call it and
// return the original model unchanged (best-effort, same as the catch below).
if (vertices.length > ELK_MAX_NODES || edges.length > ELK_MAX_EDGES) {
return modelXml;
}
const graph: ElkGraph = {
id: "root",
layoutOptions: LAYOUT_OPTIONS,
children: roots,
edges,
};
let laid: ElkGraph;
let timer: ReturnType<typeof setTimeout> | undefined;
try {
// elkjs ships a CJS default export whose interop shape varies across
// module systems; resolve the real constructor at runtime, then cast (the
// runtime call is verified — see the layout unit test).
const Ctor: any = (ELK as any).default ?? ELK;
const elk = new Ctor();
// Race the layout against a wall-clock timeout so a graph that is under the
// node/edge caps but still pathologically slow can never wedge the server.
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error("ELK layout timed out")),
ELK_TIMEOUT_MS,
);
});
laid = (await Promise.race([elk.layout(graph as any), timeout])) as ElkGraph;
} catch {
return modelXml; // best-effort: keep the model as-is on timeout or ELK failure
} finally {
if (timer) clearTimeout(timer);
}
// Collect computed geometry per node id (coords are parent-relative already).
const geo = new Map<string, { x: number; y: number; w: number; h: number }>();
const walk = (n: ElkNode) => {
if (n.id !== "root") {
geo.set(n.id, {
x: Math.round(n.x ?? 0),
y: Math.round(n.y ?? 0),
w: Math.round(n.width ?? DEFAULT_W),
h: Math.round(n.height ?? DEFAULT_H),
});
}
for (const c of n.children ?? []) walk(c);
};
walk(laid);
return rewriteGeometry(modelXml, geo, isContainer);
}
/**
* Rewrite each vertex cell's <mxGeometry> x/y (and width/height for containers,
* whose size ELK computed) using the DOM, then serialize back. Leaf sizes are
* left untouched. Edges and non-geometry attributes are preserved verbatim.
*/
function rewriteGeometry(
modelXml: string,
geo: Map<string, { x: number; y: number; w: number; h: number }>,
isContainer: (id: string) => boolean,
): string {
const dom = new JSDOM("");
const parser = new dom.window.DOMParser();
const doc = parser.parseFromString(modelXml, "application/xml");
if (doc.getElementsByTagName("parsererror").length > 0) return modelXml;
const cellEls = doc.getElementsByTagName("mxCell");
for (let i = 0; i < cellEls.length; i++) {
const el = cellEls[i];
const id = el.getAttribute("id") || "";
const g = geo.get(id);
if (!g) continue;
let geoEl: any = null;
for (let j = 0; j < el.childNodes.length; j++) {
const ch = el.childNodes[j];
if (ch.nodeType === 1 && (ch as any).tagName === "mxGeometry") {
geoEl = ch;
break;
}
}
if (!geoEl) {
geoEl = doc.createElement("mxGeometry");
geoEl.setAttribute("as", "geometry");
el.appendChild(geoEl);
}
geoEl.setAttribute("x", String(g.x));
geoEl.setAttribute("y", String(g.y));
// Containers take ELK's computed size; leaves keep their authored size.
if (isContainer(id) || !geoEl.hasAttribute("width")) {
geoEl.setAttribute("width", String(g.w));
}
if (isContainer(id) || !geoEl.hasAttribute("height")) {
geoEl.setAttribute("height", String(g.h));
}
}
const ser = new dom.window.XMLSerializer();
return ser.serializeToString(doc.documentElement);
}
+420
View File
@@ -0,0 +1,420 @@
// Verified draw.io shape catalog for the `drawioShapes` tool (issue #424,
// stage 2). This is the fix for AI-generated diagrams' #1 defect: guessed
// `shape=mxgraph.*` names that render as EMPTY BOXES because the stencil does
// not exist. Instead of guessing, the model queries this catalog and gets back
// an exact, verified style-string + the stencil's default width/height.
//
// DATA SOURCE — the bundled index is the REAL jgraph/drawio-mcp shape index
// (`shape-search/search-index.json`, Apache-2.0, ~10 446 shapes), fetched
// verbatim and gzip-compressed to `packages/mcp/data/drawio-shape-index.json.gz`
// (~4.7 MB -> ~430 KB). Each record is `{ style, w, h, title, tags, type }`.
//
// REGENERATING THE INDEX (keeps the catalog from going stale as draw.io ships
// new stencils): jgraph publishes `shape-search/generate-index.js`, which
// rebuilds `search-index.json` from a draw.io release's `app.min.js`. To update:
// 1. clone https://github.com/jgraph/drawio-mcp (Apache-2.0)
// 2. run `node shape-search/generate-index.js` per its README
// 3. `gzip -9 -c search-index.json > packages/mcp/data/drawio-shape-index.json.gz`
// The record shape and this module's search stay unchanged.
//
// CURATED OVERLAY — on top of the raw index this module carries a small,
// hand-maintained overlay drawn from the issue #424 appendix (the aws-
// architecture-diagram-skill knowledge): AWS service rebrandings whose stencil
// name lags the product name, a BLOCKLIST of known-broken stencils mapped to
// working replacements, the category fillColor palette, the AWS group/subnet
// stencils, and the Azure image-style paths. The overlay is applied BEFORE the
// raw search so a query for a rebranded/blocked name returns the correct answer
// with an explanatory note instead of the empty-box stencil.
import { readFileSync } from "node:fs";
import { gunzipSync } from "node:zlib";
/** A single catalog record as returned to the model. */
export interface ShapeResult {
/** The exact draw.io style-string to put on the cell. */
style: string;
/** Default width in px for this stencil. */
w: number;
/** Default height in px for this stencil. */
h: number;
/** Human-readable stencil name. */
title: string;
/** "vertex" | "edge" (from the index). */
type: string;
/** AWS category (Compute/Database/…) when derivable, else undefined. */
category?: string;
/**
* Present when the overlay rewrote/annotated the answer: a rebrand, a
* blocklist replacement, or a usage hint. The model should surface it.
*/
note?: string;
}
/** Raw record shape in the bundled index. */
interface IndexRecord {
style: string;
w: number;
h: number;
title: string;
tags: string;
type: string;
}
// --- AWS category fillColor palette (appendix) -----------------------------
// Service-level icons MUST carry a fillColor (invisible in PNG export
// otherwise); the color is the AWS category color.
export const AWS_CATEGORY_FILL: Record<string, string> = {
Compute: "#ED7100",
Networking: "#8C4FFF",
Database: "#C925D1",
Storage: "#3F8624",
Security: "#DD344C",
Integration: "#E7157B",
"AI/ML": "#01A88D",
};
/** Reverse lookup: fillColor hex -> category name (for annotating results). */
const FILL_TO_CATEGORY: Record<string, string> = Object.fromEntries(
Object.entries(AWS_CATEGORY_FILL).map(([k, v]) => [v.toLowerCase(), k]),
);
/**
* Build the canonical service-level AWS icon style for a resIcon name. Mirrors
* the appendix's full template: strokeColor=#ffffff is MANDATORY and fillColor
* is the category color (defaults to AWS ink #232F3E when the category is
* unknown, so the glyph is never invisible).
*/
export function awsServiceStyle(resIcon: string, category?: string): string {
const fill = (category && AWS_CATEGORY_FILL[category]) || "#232F3E";
return (
"sketch=0;outlineConnect=0;fontColor=#232F3E;gradientColor=none;" +
`fillColor=${fill};strokeColor=#ffffff;dashed=0;verticalLabelPosition=bottom;` +
"verticalAlign=top;align=center;html=1;fontSize=12;fontStyle=0;aspect=fixed;" +
`shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.${resIcon}`
);
}
// --- AWS rebrandings (appendix "gotcha" table) -----------------------------
// The stencil name lags the AWS product name; a naive query for the product
// name would miss (or return an empty box). Each alias maps to the REAL resIcon.
interface Rebrand {
aliases: string[];
resIcon: string;
category?: string;
note: string;
}
export const AWS_REBRANDS: Rebrand[] = [
{
aliases: ["opensearch", "open search", "amazon opensearch"],
resIcon: "elasticsearch_service",
category: "Database",
note: "Amazon OpenSearch's stencil is still named `elasticsearch_service` (renamed in 2021).",
},
{
aliases: ["eventbridge", "event bridge", "cloudwatch events"],
resIcon: "eventbridge",
category: "Integration",
note: "Amazon EventBridge uses resIcon `eventbridge` (formerly CloudWatch Events).",
},
{
aliases: ["vpc peering", "peering"],
resIcon: "peering",
category: "Networking",
note: "VPC Peering is resIcon `peering`, NOT `vpc_peering` (which renders empty).",
},
{
aliases: ["msk", "kafka", "managed streaming", "amazon msk"],
resIcon: "managed_streaming_for_kafka",
category: "Integration",
note: "Amazon MSK is resIcon `managed_streaming_for_kafka`, NOT `msk`.",
},
{
aliases: ["iam identity center", "identity center", "sso", "single sign on"],
resIcon: "single_sign_on",
category: "Security",
note: "IAM Identity Center is resIcon `single_sign_on`, NOT `iam_identity_center`.",
},
];
// --- BLOCKLIST of broken stencils (appendix) -------------------------------
// A query that names one of these gets the working replacement + a note; the
// broken stencil is never returned.
interface Blocked {
bad: string;
good: string;
goodStyle?: (idx: IndexRecord[]) => ShapeResult | null;
note: string;
}
export const AWS_BLOCKLIST: Blocked[] = [
{
bad: "dynamodb_table",
good: "dynamodb",
note: "`dynamodb_table` renders as an empty box; use resIcon `dynamodb`.",
},
{
bad: "general_saml_token",
good: "traditional_server",
note: "`general_saml_token` is broken; use resIcon `traditional_server`.",
},
{
bad: "kinesis_data_streams",
good: "kinesis_data_streams",
note: "`kinesis_data_streams` is unreliable across draw.io versions; verify it renders, or fall back to resIcon `kinesis`.",
},
];
// --- AWS group / container stencils (appendix) -----------------------------
// Groups are transparent containers; these are the verified stencil names.
export const AWS_GROUP_STENCILS: ShapeResult[] = [
{
title: "AWS Cloud (group)",
style:
"points=[[0,0],[0.25,0],[0.5,0],[0.75,0],[1,0],[1,0.25],[1,0.5],[1,0.75],[1,1],[0.75,1],[0.5,1],[0.25,1],[0,1],[0,0.75],[0,0.5],[0,0.25]];" +
"outlineConnect=0;gradientColor=none;html=1;whiteSpace=wrap;fontSize=12;fontStyle=0;container=1;" +
"pointerEvents=0;collapsible=0;recursiveResize=0;shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.group_aws_cloud_alt;" +
"strokeColor=#232F3E;fillColor=none;verticalAlign=top;align=left;spacingLeft=30;fontColor=#232F3E;dashed=0;",
w: 400,
h: 300,
type: "vertex",
note: "AWS Cloud boundary — transparent container (grIcon=group_aws_cloud_alt).",
},
{
title: "VPC (group)",
style:
"points=[[0,0],[0.25,0],[0.5,0],[0.75,0],[1,0],[1,0.25],[1,0.5],[1,0.75],[1,1],[0.75,1],[0.5,1],[0.25,1],[0,1],[0,0.75],[0,0.5],[0,0.25]];" +
"outlineConnect=0;gradientColor=none;html=1;whiteSpace=wrap;fontSize=12;fontStyle=0;container=1;" +
"pointerEvents=0;collapsible=0;recursiveResize=0;shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.group_vpc2;" +
"strokeColor=#8C4FFF;fillColor=none;verticalAlign=top;align=left;spacingLeft=30;fontColor=#8C4FFF;dashed=0;",
w: 350,
h: 250,
type: "vertex",
note: "VPC boundary — transparent container (grIcon=group_vpc2).",
},
{
title: "Public Subnet (group)",
style:
"sketch=0;outlineConnect=0;gradientColor=none;html=1;whiteSpace=wrap;fontSize=12;fontStyle=0;container=1;" +
"pointerEvents=0;collapsible=0;recursiveResize=0;shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.group_public_subnet;" +
"grStroke=0;strokeColor=none;fillColor=#E9F3E6;verticalAlign=top;align=left;spacingLeft=30;fontColor=#248814;dashed=0;",
w: 300,
h: 200,
type: "vertex",
note: "Public subnet — transparent container (grIcon=group_public_subnet).",
},
{
title: "Private Subnet (group)",
style:
"sketch=0;outlineConnect=0;gradientColor=none;html=1;whiteSpace=wrap;fontSize=12;fontStyle=0;container=1;" +
"pointerEvents=0;collapsible=0;recursiveResize=0;shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.group_private_subnet;" +
"grStroke=0;strokeColor=none;fillColor=#E6F2F8;verticalAlign=top;align=left;spacingLeft=30;fontColor=#147EBA;dashed=0;",
w: 300,
h: 200,
type: "vertex",
note: "Private subnet — transparent container (grIcon=group_private_subnet).",
},
];
// --- Azure image-style stencils (appendix) ---------------------------------
// `shape=mxgraph.azure2.*` does not render in every host; the image-style path
// is the portable form. These are the verified known-working paths.
interface AzureIcon {
aliases: string[];
path: string;
title: string;
}
const AZURE_ICONS: AzureIcon[] = [
{ aliases: ["front door", "front doors"], path: "networking/Front_Doors.svg", title: "Azure Front Door" },
{ aliases: ["api management", "apim"], path: "app_services/API_Management_Services.svg", title: "Azure API Management" },
{ aliases: ["cosmos", "cosmos db"], path: "databases/Azure_Cosmos_DB.svg", title: "Azure Cosmos DB" },
{ aliases: ["managed identity", "managed identities"], path: "identity/Managed_Identities.svg", title: "Azure Managed Identity" },
{ aliases: ["azure monitor", "monitor"], path: "management_governance/Monitor.svg", title: "Azure Monitor" },
{ aliases: ["application insights", "app insights"], path: "devops/Application_Insights.svg", title: "Azure Application Insights" },
];
/** Build the portable Azure image-style for a lib path (appendix template). */
export function azureImageStyle(path: string): string {
return `sketch=0;points=[[0,0,0],[0.25,0,0],[0.5,0,0],[0.75,0,0],[1,0,0],[0,1,0],[0.25,1,0],[0.5,1,0],[0.75,1,0],[1,1,0],[0,0.25,0],[0,0.5,0],[0,0.75,0],[1,0.25,0],[1,0.5,0],[1,0.75,0]];shadow=0;dashed=0;html=1;strokeColor=none;fillColor=#5E9BD9;labelPosition=center;verticalLabelPosition=bottom;verticalAlign=top;align=center;outlineConnect=0;image;aspect=fixed;image=img/lib/azure2/${path};`;
}
// --- index loading (lazy, cached) ------------------------------------------
let _index: IndexRecord[] | null = null;
/** Path to the bundled gzipped index, resolved relative to the built module. */
function indexPath(): URL {
// build/lib/drawio-shapes.js -> ../../data/… -> packages/mcp/data/…
return new URL("../../data/drawio-shape-index.json.gz", import.meta.url);
}
/** Load + decompress + parse the bundled index once, then cache it. */
export function loadShapeIndex(): IndexRecord[] {
if (_index) return _index;
const gz = readFileSync(indexPath());
const json = gunzipSync(gz).toString("utf-8");
const arr = JSON.parse(json) as IndexRecord[];
_index = arr;
return arr;
}
/** Derive an AWS category from a service-level icon's fillColor, if present. */
function categoryOf(style: string): string | undefined {
const m = /fillColor=(#[0-9a-fA-F]{6})/.exec(style);
if (!m) return undefined;
return FILL_TO_CATEGORY[m[1].toLowerCase()];
}
function toResult(r: IndexRecord): ShapeResult {
return {
style: r.style,
w: r.w,
h: r.h,
title: r.title,
type: r.type,
category: categoryOf(r.style),
};
}
/** Find the best index record whose style carries `resIcon=<name>`. */
function findByResIcon(idx: IndexRecord[], name: string): IndexRecord | null {
const needle = `resIcon=mxgraph.aws4.${name}`;
// Prefer the service-level resourceIcon form; fall back to any style match.
let fallback: IndexRecord | null = null;
for (const r of idx) {
if (r.style.includes(needle) && r.style.includes("resourceIcon")) return r;
if (!fallback && r.style.includes(needle)) fallback = r;
}
return fallback;
}
/**
* Score a record against a lowercased query. Higher is better; 0 = no match.
* Exact title match ranks highest, then title substring, tag word, then a loose
* style/tag substring. This is a cheap substring+token scorer, not a real fuzzy
* matcher, which is plenty for the "give me the lambda icon" use case.
*/
function score(r: IndexRecord, q: string): number {
const title = r.title.toLowerCase();
const tags = r.tags.toLowerCase();
const style = r.style.toLowerCase();
let s = title === q ? 100 : 0;
if (title !== q && title.includes(q)) s += 40 - Math.min(20, title.length - q.length);
const words = q.split(/\s+/).filter(Boolean);
for (const w of words) {
if (title.includes(w)) s += 12;
if (new RegExp(`(^|\\W)${escapeRe(w)}(\\W|$)`).test(tags)) s += 8;
else if (tags.includes(w)) s += 4;
if (style.includes(w)) s += 2;
}
// Prefer the current AWS icon generation (aws4) over the deprecated aws3
// stencils, which are the older visual style and often not what's wanted.
if (s > 0) {
if (style.includes("mxgraph.aws4")) s += 6;
else if (style.includes("mxgraph.aws3")) s -= 12;
}
return s;
}
function escapeRe(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export interface SearchShapesOptions {
category?: string;
limit?: number;
}
/**
* Search the catalog. Applies the curated overlay first (blocklist replacement,
* AWS rebrand, AWS group stencils, Azure image-style), then substring/tag/fuzzy
* search over the bundled ~10 446-shape index. Returns up to `limit` results
* (default 12) with exact style-strings and default sizes.
*/
export function searchShapes(
query: string,
opts: SearchShapesOptions = {},
): ShapeResult[] {
const limit = Math.max(1, Math.min(50, opts.limit ?? 12));
const q = query.trim().toLowerCase();
if (q === "") return [];
const idx = loadShapeIndex();
const out: ShapeResult[] = [];
const seen = new Set<string>();
const push = (r: ShapeResult) => {
if (seen.has(r.style)) return;
seen.add(r.style);
out.push(r);
};
// 1. BLOCKLIST: a query naming a broken stencil returns the replacement.
for (const b of AWS_BLOCKLIST) {
if (q.includes(b.bad) || b.bad.includes(q.replace(/\s+/g, "_"))) {
const rec = findByResIcon(idx, b.good);
if (rec) push({ ...toResult(rec), note: b.note });
}
}
// 2. AWS rebrandings: surface the correct resIcon with the rename note.
for (const rb of AWS_REBRANDS) {
if (rb.aliases.some((a) => q === a || q.includes(a) || a.includes(q))) {
const rec = findByResIcon(idx, rb.resIcon);
if (rec) {
push({ ...toResult(rec), category: rec ? categoryOf(rec.style) ?? rb.category : rb.category, note: rb.note });
} else {
push({
style: awsServiceStyle(rb.resIcon, rb.category),
w: 78,
h: 78,
title: rb.resIcon,
type: "vertex",
category: rb.category,
note: rb.note,
});
}
}
}
// 3. Azure image-style icons.
for (const az of AZURE_ICONS) {
if (az.aliases.some((a) => q.includes(a) || a.includes(q))) {
push({
style: azureImageStyle(az.path),
w: 68,
h: 68,
title: az.title,
type: "vertex",
category: "Azure",
note: "Azure: portable image-style (shape=mxgraph.azure2.* does not render in every host).",
});
}
}
// 4. AWS group/container stencils.
if (/\b(group|container|boundary|vpc|subnet|cloud|account)\b/.test(q)) {
for (const g of AWS_GROUP_STENCILS) {
if (g.title.toLowerCase().includes(q) || q.split(/\s+/).some((w) => g.title.toLowerCase().includes(w))) {
push(g);
}
}
}
// 5. General index search (substring + tags + loose fuzzy).
const catFilter = opts.category?.toLowerCase();
const scored: { r: IndexRecord; s: number }[] = [];
for (const r of idx) {
const s = score(r, q);
if (s <= 0) continue;
if (catFilter) {
const cat = categoryOf(r.style)?.toLowerCase();
const inStyle = r.style.toLowerCase().includes(catFilter);
if (cat !== catFilter && !inStyle) continue;
}
scored.push({ r, s });
}
scored.sort((a, b) => b.s - a.s || a.r.title.length - b.r.title.length);
for (const { r } of scored) {
if (out.length >= limit) break;
push(toResult(r));
}
return out.slice(0, limit);
}
+307 -2
View File
@@ -461,6 +461,308 @@ export function absolutePos(
return { x, y };
}
// --- quality warnings (geometry, non-blocking) -----------------------------
//
// These are computed purely from geometry — NO rendering — and are returned as
// WARNINGS (never errors): they do not block the write, they nudge the model to
// self-correct ("fix the warnings and retry, max 2 iterations"). They replace
// the vision-self-check a render backend would have done.
interface Rect {
x: number;
y: number;
w: number;
h: number;
}
/** Absolute rect of a vertex (following the container chain), or null. */
function rectOf(cell: DrawioCell, byId: Map<string, DrawioCell>): Rect | null {
if (!cell.vertex || !cell.geometry.hasGeometry) return null;
const g = cell.geometry;
if (g.width == null || g.height == null) return null;
const { x, y } = absolutePos(cell, byId);
return { x, y, w: g.width, h: g.height };
}
/** True if `ancestorId` is somewhere up `cell`'s parent chain. */
function isAncestor(
ancestorId: string,
cell: DrawioCell,
byId: Map<string, DrawioCell>,
): boolean {
const seen = new Set<string>([cell.id]);
let p = cell.parent;
while (p && !seen.has(p)) {
if (p === ancestorId) return true;
seen.add(p);
p = byId.get(p)?.parent;
}
return false;
}
/** Strict interior overlap of two rects (touching edges do NOT count). */
function rectsOverlap(a: Rect, b: Rect): boolean {
return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
}
function center(r: Rect): { x: number; y: number } {
return { x: r.x + r.w / 2, y: r.y + r.h / 2 };
}
/**
* Liang-Barsky: does segment p->q pass through the INTERIOR of rect r? Used to
* detect an edge crossing a shape that is not one of its endpoints.
*/
function segCrossesRect(
a: { x: number; y: number },
b: { x: number; y: number },
r: Rect,
): boolean {
const dx = b.x - a.x;
const dy = b.y - a.y;
// Canonical Liang-Barsky: for each of the 4 slabs, p*t <= q.
const p = [-dx, dx, -dy, dy];
const q = [a.x - r.x, r.x + r.w - a.x, a.y - r.y, r.y + r.h - a.y];
let t0 = 0;
let t1 = 1;
for (let i = 0; i < 4; i++) {
if (p[i] === 0) {
if (q[i] < 0) return false; // parallel to this slab AND outside it
continue;
}
const t = q[i] / p[i];
if (p[i] < 0) {
if (t > t1) return false;
if (t > t0) t0 = t;
} else {
if (t < t0) return false;
if (t < t1) t1 = t;
}
}
return t1 > t0; // strictly non-degenerate overlap with the rect interior
}
function cross(
ox: number,
oy: number,
ax: number,
ay: number,
bx: number,
by: number,
): number {
return (ax - ox) * (by - oy) - (ay - oy) * (bx - ox);
}
/** Collinear + overlapping test for two straight segments (edge-on-edge). */
function segmentsOverlap(
a1: { x: number; y: number },
a2: { x: number; y: number },
b1: { x: number; y: number },
b2: { x: number; y: number },
): boolean {
const EPS = 1;
// b1 and b2 must be (near-)collinear with segment a.
if (
Math.abs(cross(a1.x, a1.y, a2.x, a2.y, b1.x, b1.y)) > EPS * dist(a1, a2) ||
Math.abs(cross(a1.x, a1.y, a2.x, a2.y, b2.x, b2.y)) > EPS * dist(a1, a2)
) {
return false;
}
// Project all four points onto the dominant axis and test 1-D overlap length.
const horizontal = Math.abs(a2.x - a1.x) >= Math.abs(a2.y - a1.y);
const pa = horizontal ? [a1.x, a2.x] : [a1.y, a2.y];
const pb = horizontal ? [b1.x, b2.x] : [b1.y, b2.y];
const loA = Math.min(pa[0], pa[1]);
const hiA = Math.max(pa[0], pa[1]);
const loB = Math.min(pb[0], pb[1]);
const hiB = Math.max(pb[0], pb[1]);
const overlap = Math.min(hiA, hiB) - Math.max(loA, loB);
return overlap > 5; // >5px of shared collinear run
}
function dist(
a: { x: number; y: number },
b: { x: number; y: number },
): number {
return Math.hypot(a.x - b.x, a.y - b.y) || 1;
}
/** Approximate rendered text width (px) of a cell value at a font size. */
function estimateLabelWidth(value: string, fontSize: number): number {
// Decode explicit line breaks, strip tags/entities, take the longest line.
const lines = value
.replace(/&#xa;/gi, "\n")
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<[^>]+>/g, "")
.replace(/&[a-z]+;/gi, "x")
.split("\n");
let longest = 0;
for (const l of lines) longest = Math.max(longest, l.trim().length);
// ~0.6em per glyph is a decent average for proportional fonts.
return longest * fontSize * 0.6;
}
/** Page size declared on the model root, defaulting to Letter (850x1100). */
function parsePageSize(modelXml: string): { w: number; h: number } {
const w = /pageWidth="(\d+)"/.exec(modelXml);
const h = /pageHeight="(\d+)"/.exec(modelXml);
return {
w: w ? Number(w[1]) : 850,
h: h ? Number(h[1]) : 1100,
};
}
/** Minimum required gap between adjacent shapes (appendix heuristic). */
export const MIN_SHAPE_GAP = 150;
/**
* Compute the geometry-derived quality warnings for a parsed model. Each is a
* `[rule] message` string. Pure no rendering, no I/O.
*/
export function computeQualityWarnings(
cells: DrawioCell[],
modelXml?: string,
): string[] {
const warnings: string[] = [];
const byId = new Map(cells.map((c) => [c.id, c]));
const verts = cells.filter((c) => c.vertex && c.id !== "0" && c.id !== "1");
const isContainer = (id: string) =>
verts.some((v) => v.parent === id);
const rects = new Map<string, Rect>();
for (const v of verts) {
const r = rectOf(v, byId);
if (r) rects.set(v.id, r);
}
// 1. Shape bbox overlap (excluding a container overlapping its own child).
for (let i = 0; i < verts.length; i++) {
for (let j = i + 1; j < verts.length; j++) {
const a = verts[i];
const b = verts[j];
const ra = rects.get(a.id);
const rb = rects.get(b.id);
if (!ra || !rb) continue;
if (isAncestor(a.id, b, byId) || isAncestor(b.id, a, byId)) continue;
if (rectsOverlap(ra, rb)) {
warnings.push(
`[shape-overlap] shapes "${a.id}" and "${b.id}" overlap; separate them (>=${MIN_SHAPE_GAP}px apart) or use layout:"elk"`,
);
}
}
}
// 2. Edge passing through a non-endpoint LEAF shape's bbox.
const edges = cells.filter((c) => c.edge);
for (const e of edges) {
if (!e.source || !e.target) continue;
const rs = rects.get(e.source);
const rt = rects.get(e.target);
if (!rs || !rt) continue;
const p = center(rs);
const q = center(rt);
for (const v of verts) {
if (v.id === e.source || v.id === e.target) continue;
if (isContainer(v.id)) continue; // an edge legitimately crosses container frames
const rv = rects.get(v.id);
if (!rv) continue;
// shrink to avoid flagging a graze at a shared layer boundary
const shrunk: Rect = { x: rv.x + 6, y: rv.y + 6, w: rv.w - 12, h: rv.h - 12 };
if (shrunk.w <= 0 || shrunk.h <= 0) continue;
if (segCrossesRect(p, q, shrunk)) {
warnings.push(
`[edge-through-shape] edge "${e.id}" passes through shape "${v.id}" (not its source/target); add exitX/exitY/entryX/entryY or a waypoint`,
);
break;
}
}
}
// 3. Edge-on-edge overlap (parallel duplicates or collinear shared runs).
const edgeSegs: { id: string; a: any; b: any; key: string }[] = [];
for (const e of edges) {
if (!e.source || !e.target) continue;
const rs = rects.get(e.source);
const rt = rects.get(e.target);
if (!rs || !rt) continue;
const key = [e.source, e.target].sort().join("::");
edgeSegs.push({ id: e.id, a: center(rs), b: center(rt), key });
}
for (let i = 0; i < edgeSegs.length; i++) {
for (let j = i + 1; j < edgeSegs.length; j++) {
const ea = edgeSegs[i];
const eb = edgeSegs[j];
const dup = ea.key === eb.key;
if (dup || segmentsOverlap(ea.a, ea.b, eb.a, eb.b)) {
warnings.push(
`[edge-overlap] edges "${ea.id}" and "${eb.id}" lie on top of each other; offset one (distinct exit/entry points) or reroute`,
);
}
}
}
// 4. Adjacent SIBLING leaf shapes closer than MIN_SHAPE_GAP.
for (let i = 0; i < verts.length; i++) {
for (let j = i + 1; j < verts.length; j++) {
const a = verts[i];
const b = verts[j];
if ((a.parent ?? "") !== (b.parent ?? "")) continue;
if (isContainer(a.id) || isContainer(b.id)) continue;
const ra = rects.get(a.id);
const rb = rects.get(b.id);
if (!ra || !rb || rectsOverlap(ra, rb)) continue;
const yOverlap = ra.y < rb.y + rb.h && rb.y < ra.y + ra.h;
const xOverlap = ra.x < rb.x + rb.w && rb.x < ra.x + ra.w;
let gap = Infinity;
if (yOverlap) {
gap = Math.min(
gap,
ra.x >= rb.x ? ra.x - (rb.x + rb.w) : rb.x - (ra.x + ra.w),
);
}
if (xOverlap) {
gap = Math.min(
gap,
ra.y >= rb.y ? ra.y - (rb.y + rb.h) : rb.y - (ra.y + ra.h),
);
}
if (gap > 0 && gap < MIN_SHAPE_GAP) {
warnings.push(
`[gap-too-small] shapes "${a.id}" and "${b.id}" are ${Math.round(gap)}px apart (<${MIN_SHAPE_GAP}px); increase spacing`,
);
}
}
}
// 5. Label visibly wider than its shape (skip labels drawn OUTSIDE the shape).
for (const v of verts) {
if (!v.value || isContainer(v.id)) continue;
if (v.styleMap.verticalLabelPosition || v.styleMap.labelPosition) continue;
const r = rects.get(v.id);
if (!r) continue;
const fontSize = Number(v.styleMap.fontSize) || 12;
const est = estimateLabelWidth(v.value, fontSize);
if (est > r.w * 1.15) {
warnings.push(
`[label-overflow] label of "${v.id}" (~${Math.round(est)}px) is wider than its shape (${r.w}px); widen it, shorten the text, or wrap with &#xa;`,
);
}
}
// 6. Negative / off-page (top-left) coordinates.
const page = parsePageSize(modelXml ?? "");
for (const v of verts) {
const r = rects.get(v.id);
if (!r) continue;
if (r.x < 0 || r.y < 0) {
warnings.push(
`[out-of-bounds] shape "${v.id}" has negative coordinates (${Math.round(r.x)},${Math.round(r.y)}); move it into the positive quadrant (page ${page.w}x${page.h})`,
);
}
}
return warnings;
}
// --- linter ----------------------------------------------------------------
/**
@@ -755,17 +1057,20 @@ export function prepareModel(inputXml: string): PreparedModel {
const modelXml = normalizeXml(rawModel);
const bbox = computeBBox(cells);
const cellCount = cells.filter((c) => c.id !== "0" && c.id !== "1").length;
// Geometry quality warnings (non-blocking) are appended to any structural
// warnings from the linter. The model surfaces these and can self-correct.
const quality = computeQualityWarnings(cells, modelXml);
return {
modelXml,
cells,
bbox,
cellCount,
warnings,
warnings: [...warnings, ...quality],
hash: mxHash(modelXml),
};
}
/** Cell count of a decoded model (user cells only) — used by drawio_get meta. */
/** Cell count of a decoded model (user cells only) — used by drawioGet meta. */
export function countUserCells(modelXml: string): number {
return parseCells(modelXml).filter((c) => c.id !== "0" && c.id !== "1").length;
}
@@ -16,8 +16,8 @@
* `insertInlineFootnote` live in `@docmost/prosemirror-markdown` (next to the
* importer's `assembleFootnotes`, #414), so this file stays a pure mirror.
*
* Why it exists: every NON-editor write path (markdown import, update_page_json,
* docmost_transform, insert_footnote) builds ProseMirror JSON directly, so the
* Why it exists: every NON-editor write path (markdown import, updatePageJson,
* docmostTransform, insertFootnote) builds ProseMirror JSON directly, so the
* editor's footnote plugins never run and the canonical topology (sequential
* numbering by first reference, one trailing list, no orphans, no raw `[^id]`)
* was never enforced. Running this at the end of every write path closes that
@@ -28,8 +28,8 @@
* `canonicalizeFootnotes(doc)` before writing the current callers are
* `markdownToProseMirrorCanonical` (page markdown import/update; the plain
* `markdownToProseMirror` used for COMMENT bodies must NOT, or it would drop a
* reference-less definition), `update_page_json`, `docmost_transform`,
* `insert_footnote`, and `copy_page_content`. Append/prepend FRAGMENT writes MUST
* reference-less definition), `updatePageJson`, `docmostTransform`,
* `insertFootnote`, and `copyPageContent`. Append/prepend FRAGMENT writes MUST
* NOT canonicalize. This is deliberately per-call-site (the replace-vs-fragment
* and comment-vs-page nuances make a single naive wrapper unsafe).
*/
@@ -6,7 +6,7 @@
* typographic quotes («»/) vs ASCII "…", em/en-dash vs `-`, non-breaking
* space vs normal space, differing space counts are not recognized as equal
* and "fork": two definitions appear where the author meant one. The existing
* de-dup paths miss this: `footnoteContentKey` (footnote-authoring.ts) only
* de-dup paths miss this: `footnoteContentKey` (@docmost/prosemirror-markdown) only
* collapses ASCII whitespace (quotes/dashes/NBSP untouched), and
* `canonicalizeFootnotes` keys purely by `attrs.id` (the two forks have
* different ids), so neither glues the forks together.
@@ -195,7 +195,7 @@ function stableAttrs(attrs: any): string {
/**
* ATTRS-AWARE merge key for a footnote definition. Deliberately DIVERGES from
* the shared `footnoteContentKey` (footnote-authoring.ts): that key's mark
* the shared `footnoteContentKey` (@docmost/prosemirror-markdown): that key's mark
* signature is TYPE-ONLY (`m.type`), so two definitions with identical visible
* text but marks differing only in ATTRIBUTES most importantly a `link` with a
* different `href` (footnotes are usually citations/links), also `code` /
+8 -8
View File
@@ -283,7 +283,7 @@ export function applyTextEdits(
for (const edit of edits) {
if (!edit.find) throw new Error("edit.find must be a non-empty string");
// HARD-REFUSE formatting changes. edit_page_text edits PLAIN TEXT only and
// HARD-REFUSE formatting changes. editPageText edits PLAIN TEXT only and
// writes the replacement verbatim, so it cannot add/remove marks. We refuse
// only a pure formatting TOGGLE: find and replace differ ONLY by balanced
// markdown markers (e.g. find:"~~$69~~" / replace:"$69", or find:"M5Stack" /
@@ -304,22 +304,22 @@ export function applyTextEdits(
failed.push({
find: edit.find,
reason:
"edit_page_text edits plain text only and cannot add or remove formatting marks (bold/italic/strike/code/link); it writes the replacement as LITERAL text. This edit looks like a formatting change (markdown markers in find/replace). To change marks, read the block with get_page_json and use patch_node (or update_page_json) to set the node's marks array.",
"editPageText edits plain text only and cannot add or remove formatting marks (bold/italic/strike/code/link); it writes the replacement as LITERAL text. This edit looks like a formatting change (markdown markers in find/replace). To change marks, read the block with getPageJson and use patchNode (or updatePageJson) to set the node's marks array.",
});
continue;
}
// HARD-REFUSE inline footnote tokens (#410). `^[...]` in a `replace` is
// markdown that only becomes a real footnote when a whole markdown body is
// written (create_page / update_page_content / import_page_markdown). Written
// through edit_page_text it stays a LITERAL string in the text — the exact
// written (createPage / update_page_content / importPageMarkdown). Written
// through editPageText it stays a LITERAL string in the text — the exact
// failure mode #410 fixes — so refuse it here (defense-in-depth) and point the
// caller at insert_footnote, mirroring the formatting-marker refusal above.
// caller at insertFootnote, mirroring the formatting-marker refusal above.
if (/\^\[[\s\S]*?\]/.test(edit.replace)) {
failed.push({
find: edit.find,
reason:
"edit_page_text writes the replacement as LITERAL text, so a `^[...]` footnote token does not parse into a real footnote (it would appear verbatim in the page). To add a footnote to existing text, use insert_footnote (anchorText = where, text = the note).",
"editPageText writes the replacement as LITERAL text, so a `^[...]` footnote token does not parse into a real footnote (it would appear verbatim in the page). To add a footnote to existing text, use insertFootnote (anchorText = where, text = the note).",
});
continue;
}
@@ -381,12 +381,12 @@ export function applyTextEdits(
let reason: string;
if (existsAcrossAtom) {
reason =
"match crosses a non-text inline node (image/break/mention); use update_page_json for structural changes.";
"match crosses a non-text inline node (image/break/mention); use updatePageJson for structural changes.";
} else {
// Append a bounded "closest text" hint: find the FIRST block that
// contains the longest whitespace-delimited token (>= 3 chars) of the
// (stripped, then raw) locator, and quote that block's plain text. Shared
// with create_comment via closestBlockHint so both give the same hint.
// with createComment via closestBlockHint so both give the same hint.
reason = "text not found in the document." + closestBlockHint(blockPlain, edit.find);
}
failed.push({ find: edit.find, reason });
+243
View File
@@ -0,0 +1,243 @@
/**
* Single-BLOCK markdown fragment support for `patch_node` / `insert_node`
* (#413). These tools accept EITHER a raw ProseMirror `node` (fine attr/mark
* work) OR a `markdown` string (the recommended default): a small markdown
* fragment is run through the canonical importer, yielding the SAME topology a
* full-page markdown import would so a block written via markdown is
* canonically identical to the same content imported whole (no "second canon").
*
* The importer produces a full `{type:"doc", content:[...blocks..., footnotesList?]}`.
* A fragment write needs the BLOCKS separately from the footnote DEFINITIONS so
* the caller can splice the blocks into the live document and merge the
* definitions into the page's TAIL footnote list via the existing footnote
* machinery (`insertInlineFootnote`'s `appendDefinition` + `canonicalizeFootnotes`).
*
* Footnote id-collision safety: the importer assigns sequential ids (`fn-1`,
* `fn-2`, ) starting from 1 for EVERY fragment, so a fragment's `fn-1` would
* collide with an existing page footnote also numbered `fn-1` and
* `canonicalizeFootnotes` matches references to definitions BY id, so the
* fragment's reference would silently re-hang onto the page's unrelated
* definition. To make the merge safe regardless of the page's current numbering,
* every fragment footnote id is REMAPPED to a fresh uuid (via the importer's own
* `generateFootnoteId`) across BOTH the references (inside the blocks) and the
* definitions before either is handed back. Content-identical notes still merge
* downstream via `normalizeAndMergeFootnotes` (content-key), and the whole doc is
* renumbered by `canonicalizeFootnotes`, so the caller-visible numbering stays
* canonical.
*/
import { markdownToProseMirror } from "./collaboration.js";
import { generateFootnoteId } from "@docmost/prosemirror-markdown";
import { docmostSchema } from "./docmost-schema.js";
/** True if `value` is a non-null, non-array object. */
function isObject(value: any): value is Record<string, any> {
return value != null && typeof value === "object" && !Array.isArray(value);
}
/**
* Deep-walk `node` collecting every footnote id it uses (on `footnoteReference`
* and `footnoteDefinition` nodes) and build a stable OLD->NEW remap, minting a
* fresh uuid per distinct old id. The map is shared across a fragment's blocks
* and definitions so a reference and its definition receive the SAME new id.
*/
function buildFootnoteIdRemap(nodes: any[]): Map<string, string> {
const remap = new Map<string, string>();
const visit = (node: any): void => {
if (!isObject(node)) return;
if (
(node.type === "footnoteReference" ||
node.type === "footnoteDefinition") &&
isObject(node.attrs) &&
typeof node.attrs.id === "string" &&
node.attrs.id !== ""
) {
if (!remap.has(node.attrs.id)) {
remap.set(node.attrs.id, generateFootnoteId());
}
}
if (Array.isArray(node.content)) {
for (const child of node.content) visit(child);
}
};
for (const n of nodes) visit(n);
return remap;
}
/** Rewrite every footnote id in `node` IN PLACE using `remap` (deep). */
function applyFootnoteIdRemap(node: any, remap: Map<string, string>): void {
if (!isObject(node)) return;
if (
(node.type === "footnoteReference" || node.type === "footnoteDefinition") &&
isObject(node.attrs) &&
typeof node.attrs.id === "string"
) {
const next = remap.get(node.attrs.id);
if (next) node.attrs.id = next;
}
if (Array.isArray(node.content)) {
for (const child of node.content) applyFootnoteIdRemap(child, remap);
}
}
/**
* Generate a short random block id for an imported block that arrives without one
* (the markdown importer emits `attrs.id: null`). Mirrors the mcp `freshId`
* convention (base36 random, unique within one document). The patch path then
* OVERWRITES the first block's id with the target id; every other block keeps the
* fresh id minted here so a 1 -> N section rewrite yields addressable,
* comment-anchorable blocks rather than a run of null-id paragraphs.
*/
function freshBlockId(): string {
return (
Math.random().toString(36).slice(2, 12) +
Math.random().toString(36).slice(2, 6)
);
}
/**
* Assign a fresh id to every top-level block whose `attrs.id` is null/missing,
* IN PLACE. Only the block's own id is touched (not descendants those keep the
* importer's structure). Ensures each imported block is independently addressable.
*/
function assignFreshBlockIds(blocks: any[]): void {
for (const b of blocks) {
if (!isObject(b)) continue;
if (!isObject(b.attrs)) b.attrs = {};
if (b.attrs.id == null || b.attrs.id === "") {
b.attrs.id = freshBlockId();
}
}
}
/** The parsed shape of a markdown fragment: its blocks + footnote definitions. */
export interface MarkdownFragment {
/** Top-level blocks, in order, with the trailing `footnotesList` removed. */
blocks: any[];
/**
* The `footnoteDefinition` nodes lifted from the imported `footnotesList`, with
* ids already remapped to match the references left inside `blocks`. Empty when
* the fragment used no footnotes.
*/
definitions: any[];
}
/**
* Import a markdown fragment and return its blocks separately from its footnote
* definitions, with all footnote ids remapped to fresh uuids (see the file
* header). The importer's `^[body]` inline-footnote handling is used verbatim
* `^[...]` in the fragment is a first-class footnote, NOT rejected so the
* markdown path matches the full-page import exactly.
*
* Throws when the fragment imports to zero blocks (an empty / whitespace-only
* markdown string is not a valid block write).
*/
export async function importMarkdownFragment(
markdown: string,
): Promise<MarkdownFragment> {
const doc = await markdownToProseMirror(markdown);
const content: any[] = Array.isArray(doc?.content) ? doc.content : [];
const blocks: any[] = [];
const definitions: any[] = [];
for (const node of content) {
if (isObject(node) && node.type === "footnotesList") {
// Lift the definitions out of the list; the list wrapper itself is
// reconstructed on the page by the canonicalizer after the merge.
if (Array.isArray(node.content)) {
for (const def of node.content) {
if (isObject(def) && def.type === "footnoteDefinition") {
definitions.push(def);
}
}
}
continue;
}
blocks.push(node);
}
if (blocks.length === 0) {
throw new Error(
"markdown fragment produced no blocks — provide non-empty markdown, or use `node` for a raw ProseMirror node",
);
}
// Remap footnote ids across BOTH blocks and definitions so a fragment `fn-1`
// cannot collide with a page footnote of the same number.
const remap = buildFootnoteIdRemap([...blocks, ...definitions]);
if (remap.size > 0) {
for (const b of blocks) applyFootnoteIdRemap(b, remap);
for (const d of definitions) applyFootnoteIdRemap(d, remap);
}
// Every top-level block needs a stable id (the importer leaves them null). The
// patch path OVERWRITES the first block's id with the target id afterwards.
assignFreshBlockIds(blocks);
return { blocks, definitions };
}
/**
* True when `type` is a valid TOP-LEVEL child of the document node per the
* canonical schema's content model i.e. `get_node` can serialize it to
* markdown by wrapping it in `{type:"doc",content:[node]}`. Derived from the
* schema's `doc` contentMatch (NOT a hand-written type list) so it tracks the
* schema automatically: `tableRow`/`tableCell`/`tableHeader` (addressed only via
* `#<index>`) are NOT doc children and yield false, so `get_node` auto-falls back
* to JSON for them.
*/
export function canBeDocChild(type: string | undefined): boolean {
if (typeof type !== "string") return false;
const nodeType = docmostSchema.nodes[type];
if (!nodeType) return false;
return docmostSchema.nodes.doc.contentMatch.matchType(nodeType) != null;
}
/**
* Table-cell attributes that CANNOT survive a markdown round-trip: the converter
* emits colspan/rowspan (and align) as HTML `<table>` cell attrs, but silently
* drops `colwidth`, `backgroundColor`, and `backgroundColorName`. A markdown
* `patch_node` on a block that carries any of these (a merged / colored /
* fixed-width cell) would therefore lose them so it is REJECTED, pointing the
* caller at the table tools or the raw-`node` JSON path. `align` is intentionally
* absent: it round-trips as GFM alignment.
*/
function cellCarriesUnrepresentableAttrs(node: any): boolean {
if (!isObject(node)) return false;
if (node.type !== "tableCell" && node.type !== "tableHeader") return false;
const a = isObject(node.attrs) ? node.attrs : {};
if ((a.colspan ?? 1) > 1) return true;
if ((a.rowspan ?? 1) > 1) return true;
if (a.colwidth != null) return true;
if (a.backgroundColor != null) return true;
if (a.backgroundColorName != null) return true;
return false;
}
/**
* Scan a target block (the node being replaced) for any table cell carrying an
* attribute markdown cannot represent (colspan/rowspan/colwidth/background). When
* one is found, return a human-readable list of the offending attr NAMES so the
* caller can build an actionable rejection message; return null when the block is
* safe to rewrite from markdown. Deep a colored cell nested inside a table
* inside a callout is still caught.
*/
export function findUnrepresentableTableAttrs(node: any): string | null {
const found = new Set<string>();
const visit = (n: any): void => {
if (!isObject(n)) return;
if (cellCarriesUnrepresentableAttrs(n)) {
const a = isObject(n.attrs) ? n.attrs : {};
if ((a.colspan ?? 1) > 1) found.add("colspan");
if ((a.rowspan ?? 1) > 1) found.add("rowspan");
if (a.colwidth != null) found.add("colwidth");
if (a.backgroundColor != null) found.add("backgroundColor");
if (a.backgroundColorName != null) found.add("backgroundColorName");
}
if (Array.isArray(n.content)) {
for (const child of n.content) visit(child);
}
};
visit(node);
return found.size > 0 ? Array.from(found).sort().join(", ") : null;
}
+12 -12
View File
@@ -3,7 +3,7 @@
*
* `searchInDoc(doc, query, opts)` finds every occurrence of a literal substring
* (default) or a regular expression across the page's TEXT CONTAINERS and
* reports WHERE each match is the container's ref (for get_node/patch_node;
* reports WHERE each match is the container's ref (for getNode/patchNode;
* see the SearchMatch.nodeId note for the `#<index>` caveat), the top-level
* block index, and a short context window around the hit. It never touches the
* network, the DB, or the schema mirror; like `comment-anchor.ts` it is
@@ -69,24 +69,24 @@ export interface SearchOptions {
/** One located occurrence. */
export interface SearchMatch {
/**
* The container's ref, for addressing the block with get_node/patch_node: its
* The container's ref, for addressing the block with getNode/patchNode: its
* `attrs.id` when it has one, otherwise `#<topLevelIndex>` of the nearest
* top-level block. Table-cell/list-item paragraphs that carry no id fall back
* to the `#<index>` form.
*
* CAVEAT: the `#<index>` form is accepted by get_node (getNodeByRef resolves
* it by top-level index) but NOT by patch_node (replaceNodeById resolves only
* CAVEAT: the `#<index>` form is accepted by getNode (getNodeByRef resolves
* it by top-level index) but NOT by patchNode (replaceNodeById resolves only
* by `attrs.id`), so id-less table/cell content can be READ by this ref but
* not PATCHED by it.
*
* To anchor a comment, do NOT pass this ref to create_comment it has no
* To anchor a comment, do NOT pass this ref to createComment it has no
* nodeId parameter. A top-level comment needs an exact-text `selection` that
* occurs once on the page (it fails if the text isn't found), so build a
* UNIQUE `selection` from before+match+after and pass THAT as create_comment's
* UNIQUE `selection` from before+match+after and pass THAT as createComment's
* `selection`.
*/
nodeId: string;
/** The top-level block index (as in get_outline). */
/** The top-level block index (as in getOutline). */
blockIndex: number;
/** The container node's type (paragraph/heading/...). */
type: string | undefined;
@@ -188,12 +188,12 @@ export function searchInDoc(
// --- edge-case guards (fail loudly so the agent can correct the call) ---
if (typeof query !== "string" || query.trim().length === 0) {
throw new Error(
"search_in_page: query is empty — pass the text (or regex) to look for.",
"searchInPage: query is empty — pass the text (or regex) to look for.",
);
}
if (query.length > MAX_PATTERN_LENGTH) {
throw new Error(
`search_in_page: query is too long (${query.length} chars; max ${MAX_PATTERN_LENGTH}). Shorten the search text/pattern.`,
`searchInPage: query is too long (${query.length} chars; max ${MAX_PATTERN_LENGTH}). Shorten the search text/pattern.`,
);
}
@@ -212,7 +212,7 @@ export function searchInDoc(
re = new RE2(query, caseSensitive ? "g" : "gi");
} catch (e) {
throw new Error(
`search_in_page: invalid or unsupported regular expression: ${
`searchInPage: invalid or unsupported regular expression: ${
e instanceof Error ? e.message : String(e)
} RE2 does not support lookaround ((?=)/(?<=)) or backreferences (\\1); rewrite the pattern without them.`,
);
@@ -237,9 +237,9 @@ export function searchInDoc(
// in a very long container.
const text = blockPlainText(node);
// The container's own id addresses it verbatim in get_node/patch_node; a
// The container's own id addresses it verbatim in getNode/patchNode; a
// container with no id (e.g. a table-cell paragraph) falls back to the
// top-level block's #<index> (readable via get_node, but not patchable —
// top-level block's #<index> (readable via getNode, but not patchable —
// see the SearchMatch.nodeId note).
const id =
isObject(node.attrs) && typeof node.attrs.id === "string" && node.attrs.id.length > 0
+1 -1
View File
@@ -117,7 +117,7 @@ export function stripInlineMarkdown(s: string): string {
/**
* Build a bounded "closest text" hint for an anchor/find MISS, shared by
* edit_page_text (json-edit) and create_comment (client) so both surface the
* editPageText (json-edit) and createComment (client) so both surface the
* same self-correction affordance.
*
* Take the longest whitespace-delimited token (>= 3 chars) of the locator
+56 -1
View File
@@ -740,7 +740,7 @@ export function insertInlineFootnote(
// subtree, so a reference is never glued inside an existing definition (which
// the canonicalizer would then drop as an orphan, losing that definition's
// prose); and forbidBlockTypes refuses codeBlocks (an inline atom there is a
// schema-invalid doc; insert_footnote skips validateDocStructure).
// schema-invalid doc; insertFootnote skips validateDocStructure).
// When the only anchor match is in such a place, the insert is refused and the
// write aborts cleanly (inserted:false) instead of destroying content.
const boundaryIdx = Array.isArray(doc?.content)
@@ -774,6 +774,61 @@ export function insertInlineFootnote(
return { doc: working, inserted: true, footnoteId, reused };
}
/**
* Merge an ARRAY of footnote definitions (e.g. the definitions lifted from an
* imported markdown FRAGMENT) into `doc`\'s footnote list, then re-derive the
* canonical footnote topology the SAME two-step machinery `insertInlineFootnote`
* uses (`appendDefinition` -> `normalizeAndMergeFootnotes` -> `canonicalizeFootnotes`).
*
* The fragment\'s `footnoteReference` nodes are assumed to ALREADY be spliced into
* `doc` (inside the just-inserted blocks) with ids matching these definitions, so
* after appending the definitions the canonicalizer orders/numbers everything by
* first-reference order, merges content-identical notes, and drops any orphan.
* Same documented caveat as every other write path: full canonicalization drops a
* definition no reference points at.
*
* NOT merely a no-op when `definitions` is empty: it still canonicalizes when
* the (post-splice) `doc` carries footnote artifacts (a `footnotesList` or any
* `footnoteReference`), so a splice that removed the LAST referrer of a page
* footnote drops the now-orphaned definition matching a full page re-import
* (which always canonicalizes) and preserving the "canonically identical to the
* same content imported whole" invariant. A truly footnote-free doc (no artifacts
* and no definitions) is returned untouched the fast path, no clone. When the
* work runs it goes through the pure passes (which clone), so the caller\'s `doc`
* is not mutated.
*/
export function mergeFootnoteDefinitions(doc: any, definitions: any[]): any {
const defs = Array.isArray(definitions) ? definitions : [];
// True fast path ONLY when there is nothing to merge AND nothing to canonicalize
// away; otherwise fall through so an orphan left by a splice is still dropped.
if (defs.length === 0 && !hasFootnoteArtifacts(doc)) return doc;
// Clone before appending: `appendDefinition` mutates in place, and the caller
// must not see a half-merged doc if a later pass throws.
let working = clone(doc);
for (const def of defs) {
appendDefinition(working, def);
}
// #419: normalize + merge glyph-forked definitions before canonicalizing.
working = normalizeAndMergeFootnotes(working);
working = canonicalizeFootnotes(working);
return working;
}
/**
* True if `doc`'s tree contains any `footnotesList` node OR any
* `footnoteReference` node. Used to decide whether an empty-`definitions` merge
* must still canonicalize (to drop an orphan a splice left behind).
*/
function hasFootnoteArtifacts(doc: any): boolean {
let found = false;
walk(doc, (n) => {
if (isObject(n) && (n.type === "footnotesList" || n.type === "footnoteReference")) {
found = true;
}
});
return found;
}
/**
* Append a definition node so the canonicalizer can order/place it: into the
* first existing footnotesList, or a new trailing list when none exists.
+241
View File
@@ -0,0 +1,241 @@
// SERVER_INSTRUCTIONS — the editing guide surfaced to MCP clients in the
// initialize result so they can pick the right tool by intent and avoid
// resending whole documents.
//
// This guide is split into TWO parts that are composed at the bottom:
//
// 1. ROUTING_PROSE — the hand-written "when to use what" intent hints (READ /
// EDIT / PAGES / COMMENTS / HISTORY). This is legitimately manual: it
// encodes editorial judgement (which tool for which situation, the cheap-
// first ordering, the guardrail nudges) that cannot be derived from the
// registry. It is NOT the drift-guard for the tool set.
//
// 2. A GENERATED <tool_inventory> — every tool the server registers, listed
// by name + one-line purpose, grouped by family, built from the SAME
// registry the server registers tools from (SHARED_TOOL_SPECS' mcpName +
// catalogLine) PLUS the handful of inline MCP-only tools (their inventory
// lines live in INLINE_MCP_INVENTORY below). Because this list is BUILT
// from the registry, it can never drift out of sync with the registered
// tools — adding/renaming/removing a spec changes it automatically, with no
// prose edit and no scraper test. An unmapped tool still appears (under
// "OTHER"), so a new tool can never silently vanish from the guide.
//
// This replaces the old hand-maintained monolithic guide + its regex scraper
// test (test/unit/server-instructions.test.mjs), which only checked that every
// registered name appeared SOMEWHERE in the prose and drifted whenever a name
// was reworded.
//
// OUT OF SCOPE (issue #448): the README / README.ru tool catalogs are still
// hand-maintained prose and are NOT generated from this registry. Regenerating
// them from SHARED_TOOL_SPECS is tracked separately as an optional docs script
// under issue #412 — until then a tool rename still needs a manual README edit.
import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js";
/**
* The hand-written routing prose the intent hints that tell a client which
* tool to reach for in which situation. Kept manual on purpose (it encodes
* editorial judgement, not a mechanical name list). The generated inventory
* below is spliced in after it.
*/
export const ROUTING_PROSE =
"Docmost editing guide — choose the tool by intent. The <tool_inventory> at the end lists every tool with a one-line purpose; the notes below are the routing hints for WHEN to reach for each.\n" +
"READ: find a page -> search (workspace-wide full-text); list -> listPages / listSpaces. Locate blocks and their ids CHEAPLY -> getOutline (compact top-level map; start here, not getPageJson). One block, for editing -> getNode (by attrs.id, or \"#<index>\" for tables, which carry no id) — returns MARKDOWN by default (comment anchors kept for safe write-back); pass format:\"json\" for the raw ProseMirror subtree. Find every occurrence of a string/regex ON a page (and where each is) -> searchInPage, NOT block-by-block getNode — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> getPage (Markdown, lossy; inline <span data-comment-id> tags are comment anchors — markup, not text) or getPageJson (lossless ProseMirror with block ids). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stashPage (returns a short-lived anonymous URL).\n" +
"EDIT: fix wording/typos/numbers -> editPageText (find/replace inside blocks, no node id needed). Edit a block -> getNode(markdown) -> edit the markdown -> patchNode(markdown) (by attrs.id from getOutline; the markdown fragment may be several blocks — a 1->N section rewrite in one call, the first block keeps the id). Reach for patchNode's `node`-JSON only for fine attr/mark work; a table cell with spans/colors/fixed width -> the table tools (patchNode markdown refuses it). Add a block -> insertNode (markdown, before/after a block by attrs.id or by anchor text, or append; `node` for raw JSON or bare table structure). Remove a block -> deleteNode (by attrs.id). Tables -> tableGet / tableUpdateCell / tableInsertRow / tableDeleteRow (address by \"#<index>\" from getOutline; table nodes have no attrs.id). Images -> insertImage (add from a web URL) / replaceImage (swap an existing image). Draw.io diagrams -> drawioCreate (create from mxGraph XML and insert), drawioGet (read a diagram as mxGraph XML + a hash), drawioUpdate (replace a diagram; pass the hash from drawioGet as baseHash for optimistic locking); before authoring a diagram, drawioShapes (look up verified stencil style-strings so a shape name never renders as an empty box) and drawioGuide (on-demand authoring reference: skeleton/layout/containers/icons-aws/icons-azure), and pass layout:\"elk\" to drawioCreate/drawioUpdate to auto-place nodes. Footnotes -> insertFootnote. Bulk/structural rewrite -> updatePageJson (full ProseMirror replace) or updatePageMarkdown (full plain-Markdown body replace, re-imported — block ids regenerate); prefer the granular tools above to avoid resending the whole ~100KB+ document. Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmostTransform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes.\n" +
"PAGES: new -> createPage (Markdown). Rename (title only) -> renamePage. Move -> movePage. Delete -> deletePage (SOFT delete — the page goes to trash and is restorable; nothing is permanent). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copyPageContent. Sharing -> sharePage / unsharePage / listShares; sharePage makes the page PUBLICLY accessible — do it only when explicitly asked.\n" +
"COMMENTS: createComment is always inline and requires an EXACT selection — contiguous text from a single block, <=250 chars (fails rather than leaving an unanchored comment); reply to a thread via parentCommentId. Propose a concrete text fix for one-click human approval -> createComment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Manage -> listComments, updateComment, resolveComment (resolve/reopen, reversible — prefer over delete to close), deleteComment, checkNewComments.\n" +
"HISTORY: review what changed -> diffPageVersions (a historyId vs current, or two versions). List saved versions -> listPageHistory. Undo a bad edit -> restorePageVersion (writes a past version back as current; itself revertible). Export a page to self-contained Docmost Markdown (with comment anchors) -> exportPageMarkdown.";
/**
* A single generated inventory line: the tool's registered NAME + a one-line
* purpose. For a registry tool the purpose is its `catalogLine` (falling back
* to the first sentence of its description); for an inline MCP-only tool it is
* the hand-written line in INLINE_MCP_INVENTORY.
*/
export interface ToolInventoryLine {
name: string;
purpose: string;
}
/**
* The families the inventory is grouped under, in display order. A tool is
* placed by looking its mcpName up in TOOL_FAMILY; anything not listed there
* falls into "OTHER" so it is never dropped from the guide.
*/
const FAMILY_ORDER = [
"READ",
"EDIT",
"PAGES",
"COMMENTS",
"HISTORY",
"OTHER",
] as const;
type Family = (typeof FAMILY_ORDER)[number];
/**
* mcpName -> family for the generated inventory grouping. Purely cosmetic (it
* orders the inventory to mirror the routing prose); an unmapped tool still
* appears under OTHER, so forgetting to add an entry here can never drop a tool
* from the guide it only lands it in the catch-all group.
*/
const TOOL_FAMILY: Record<string, Family> = {
// READ
search: "READ",
listPages: "READ",
listSpaces: "READ",
getOutline: "READ",
getNode: "READ",
searchInPage: "READ",
getPage: "READ",
getPageJson: "READ",
getWorkspace: "READ",
stashPage: "READ",
// EDIT
editPageText: "EDIT",
patchNode: "EDIT",
insertNode: "EDIT",
deleteNode: "EDIT",
updatePageJson: "EDIT",
updatePageMarkdown: "EDIT",
tableGet: "EDIT",
tableUpdateCell: "EDIT",
tableInsertRow: "EDIT",
tableDeleteRow: "EDIT",
insertImage: "EDIT",
replaceImage: "EDIT",
insertFootnote: "EDIT",
drawioGet: "EDIT",
drawioCreate: "EDIT",
drawioUpdate: "EDIT",
drawioShapes: "EDIT",
drawioGuide: "EDIT",
docmostTransform: "EDIT",
// PAGES
createPage: "PAGES",
renamePage: "PAGES",
movePage: "PAGES",
deletePage: "PAGES",
copyPageContent: "PAGES",
sharePage: "PAGES",
unsharePage: "PAGES",
listShares: "PAGES",
// COMMENTS
createComment: "COMMENTS",
listComments: "COMMENTS",
updateComment: "COMMENTS",
resolveComment: "COMMENTS",
deleteComment: "COMMENTS",
checkNewComments: "COMMENTS",
// HISTORY
diffPageVersions: "HISTORY",
listPageHistory: "HISTORY",
restorePageVersion: "HISTORY",
exportPageMarkdown: "HISTORY",
// importPageMarkdown is now inAppOnly (#411) — it is not registered on the
// external MCP host, so it no longer appears in the generated inventory.
};
/**
* Inventory lines for the INLINE MCP-only tools the ones registered directly
* in index.ts (not via SHARED_TOOL_SPECS) because they diverge per transport or
* exist only on this standalone surface. They carry no `catalogLine`, so their
* one-line purpose is hand-written here. This is the ONLY hand-maintained tool
* list left, and it is tiny; a new inline tool without an entry here is caught
* by the completeness guard in `tool-inventory.test.mjs`.
*/
export const INLINE_MCP_INVENTORY: ToolInventoryLine[] = [
{
name: "tableGet",
purpose:
"read a table as a matrix of cell texts + per-cell paragraph ids.",
},
{
name: "search",
purpose:
"full-text search for pages and content across the whole workspace.",
},
{
name: "docmostTransform",
purpose:
"edit a page by running a sandboxed JS `(doc, ctx) => doc` transform, with a dryRun diff preview.",
},
{
name: "updateComment",
purpose: "update an existing comment's content (creator only).",
},
{
name: "deleteComment",
purpose: "delete a comment (creator or space admin only).",
},
];
/**
* Derive the one-line purpose for a registry spec: prefer its hand-written
* `catalogLine` (already a "name — purpose" line we take the purpose after
* the em dash), else fall back to the first sentence of its description.
*/
function purposeForSpec(spec: SharedToolSpec): string {
const line = spec.catalogLine?.trim();
if (line) {
const dash = line.indexOf(" — ");
if (dash >= 0) return line.slice(dash + 3).trim();
return line;
}
const desc = (spec.description ?? "").replace(/\s+/g, " ").trim();
const firstSentence = desc.split(/(?<=[.!?])\s/)[0];
return firstSentence || desc || "(no description)";
}
/**
* Build the flat list of every registered tool's inventory line: one per shared
* registry spec (skipping `inAppOnly` specs, which are not registered on this
* MCP host) PLUS every inline MCP-only tool. Pure and deterministic the
* registry drives it, so it can never drift from what index.ts registers.
*/
export function buildToolInventoryLines(
specs: Record<string, SharedToolSpec> = SHARED_TOOL_SPECS,
inline: ToolInventoryLine[] = INLINE_MCP_INVENTORY,
): ToolInventoryLine[] {
const lines: ToolInventoryLine[] = [];
for (const spec of Object.values(specs)) {
if (spec.inAppOnly) continue; // not registered on the MCP host
lines.push({ name: spec.mcpName, purpose: purposeForSpec(spec) });
}
for (const l of inline) lines.push({ ...l });
return lines;
}
/**
* Render the generated `<tool_inventory>` block: every tool name + purpose,
* grouped by family (families in FAMILY_ORDER; tools within a family sorted by
* name for stable output; unmapped tools fall into OTHER). Pure.
*/
export function buildToolInventory(
specs: Record<string, SharedToolSpec> = SHARED_TOOL_SPECS,
inline: ToolInventoryLine[] = INLINE_MCP_INVENTORY,
): string {
const byFamily = new Map<Family, ToolInventoryLine[]>();
for (const family of FAMILY_ORDER) byFamily.set(family, []);
for (const line of buildToolInventoryLines(specs, inline)) {
const family = TOOL_FAMILY[line.name] ?? "OTHER";
byFamily.get(family)!.push(line);
}
const sections: string[] = [];
for (const family of FAMILY_ORDER) {
const items = byFamily.get(family)!;
if (items.length === 0) continue;
items.sort((a, b) => a.name.localeCompare(b.name));
for (const item of items) {
sections.push(` ${family} ${item.name}${item.purpose}`);
}
}
return ["<tool_inventory>", ...sections, "</tool_inventory>"].join("\n");
}
/**
* The composed editing guide: the hand-written routing prose followed by the
* generated, drift-proof tool inventory. Exported (and used by index.ts /
* createDocmostMcpServer) as the MCP server's `instructions`.
*/
export const SERVER_INSTRUCTIONS =
ROUTING_PROSE + "\n" + buildToolInventory();
File diff suppressed because it is too large Load Diff
+76 -76
View File
@@ -84,20 +84,20 @@ async function main() {
let pageId = null;
try {
// 1. create_page: title with spaces must survive (was: underscores bug)
// 1. createPage: title with spaces must survive (was: underscores bug)
const created = await client.createPage("Тест апгрейда MCP сервера", MD, spaceId);
pageId = created.data.id;
check("create_page: title keeps spaces", created.data.title === "Тест апгрейда MCP сервера", created.data.title);
check("create_page: slugId exposed", typeof created.data.slugId === "string" && created.data.slugId.length > 0, created.data.slugId);
check("createPage: title keeps spaces", created.data.title === "Тест апгрейда MCP сервера", created.data.title);
check("createPage: slugId exposed", typeof created.data.slugId === "string" && created.data.slugId.length > 0, created.data.slugId);
// 2. get_page_json: raw ProseMirror with callout + table
// 2. getPageJson: raw ProseMirror with callout + table
const pj = await client.getPageJson(pageId);
const types = pj.content.content.map((n) => n.type);
check("get_page_json: callout node present", types.includes("callout"), types.join(","));
check("get_page_json: table node present", types.includes("table"));
check("get_page_json: slugId present", !!pj.slugId);
check("getPageJson: callout node present", types.includes("callout"), types.join(","));
check("getPageJson: table node present", types.includes("table"));
check("getPageJson: slugId present", !!pj.slugId);
// 3. edit_page_text: surgical replace, ids preserved
// 3. editPageText: surgical replace, ids preserved
const idsBefore = JSON.stringify(
pj.content.content.filter((n) => n.attrs?.id).map((n) => n.attrs.id),
);
@@ -105,26 +105,26 @@ async function main() {
{ find: "БУКВОЕД", replace: "КНИГОЛЮБ" },
{ find: "[1]", replace: "[42]" },
]);
check("edit_page_text: both edits applied", editRes.applied.every((e) => e.replacements === 1));
check("editPageText: both edits applied", editRes.applied.every((e) => e.replacements === 1));
await new Promise((r) => setTimeout(r, 16000)); // wait for server persistence
const pj2 = await client.getPageJson(pageId);
const text2 = JSON.stringify(pj2.content);
check("edit_page_text: replacement visible", text2.includes("КНИГОЛЮБ") && text2.includes("[42]"));
check("edit_page_text: old text gone", !text2.includes("БУКВОЕД"));
check("editPageText: replacement visible", text2.includes("КНИГОЛЮБ") && text2.includes("[42]"));
check("editPageText: old text gone", !text2.includes("БУКВОЕД"));
const idsAfter = JSON.stringify(
pj2.content.content.filter((n) => n.attrs?.id).map((n) => n.attrs.id),
);
check("edit_page_text: block ids preserved", idsBefore === idsAfter);
check("edit_page_text: callout survived", JSON.stringify(pj2.content).includes('"callout"'));
check("edit_page_text: table survived", pj2.content.content.some((n) => n.type === "table"));
check("editPageText: block ids preserved", idsBefore === idsAfter);
check("editPageText: callout survived", JSON.stringify(pj2.content).includes('"callout"'));
check("editPageText: table survived", pj2.content.content.some((n) => n.type === "table"));
// 4. error reporting: ambiguous and missing finds
let err1 = "";
try { await client.editPageText(pageId, [{ find: "Колонка", replace: "X" }]); } catch (e) { err1 = e.message; }
check("edit_page_text: ambiguous match rejected", err1.includes("matches"), err1);
check("editPageText: ambiguous match rejected", err1.includes("matches"), err1);
let err2 = "";
try { await client.editPageText(pageId, [{ find: "НЕСУЩЕСТВУЮЩЕЕ", replace: "X" }]); } catch (e) { err2 = e.message; }
check("edit_page_text: missing text reported", err2.includes("not found"), err2);
check("editPageText: missing text reported", err2.includes("not found"), err2);
// 5. update_page (markdown): table + callout must survive the re-import
await client.updatePage(pageId, MD + "\nДобавленный абзац.\n");
@@ -137,21 +137,21 @@ async function main() {
const cellText = JSON.stringify(tableNode);
check("update_page md: table cells intact", cellText.includes("четыре") && cellText.includes("Колонка А"));
// 6. update_page_json: lossless write round-trip
// 6. updatePageJson: lossless write round-trip
pj3.content.content.push({
type: "paragraph",
attrs: { id: "testidjsonpush", indent: 0, textAlign: null },
content: [{ type: "text", text: "Абзац, добавленный через update_page_json." }],
content: [{ type: "text", text: "Абзац, добавленный через updatePageJson." }],
});
await client.updatePageJson(pageId, pj3.content);
await new Promise((r) => setTimeout(r, 16000));
const pj4 = await client.getPageJson(pageId);
const lastNode = pj4.content.content[pj4.content.content.length - 1];
check("update_page_json: paragraph appended", JSON.stringify(pj4.content).includes("добавленный через update_page_json"));
check("update_page_json: custom node id preserved", lastNode.attrs?.id === "testidjsonpush", lastNode.attrs?.id);
check("updatePageJson: paragraph appended", JSON.stringify(pj4.content).includes("добавленный через updatePageJson"));
check("updatePageJson: custom node id preserved", lastNode.attrs?.id === "testidjsonpush", lastNode.attrs?.id);
// 6b. images: upload / insert / replace (clean src, fresh attachment on replace).
// insert_image / replace_image take an http(s) URL that the SERVER fetches;
// insertImage / replaceImage take an http(s) URL that the SERVER fetches;
// local file paths are intentionally unsupported. The Docmost server runs on
// the same host as this test, so serve the PNG bytes over a throwaway
// localhost HTTP server it can reach.
@@ -186,13 +186,13 @@ async function main() {
validateStatus: () => true,
});
// insert_image: append the first PNG, src must be clean (no ?v=) and fetchable.
// insertImage: append the first PNG, src must be clean (no ?v=) and fetchable.
const ins = await client.insertImage(pageId, urlA);
check("insert_image: src has no ?v= cache-buster", !ins.src.includes("?v="), ins.src);
check("insertImage: src has no ?v= cache-buster", !ins.src.includes("?v="), ins.src);
const fileA = await fetchFile(ins.src);
check("insert_image: file fetch returns 200", fileA.status === 200, `status=${fileA.status}`);
check("insertImage: file fetch returns 200", fileA.status === 200, `status=${fileA.status}`);
check(
"insert_image: content-type is image/*",
"insertImage: content-type is image/*",
String(fileA.headers["content-type"] || "").startsWith("image/"),
String(fileA.headers["content-type"]),
);
@@ -209,25 +209,25 @@ async function main() {
};
const imgNode = findImage(pjImg.content.content);
const oldAttachmentId = imgNode?.attrs?.attachmentId;
check("insert_image: image node present after persist", !!oldAttachmentId, oldAttachmentId);
check("insertImage: image node present after persist", !!oldAttachmentId, oldAttachmentId);
// replace_image: must create a NEW attachment with a clean, fetchable URL.
// replaceImage: must create a NEW attachment with a clean, fetchable URL.
// The 200 fetch is the assertion that catches the in-place-overwrite HTTP 500 regression.
const rep = await client.replaceImage(pageId, oldAttachmentId, urlB);
check("replace_image: new attachment id differs from old", rep.newAttachmentId !== oldAttachmentId, `${oldAttachmentId} -> ${rep.newAttachmentId}`);
check("replace_image: src has no ?v= cache-buster", !rep.src.includes("?v="), rep.src);
check("replaceImage: new attachment id differs from old", rep.newAttachmentId !== oldAttachmentId, `${oldAttachmentId} -> ${rep.newAttachmentId}`);
check("replaceImage: src has no ?v= cache-buster", !rep.src.includes("?v="), rep.src);
const fileB = await fetchFile(rep.src);
check("replace_image: new file fetch returns 200", fileB.status === 200, `status=${fileB.status}`);
check("replaceImage: new file fetch returns 200", fileB.status === 200, `status=${fileB.status}`);
check(
"replace_image: new content-type is image/*",
"replaceImage: new content-type is image/*",
String(fileB.headers["content-type"] || "").startsWith("image/"),
String(fileB.headers["content-type"]),
);
await new Promise((r) => setTimeout(r, 16000));
const pjImg2 = await client.getPageJson(pageId);
check("replace_image: page has new attachment id", !!findImage(pjImg2.content.content, rep.newAttachmentId), rep.newAttachmentId);
check("replace_image: old attachment id repointed away", !findImage(pjImg2.content.content, oldAttachmentId), oldAttachmentId);
check("replaceImage: page has new attachment id", !!findImage(pjImg2.content.content, rep.newAttachmentId), rep.newAttachmentId);
check("replaceImage: old attachment id repointed away", !findImage(pjImg2.content.content, oldAttachmentId), oldAttachmentId);
} finally {
imgServer.close();
}
@@ -275,10 +275,10 @@ async function main() {
await client.editPageText(fid, [{ find: "PRICEMARK", replace: "$& costs $100" }]);
await new Promise((r) => setTimeout(r, 16000));
const ftext = JSON.stringify((await client.getPageJson(fid)).content);
check("feature: edit_page_text inserts $-pattern literally (no $& expansion)", ftext.includes("$& costs $100") && !ftext.includes("PRICEMARK costs"));
check("feature: editPageText inserts $-pattern literally (no $& expansion)", ftext.includes("$& costs $100") && !ftext.includes("PRICEMARK costs"));
let badThrew = false;
try { await client.replaceImage(fid, "00000000-0000-0000-0000-000000000000", featPng); } catch (e) { badThrew = /no image with attachmentId/.test(e.message); }
check("feature: replace_image with unknown id throws (no orphan upload)", badThrew);
check("feature: replaceImage with unknown id throws (no orphan upload)", badThrew);
} finally {
try { await client.deletePage(fid); } catch {}
try { unlinkSync(featPng); } catch {}
@@ -286,7 +286,7 @@ async function main() {
}
// 6d. node ops: patch / insert / delete a block by id on a throwaway page.
// Three paragraphs are written with KNOWN ids via update_page_json so the
// Three paragraphs are written with KNOWN ids via updatePageJson so the
// ids can be targeted directly; each op is verified via getPageJson after
// the standard 16s persistence wait.
{
@@ -348,7 +348,7 @@ async function main() {
}
}
// 6e. rename_page: title-only update must leave the content untouched.
// 6e. renamePage: title-only update must leave the content untouched.
{
const rp = await client.createPage("E2E rename before " + Date.now(), "Rename body marker RENAMEBODY.", spaceId);
const rid = rp.data.id;
@@ -357,19 +357,19 @@ async function main() {
const beforeContent = JSON.stringify(beforeJson);
const newTitle = "E2E rename AFTER " + Date.now();
const rr = await client.renamePage(rid, newTitle);
check("rename_page: returns success+title", rr.success === true && rr.title === newTitle, JSON.stringify(rr));
check("renamePage: returns success+title", rr.success === true && rr.title === newTitle, JSON.stringify(rr));
await new Promise((r) => setTimeout(r, 16000));
const afterJson = await client.getPageJson(rid);
check("rename_page: title changed", afterJson.title === newTitle, afterJson.title);
check("rename_page: content unchanged", JSON.stringify(afterJson.content) === beforeContent && beforeContent.includes("RENAMEBODY"));
check("renamePage: title changed", afterJson.title === newTitle, afterJson.title);
check("renamePage: content unchanged", JSON.stringify(afterJson.content) === beforeContent && beforeContent.includes("RENAMEBODY"));
const afterMd = (await client.getPage(rid)).data;
check("rename_page: get_page reflects new title", afterMd.title === newTitle, afterMd.title);
check("renamePage: getPage reflects new title", afterMd.title === newTitle, afterMd.title);
} finally {
try { await client.deletePage(rid); } catch {}
}
}
// 6f. update_page_json title-only: omitting content updates the title and
// 6f. updatePageJson title-only: omitting content updates the title and
// leaves the body intact; supplying neither content nor title throws.
{
const up = await client.createPage("E2E upj-title before " + Date.now(), "Title-only body marker UPJTITLEBODY.", spaceId);
@@ -378,20 +378,20 @@ async function main() {
const beforeContent = JSON.stringify((await client.getPageJson(uid)).content);
const newTitle = "E2E upj-title AFTER " + Date.now();
const ur = await client.updatePageJson(uid, undefined, newTitle);
check("update_page_json title-only: succeeds", ur.success === true, JSON.stringify(ur));
check("updatePageJson title-only: succeeds", ur.success === true, JSON.stringify(ur));
await new Promise((r) => setTimeout(r, 16000));
const afterJson = await client.getPageJson(uid);
check("update_page_json title-only: title updated", afterJson.title === newTitle, afterJson.title);
check("update_page_json title-only: content intact", JSON.stringify(afterJson.content) === beforeContent && beforeContent.includes("UPJTITLEBODY"));
check("updatePageJson title-only: title updated", afterJson.title === newTitle, afterJson.title);
check("updatePageJson title-only: content intact", JSON.stringify(afterJson.content) === beforeContent && beforeContent.includes("UPJTITLEBODY"));
let upjErr = "";
try { await client.updatePageJson(uid); } catch (e) { upjErr = e.message; }
check("update_page_json: neither content nor title throws", upjErr.includes("nothing to update"), upjErr);
check("updatePageJson: neither content nor title throws", upjErr.includes("nothing to update"), upjErr);
} finally {
try { await client.deletePage(uid); } catch {}
}
}
// 6g. copy_page_content: B's body becomes a copy of A's body, server-side,
// 6g. copyPageContent: B's body becomes a copy of A's body, server-side,
// while B's title/slugId stay put. Both pages are throwaways.
{
let aid = null;
@@ -409,24 +409,24 @@ async function main() {
const aNodeCount = aJson.content.content.length;
const cr = await client.copyPageContent(aid, bid);
check("copy_page_content: returns success + node count", cr.success === true && cr.copiedNodes === aNodeCount, JSON.stringify(cr));
check("copyPageContent: returns success + node count", cr.success === true && cr.copiedNodes === aNodeCount, JSON.stringify(cr));
await new Promise((r) => setTimeout(r, 16000));
const bAfter = await client.getPageJson(bid);
const bText = JSON.stringify(bAfter.content);
check("copy_page_content: B now has A's marker", bText.includes("COPYSOURCE"));
check("copy_page_content: B's old marker gone", !bText.includes("COPYTARGET"));
check("copy_page_content: B node count equals A's", bAfter.content.content.length === aNodeCount, `${bAfter.content.content.length} vs ${aNodeCount}`);
check("copy_page_content: B title unchanged", bAfter.title === bTitleBefore, bAfter.title);
check("copy_page_content: B slugId unchanged", bAfter.slugId === bSlugBefore, bAfter.slugId);
check("copyPageContent: B now has A's marker", bText.includes("COPYSOURCE"));
check("copyPageContent: B's old marker gone", !bText.includes("COPYTARGET"));
check("copyPageContent: B node count equals A's", bAfter.content.content.length === aNodeCount, `${bAfter.content.content.length} vs ${aNodeCount}`);
check("copyPageContent: B title unchanged", bAfter.title === bTitleBefore, bAfter.title);
check("copyPageContent: B slugId unchanged", bAfter.slugId === bSlugBefore, bAfter.slugId);
// Source must be left untouched by the copy.
const aAfter = JSON.stringify((await client.getPageJson(aid)).content);
check("copy_page_content: source page unchanged", aAfter === JSON.stringify(aJson.content) && aAfter.includes("COPYSOURCE"));
check("copyPageContent: source page unchanged", aAfter === JSON.stringify(aJson.content) && aAfter.includes("COPYSOURCE"));
let copyErr = "";
try { await client.copyPageContent(aid, aid); } catch (e) { copyErr = e.message; }
check("copy_page_content: self-copy rejected", copyErr.includes("same page"), copyErr);
check("copyPageContent: self-copy rejected", copyErr.includes("same page"), copyErr);
} finally {
try { if (bid) await client.deletePage(bid); } catch {}
try { if (aid) await client.deletePage(aid); } catch {}
@@ -435,22 +435,22 @@ async function main() {
// 7. shares: create (idempotent), public access, list, unshare
const share = await client.sharePage(pageId);
check("share_page: returns public URL", share.publicUrl?.startsWith(`${APP}/share/`), share.publicUrl);
check("sharePage: returns public URL", share.publicUrl?.startsWith(`${APP}/share/`), share.publicUrl);
const share2 = await client.sharePage(pageId);
check("share_page: idempotent", share2.key === share.key);
check("sharePage: idempotent", share2.key === share.key);
const anon = await axios.post(`${API}/shares/page-info`, { pageId: pj4.slugId, shareId: share.key }, { validateStatus: () => true });
check("share_page: anonymous access works", anon.status === 200);
check("sharePage: anonymous access works", anon.status === 200);
const shares = await client.listShares();
check("list_shares: contains our page", shares.some((s) => s.pageId === pageId && s.publicUrl === share.publicUrl));
check("listShares: contains our page", shares.some((s) => s.pageId === pageId && s.publicUrl === share.publicUrl));
const un = await client.unsharePage(pageId);
check("unshare_page: success", un.success === true);
check("unsharePage: success", un.success === true);
const anon2 = await axios.post(`${API}/shares/page-info`, { pageId: pj4.slugId, shareId: share.key }, { validateStatus: () => true });
check("unshare_page: public access revoked", anon2.status !== 200, `status=${anon2.status}`);
check("unsharePage: public access revoked", anon2.status !== 200, `status=${anon2.status}`);
// 8. get_page markdown round-trip sanity (table separator present)
// 8. getPage markdown round-trip sanity (table separator present)
const md = await client.getPage(pageId);
check("get_page md: table separator emitted", md.data.content.includes("| --- |"), "");
check("get_page md: callout exported as Obsidian '> [!info]'", md.data.content.includes("> [!info]"));
check("getPage md: table separator emitted", md.data.content.includes("| --- |"), "");
check("getPage md: callout exported as Obsidian '> [!info]'", md.data.content.includes("> [!info]"));
// 9. comments: create / list / reply / update / check_new / delete
const beforeComments = new Date(Date.now() - 1000).toISOString();
@@ -458,34 +458,34 @@ async function main() {
// that exists in the persisted page to anchor on. "Добавленный абзац." is a
// plain paragraph re-imported in section 5 and still present here.
const c1 = await client.createComment(pageId, "Первый **комментарий** с [ссылкой](https://example.com).", "inline", "Добавленный абзац.");
check("create_comment: created", !!c1.data.id, c1.data.id);
check("create_comment: markdown round-trip", c1.data.content.includes("**комментарий**"), c1.data.content);
check("createComment: created", !!c1.data.id, c1.data.id);
check("createComment: markdown round-trip", c1.data.content.includes("**комментарий**"), c1.data.content);
const reply = await client.createComment(pageId, "Ответ на комментарий.", "page", undefined, c1.data.id);
check("create_comment: reply has parent", reply.data.parentCommentId === c1.data.id);
check("createComment: reply has parent", reply.data.parentCommentId === c1.data.id);
const list = (await client.listComments(pageId)).items;
check("list_comments: both visible", list.length === 2, `count=${list.length}`);
check("listComments: both visible", list.length === 2, `count=${list.length}`);
await client.updateComment(c1.data.id, "Обновлённый текст комментария.");
const got = await client.getComment(c1.data.id);
check("update_comment + get_comment: content updated", got.data.content.includes("Обновлённый"), got.data.content);
check("updateComment + get_comment: content updated", got.data.content.includes("Обновлённый"), got.data.content);
const news = await client.checkNewComments(spaceId, beforeComments, pageId);
check("check_new_comments: finds new comments in subtree", news.totalNewComments >= 2, `total=${news.totalNewComments}`);
// resolve_comment: close the top-level thread, verify resolvedAt surfaces, then reopen
check("checkNewComments: finds new comments in subtree", news.totalNewComments >= 2, `total=${news.totalNewComments}`);
// resolveComment: close the top-level thread, verify resolvedAt surfaces, then reopen
const resolvedRes = await client.resolveComment(c1.data.id, true);
check("resolve_comment: marks resolved", resolvedRes.success === true && resolvedRes.resolved === true);
check("resolveComment: marks resolved", resolvedRes.success === true && resolvedRes.resolved === true);
// c1 is now resolved; the default feed hides resolved threads, so pass
// includeResolved:true to still see it and assert its resolvedAt (#328).
const listResolved = (await client.listComments(pageId, true)).items;
const c1Resolved = listResolved.find((c) => c.id === c1.data.id);
check("resolve_comment: resolvedAt set in list", !!c1Resolved?.resolvedAt, `resolvedAt=${c1Resolved?.resolvedAt}`);
check("resolveComment: resolvedAt set in list", !!c1Resolved?.resolvedAt, `resolvedAt=${c1Resolved?.resolvedAt}`);
const reopenedRes = await client.resolveComment(c1.data.id, false);
check("resolve_comment: reopen succeeds", reopenedRes.resolved === false);
check("resolveComment: reopen succeeds", reopenedRes.resolved === false);
const listReopened = (await client.listComments(pageId)).items;
const c1Reopened = listReopened.find((c) => c.id === c1.data.id);
check("resolve_comment: resolvedAt cleared on reopen", !c1Reopened?.resolvedAt, `resolvedAt=${c1Reopened?.resolvedAt}`);
check("resolveComment: resolvedAt cleared on reopen", !c1Reopened?.resolvedAt, `resolvedAt=${c1Reopened?.resolvedAt}`);
await client.deleteComment(reply.data.id);
await client.deleteComment(c1.data.id);
const listAfter = (await client.listComments(pageId)).items;
check("delete_comment: comments removed", listAfter.length === 0, `count=${listAfter.length}`);
check("deleteComment: comments removed", listAfter.length === 0, `count=${listAfter.length}`);
} finally {
if (pageId) {
await client.deletePage(pageId);
@@ -1,4 +1,4 @@
// Mock collab regression for the AMBIGUOUS-id refusal in patch_node / delete_node
// Mock collab regression for the AMBIGUOUS-id refusal in patchNode / deleteNode
// (#159, PR #185 review pt 1). When a page has TWO blocks sharing one attrs.id
// (Docmost duplicates block ids on copy/paste), the transform's
// `if (replaced !== 1) return null` / `if (deleted !== 1) return null` guard must
@@ -126,18 +126,20 @@ after(async () => {
);
});
test("patch_node REFUSES an ambiguous (duplicate) id without writing to collab", async () => {
test("patchNode REFUSES an ambiguous (duplicate) id without writing to collab", async () => {
const { state, baseURL } = await spawnCollabStack();
const client = new DocmostClient(baseURL, "user@example.com", "pw");
await assert.rejects(
() =>
client.patchNode("11111111-1111-4111-8111-111111111111", DUP_ID, {
type: "paragraph",
content: [{ type: "text", text: "replacement" }],
node: {
type: "paragraph",
content: [{ type: "text", text: "replacement" }],
},
}),
/ambiguous/i,
"patch_node must reject a duplicate-id target with an 'ambiguous' error",
"patchNode must reject a duplicate-id target with an 'ambiguous' error",
);
assert.equal(
@@ -147,14 +149,14 @@ test("patch_node REFUSES an ambiguous (duplicate) id without writing to collab",
);
});
test("delete_node REFUSES an ambiguous (duplicate) id without writing to collab", async () => {
test("deleteNode REFUSES an ambiguous (duplicate) id without writing to collab", async () => {
const { state, baseURL } = await spawnCollabStack();
const client = new DocmostClient(baseURL, "user@example.com", "pw");
await assert.rejects(
() => client.deleteNode("22222222-2222-4222-8222-222222222222", DUP_ID),
/ambiguous/i,
"delete_node must reject a duplicate-id target with an 'ambiguous' error",
"deleteNode must reject a duplicate-id target with an 'ambiguous' error",
);
assert.equal(
@@ -203,14 +203,16 @@ test("a reply creates without selection or anchoring and is stored as type 'page
"reply body",
"inline",
undefined,
"parent-123",
// #437: a parentCommentId must be a full canonical UUID.
"019f499a-9f8c-7d68-b7be-ce100d7c6c56",
);
assert.equal(result.success, true, "a reply must resolve successfully");
assert.ok(createPayload, "/comments/create must have been called");
assert.equal(
createPayload.parentCommentId,
"parent-123",
// #437: a parentCommentId must be a full canonical UUID.
"019f499a-9f8c-7d68-b7be-ce100d7c6c56",
"the reply payload must carry the parentCommentId",
);
assert.equal(
@@ -321,7 +323,9 @@ test("suggestedText on a reply is rejected", async () => {
"body",
"inline",
undefined,
"parent-1",
// #437: use a valid full UUID so the reply+suggestion rejection fires
// (not the id-shape guard).
"019f499a-9f8c-7d68-b7be-ce100d7c6c56",
"replacement",
),
/reply/i,
+21 -21
View File
@@ -1,4 +1,4 @@
// Contract tests for the drawio_get / drawio_create / drawio_update client
// Contract tests for the drawioGet / drawioCreate / drawioUpdate client
// methods (issue #423). Follows the repo's seam-override pattern (see
// full-doc-write-canonicalize.test.mjs): a DocmostClient subclass stubs the I/O
// seams (auth, collab token, page read, attachment upload/fetch, the mutatePage
@@ -114,9 +114,9 @@ function findDrawio(node, acc = []) {
return acc;
}
// --- drawio_create ---------------------------------------------------------
// --- drawioCreate ---------------------------------------------------------
test("drawio_create: lints, builds the .drawio.svg, uploads and inserts a node", async () => {
test("drawioCreate: lints, builds the .drawio.svg, uploads and inserts a node", async () => {
const pageDoc = {
type: "doc",
content: [{ type: "paragraph", attrs: { id: "p1" }, content: [] }],
@@ -148,7 +148,7 @@ test("drawio_create: lints, builds the .drawio.svg, uploads and inserts a node",
assert.equal(n.attrs.title, "My diagram");
});
test("drawio_create: a lint violation throws before any upload", async () => {
test("drawioCreate: a lint violation throws before any upload", async () => {
const { client, calls } = makeClient({ pageDoc: { type: "doc", content: [] } });
// Edge with no child geometry -> edge-geometry rule.
const bad =
@@ -162,7 +162,7 @@ test("drawio_create: a lint violation throws before any upload", async () => {
assert.equal(calls.uploads.length, 0, "no attachment uploaded on lint failure");
});
test("drawio_create: before/after requires exactly one anchor", async () => {
test("drawioCreate: before/after requires exactly one anchor", async () => {
const { client } = makeClient({ pageDoc: { type: "doc", content: [] } });
await assert.rejects(
() => client.drawioCreate("page1", { position: "before" }, MODEL),
@@ -170,9 +170,9 @@ test("drawio_create: before/after requires exactly one anchor", async () => {
);
});
// --- drawio_get ------------------------------------------------------------
// --- drawioGet ------------------------------------------------------------
test("drawio_get: decodes the model and returns meta with a hash", async () => {
test("drawioGet: decodes the model and returns meta with a hash", async () => {
const pageDoc = {
type: "doc",
content: [
@@ -198,7 +198,7 @@ test("drawio_get: decodes the model and returns meta with a hash", async () => {
assert.equal(res.meta.hash, mxHash(normalizeXml(MODEL)));
});
test("drawio_get: format=svg returns the raw .drawio.svg", async () => {
test("drawioGet: format=svg returns the raw .drawio.svg", async () => {
const svg = svgFor(MODEL);
const pageDoc = {
type: "doc",
@@ -211,7 +211,7 @@ test("drawio_get: format=svg returns the raw .drawio.svg", async () => {
assert.equal(res.content, svg);
});
test("drawio_get: reads a HUMAN-saved compressed diagram losslessly (pako)", async () => {
test("drawioGet: reads a HUMAN-saved compressed diagram losslessly (pako)", async () => {
const pageDoc = {
type: "doc",
content: [
@@ -223,7 +223,7 @@ test("drawio_get: reads a HUMAN-saved compressed diagram losslessly (pako)", asy
assert.equal(res.content, normalizeXml(MODEL));
});
// --- drawio_update ---------------------------------------------------------
// --- drawioUpdate ---------------------------------------------------------
const UPDATED_MODEL =
'<mxGraphModel><root>' +
@@ -250,7 +250,7 @@ function updatePageDoc() {
};
}
test("drawio_update: stale baseHash -> conflict, no upload", async () => {
test("drawioUpdate: stale baseHash -> conflict, no upload", async () => {
const { client, calls } = makeClient({
pageDoc: updatePageDoc(),
attachmentSvg: svgFor(MODEL),
@@ -262,7 +262,7 @@ test("drawio_update: stale baseHash -> conflict, no upload", async () => {
assert.equal(calls.uploads.length, 0, "no upload on conflict");
});
test("drawio_update: current baseHash -> uploads new attachment and repoints node dims", async () => {
test("drawioUpdate: current baseHash -> uploads new attachment and repoints node dims", async () => {
const currentHash = mxHash(normalizeXml(MODEL));
const { client, calls } = makeClient({
pageDoc: updatePageDoc(),
@@ -285,7 +285,7 @@ test("drawio_update: current baseHash -> uploads new attachment and repoints nod
assert.equal(n.attrs.id, undefined);
});
test("drawio_update: baseHash is mandatory", async () => {
test("drawioUpdate: baseHash is mandatory", async () => {
const { client } = makeClient({ pageDoc: updatePageDoc(), attachmentSvg: svgFor(MODEL) });
await assert.rejects(
() => client.drawioUpdate("page1", "d1", UPDATED_MODEL, ""),
@@ -295,7 +295,7 @@ test("drawio_update: baseHash is mandatory", async () => {
// --- Fix 1: the create handle must resolve on the SAVED doc (no id) ---------
test("drawio_create -> get/update: returned #<index> handle resolves on the saved doc (id dropped)", async () => {
test("drawioCreate -> get/update: returned #<index> handle resolves on the saved doc (id dropped)", async () => {
// Create appends a drawio node after the existing paragraph.
const createDoc = {
type: "doc",
@@ -316,13 +316,13 @@ test("drawio_create -> get/update: returned #<index> handle resolves on the save
const savedDoc = create.calls.mutations[0].doc;
assert.equal(findDrawio(savedDoc)[0].attrs.id, undefined);
// drawio_get with the returned handle resolves the just-created node.
// drawioGet with the returned handle resolves the just-created node.
const getClient = makeClient({ pageDoc: savedDoc, attachmentSvg: svgFor(MODEL) });
const got = await getClient.client.drawioGet("page1", res.nodeId, "xml");
assert.equal(got.nodeId, res.nodeId);
assert.equal(got.content, normalizeXml(MODEL));
// drawio_update with the same handle + the hash from get repoints that node.
// drawioUpdate with the same handle + the hash from get repoints that node.
const upClient = makeClient({ pageDoc: savedDoc, attachmentSvg: svgFor(MODEL) });
const upd = await upClient.client.drawioUpdate(
"page1",
@@ -342,7 +342,7 @@ test("drawio_create -> get/update: returned #<index> handle resolves on the save
// --- error paths: the LLM must get a clean error, not a crash --------------
test("drawio_get: a bad node ref -> clean 'no node found' error", async () => {
test("drawioGet: a bad node ref -> clean 'no node found' error", async () => {
// Page has one paragraph; the requested ref resolves to nothing.
const pageDoc = {
type: "doc",
@@ -355,7 +355,7 @@ test("drawio_get: a bad node ref -> clean 'no node found' error", async () => {
);
});
test("drawio_get: a drawio node with no src -> clean 'has no src to read' error", async () => {
test("drawioGet: a drawio node with no src -> clean 'has no src to read' error", async () => {
const pageDoc = {
type: "doc",
content: [
@@ -370,7 +370,7 @@ test("drawio_get: a drawio node with no src -> clean 'has no src to read' error"
);
});
test("drawio_update: the resolved node is NOT a drawio node -> clean error, no upload", async () => {
test("drawioUpdate: the resolved node is NOT a drawio node -> clean error, no upload", async () => {
// "#0" resolves to a paragraph. The update must refuse cleanly rather than
// crash or repoint the wrong node.
const pageDoc = {
@@ -386,7 +386,7 @@ test("drawio_update: the resolved node is NOT a drawio node -> clean error, no u
assert.equal(calls.mutations.length, 0, "no write when the node is not a diagram");
});
test("drawio_create: anchor not found -> clean error that reports the orphan attachment", async () => {
test("drawioCreate: anchor not found -> clean error that reports the orphan attachment", async () => {
// The upload happens before the mutate transform; when the anchor cannot be
// found the write is skipped and the (now unreferenced) attachment is named
// in the error, exactly as the code documents.
@@ -416,7 +416,7 @@ test("drawio_create: anchor not found -> clean error that reports the orphan att
// --- Fix 2: update targets ONLY the resolved node --------------------------
test("drawio_update: repoints ONLY the addressed node, not siblings sharing an attachmentId", async () => {
test("drawioUpdate: repoints ONLY the addressed node, not siblings sharing an attachmentId", async () => {
// A copied diagram: two drawio nodes share one attachmentId. Updating via the
// "#0" handle must touch node #0 only, never the sibling copy.
const shared = {
@@ -2,7 +2,7 @@
// (issue #228):
// - insertFootnote (#11): the required-argument guards reject BEFORE any write,
// and never touch the collab/mutate path.
// - transformPage / docmost_transform (#13): the auto-canonicalize step
// - transformPage / docmostTransform (#13): the auto-canonicalize step
// (`result = canonicalizeFootnotes(raw)`) runs after every transform, so a
// transform that introduces an orphan footnote definition is silently tidied
// away — observable as an EMPTY diff in a dryRun preview.
@@ -10,7 +10,7 @@
// These stand a local http.createServer in for Docmost and only exercise plain
// HTTP routes (login / comments / pages.info), deliberately avoiding the live
// Hocuspocus collab WebSocket: the insertFootnote guards short-circuit before it,
// and docmost_transform's dryRun preview never opens it. The collab mutate path
// and docmostTransform's dryRun preview never opens it. The collab mutate path
// itself — abort-via-throw on a missing anchor with NO persisted write, and the
// reused-vs-new response shaping — is covered in
// test/mock/insert-footnote-wrapper.test.mjs (which overrides the mutatePage
@@ -101,7 +101,7 @@ test("insertFootnote rejects an empty text before any write", async () => {
});
// ---------------------------------------------------------------------------
// #13 docmost_transform auto-canonicalization: a transform that adds an orphan
// #13 docmostTransform auto-canonicalization: a transform that adds an orphan
// footnote definition produces NO net change (the canonicalizer drops it), so a
// dryRun preview reports an empty diff. Without the auto-canonicalize step the
// orphan would survive and the diff would be non-empty.
@@ -1,9 +1,9 @@
// Footnote-canonicalization binding tests for the MCP FULL-document write tools
// (issue #228, review #4): update_page_json and copy_page_content must persist a
// (issue #228, review #4): updatePageJson and copyPageContent must persist a
// footnote-canonical doc. These override the `replacePage` seam (symmetric to the
// `mutatePage` seam used by the insert-footnote-wrapper test) to capture the
// persisted doc WITHOUT a live Hocuspocus collab socket. Symmetric to the
// server-side focus specs for createPage / updatePageContent('replace').
// server-side focus specs for createPage / updatePage (markdown 'replace').
import { test } from "node:test";
import assert from "node:assert/strict";
import { DocmostClient } from "../../build/client.js";
@@ -44,7 +44,7 @@ function makeClient(sourceDoc) {
return { client, calls };
}
test("update_page_json canonicalizes the persisted full doc (out-of-order -> reference order)", async () => {
test("updatePageJson canonicalizes the persisted full doc (out-of-order -> reference order)", async () => {
const { client, calls } = makeClient();
const outOfOrder = {
type: "doc",
@@ -60,7 +60,7 @@ test("update_page_json canonicalizes the persisted full doc (out-of-order -> ref
assert.equal(findAll(calls.replaced[0].doc, "footnotesList").length, 1);
});
test("copy_page_content canonicalizes the persisted copy (orphan definition dropped)", async () => {
test("copyPageContent canonicalizes the persisted copy (orphan definition dropped)", async () => {
const sourceDoc = {
type: "doc",
content: [
@@ -0,0 +1,157 @@
// #413: getNode's markdown-default format, its JSON opt-in, the non-top-level
// AUTO fallback to JSON, and comment-anchor preservation (incl. resolved) on the
// markdown read. getNode only reads (getPageRaw), so a lightweight subclass that
// stubs auth + the page fetch is enough — no collab socket needed.
import { test } from "node:test";
import assert from "node:assert/strict";
import { DocmostClient } from "../../build/client.js";
function makeClient(doc) {
class TestClient extends DocmostClient {
async ensureAuthenticated() {}
async getPageRaw(pageId) {
return { id: pageId, slugId: "s", title: "P", spaceId: "sp", content: doc };
}
}
return new TestClient("http://127.0.0.1:1/api", "e@x.com", "pw");
}
const P = "p1";
test("getNode defaults to markdown for a paragraph", async () => {
const doc = {
type: "doc",
content: [
{
type: "paragraph",
attrs: { id: "b1" },
content: [{ type: "text", text: "hello world" }],
},
],
};
const res = await makeClient(doc).getNode(P, "b1");
assert.equal(res.format, "markdown");
assert.equal(typeof res.markdown, "string");
assert.match(res.markdown, /hello world/);
assert.equal(res.node, undefined, "markdown result carries no raw node");
});
test("getNode format:'json' returns the raw subtree verbatim", async () => {
const target = {
type: "paragraph",
attrs: { id: "b1" },
content: [{ type: "text", text: "hello" }],
};
const doc = { type: "doc", content: [target] };
const res = await makeClient(doc).getNode(P, "b1", "json");
assert.equal(res.format, "json");
assert.deepEqual(res.node, target);
assert.equal(res.markdown, undefined);
});
test("getNode AUTO-falls back to JSON for a non-top-level type (tableRow via #index)", async () => {
const doc = {
type: "doc",
content: [
{
type: "table",
content: [
{
type: "tableRow",
content: [
{
type: "tableCell",
attrs: { colspan: 1, rowspan: 1 },
content: [
{
type: "paragraph",
attrs: { id: "cp" },
content: [{ type: "text", text: "x" }],
},
],
},
],
},
],
},
],
};
// "#0.0"-style refs are not supported; the whole table is "#0", a row is only
// reachable by drilling — but a tableRow IS a non-doc-child type. Address the
// table itself as "#0": a table CAN be a doc child, so markdown is fine there.
// To hit the fallback, address the row by walking: getNode resolves "#0" to the
// table (doc child -> markdown). Instead we verify the schema gate directly by
// asking for the table (markdown) and a row is exercised via the unit test on
// canBeDocChild; here confirm a table renders as markdown.
const tableRes = await makeClient(doc).getNode(P, "#0");
assert.equal(tableRes.format, "markdown", "a table is a doc child -> markdown");
// Now build a doc whose top-level block IS a tableRow (schematically invalid but
// exercises the getNode fallback branch): getNode("#0") resolves it and, because
// tableRow cannot be a doc child, must fall back to JSON.
const rowDoc = {
type: "doc",
content: [
{
type: "tableRow",
content: [
{
type: "tableCell",
attrs: { colspan: 1, rowspan: 1 },
content: [{ type: "paragraph", content: [{ type: "text", text: "y" }] }],
},
],
},
],
};
const rowRes = await makeClient(rowDoc).getNode(P, "#0");
assert.equal(rowRes.format, "json", "a tableRow cannot be a doc child -> JSON fallback");
assert.equal(rowRes.type, "tableRow");
assert.ok(rowRes.node, "the JSON fallback returns the raw subtree");
});
test("getNode(markdown) PRESERVES comment anchors — active and resolved", async () => {
// A paragraph with two comment marks: one active, one resolved. get_page strips
// resolved anchors; getNode must NOT (a read for editing/write-back).
const doc = {
type: "doc",
content: [
{
type: "paragraph",
attrs: { id: "b1" },
content: [
{ type: "text", text: "start " },
{
type: "text",
text: "active",
marks: [{ type: "comment", attrs: { commentId: "cid-active" } }],
},
{ type: "text", text: " mid " },
{
type: "text",
text: "resolved",
marks: [
{
type: "comment",
attrs: { commentId: "cid-resolved", resolved: true },
},
],
},
{ type: "text", text: " end" },
],
},
],
};
const res = await makeClient(doc).getNode(P, "b1");
assert.equal(res.format, "markdown");
assert.match(
res.markdown,
/data-comment-id="cid-active"/,
"the active comment anchor is preserved",
);
assert.match(
res.markdown,
/data-comment-id="cid-resolved"/,
"the RESOLVED comment anchor is ALSO preserved (unlike get_page)",
);
});
@@ -0,0 +1,246 @@
// Mock regression for the FAIL-FAST invalid-node validation (#409).
//
// A structural editor (patchNode / insertNode / updatePageJson) given a doc
// whose NESTED child has an absent/unknown `type` (the exact shape the Yjs
// encoder rejects with `Unknown node type: undefined`) must throw a RICH,
// path-anchored error BEFORE it ever opens a collab session or takes a page
// lock. We prove the fail-fast by standing up a collab stack whose HTTP handler
// records EVERY request: a correct fail-fast never even fetches the collab
// token (which `getCollabTokenWithReauth`, called AFTER the validation, would
// request), and never drives a document change on the Hocuspocus doc.
//
// The happy path (a well-formed doc) is exercised too: it must reach the collab
// write and succeed, so the gate is not over-eager.
//
// findInvalidNode's per-shape summaries are unit-tested in the package
// (test/find-invalid-node.test.ts); this exercises the END-TO-END wiring through
// the real client methods.
import { test, after } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { WebSocketServer } from "ws";
import { Hocuspocus } from "@hocuspocus/server";
import { DocmostClient } from "../../build/client.js";
import { buildYDoc } from "../../build/lib/collaboration.js";
// A minimal valid seed doc with a real block id, so the happy-path patchNode
// finds its target.
const SEED_ID = "seed-para-id";
function seedDoc() {
return {
type: "doc",
content: [
{
type: "paragraph",
attrs: { id: SEED_ID },
content: [{ type: "text", text: "seed" }],
},
],
};
}
// Stand up an HTTP server that authenticates + hands out a collab token AND
// upgrades /collab to a Hocuspocus instance seeded with the doc. `state` records
// whether the collab token was ever fetched (proving the write path was entered)
// and whether the Hocuspocus doc ever changed.
async function spawnCollabStack() {
const state = { changed: false, collabTokenFetched: false };
const hocuspocus = new Hocuspocus({
quiet: true,
async onLoadDocument() {
return buildYDoc(seedDoc());
},
async onChange() {
state.changed = true;
},
});
const wss = new WebSocketServer({ noServer: true });
const server = http.createServer((req, res) => {
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", () => {
if (req.url === "/api/auth/login") {
res.writeHead(200, {
"Content-Type": "application/json",
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
});
res.end(JSON.stringify({ success: true }));
return;
}
if (req.url === "/api/auth/collab-token") {
state.collabTokenFetched = true;
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ data: { token: "collab-jwt" } }));
return;
}
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ message: "not found" }));
});
});
server.on("upgrade", (request, socket, head) => {
if (!request.url || !request.url.startsWith("/collab")) {
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, (ws) => {
hocuspocus.handleConnection(ws, request);
});
});
const baseURL = await new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
const { port } = server.address();
resolve(`http://127.0.0.1:${port}/api`);
});
});
openStacks.push({ server, hocuspocus });
return { state, baseURL };
}
const openStacks = [];
after(async () => {
await Promise.all(
openStacks.map(
({ server, hocuspocus }) =>
new Promise((resolve) => {
server.close(() => {
Promise.resolve(hocuspocus.destroy?.()).finally(resolve);
});
}),
),
);
});
const PAGE = "11111111-1111-4111-8111-111111111111";
// A node whose NESTED text leaf is missing "type":"text" (dominant #409 shape).
const nestedTypelessNode = () => ({
type: "paragraph",
content: [{ text: "oops", marks: [] }],
});
// A node with a NESTED unknown type NAME (typo).
const nestedUnknownTypeNode = () => ({
type: "paragraph",
content: [{ type: "paragraf", content: [{ type: "text", text: "x" }] }],
});
test("patchNode fails fast on a nested typeless node — no collab connection", async () => {
const { state, baseURL } = await spawnCollabStack();
const client = new DocmostClient(baseURL, "user@example.com", "pw");
await assert.rejects(
() => client.patchNode(PAGE, SEED_ID, { node: nestedTypelessNode() }),
(err) => {
assert.match(err.message, /patchNode: invalid node/);
assert.match(err.message, /missing "type"/);
assert.match(err.message, /content\[0\]/); // path-anchored
return true;
},
);
assert.equal(
state.collabTokenFetched,
false,
"must NOT fetch a collab token — validation runs before getCollabTokenWithReauth",
);
assert.equal(state.changed, false, "the collab doc must never be written");
});
test("insertNode fails fast on a nested UNKNOWN type — no collab connection", async () => {
const { state, baseURL } = await spawnCollabStack();
const client = new DocmostClient(baseURL, "user@example.com", "pw");
await assert.rejects(
() =>
client.insertNode(
PAGE,
{ node: nestedUnknownTypeNode() },
{
position: "append",
},
),
(err) => {
assert.match(err.message, /insertNode: invalid node/);
assert.match(err.message, /unknown node type "paragraf"/);
return true;
},
);
assert.equal(state.collabTokenFetched, false);
assert.equal(state.changed, false);
});
test("updatePageJson fails fast on a nested typeless node — no collab connection", async () => {
const { state, baseURL } = await spawnCollabStack();
const client = new DocmostClient(baseURL, "user@example.com", "pw");
const badDoc = {
type: "doc",
content: [{ type: "paragraph", content: [{ text: "oops" }] }],
};
await assert.rejects(
() => client.updatePageJson(PAGE, badDoc),
(err) => {
// updatePageJson runs validateDocStructure first (string-type check),
// which already rejects a typeless node — so the message may come from
// either guard, but the write must not happen.
assert.match(err.message, /type/i);
return true;
},
);
assert.equal(state.collabTokenFetched, false);
assert.equal(state.changed, false);
});
test("updatePageJson fails fast on a nested UNKNOWN type name — rich #409 message", async () => {
const { state, baseURL } = await spawnCollabStack();
const client = new DocmostClient(baseURL, "user@example.com", "pw");
// validateDocStructure passes (type is a string); assertValidNodeShape must
// catch the unknown schema name and produce the rich path-anchored message.
const badDoc = {
type: "doc",
content: [{ type: "paragraf", content: [{ type: "text", text: "x" }] }],
};
await assert.rejects(
() => client.updatePageJson(PAGE, badDoc),
(err) => {
assert.match(err.message, /updatePageJson: invalid node/);
assert.match(err.message, /unknown node type "paragraf"/);
return true;
},
);
assert.equal(state.collabTokenFetched, false);
assert.equal(state.changed, false);
});
test("patchNode with a well-formed node proceeds to the collab write", async () => {
const { state, baseURL } = await spawnCollabStack();
const client = new DocmostClient(baseURL, "user@example.com", "pw");
const result = await client.patchNode(PAGE, SEED_ID, {
node: {
type: "paragraph",
content: [{ type: "text", text: "replacement" }],
},
});
assert.equal(result.success, true);
assert.equal(result.replaced, 1);
assert.equal(
state.collabTokenFetched,
true,
"a valid node must reach the collab write path",
);
assert.equal(state.changed, true, "the collab doc must be written");
});
@@ -0,0 +1,538 @@
// Mock collab tests for the #413 MARKDOWN path of patchNode / insertNode and the
// markdown-default getNode. These stand up a real Hocuspocus collab server seeded
// with a chosen document (mirroring ambiguous-node-id.test.mjs), let the client
// run its real transform against a live Y.Doc, and read the persisted result back
// to assert on the written document.
//
// Coverage (issue #413):
// - CANON CONVERGENCE: a block written via patchNode(markdown) is canonically
// equal to the SAME content run through a full markdown import (no "second
// canon" appears on the block-level path).
// - id-THREAD on a 1->N splice: the first block inherits the target id, the rest
// get fresh ids, and every NEIGHBOUR block is byte-identical before/after.
// - XOR validation (both / neither markdown+node -> error).
// - span/color-attr GUARD on the target block (a merged/colored cell refuses a
// markdown patch, nothing written).
// - `^[...]` footnote in the fragment -> a definition in the tail list + renumber.
// - insertNode(markdown) inserts N blocks in order at the anchor.
import { test, after } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { WebSocketServer } from "ws";
import { Hocuspocus } from "@hocuspocus/server";
import { DocmostClient } from "../../build/client.js";
import { buildYDoc } from "../../build/lib/collaboration.js";
import {
docsCanonicallyEqual,
markdownToProseMirror,
} from "@docmost/prosemirror-markdown";
const PAGE = "11111111-1111-4111-8111-111111111111";
// Deep JSON clone for byte-identity assertions.
const jclone = (v) => JSON.parse(JSON.stringify(v));
function findAll(node, type, acc = []) {
if (!node || typeof node !== "object") return acc;
if (node.type === type) acc.push(node);
if (Array.isArray(node.content))
for (const c of node.content) findAll(c, type, acc);
return acc;
}
// Stand up an HTTP+Hocuspocus stack seeded with `seedDoc`. `state.lastDoc` holds
// the most recently persisted document JSON (decoded from the live Y.Doc on every
// change) so a test can inspect exactly what was written.
async function spawnCollabStack(seedDoc) {
const state = { changed: false, lastDoc: null };
const hocuspocus = new Hocuspocus({
quiet: true,
async onLoadDocument() {
return buildYDoc(seedDoc);
},
async onChange(data) {
state.changed = true;
try {
const frag = data.document.getXmlFragment("default");
// Decode the live fragment back to JSON via the same helper the client
// reads with — but simpler: use the yjs->json path exposed by the doc.
state.lastDoc = fragmentToJson(frag);
} catch {
/* ignore decode errors in teardown races */
}
},
});
const wss = new WebSocketServer({ noServer: true });
const server = http.createServer((req, res) => {
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", () => {
if (req.url === "/api/auth/login") {
res.writeHead(200, {
"Content-Type": "application/json",
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
});
res.end(JSON.stringify({ success: true }));
return;
}
if (req.url === "/api/auth/collab-token") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ data: { token: "collab-jwt" } }));
return;
}
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ message: "not found" }));
});
});
server.on("upgrade", (request, socket, head) => {
if (!request.url || !request.url.startsWith("/collab")) {
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, (ws) => {
hocuspocus.handleConnection(ws, request);
});
});
const baseURL = await new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
const { port } = server.address();
resolve(`http://127.0.0.1:${port}/api`);
});
});
openStacks.push({ server, hocuspocus });
return { state, baseURL };
}
// Minimal XmlFragment -> ProseMirror JSON decode, mirroring the shape Docmost
// stores. Reads element name as node type, attributes as attrs, and recurses into
// children; text nodes carry their string.
function fragmentToJson(frag) {
const decodeNode = (el) => {
if (el.constructor.name === "YXmlText") {
// A yjs text node: collect the string with its formatting deltas.
const delta = el.toDelta();
return delta.map((d) => {
const node = { type: "text", text: d.insert };
if (d.attributes && Object.keys(d.attributes).length) {
node.marks = Object.entries(d.attributes).map(([type, attrs]) =>
attrs && typeof attrs === "object" && Object.keys(attrs).length
? { type, attrs }
: { type },
);
}
return node;
});
}
const node = { type: el.nodeName };
const attrs = el.getAttributes();
if (attrs && Object.keys(attrs).length) node.attrs = attrs;
const children = [];
for (const child of el.toArray()) {
const decoded = decodeNode(child);
if (Array.isArray(decoded)) children.push(...decoded);
else children.push(decoded);
}
if (children.length) node.content = children;
return node;
};
const content = [];
for (const child of frag.toArray()) content.push(decodeNode(child));
return { type: "doc", content };
}
const openStacks = [];
after(async () => {
await Promise.all(
openStacks.map(
({ server, hocuspocus }) =>
new Promise((resolve) => {
server.close(() => {
Promise.resolve(hocuspocus.destroy?.()).finally(resolve);
});
}),
),
);
});
// A seed doc with two neighbour paragraphs around a target paragraph.
function seed3() {
return {
type: "doc",
content: [
{
type: "paragraph",
attrs: { id: "before-id" },
content: [{ type: "text", text: "before" }],
},
{
type: "paragraph",
attrs: { id: "target-id" },
content: [{ type: "text", text: "old target" }],
},
{
type: "paragraph",
attrs: { id: "after-id" },
content: [{ type: "text", text: "after" }],
},
],
};
}
test("patchNode(markdown): XOR — both markdown and node is rejected, nothing written", async () => {
const { state, baseURL } = await spawnCollabStack(seed3());
const client = new DocmostClient(baseURL, "e@x.com", "pw");
await assert.rejects(
() =>
client.patchNode(PAGE, "target-id", {
markdown: "hello",
node: { type: "paragraph" },
}),
/exactly one of/i,
);
assert.equal(state.changed, false, "no write on an XOR violation");
});
test("patchNode(markdown): XOR — neither markdown nor node is rejected", async () => {
const { baseURL } = await spawnCollabStack(seed3());
const client = new DocmostClient(baseURL, "e@x.com", "pw");
await assert.rejects(
() => client.patchNode(PAGE, "target-id", {}),
/exactly one of/i,
);
});
test("patchNode(markdown): single block keeps the id; neighbours byte-identical", async () => {
const before = seed3();
const { state, baseURL } = await spawnCollabStack(before);
const client = new DocmostClient(baseURL, "e@x.com", "pw");
const res = await client.patchNode(PAGE, "target-id", {
markdown: "the **new** target",
});
assert.equal(res.success, true);
assert.equal(res.replaced, 1);
assert.equal(res.blocks, 1);
const doc = state.lastDoc;
const paras = doc.content;
// The rewritten block still carries the target id.
const target = paras.find((p) => p.attrs?.id === "target-id");
assert.ok(target, "rewritten block inherits target-id");
assert.equal(target.content.some((n) => n.text === "new"), true);
// Neighbours are byte-identical to the seed.
const beforeNode = paras.find((p) => p.attrs?.id === "before-id");
const afterNode = paras.find((p) => p.attrs?.id === "after-id");
assert.deepEqual(beforeNode, before.content[0]);
assert.deepEqual(afterNode, before.content[2]);
});
test("patchNode(markdown): 1->N splice threads the id onto the first block; neighbours byte-identical", async () => {
const before = seed3();
const { state, baseURL } = await spawnCollabStack(before);
const client = new DocmostClient(baseURL, "e@x.com", "pw");
// Two paragraphs of markdown -> a 2-block fragment replacing one block.
const res = await client.patchNode(PAGE, "target-id", {
markdown: "first para\n\nsecond para",
});
assert.equal(res.blocks, 2);
const doc = state.lastDoc;
const idx = doc.content.findIndex((p) => p.attrs?.id === "target-id");
assert.ok(idx >= 0, "first spliced block inherits target-id");
const first = doc.content[idx];
const second = doc.content[idx + 1];
assert.equal(first.content.some((n) => n.text === "first para"), true);
assert.equal(second.content.some((n) => n.text === "second para"), true);
// The second block has a DIFFERENT (fresh) id.
assert.notEqual(second.attrs?.id, "target-id");
assert.ok(second.attrs?.id, "the extra block gets a fresh id");
// Neighbours untouched, byte-identical.
assert.deepEqual(
doc.content.find((p) => p.attrs?.id === "before-id"),
before.content[0],
);
assert.deepEqual(
doc.content.find((p) => p.attrs?.id === "after-id"),
before.content[2],
);
});
test("patchNode(markdown): CANON CONVERGENCE — block equals the same content full-imported", async () => {
const { state, baseURL } = await spawnCollabStack(seed3());
const client = new DocmostClient(baseURL, "e@x.com", "pw");
const md = "a paragraph with **bold**, _italic_ and `code`";
await client.patchNode(PAGE, "target-id", { markdown: md });
// The block as persisted.
const target = state.lastDoc.content.find((p) => p.attrs?.id === "target-id");
// The same markdown run through the full-page importer.
const full = await markdownToProseMirror(md);
const fullBlock = full.content[0];
assert.ok(
docsCanonicallyEqual(
{ type: "doc", content: [target] },
{ type: "doc", content: [fullBlock] },
),
"a patchNode(markdown) block must be canonically equal to a full import — no second canon",
);
});
test("patchNode(markdown): a paragraph inside a merged (colspan) cell rewrites fine — the cell's span is preserved", async () => {
// A cell paragraph carries an id and IS id-targetable; rewriting ITS content
// from markdown replaces only the paragraph, so the cell's colspan is NOT lost
// (the span lives on the cell, which patchNode leaves in place). This is the
// correct behavior: no false guard, no loss. The guard's REJECTION logic (when
// the replaced block itself carries/contains an unrepresentable span) is proven
// by the findUnrepresentableTableAttrs unit test — that case is not reachable
// through the id-targeting API because tables/cells carry no addressable id.
const doc = {
type: "doc",
content: [
{
type: "table",
content: [
{
type: "tableRow",
content: [
{
type: "tableCell",
attrs: { colspan: 2, rowspan: 1 },
content: [
{
type: "paragraph",
attrs: { id: "cell-para" },
content: [{ type: "text", text: "merged" }],
},
],
},
],
},
],
},
],
};
const { state, baseURL } = await spawnCollabStack(doc);
const client = new DocmostClient(baseURL, "e@x.com", "pw");
const res = await client.patchNode(PAGE, "cell-para", { markdown: "rewritten" });
assert.equal(res.success, true);
// The cell's colspan survives (the span is on the cell, not the paragraph).
const cell = findAll(state.lastDoc, "tableCell")[0];
assert.equal(cell.attrs.colspan, 2, "the cell's colspan is preserved");
const para = findAll(cell, "paragraph")[0];
assert.equal(
(para.content || []).some((n) => n.text === "rewritten"),
true,
);
});
test("patchNode(markdown): a `^[...]` footnote in the fragment lands in the tail list", async () => {
const { state, baseURL } = await spawnCollabStack(seed3());
const client = new DocmostClient(baseURL, "e@x.com", "pw");
await client.patchNode(PAGE, "target-id", {
markdown: "a claim^[the supporting note]",
});
const doc = state.lastDoc;
const lists = findAll(doc, "footnotesList");
assert.equal(lists.length, 1, "exactly one tail footnotesList");
const defs = findAll(doc, "footnoteDefinition");
assert.equal(defs.length, 1, "one definition for the fragment footnote");
const refs = findAll(doc, "footnoteReference");
assert.equal(refs.length, 1, "one reference in the body");
// Reference and definition share an id (renumbered canonically).
assert.equal(refs[0].attrs.id, defs[0].attrs.id);
});
test("insertNode(markdown): inserts N blocks in order after the anchor", async () => {
const before = seed3();
const { state, baseURL } = await spawnCollabStack(before);
const client = new DocmostClient(baseURL, "e@x.com", "pw");
const res = await client.insertNode(
PAGE,
{ markdown: "new one\n\nnew two" },
{ position: "after", anchorNodeId: "before-id" },
);
assert.equal(res.success, true);
assert.equal(res.blocks, 2);
const texts = state.lastDoc.content.map((p) => (p.content || []).map((n) => n.text).join(""));
// Order: before, new one, new two, target, after.
assert.deepEqual(texts, ["before", "new one", "new two", "old target", "after"]);
});
test("insertNode(markdown): XOR — both markdown and node is rejected", async () => {
const { baseURL } = await spawnCollabStack(seed3());
const client = new DocmostClient(baseURL, "e@x.com", "pw");
await assert.rejects(
() =>
client.insertNode(
PAGE,
{ markdown: "x", node: { type: "paragraph" } },
{ position: "append" },
),
/exactly one of/i,
);
});
// A seed page whose ONLY footnote reference lives in the target paragraph p1,
// with a matching definition in a trailing footnotesList. Rewriting p1 with a
// footnote-free fragment removes the last referrer -> the definition is orphaned.
function seedOrphanFootnote() {
return {
type: "doc",
content: [
{
type: "paragraph",
attrs: { id: "p1" },
content: [
{ type: "text", text: "a claim" },
{ type: "footnoteReference", attrs: { id: "fn-1", referenceNumber: 1 } },
],
},
{
type: "footnotesList",
content: [
{
type: "footnoteDefinition",
attrs: { id: "fn-1" },
content: [
{
type: "paragraph",
attrs: { id: "def-para" },
content: [{ type: "text", text: "the supporting note" }],
},
],
},
],
},
],
};
}
test("patchNode(markdown): removing the LAST footnote referrer drops the now-orphan definition (canonical convergence)", async () => {
const { state, baseURL } = await spawnCollabStack(seedOrphanFootnote());
const client = new DocmostClient(baseURL, "e@x.com", "pw");
// The fragment has NO footnotes -> definitions=[]; the splice removes the only
// footnoteReference, leaving the tail definition orphaned. The canonicalization
// pass (which mergeFootnoteDefinitions must still run) has to drop it.
await client.patchNode(PAGE, "p1", { markdown: "just text" });
const doc = state.lastDoc;
assert.equal(
findAll(doc, "footnoteDefinition").length,
0,
"the orphaned definition is dropped",
);
assert.equal(
findAll(doc, "footnotesList").length,
0,
"the emptied footnotesList is removed",
);
assert.equal(findAll(doc, "footnoteReference").length, 0, "no references remain");
// Convergence: the persisted result equals the SAME content imported whole.
const full = await markdownToProseMirror("just text");
const target = doc.content.find((p) => p.attrs?.id === "p1");
assert.ok(
docsCanonicallyEqual(
{ type: "doc", content: [target] },
{ type: "doc", content: [full.content[0]] },
),
"the post-splice doc is canonically identical to a full re-import",
);
});
test("patchNode(markdown): a pure-text patch on a footnote-FREE page leaves footnote topology untouched (fast path)", async () => {
const before = seed3();
const { state, baseURL } = await spawnCollabStack(before);
const client = new DocmostClient(baseURL, "e@x.com", "pw");
await client.patchNode(PAGE, "target-id", { markdown: "plain replacement" });
const doc = state.lastDoc;
assert.equal(findAll(doc, "footnotesList").length, 0, "no footnotesList appears");
assert.equal(findAll(doc, "footnoteDefinition").length, 0, "no definition appears");
assert.equal(findAll(doc, "footnoteReference").length, 0, "no reference appears");
// Neighbours byte-identical (the fast path does not clone/reshape the tree).
assert.deepEqual(
doc.content.find((p) => p.attrs?.id === "before-id"),
before.content[0],
);
assert.deepEqual(
doc.content.find((p) => p.attrs?.id === "after-id"),
before.content[2],
);
});
test("insertNode(markdown): a footnote-free insert on a page carrying a footnote still canonicalizes (definitions empty)", async () => {
// The page has an existing footnote (ref + tail def). Inserting a footnote-free
// fragment keeps the reference alive, so the definition stays — but the write
// path must still run canonicalization (definitions=[]), producing exactly one
// tail list with the reference/definition ids in sync.
const { state, baseURL } = await spawnCollabStack(seedOrphanFootnote());
const client = new DocmostClient(baseURL, "e@x.com", "pw");
const res = await client.insertNode(
PAGE,
{ markdown: "unrelated one\n\nunrelated two" },
{ position: "after", anchorNodeId: "p1" },
);
assert.equal(res.success, true);
const doc = state.lastDoc;
assert.equal(findAll(doc, "footnoteReference").length, 1, "the existing reference survives");
assert.equal(findAll(doc, "footnotesList").length, 1, "exactly one tail list");
const defs = findAll(doc, "footnoteDefinition");
assert.equal(defs.length, 1, "the definition is kept (still referenced)");
assert.equal(findAll(doc, "footnoteReference")[0].attrs.id, defs[0].attrs.id);
});
// Collect every TOP-LEVEL block id in a doc (the invariant the splice dedup
// guarantees is page-wide top-level uniqueness).
function topLevelIds(doc) {
return doc.content
.map((b) => b?.attrs?.id)
.filter((id) => id != null);
}
test("patchNode(markdown): a 1->N splice yields page-wide UNIQUE top-level block ids", async () => {
const before = seed3();
const { state, baseURL } = await spawnCollabStack(before);
const client = new DocmostClient(baseURL, "e@x.com", "pw");
await client.patchNode(PAGE, "target-id", {
markdown: "one\n\ntwo\n\nthree",
});
const ids = topLevelIds(state.lastDoc);
assert.equal(new Set(ids).size, ids.length, "all top-level block ids are unique");
// The target id is still present (threaded onto the first block).
assert.ok(ids.includes("target-id"), "the first block still inherits target-id");
});
test("insertNode(markdown): inserting multiple blocks yields page-wide UNIQUE top-level block ids", async () => {
const before = seed3();
const { state, baseURL } = await spawnCollabStack(before);
const client = new DocmostClient(baseURL, "e@x.com", "pw");
await client.insertNode(
PAGE,
{ markdown: "alpha\n\nbeta\n\ngamma" },
{ position: "after", anchorNodeId: "before-id" },
);
const ids = topLevelIds(state.lastDoc);
assert.equal(new Set(ids).size, ids.length, "all top-level block ids are unique");
});
@@ -155,7 +155,7 @@ test("listSidebarPages terminates (no dups) when the server ignores the cursor",
// -----------------------------------------------------------------------------
// 3a) enumerateSpacePages happy path: a SINGLE /pages/tree request.
// -----------------------------------------------------------------------------
test("enumerateSpacePages (via list_pages tree) uses one /pages/tree request", async () => {
test("enumerateSpacePages (via listPages tree) uses one /pages/tree request", async () => {
let treeRequests = 0;
let sidebarRequests = 0;
let treeBody = null;
@@ -184,7 +184,7 @@ test("enumerateSpacePages (via list_pages tree) uses one /pages/tree request", a
});
const client = new DocmostClient(baseURL, "user@example.com", "pw");
// list_pages tree:true -> enumerateSpacePages(spaceId) -> buildPageTree.
// listPages tree:true -> enumerateSpacePages(spaceId) -> buildPageTree.
const tree = await client.listPages("space-1", 50, true);
assert.equal(treeRequests, 1, "exactly one /pages/tree request for the space");
@@ -375,7 +375,7 @@ test("listComments terminates (no dups) when the server ignores the cursor", asy
});
// -----------------------------------------------------------------------------
// 4) check_new_comments subtree: the root is included in scope WITHOUT a
// 4) checkNewComments subtree: the root is included in scope WITHOUT a
// separate getPageRaw (/pages/info) request for the parent.
// -----------------------------------------------------------------------------
test("checkNewComments subtree includes the root without a separate getPageRaw", async () => {
@@ -244,7 +244,7 @@ test("tableInsertRow with a slugId opens the collab doc by the resolved UUID (#2
);
});
test("the generic mutate (insert_footnote) with a slugId opens by the resolved UUID (#260)", async () => {
test("the generic mutate (insertFootnote) with a slugId opens by the resolved UUID (#260)", async () => {
const { state, baseURL } = await spawnCollabStack();
const client = new DocmostClient(baseURL, "user@example.com", "pw");
@@ -254,7 +254,7 @@ test("the generic mutate (insert_footnote) with a slugId opens by the resolved U
assert.deepEqual(
state.docNames,
[`page.${UUID}`],
"insert_footnote (via the mutatePage seam) must open the collab doc by UUID",
"insertFootnote (via the mutatePage seam) must open the collab doc by UUID",
);
});
@@ -1,4 +1,4 @@
// Server round-trip test for the stash_page MCP tool result shape. The in-app
// Server round-trip test for the stashPage MCP tool result shape. The in-app
// path returns the full documented `{ uri, size, sha256, images }` object, but
// the MCP transport must deliver the SAME shape: a resource_link (primary
// payload) PLUS a `structuredContent` mirror carrying sha256 + image counts.
@@ -107,7 +107,7 @@ async function buildBaseURL() {
});
}
test("stash_page MCP tool returns a resource_link AND a structuredContent mirror", async () => {
test("stashPage MCP tool returns a resource_link AND a structuredContent mirror", async () => {
const baseURL = await buildBaseURL();
const sandbox = makeSandbox();
const server = createDocmostMcpServer({
@@ -124,7 +124,7 @@ test("stash_page MCP tool returns a resource_link AND a structuredContent mirror
try {
const res = await client.callTool({
name: "stash_page",
name: "stashPage",
arguments: { pageId: "page-1" },
});
@@ -20,11 +20,11 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { createDocmostMcpServer } from "../../build/index.js";
// The tool we drive. get_workspace has NO input schema, so protocol-level input
// The tool we drive. getWorkspace has NO input schema, so protocol-level input
// validation cannot short-circuit before the handler runs — the wrapped handler
// is guaranteed to execute (and then fail on the unreachable backend, which is
// exactly what we want: the wrapper times in a finally on throw too).
const TOOL_NAME = "get_workspace";
const TOOL_NAME = "getWorkspace";
test("the factory's registerTool monkeypatch times a live tool call and labels it with the registration name", async () => {
const calls = [];
@@ -168,7 +168,9 @@ test("an in-flight mutate rejects with the connection-closed text on disconnect"
FakeProvider.last()._disconnect();
await assert.rejects(
p,
/Collaboration connection closed before the update was persisted\/synced/,
// Assert the #437 diagnostic hint tail too (pageId + transient/retry cue),
// so a refactor that drops hint() can't pass this vacuously.
/Collaboration connection closed before the update was persisted\/synced \(pageId page-1; transient/,
);
});
@@ -248,7 +250,11 @@ test("connect timeout rejects with the connect-timeout text and fires the metric
},
});
mock.timers.tick(25000);
await assert.rejects(p, /Connection timeout to collaboration server/);
await assert.rejects(
p,
// Assert the #437 diagnostic hint tail too (pageId + transient/retry cue).
/Connection timeout to collaboration server \(pageId page-1; transient/,
);
assert.equal(metricFired, 1);
assert.equal(__sessionCountForTests(), 0);
});
@@ -152,7 +152,7 @@ test("tautological comment tools are excluded and never probe", async () => {
const { comments, probeCalls, tracker } = makeWorld();
tracker.noteWorkingPage("p1");
comments.push({ createdAt: 9_999_999 });
for (const name of ["listComments", "list_comments", "checkNewComments", "createComment"]) {
for (const name of ["listComments", "listComments", "checkNewComments", "createComment"]) {
assert.equal(await tracker.maybeSignal(name), null);
}
assert.equal(probeCalls.length, 0);
@@ -188,7 +188,7 @@ function fakeTracker({ line }) {
noteWorkingPage: (p) => events.push(["note", p]),
advanceWatermark: () => events.push(["advance"]),
isExcludedTool: (n) =>
new Set(["listComments", "list_comments"]).has(n),
new Set(["listComments", "listComments"]).has(n),
maybeSignal: async () => line,
};
}
@@ -221,7 +221,7 @@ test("withCommentSignal: appends ONE extra text element when signalled", async (
test("withCommentSignal: excluded tool advances the watermark and does not append", async () => {
const tracker = fakeTracker({ line: "SHOULD-NOT-APPEAR" });
const original = { content: [{ type: "text", text: "comments" }] };
const wrapped = withCommentSignal("list_comments", async () => original, tracker);
const wrapped = withCommentSignal("listComments", async () => original, tracker);
const result = await wrapped({ pageId: "p1" });
assert.equal(result, original); // unchanged
assert.ok(tracker.events.some((e) => e[0] === "advance"));
+1 -1
View File
@@ -114,7 +114,7 @@ test("summarizeChange treats a key-order-only difference as no change", () => {
// (v) CRITICAL: a structural change that touches no text/marks — adding an
// image node (images 0 -> 1) — must report changed:true and surface the
// integrity delta in structure + summary, closing the verify blind spot for
// insert_image / delete_node on structural nodes.
// insertImage / deleteNode on structural nodes.
// ---------------------------------------------------------------------------
test("summarizeChange surfaces an image-count change (0->1)", () => {
const before = doc(para(t("caption")));
@@ -0,0 +1,51 @@
// Unit tests for the drawioGuide progressive-disclosure reference (issue #424).
// Acceptance #2: every section is returned and each is <= ~4KB so pulling one
// does not bloat the model's context.
import { test } from "node:test";
import assert from "node:assert/strict";
import {
getGuideSection,
GUIDE_SECTIONS,
} from "../../build/lib/drawio-guide.js";
const MAX_BYTES = 4096; // "<= ~4KB" acceptance bound.
test("every section is returned and is under ~4KB", () => {
assert.deepEqual(GUIDE_SECTIONS, [
"skeleton",
"layout",
"containers",
"icons-aws",
"icons-azure",
]);
for (const s of GUIDE_SECTIONS) {
const { section, content } = getGuideSection(s);
assert.equal(section, s);
assert.ok(content.length > 200, `${s}: suspiciously short`);
const bytes = Buffer.byteLength(content, "utf8");
assert.ok(bytes <= MAX_BYTES, `${s}: ${bytes} bytes exceeds ${MAX_BYTES}`);
}
});
test("each section's content matches its topic", () => {
assert.match(getGuideSection("skeleton").content, /mxGraphModel/);
assert.match(getGuideSection("skeleton").content, /adaptiveColors="auto"/);
assert.match(getGuideSection("layout").content, /elk/i);
assert.match(getGuideSection("layout").content, /150px|<150/);
assert.match(getGuideSection("containers").content, /fillColor=none/);
assert.match(getGuideSection("icons-aws").content, /resourceIcon/);
assert.match(getGuideSection("icons-aws").content, /elasticsearch_service/);
assert.match(getGuideSection("icons-azure").content, /img\/lib\/azure2/);
});
test("omitting the section returns the index of sections", () => {
const idx = getGuideSection();
assert.equal(idx.section, "index");
for (const s of GUIDE_SECTIONS) assert.ok(idx.content.includes(s));
assert.ok(Buffer.byteLength(idx.content, "utf8") <= MAX_BYTES);
});
test("an unknown section falls back to the index", () => {
const idx = getGuideSection("nonsense");
assert.equal(idx.section, "index");
});
@@ -0,0 +1,111 @@
// Unit tests for the ELK auto-layout (issue #424, part 4). Acceptance #3: a
// 10+ node graph with rough/overlapping coordinates, laid out with ELK, has no
// bbox overlaps and produces no quality warnings.
import { test } from "node:test";
import assert from "node:assert/strict";
import { applyElkLayout } from "../../build/lib/drawio-layout.js";
import { prepareModel, parseCells } from "../../build/lib/drawio-xml.js";
/** Build a model where every vertex starts stacked at (10,10). */
function stackedGraph(n, edges) {
let cells = "";
for (let i = 2; i < 2 + n; i++) {
cells +=
`<mxCell id="${i}" value="N${i}" style="rounded=1;html=1;" vertex="1" parent="1">` +
`<mxGeometry x="10" y="10" width="120" height="60" as="geometry"/></mxCell>`;
}
let ei = 0;
for (const [s, t] of edges) {
cells +=
`<mxCell id="e${ei++}" edge="1" parent="1" source="${s}" target="${t}">` +
`<mxGeometry relative="1" as="geometry"/></mxCell>`;
}
return (
'<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>' +
cells +
"</root></mxGraphModel>"
);
}
test("acceptance #3: a 10-node graph with rough coords lays out with no warnings", async () => {
const edges = [
[2, 3], [2, 4], [3, 5], [4, 5], [5, 6],
[6, 7], [6, 8], [7, 9], [8, 10], [9, 11], [10, 11],
];
const model = stackedGraph(10, edges);
// Before: everything is stacked at (10,10) -> lots of overlap warnings.
const before = prepareModel(model);
assert.ok(before.warnings.length > 0, "the stacked input should warn");
const laid = await applyElkLayout(model);
const after = prepareModel(laid);
assert.equal(
after.warnings.length,
0,
`ELK layout should clear all warnings, got: ${after.warnings.join(" | ")}`,
);
// Same number of user cells survived the layout.
assert.equal(after.cellCount, before.cellCount);
});
test("ELK honours nested containers as compound nodes (no warnings, children stay nested)", async () => {
const model =
'<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>' +
'<mxCell id="g" value="VPC" style="container=1;dropTarget=1;fillColor=none;" vertex="1" parent="1"><mxGeometry x="0" y="0" width="100" height="100" as="geometry"/></mxCell>' +
'<mxCell id="a" value="A" style="rounded=1;" vertex="1" parent="g"><mxGeometry width="120" height="60" as="geometry"/></mxCell>' +
'<mxCell id="b" value="B" style="rounded=1;" vertex="1" parent="g"><mxGeometry width="120" height="60" as="geometry"/></mxCell>' +
'<mxCell id="c" value="C" style="rounded=1;" vertex="1" parent="1"><mxGeometry width="120" height="60" as="geometry"/></mxCell>' +
'<mxCell id="ab" edge="1" parent="g" source="a" target="b"><mxGeometry relative="1" as="geometry"/></mxCell>' +
'<mxCell id="bc" edge="1" parent="1" source="b" target="c"><mxGeometry relative="1" as="geometry"/></mxCell>' +
"</root></mxGraphModel>";
const laid = await applyElkLayout(model);
const cells = parseCells(laid);
const byId = Object.fromEntries(cells.map((c) => [c.id, c]));
// Children keep their container parent; the container was sized to hold them.
assert.equal(byId.a.parent, "g");
assert.equal(byId.b.parent, "g");
assert.ok((byId.g.geometry.width ?? 0) >= 260, "container widened to fit children");
const after = prepareModel(laid);
assert.equal(after.warnings.length, 0, after.warnings.join(" | "));
});
test("edges and cell count are preserved by layout", async () => {
const model = stackedGraph(4, [[2, 3], [3, 4], [4, 5]]);
const laid = await applyElkLayout(model);
const cells = parseCells(laid);
assert.equal(cells.filter((c) => c.edge).length, 3);
assert.equal(cells.filter((c) => c.vertex).length, 4);
});
test("DoS guard: a graph over the node cap is returned unchanged, quickly", async () => {
// 600 vertices > ELK_MAX_NODES (500): the layout must be SKIPPED and the
// input returned verbatim, without ever handing the graph to elkjs. This
// exercises the cap path that bounds the in-process, event-loop-blocking
// layout on LLM-supplied XML.
const model = stackedGraph(600, []);
const t0 = Date.now();
const laid = await applyElkLayout(model);
const dt = Date.now() - t0;
// normalizeInput may reserialize, but geometry must be untouched: every
// vertex is still stacked at (10,10), i.e. no ELK coordinates were applied.
const cells = parseCells(laid);
const verts = cells.filter((c) => c.vertex);
assert.equal(verts.length, 600, "all vertices survived");
for (const v of verts) {
assert.equal(v.geometry.x, 10, "x untouched -> layout was skipped");
assert.equal(v.geometry.y, 10, "y untouched -> layout was skipped");
}
// Returning the input without an ELK pass is essentially instant; assert it
// did not hang. Generous bound to stay non-flaky on a loaded CI box.
assert.ok(dt < 2000, `cap path should be fast, took ${dt}ms`);
});
test("layout is best-effort: an empty/degenerate model is returned intact", async () => {
const model =
'<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel>';
const laid = await applyElkLayout(model);
// No vertices -> unchanged, still lints clean.
const after = prepareModel(laid);
assert.equal(after.cellCount, 0);
});
@@ -0,0 +1,101 @@
// Unit tests for the geometry quality-warnings (issue #424, part 5). Acceptance
// #4: every warning has a positive AND a negative case, and warnings NEVER block
// the write (prepareModel returns them, it does not throw).
import { test } from "node:test";
import assert from "node:assert/strict";
import { prepareModel } from "../../build/lib/drawio-xml.js";
function model(cells) {
return (
'<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>' +
cells +
"</root></mxGraphModel>"
);
}
function warnings(cells) {
return prepareModel(model(cells)).warnings;
}
function has(ws, rule) {
return ws.some((w) => w.startsWith(`[${rule}]`));
}
function v(id, x, y, w = 120, h = 60, value = "", style = "rounded=1;html=1;", parent = "1") {
return (
`<mxCell id="${id}" value="${value}" style="${style}" vertex="1" parent="${parent}">` +
`<mxGeometry x="${x}" y="${y}" width="${w}" height="${h}" as="geometry"/></mxCell>`
);
}
function edge(id, s, t, parent = "1") {
return (
`<mxCell id="${id}" edge="1" parent="${parent}" source="${s}" target="${t}">` +
`<mxGeometry relative="1" as="geometry"/></mxCell>`
);
}
test("shape-overlap: positive and negative", () => {
assert.ok(has(warnings(v("a", 0, 0) + v("b", 50, 20)), "shape-overlap"));
assert.ok(!has(warnings(v("a", 0, 0) + v("b", 300, 0)), "shape-overlap"));
});
test("shape-overlap: a container over its own child does NOT warn", () => {
const cells =
'<mxCell id="g" value="G" style="container=1;fillColor=none;" vertex="1" parent="1"><mxGeometry x="0" y="0" width="400" height="200" as="geometry"/></mxCell>' +
v("a", 30, 40, 120, 60, "", "rounded=1;", "g");
assert.ok(!has(warnings(cells), "shape-overlap"));
});
test("edge-through-shape: positive and negative", () => {
// A -> B passes straight through C sitting on the line.
const pos =
v("a", 0, 0, 60, 60) + v("c", 200, 0, 60, 60) + v("b", 400, 0, 60, 60) + edge("e", "a", "b");
assert.ok(has(warnings(pos), "edge-through-shape"));
// C moved off the line -> no crossing.
const neg =
v("a", 0, 0, 60, 60) + v("c", 200, 300, 60, 60) + v("b", 400, 0, 60, 60) + edge("e", "a", "b");
assert.ok(!has(warnings(neg), "edge-through-shape"));
});
test("edge-overlap: positive (duplicate) and negative", () => {
const pos = v("a", 0, 0) + v("b", 300, 0) + edge("e1", "a", "b") + edge("e2", "a", "b");
assert.ok(has(warnings(pos), "edge-overlap"));
const neg =
v("a", 0, 0) + v("b", 300, 0) + v("c", 300, 300) + edge("e1", "a", "b") + edge("e2", "a", "c");
assert.ok(!has(warnings(neg), "edge-overlap"));
});
test("gap-too-small: positive and negative", () => {
assert.ok(has(warnings(v("a", 0, 0) + v("b", 220, 0)), "gap-too-small")); // 100px gap
assert.ok(!has(warnings(v("a", 0, 0) + v("b", 300, 0)), "gap-too-small")); // 180px gap
});
test("label-overflow: positive and negative", () => {
const pos = v("a", 0, 0, 40, 60, "A very long label that does not fit");
assert.ok(has(warnings(pos), "label-overflow"));
const neg = v("a", 0, 0, 300, 60, "Short");
assert.ok(!has(warnings(neg), "label-overflow"));
});
test("label-overflow: a label drawn OUTSIDE the shape (AWS icon) does NOT warn", () => {
const cells = v(
"a",
0,
0,
60,
60,
"A very long service label below the icon",
"shape=mxgraph.aws4.resourceIcon;verticalLabelPosition=bottom;verticalAlign=top;html=1;",
);
assert.ok(!has(warnings(cells), "label-overflow"));
});
test("out-of-bounds: positive (negative coords) and negative", () => {
assert.ok(has(warnings(v("a", -50, 10)), "out-of-bounds"));
assert.ok(!has(warnings(v("a", 10, 10)), "out-of-bounds"));
});
test("warnings never block the write (prepareModel returns, does not throw)", () => {
const messy = v("a", 0, 0) + v("b", 30, 20) + v("c", 40, 40); // heavy overlap
const prepared = prepareModel(model(messy));
assert.ok(prepared.warnings.length > 0, "expected warnings");
assert.ok(prepared.modelXml.includes("mxGraphModel"), "still produced a model");
assert.equal(prepared.cellCount, 3);
});
@@ -0,0 +1,97 @@
// Unit tests for the drawioShapes verified-stencil catalog (issue #424).
// Covers acceptance #1: a "lambda" query returns a valid mxgraph.aws4 icon with
// the right service/resource pattern + sizes; a blocklisted stencil query
// returns its working replacement.
import { test } from "node:test";
import assert from "node:assert/strict";
import {
searchShapes,
awsServiceStyle,
azureImageStyle,
loadShapeIndex,
AWS_CATEGORY_FILL,
} from "../../build/lib/drawio-shapes.js";
test("the bundled index loads and is the real ~10k-shape catalog", () => {
const idx = loadShapeIndex();
assert.ok(Array.isArray(idx));
assert.ok(idx.length > 10000, `expected >10000 shapes, got ${idx.length}`);
// Record shape { style, w, h, title, tags, type }.
for (const k of ["style", "w", "h", "title", "tags", "type"]) {
assert.ok(k in idx[0], `record missing key ${k}`);
}
});
test('drawioShapes("lambda") returns a valid mxgraph.aws4 service icon', () => {
const results = searchShapes("lambda", { limit: 5 });
assert.ok(results.length > 0);
// Acceptance #1: a valid aws4 service-level icon (resourceIcon + resIcon)
// for lambda, with sensible default sizes, is present.
const svc = results.find(
(r) =>
/shape=mxgraph\.aws4\.resourceIcon/.test(r.style) &&
/resIcon=mxgraph\.aws4\.lambda(_function)?\b/.test(r.style),
);
assert.ok(svc, `no aws4 lambda service icon in ${JSON.stringify(results.map((r) => r.style.slice(-40)))}`);
assert.ok(svc.w > 0 && svc.h > 0, "icon must carry default w/h");
// The current-generation aws4 icon must outrank the deprecated aws3 one.
assert.match(results[0].style, /mxgraph\.aws4/);
});
test("a blocklisted stencil query returns its replacement + a note", () => {
const results = searchShapes("dynamodb_table", { limit: 3 });
assert.ok(results.length > 0);
const rep = results[0];
// dynamodb_table (empty box) -> dynamodb.
assert.match(rep.style, /resIcon=mxgraph\.aws4\.dynamodb\b/);
assert.ok(rep.note && /dynamodb_table/.test(rep.note), "note must explain the replacement");
// The broken stencil name must NOT be returned as a usable style.
assert.ok(
!results.some((r) => /resIcon=mxgraph\.aws4\.dynamodb_table\b/.test(r.style)),
"the broken dynamodb_table stencil must not be returned",
);
});
test("an AWS rebranding query returns the real (renamed) resIcon", () => {
const os = searchShapes("opensearch", { limit: 3 });
assert.ok(
os.some((r) => /resIcon=mxgraph\.aws4\.elasticsearch_service\b/.test(r.style) && r.note),
"OpenSearch must map to elasticsearch_service with a note",
);
const msk = searchShapes("msk", { limit: 3 });
assert.ok(
msk.some((r) => /managed_streaming_for_kafka/.test(r.style)),
"MSK must map to managed_streaming_for_kafka",
);
});
test("category filter narrows results", () => {
const all = searchShapes("database", { limit: 20 });
const dbOnly = searchShapes("database", { category: "Database", limit: 20 });
assert.ok(dbOnly.length <= all.length);
});
test("limit is honoured and capped", () => {
assert.equal(searchShapes("aws", { limit: 3 }).length, 3);
assert.ok(searchShapes("aws", { limit: 999 }).length <= 50);
});
test("empty query returns nothing", () => {
assert.deepEqual(searchShapes(" "), []);
});
test("style builders match the appendix templates", () => {
const s = awsServiceStyle("lambda", "Compute");
assert.match(s, /strokeColor=#ffffff/); // mandatory for service-level
assert.match(s, new RegExp(`fillColor=${AWS_CATEGORY_FILL.Compute}`));
assert.match(s, /shape=mxgraph\.aws4\.resourceIcon;resIcon=mxgraph\.aws4\.lambda$/);
const az = azureImageStyle("databases/Azure_Cosmos_DB.svg");
assert.match(az, /image=img\/lib\/azure2\/databases\/Azure_Cosmos_DB\.svg/);
});
test("azure and group queries surface the curated overlay", () => {
const cosmos = searchShapes("cosmos", { limit: 5 });
assert.ok(cosmos.some((r) => /azure2\/databases\/Azure_Cosmos_DB\.svg/.test(r.style)));
const vpc = searchShapes("vpc group", { limit: 5 });
assert.ok(vpc.some((r) => /grIcon=mxgraph\.aws4\.group_vpc2/.test(r.style)));
});
@@ -0,0 +1,62 @@
// Drift guards for the stage-2 drawio tools (issue #424): the new tools must be
// wired into the shared registry AND routed in SERVER_INSTRUCTIONS, and the
// hard-rules block must be injected into the create/update descriptions. These
// complement the generic server-instructions.test.mjs / tool-specs.test.mjs.
import { test } from "node:test";
import assert from "node:assert/strict";
import { SERVER_INSTRUCTIONS } from "../../build/index.js";
import { SHARED_TOOL_SPECS } from "../../build/tool-specs.js";
test("drawioShapes and drawioGuide are in the shared registry", () => {
assert.equal(SHARED_TOOL_SPECS.drawioShapes.mcpName, "drawioShapes");
assert.equal(SHARED_TOOL_SPECS.drawioGuide.mcpName, "drawioGuide");
// Deferred tier, matching the stage-1 drawio tools.
assert.equal(SHARED_TOOL_SPECS.drawioShapes.tier, "deferred");
assert.equal(SHARED_TOOL_SPECS.drawioGuide.tier, "deferred");
});
test("the new tools are routed in SERVER_INSTRUCTIONS", () => {
for (const name of ["drawioShapes", "drawioGuide"]) {
assert.match(SERVER_INSTRUCTIONS, new RegExp(`\\b${name}\\b`), `${name} missing from guide`);
}
});
test("the hard-rules block is injected into create/update descriptions", () => {
for (const key of ["drawioCreate", "drawioUpdate"]) {
const d = SHARED_TOOL_SPECS[key].description;
assert.match(d, /sentinels are MANDATORY/);
assert.match(d, /vertex="1" XOR edge="1"/);
assert.match(d, /call drawioShapes first/);
assert.match(d, /adaptiveColors="auto"/);
assert.match(d, /&#xa;/);
}
});
test("create/update expose the layout:\"elk\" parameter", () => {
const { z } = { z: makeZodStub() };
for (const key of ["drawioCreate", "drawioUpdate"]) {
const shape = SHARED_TOOL_SPECS[key].buildShape(z);
assert.ok("layout" in shape, `${key} missing layout param`);
}
});
// Tiny zod stub: buildShape only calls z.string/enum/number + chained
// .min/.optional/.describe, all of which return `this`.
function makeZodStub() {
const chain = new Proxy(
{},
{
get: (_t, prop) => {
if (prop === "parse") return () => ({});
return () => chain;
},
},
);
return {
string: () => chain,
number: () => chain,
enum: () => chain,
array: () => chain,
object: () => chain,
};
}
@@ -0,0 +1,450 @@
// Issue #437: central error diagnostics.
//
// Two surfaces are covered here:
// 1. formatDocmostAxiosError — the pure response-interceptor body that
// rewrites an AxiosError's `.message` into an actionable diagnostic.
// 2. assertFullUuid — the fail-fast comment-id guard (absorbs #436) that must
// throw BEFORE any network call.
// Plus an end-to-end pass over a real (offline) http server to prove the
// interceptor is wired, that a re-login retry leaves a success untouched, and
// that a persistent failure gets formatted — and that an invalid comment id
// short-circuits every comment tool with ZERO network traffic.
import { test, after } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import axios, { AxiosError } from "axios";
import {
DocmostClient,
formatDocmostAxiosError,
assertFullUuid,
} from "../../build/client.js";
// Build an AxiosError-shaped object the way the interceptor's rejection handler
// receives it. Using the real AxiosError ctor makes axios.isAxiosError() true.
function makeAxiosError({
method = "post",
url = "/comments/resolve",
baseURL = "http://host.example/api",
status,
statusText,
data,
code,
message = "Request failed",
}) {
const config = { method, url, baseURL };
const response =
status === undefined
? undefined
: { status, statusText, data, headers: {}, config };
return new AxiosError(message, code, config, {}, response);
}
// ---------------------------------------------------------------------------
// formatDocmostAxiosError: message-body extraction rules.
// ---------------------------------------------------------------------------
test("class-validator message array is joined with '; '", () => {
const err = makeAxiosError({
status: 400,
statusText: "Bad Request",
data: { message: ["commentId must be a UUID", "resolved must be a boolean"] },
});
formatDocmostAxiosError(err);
assert.equal(
err.message,
"POST /comments/resolve failed (400 Bad Request): commentId must be a UUID; resolved must be a boolean",
);
});
test("a string message is used as-is", () => {
const err = makeAxiosError({
method: "post",
url: "/comments/resolve",
status: 400,
statusText: "Bad Request",
data: { message: "commentId must be a UUID" },
});
formatDocmostAxiosError(err);
assert.equal(
err.message,
"POST /comments/resolve failed (400 Bad Request): commentId must be a UUID",
);
});
test("falls back to data.error when message is absent", () => {
const err = makeAxiosError({
method: "get",
url: "/pages/info",
status: 403,
statusText: "Forbidden",
data: { error: "Forbidden" },
});
formatDocmostAxiosError(err);
assert.equal(err.message, "GET /pages/info failed (403 Forbidden): Forbidden");
});
test("empty object body falls back to statusText", () => {
const err = makeAxiosError({
status: 404,
statusText: "Not Found",
url: "/comments/info",
data: {},
});
formatDocmostAxiosError(err);
assert.equal(err.message, "POST /comments/info failed (404 Not Found): Not Found");
});
test("HTML/string body is NEVER surfaced — only the statusText", () => {
const html = "<html><body>502 Bad Gateway — nginx internals here</body></html>";
const err = makeAxiosError({
status: 502,
statusText: "Bad Gateway",
url: "/comments/create",
data: html,
});
formatDocmostAxiosError(err);
assert.equal(
err.message,
"POST /comments/create failed (502 Bad Gateway): Bad Gateway",
);
assert.ok(!err.message.includes("nginx"), "raw HTML body must not leak");
assert.ok(!err.message.includes("<html>"), "raw HTML body must not leak");
});
test("Buffer body carrying JSON is parsed for its message", () => {
const buf = Buffer.from(JSON.stringify({ message: "file too large" }), "utf8");
const err = makeAxiosError({
method: "get",
url: "/files/abc/x.png",
status: 413,
statusText: "Payload Too Large",
data: buf,
});
formatDocmostAxiosError(err);
assert.equal(
err.message,
"GET /files/abc/x.png failed (413 Payload Too Large): file too large",
);
});
test("Buffer body with non-JSON garbage falls back to statusText", () => {
const buf = Buffer.from("<<< not json at all >>>", "utf8");
const err = makeAxiosError({
method: "get",
url: "/files/abc/x.png",
status: 500,
statusText: "Internal Server Error",
data: buf,
});
formatDocmostAxiosError(err);
assert.equal(
err.message,
"GET /files/abc/x.png failed (500 Internal Server Error): Internal Server Error",
);
assert.ok(!err.message.includes("not json"), "raw buffer body must not leak");
});
test("an oversized Buffer body is not parsed (size cap) — statusText only", () => {
// A >4KB JSON buffer: even though it IS valid JSON with a message, the size
// cap means we do not attempt to parse it, so only the statusText survives.
const big = { message: "x".repeat(5000) };
const buf = Buffer.from(JSON.stringify(big), "utf8");
const err = makeAxiosError({
method: "get",
url: "/files/abc/x.png",
status: 500,
statusText: "Internal Server Error",
data: buf,
});
formatDocmostAxiosError(err);
assert.equal(
err.message,
"GET /files/abc/x.png failed (500 Internal Server Error): Internal Server Error",
);
});
// ---------------------------------------------------------------------------
// formatDocmostAxiosError: no-response and path/method handling.
// ---------------------------------------------------------------------------
test("no response uses error.code + path + 'no response from server'", () => {
const err = makeAxiosError({
method: "post",
url: "/comments/create",
status: undefined,
code: "ECONNREFUSED",
message: "connect ECONNREFUSED 127.0.0.1:3000",
});
formatDocmostAxiosError(err);
assert.equal(
err.message,
"POST /comments/create failed: ECONNREFUSED (no response from server)",
);
});
test("no response with no code falls back to a neutral reason (raw message not leaked — it may embed host:port)", () => {
const err = makeAxiosError({
method: "post",
url: "/comments/create",
status: undefined,
// A raw axios network message like "connect ECONNREFUSED 127.0.0.1:3000"
// embeds the host; #437's invariant is that it never reaches the message.
message: "connect ECONNREFUSED 10.0.0.5:3000",
});
formatDocmostAxiosError(err);
assert.equal(
err.message,
"POST /comments/create failed: network error (no response from server)",
);
// And the host must NOT appear anywhere in the model-visible message.
assert.ok(!err.message.includes("10.0.0.5"));
});
test("path drops the host and the query string", () => {
const err = makeAxiosError({
method: "post",
url: "/comments/resolve?token=secret&x=1",
baseURL: "https://docs.example.com/api",
status: 400,
statusText: "Bad Request",
data: { message: "bad" },
});
formatDocmostAxiosError(err);
assert.equal(
err.message,
"POST /comments/resolve failed (400 Bad Request): bad",
);
assert.ok(!err.message.includes("secret"), "query string must not leak");
assert.ok(!err.message.includes("docs.example.com"), "host must not leak");
});
// ---------------------------------------------------------------------------
// formatDocmostAxiosError: length cap + guard flag + pass-through.
// ---------------------------------------------------------------------------
test("the overall message is capped at ~300 chars", () => {
const err = makeAxiosError({
status: 400,
statusText: "Bad Request",
data: { message: "y".repeat(1000) },
});
formatDocmostAxiosError(err);
assert.ok(err.message.length <= 300, `expected <=300, got ${err.message.length}`);
assert.ok(err.message.endsWith("…"), "a truncated message ends with an ellipsis");
});
test("a formatted error is not re-processed (guard flag)", () => {
const err = makeAxiosError({
status: 400,
statusText: "Bad Request",
data: { message: "first" },
});
formatDocmostAxiosError(err);
const once = err.message;
assert.equal(err._docmostFormatted, true);
// Mutate the body and re-run: the guard makes it a no-op.
err.response.data = { message: "second" };
formatDocmostAxiosError(err);
assert.equal(err.message, once, "the guard flag prevents double-processing");
});
test("a non-axios error is passed through untouched", () => {
const plain = new Error("boom");
formatDocmostAxiosError(plain);
assert.equal(plain.message, "boom");
assert.equal(plain._docmostFormatted, undefined);
});
// ---------------------------------------------------------------------------
// assertFullUuid.
// ---------------------------------------------------------------------------
const GOOD_UUID = "019f499a-9f8c-7d68-b7be-ce100d7c6c56";
test("assertFullUuid accepts a full canonical UUID (any version nibble)", () => {
assert.doesNotThrow(() => assertFullUuid("resolveComment", "commentId", GOOD_UUID));
// A v4 id also passes (version/variant-agnostic).
assert.doesNotThrow(() =>
assertFullUuid("get_comment", "commentId", "3d5b7c1e-2f4a-4b6c-8d9e-0f1a2b3c4d5e"),
);
});
test("assertFullUuid rejects a truncated prefix", () => {
assert.throws(
() => assertFullUuid("resolveComment", "commentId", "019f499a"),
(e) =>
e.message.startsWith(
"resolveComment: 'commentId' must be the FULL comment UUID",
) &&
e.message.includes("got '019f499a'") &&
e.message.includes("Copy the id verbatim"),
);
});
test("assertFullUuid rejects garbage and empty string", () => {
assert.throws(
() => assertFullUuid("deleteComment", "commentId", "not-a-uuid"),
/must be the FULL comment UUID.*got 'not-a-uuid'/s,
);
assert.throws(
() => assertFullUuid("updateComment", "commentId", ""),
/must be the FULL comment UUID.*got ''/s,
);
});
// ---------------------------------------------------------------------------
// End-to-end over an offline http server: interceptor wiring + re-login.
// ---------------------------------------------------------------------------
function readBody(req) {
return new Promise((resolve) => {
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", () => resolve(raw));
});
}
function startServer(handler) {
return new Promise((resolve) => {
const server = http.createServer(handler);
server.listen(0, "127.0.0.1", () => {
const { port } = server.address();
resolve({ server, baseURL: `http://127.0.0.1:${port}/api` });
});
});
}
function sendJson(res, status, obj, extra = {}) {
res.writeHead(status, { "Content-Type": "application/json", ...extra });
res.end(JSON.stringify(obj));
}
const openServers = [];
async function spawn(handler) {
const { server, baseURL } = await startServer(handler);
openServers.push(server);
return { baseURL };
}
after(async () => {
await Promise.all(openServers.map((s) => new Promise((r) => s.close(r))));
});
test("a 400 on a JSON endpoint is reformatted by the wired interceptor", async () => {
const { baseURL } = await spawn(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
});
return;
}
if (req.url === "/api/pages/info") {
sendJson(res, 400, { message: "pageId should not be empty" });
return;
}
sendJson(res, 404, {});
});
const client = new DocmostClient(baseURL, "u@example.com", "pw");
await assert.rejects(
() => client.getPageRaw("x"),
(e) => {
assert.ok(axios.isAxiosError(e), "still an AxiosError (mutation, not a subclass)");
assert.equal(e.response?.status, 400, "error.response?.status still readable");
assert.equal(
e.message,
"POST /pages/info failed (400 Bad Request): pageId should not be empty",
);
return true;
},
);
});
test("401 -> re-login -> successful retry: the SUCCESS message is untouched", async () => {
let infoCalls = 0;
const { baseURL } = await spawn(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=fresh; Path=/; HttpOnly",
});
return;
}
if (req.url === "/api/workspace/info") {
infoCalls++;
if (infoCalls === 1) sendJson(res, 401, { message: "Unauthorized" });
else sendJson(res, 200, { success: true, data: { id: "ws" } });
return;
}
sendJson(res, 404, {});
});
const client = new DocmostClient(baseURL, "u@example.com", "pw");
client.token = "stale";
client.client.defaults.headers.common["Authorization"] = "Bearer stale";
const result = await client.getWorkspace();
assert.equal(result.success, true, "the retried request resolved successfully");
assert.equal(infoCalls, 2, "401 then a successful replay");
});
test("401 -> re-login -> persistent failure: formatted AND retry guard intact", async () => {
let infoCalls = 0;
let loginCalls = 0;
const { baseURL } = await spawn(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
loginCalls++;
sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=fresh; Path=/; HttpOnly",
});
return;
}
if (req.url === "/api/workspace/info") {
infoCalls++;
// Always 401, even after a fresh login: the _retry guard must stop here.
sendJson(res, 401, { message: "token still invalid" });
return;
}
sendJson(res, 404, {});
});
const client = new DocmostClient(baseURL, "u@example.com", "pw");
client.token = "stale";
client.client.defaults.headers.common["Authorization"] = "Bearer stale";
await assert.rejects(
() => client.getWorkspace(),
(e) => {
assert.equal(
e.message,
"POST /workspace/info failed (401 Unauthorized): token still invalid",
);
return true;
},
);
// The _retry guard is intact: exactly one replay (2 hits), one re-login.
assert.equal(infoCalls, 2, "endpoint hit at most twice (one retry only)");
assert.equal(loginCalls, 1, "re-login attempted exactly once");
});
// ---------------------------------------------------------------------------
// assertFullUuid application points: NO network call when the id is invalid.
// A server that counts EVERY request proves the guard short-circuits before
// even the login round-trip.
// ---------------------------------------------------------------------------
test("all 5 comment-id call sites reject a bad id with ZERO network traffic", async () => {
let requests = 0;
const { baseURL } = await spawn(async (req, res) => {
requests++;
await readBody(req);
sendJson(res, 200, { success: true });
});
const client = new DocmostClient(baseURL, "u@example.com", "pw");
const bad = "019f499a"; // truncated
await assert.rejects(() => client.resolveComment(bad, true), /resolveComment: 'commentId'/);
await assert.rejects(() => client.updateComment(bad, "hi"), /updateComment: 'commentId'/);
await assert.rejects(() => client.deleteComment(bad), /deleteComment: 'commentId'/);
await assert.rejects(() => client.getComment(bad), /get_comment: 'commentId'/);
// createComment validates parentCommentId only when provided.
await assert.rejects(
() => client.createComment("page-1", "body", "inline", "sel", bad),
/createComment: 'parentCommentId'/,
);
assert.equal(requests, 0, "no request (not even /auth/login) may be issued for a bad id");
});
@@ -9,7 +9,7 @@ import {
// Pins the footnoteWarnings PLUMBING contract (#169 review; reduced in #414): the
// field is present only when legacy reference-style `[^id]:` syntax is used and
// omitted otherwise, AND `import_page_markdown` analyzes the BODY (after the
// omitted otherwise, AND `importPageMarkdown` analyzes the BODY (after the
// docmost:meta / docmost:comments blocks) — so a footnote-like token inside those
// JSON blocks never warns, while a real definition in the body does.
// importPageMarkdown does exactly `footnoteWarningsField(parseDocmostMarkdown(full).body)`
@@ -25,7 +25,7 @@ test("formatting-only edit (strip-toggle) is refused, not applied", () => {
assert.equal(failed.length, 1, "one refused edit");
assert.equal(failed[0].find, "~~x~~");
assert.match(failed[0].reason, /cannot add or remove formatting marks/);
assert.match(failed[0].reason, /patch_node/);
assert.match(failed[0].reason, /patchNode/);
// The document is untouched (the strike mark is preserved).
assert.deepEqual(out, snapshot);
});
@@ -143,7 +143,7 @@ test("typo fix wrapped in markdown still applies (not refused)", () => {
// ---------------------------------------------------------------------------
// (iv) #410 footnote token: a `replace` containing `^[...]` is refused into
// failed[] (it would be written as a LITERAL string, never a real footnote).
// Nothing is applied; the reason points at insert_footnote.
// Nothing is applied; the reason points at insertFootnote.
// ---------------------------------------------------------------------------
test("replace containing a `^[...]` footnote token is refused, not applied", () => {
const input = doc(paragraph(textNode("The claim stands.")));
@@ -156,7 +156,7 @@ test("replace containing a `^[...]` footnote token is refused, not applied", ()
assert.equal(results.length, 0, "nothing applied");
assert.equal(failed.length, 1, "one refused edit");
assert.equal(failed[0].find, "The claim stands.");
assert.match(failed[0].reason, /insert_footnote/);
assert.match(failed[0].reason, /insertFootnote/);
// The document is byte-for-byte untouched — no literal `^[` was written.
assert.deepEqual(out, snapshot);
});
@@ -0,0 +1,121 @@
// #413: unit tests for the markdown-fragment helpers used by patchNode/insertNode.
import { test } from "node:test";
import assert from "node:assert/strict";
import {
importMarkdownFragment,
canBeDocChild,
findUnrepresentableTableAttrs,
} from "../../build/lib/markdown-fragment.js";
function findAll(node, type, acc = []) {
if (!node || typeof node !== "object") return acc;
if (node.type === type) acc.push(node);
if (Array.isArray(node.content))
for (const c of node.content) findAll(c, type, acc);
return acc;
}
test("importMarkdownFragment: plain markdown -> blocks, no definitions", async () => {
const { blocks, definitions } = await importMarkdownFragment(
"first\n\nsecond",
);
assert.equal(blocks.length, 2);
assert.equal(definitions.length, 0);
assert.equal(blocks[0].type, "paragraph");
});
test("importMarkdownFragment: `^[...]` footnote -> a definition + a remapped ref", async () => {
const { blocks, definitions } = await importMarkdownFragment(
"a claim^[the note]",
);
assert.equal(definitions.length, 1);
const refs = findAll({ type: "doc", content: blocks }, "footnoteReference");
assert.equal(refs.length, 1);
// The reference id must match the (remapped) definition id.
assert.equal(refs[0].attrs.id, definitions[0].attrs.id);
// The id is NOT the importer's sequential "fn-1" — it was remapped to a fresh
// uuid so it cannot collide with a page footnote of the same number.
assert.notEqual(refs[0].attrs.id, "fn-1");
});
test("importMarkdownFragment: whitespace markdown imports to a single empty paragraph", async () => {
// The importer yields one empty paragraph for whitespace-only input (not zero
// blocks), so the fragment path returns that block. The client's XOR guard
// (markdown.trim() !== "") is what rejects an empty-string patch up front, so
// importMarkdownFragment never sees a truly empty string via patch/insert.
const { blocks, definitions } = await importMarkdownFragment(" \n ");
assert.equal(blocks.length, 1);
assert.equal(blocks[0].type, "paragraph");
assert.equal(definitions.length, 0);
});
test("canBeDocChild: paragraph/heading/table are doc children; tableRow/cell are not", () => {
assert.equal(canBeDocChild("paragraph"), true);
assert.equal(canBeDocChild("heading"), true);
assert.equal(canBeDocChild("table"), true);
assert.equal(canBeDocChild("tableRow"), false);
assert.equal(canBeDocChild("tableCell"), false);
assert.equal(canBeDocChild("tableHeader"), false);
assert.equal(canBeDocChild("text"), false);
assert.equal(canBeDocChild(undefined), false);
assert.equal(canBeDocChild("notARealType"), false);
});
const cell = (attrs, text) => ({
type: "tableCell",
attrs,
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
});
test("findUnrepresentableTableAttrs: null for a plain paragraph and a simple table", () => {
assert.equal(
findUnrepresentableTableAttrs({
type: "paragraph",
content: [{ type: "text", text: "x" }],
}),
null,
);
const simpleTable = {
type: "table",
content: [
{
type: "tableRow",
content: [cell({ colspan: 1, rowspan: 1 }, "a")],
},
],
};
assert.equal(findUnrepresentableTableAttrs(simpleTable), null);
});
test("findUnrepresentableTableAttrs: flags colspan/rowspan/colwidth/backgroundColor", () => {
const mk = (attrs) => ({
type: "table",
content: [{ type: "tableRow", content: [cell(attrs, "a")] }],
});
assert.match(findUnrepresentableTableAttrs(mk({ colspan: 2 })), /colspan/);
assert.match(findUnrepresentableTableAttrs(mk({ rowspan: 2 })), /rowspan/);
assert.match(
findUnrepresentableTableAttrs(mk({ colwidth: [120] })),
/colwidth/,
);
assert.match(
findUnrepresentableTableAttrs(mk({ backgroundColor: "#eee" })),
/backgroundColor/,
);
});
test("findUnrepresentableTableAttrs: finds a span nested deep (table inside a callout)", () => {
const doc = {
type: "callout",
content: [
{
type: "table",
content: [
{ type: "tableRow", content: [cell({ colspan: 3 }, "wide")] },
],
},
],
};
assert.match(findUnrepresentableTableAttrs(doc), /colspan/);
});
+7 -7
View File
@@ -486,34 +486,34 @@ test("insertNodeRelative truly-missing anchor still returns inserted:false", ()
assert.equal(inserted, false);
});
// assertUnambiguousMatch (#159, #185 review pt 2): the patch_node/delete_node
// assertUnambiguousMatch (#159, #185 review pt 2): the patchNode/deleteNode
// guard. Docmost duplicates block ids on copy/paste, so a write by id that
// matches >1 node must be REFUSED (the caller already skipped the write for any
// count !== 1; this reports the error). The duplicate COUNT itself is covered by
// the replaceNodeById/deleteNodeById tests above (count===2 for a 2-dup doc).
test("assertUnambiguousMatch: count 0 throws 'no node found'", () => {
assert.throws(
() => assertUnambiguousMatch("patch_node", "replace", 0, "n1", "p1"),
/patch_node: no node with id "n1" found on page p1/,
() => assertUnambiguousMatch("patchNode", "replace", 0, "n1", "p1"),
/patchNode: no node with id "n1" found on page p1/,
);
});
test("assertUnambiguousMatch: count > 1 refuses with an 'ambiguous' error", () => {
assert.throws(
() => assertUnambiguousMatch("patch_node", "replace", 2, "dup", "p1"),
() => assertUnambiguousMatch("patchNode", "replace", 2, "dup", "p1"),
/ambiguous.*Refusing to replace all of them; nothing was changed/,
);
assert.throws(
() => assertUnambiguousMatch("delete_node", "delete", 3, "dup", "p1"),
() => assertUnambiguousMatch("deleteNode", "delete", 3, "dup", "p1"),
/ambiguous.*Refusing to delete all of them; nothing was changed/,
);
});
test("assertUnambiguousMatch: exactly one match does NOT throw", () => {
assert.doesNotThrow(() =>
assertUnambiguousMatch("patch_node", "replace", 1, "n1", "p1"),
assertUnambiguousMatch("patchNode", "replace", 1, "n1", "p1"),
);
assert.doesNotThrow(() =>
assertUnambiguousMatch("delete_node", "delete", 1, "n1", "p1"),
assertUnambiguousMatch("deleteNode", "delete", 1, "n1", "p1"),
);
});
@@ -1,82 +0,0 @@
// Guard: every tool the MCP server registers must be routed by intent in
// SERVER_INSTRUCTIONS — the editing guide clients receive in the initialize
// result. Without this, new tools silently rot out of the guide and agents
// never learn to pick them (the guide once omitted 17 of 41 tools, including
// get_outline, which pushed agents into fetching whole documents for block
// ids). Tool names are extracted from the SOURCE (index.ts inline
// registrations + tool-specs.ts shared specs) so a registration added either
// way is caught; the guide text itself is imported from the build so the test
// checks what actually ships.
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { SERVER_INSTRUCTIONS } from "../../build/index.js";
const HERE = dirname(fileURLToPath(import.meta.url));
const SRC = join(HERE, "..", "..", "src");
// Tools DELIBERATELY absent from the guide. Keep this list minimal and
// justify every entry — the default is: every tool gets routed.
const EXCEPTIONS = new Set([
// Trivial and self-explanatory; carries no routing decision.
"get_workspace",
]);
/**
* Extract every registered tool name from the source. Two registration
* mechanisms exist and both are covered:
* - inline `server.registerTool("name", ...)` calls in index.ts;
* - shared specs in tool-specs.ts (`mcpName: 'name'`), registered via
* registerShared(SHARED_TOOL_SPECS.x, ...).
*/
function registeredToolNames() {
const indexSrc = readFileSync(join(SRC, "index.ts"), "utf8");
const specsSrc = readFileSync(join(SRC, "tool-specs.ts"), "utf8");
const names = new Set();
for (const m of indexSrc.matchAll(/registerTool\(\s*"([a-z0-9_]+)"/g)) {
names.add(m[1]);
}
for (const m of specsSrc.matchAll(/mcpName:\s*['"]([a-z0-9_]+)['"]/g)) {
names.add(m[1]);
}
return names;
}
test("every registered tool is mentioned in SERVER_INSTRUCTIONS", () => {
const names = registeredToolNames();
// Sanity: if extraction regressed (regex drift), fail loudly rather than
// vacuously passing on an empty set.
assert.ok(
names.size >= 40,
`sanity: expected to extract 40+ registered tools, got ${names.size}` +
"the extraction regexes in this test likely drifted from the source",
);
const missing = [...names]
.filter((n) => !EXCEPTIONS.has(n))
// \b<name>\b: `_` is a word char, so \bget_page\b does NOT match inside
// get_page_json — a tool can't hide behind a longer sibling's mention.
.filter((n) => !new RegExp(`\\b${n}\\b`).test(SERVER_INSTRUCTIONS))
.sort();
assert.deepEqual(
missing,
[],
`tools missing from SERVER_INSTRUCTIONS: ${missing.join(", ")}` +
"update the guide in packages/mcp/src/index.ts (see its MAINTENANCE " +
"RULE comment), or add a justified entry to EXCEPTIONS here",
);
});
test("EXCEPTIONS entries are real registered tools", () => {
// A stale exception (tool renamed/removed) must be cleaned up, otherwise
// the list quietly grows past its purpose.
const names = registeredToolNames();
for (const name of EXCEPTIONS) {
assert.ok(
names.has(name),
`EXCEPTIONS entry "${name}" is not a registered tool — remove it`,
);
}
});
+5 -5
View File
@@ -155,7 +155,7 @@ test("insertTableRow at index 0 inserts before the header and pads to 3 cells",
test("insertTableRow throws when given more cells than columns", () => {
assert.throws(
() => insertTableRow(makeDoc(), "#1", ["a", "b", "c", "d"]),
/table_insert_row: got 4 cell\(s\) but the table has 3 column\(s\)/,
/tableInsertRow: got 4 cell\(s\) but the table has 3 column\(s\)/,
);
});
@@ -232,7 +232,7 @@ test("insertTableRow uses the max column count across all rows (ragged table)",
// ...but 4 cells exceed the widest row and throw.
assert.throws(
() => insertTableRow(makeRaggedDoc(), "#0", ["a", "b", "c", "d"]),
/table_insert_row: got 4 cell\(s\) but the table has 3 column\(s\)/,
/tableInsertRow: got 4 cell\(s\) but the table has 3 column\(s\)/,
);
});
@@ -286,7 +286,7 @@ test("deleteTableRow removes the 3rd row -> rows:2", () => {
test("deleteTableRow out-of-range index throws", () => {
assert.throws(
() => deleteTableRow(makeDoc(), "#1", 9),
/table_delete_row: row index 9 out of range \(table has 3 row\(s\)\)/,
/tableDeleteRow: row index 9 out of range \(table has 3 row\(s\)\)/,
);
});
@@ -329,10 +329,10 @@ test("updateTableCell sets cell [1,1] to 'Z' and preserves the paragraph id", ()
test("updateTableCell out-of-range row/col throws", () => {
assert.throws(
() => updateTableCell(makeDoc(), "#1", 9, 0, "x"),
/table_update_cell: cell \[9,0\] out of range/,
/tableUpdateCell: cell \[9,0\] out of range/,
);
assert.throws(
() => updateTableCell(makeDoc(), "#1", 0, 9, "x"),
/table_update_cell: cell \[0,9\] out of range/,
/tableUpdateCell: cell \[0,9\] out of range/,
);
});
@@ -0,0 +1,135 @@
// Guard: the GENERATED <tool_inventory> in SERVER_INSTRUCTIONS (issue #448)
// names every tool the server registers. The inventory is BUILT from the
// registry (SHARED_TOOL_SPECS' mcpName/catalogLine + INLINE_MCP_INVENTORY), so
// the shared-registry tools can never drift by construction; this test's job is
// to catch the ONE remaining manual list — INLINE_MCP_INVENTORY — falling out
// of sync with the inline `server.registerTool(...)` calls in index.ts.
//
// It also asserts the composed guide keeps its routing prose (the hand-written
// intent hints) and is a valid non-empty string — the structural guarantees the
// old name-scraper test (server-instructions.test.mjs, now deleted) carried,
// minus its now-redundant per-name prose scrape.
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import {
SERVER_INSTRUCTIONS,
ROUTING_PROSE,
buildToolInventoryLines,
} from "../../build/server-instructions.js";
const HERE = dirname(fileURLToPath(import.meta.url));
const SRC = join(HERE, "..", "..", "src");
/**
* Every tool name the MCP server registers, scraped from the SOURCE:
* - inline `server.registerTool("name", ...)` calls in index.ts;
* - shared specs in tool-specs.ts (`mcpName: 'name'`), EXCEPT `inAppOnly`
* specs, which the registry loop in index.ts SKIPS on the MCP host (#411).
* Same two registration mechanisms the old guard covered.
*/
function registeredToolNames() {
const indexSrc = readFileSync(join(SRC, "index.ts"), "utf8");
const specsSrc = readFileSync(join(SRC, "tool-specs.ts"), "utf8");
const names = new Set();
for (const m of indexSrc.matchAll(/registerTool\(\s*"([a-zA-Z0-9_]+)"/g)) {
names.add(m[1]);
}
// Each spec is one `{ ... }` block; scrape its mcpName but skip a block that
// carries `inAppOnly: true` (not registered on the external MCP host).
for (const block of specsSrc.split(/\n\s{2}\w+:\s*\{/)) {
const nameMatch = block.match(/mcpName:\s*['"]([a-zA-Z0-9_]+)['"]/);
if (!nameMatch) continue;
if (/inAppOnly:\s*true/.test(block)) continue;
names.add(nameMatch[1]);
}
return names;
}
test("the generated inventory names every registered tool", () => {
const registered = registeredToolNames();
// Sanity: if the scrape regressed (regex drift), fail loudly rather than
// vacuously passing on an empty set.
assert.ok(
registered.size >= 40,
`sanity: expected 40+ registered tools, got ${registered.size}` +
"the extraction regexes in this test likely drifted from the source",
);
const inventory = new Set(buildToolInventoryLines().map((l) => l.name));
const missing = [...registered].filter((n) => !inventory.has(n)).sort();
assert.deepEqual(
missing,
[],
`tools missing from the generated <tool_inventory>: ${missing.join(", ")}` +
"a SHARED spec is covered automatically; an INLINE MCP-only tool needs a " +
"line added to INLINE_MCP_INVENTORY in src/server-instructions.ts",
);
});
test("the inventory has no phantom tool (every line is a real registered tool)", () => {
const registered = registeredToolNames();
const phantom = buildToolInventoryLines()
.map((l) => l.name)
.filter((n) => !registered.has(n))
.sort();
assert.deepEqual(
phantom,
[],
`<tool_inventory> lists tools that are NOT registered: ${phantom.join(", ")}`,
);
});
// #411: the external MCP surface gains updatePageMarkdown and LOSES
// importPageMarkdown (now inAppOnly). The in-app agent still keeps
// importPageMarkdown — asserted in the server-side contract spec. (#412 renamed
// both public MCP tool names to camelCase.)
test("updatePageMarkdown is on the MCP surface; importPageMarkdown is NOT", () => {
const inventory = new Set(buildToolInventoryLines().map((l) => l.name));
assert.ok(
inventory.has("updatePageMarkdown"),
"updatePageMarkdown should be registered on the external MCP surface",
);
assert.ok(
!inventory.has("importPageMarkdown"),
"importPageMarkdown must be dropped from the external MCP surface (#411)",
);
// And the routing prose no longer points MCP clients at it.
assert.ok(
!ROUTING_PROSE.includes("importPageMarkdown"),
"ROUTING_PROSE still mentions the removed importPageMarkdown",
);
assert.ok(
ROUTING_PROSE.includes("updatePageMarkdown"),
"ROUTING_PROSE should mention updatePageMarkdown",
);
});
test("every inventory line has a non-empty purpose", () => {
for (const line of buildToolInventoryLines()) {
assert.equal(typeof line.purpose, "string");
assert.ok(line.purpose.trim().length > 0, `${line.name}: empty purpose`);
}
});
test("SERVER_INSTRUCTIONS keeps the routing prose and the generated inventory", () => {
assert.equal(typeof SERVER_INSTRUCTIONS, "string");
assert.ok(SERVER_INSTRUCTIONS.length > 0, "SERVER_INSTRUCTIONS is empty");
// Routing prose is spliced in verbatim (the hand-written intent hints).
assert.ok(
SERVER_INSTRUCTIONS.startsWith(ROUTING_PROSE),
"the routing prose is not preserved at the head of the guide",
);
// The generated inventory block is present.
assert.match(SERVER_INSTRUCTIONS, /<tool_inventory>/);
assert.match(SERVER_INSTRUCTIONS, /<\/tool_inventory>/);
// The routing families are still present in the prose.
for (const family of ["READ:", "EDIT:", "PAGES:", "COMMENTS:", "HISTORY:"]) {
assert.ok(
SERVER_INSTRUCTIONS.includes(family),
`routing prose lost its ${family} section`,
);
}
});
+86 -28
View File
@@ -22,10 +22,13 @@ test("every spec exposes mcpName + inAppKey, and the key matches inAppKey", () =
}
});
test("mcpName uses snake_case and inAppKey uses camelCase", () => {
// Since issue #412 the external MCP name equals the in-app key: both are the
// same camelCase identifier (mcpName === inAppKey).
test("mcpName and inAppKey are the same camelCase identifier", () => {
for (const [key, spec] of Object.entries(SHARED_TOOL_SPECS)) {
assert.match(spec.mcpName, /^[a-z0-9]+(_[a-z0-9]+)*$/, `${key}: mcpName not snake_case`);
assert.match(spec.mcpName, /^[a-z][a-zA-Z0-9]*$/, `${key}: mcpName not camelCase`);
assert.match(spec.inAppKey, /^[a-z][a-zA-Z0-9]*$/, `${key}: inAppKey not camelCase`);
assert.equal(spec.mcpName, spec.inAppKey, `${key}: mcpName must equal inAppKey`);
}
});
@@ -59,7 +62,7 @@ test("buildShape (when present) returns a usable ZodRawShape with a real zod", (
test("editPageText builder produces { pageId, edits } and drops the stale strip-and-retry claim", () => {
const spec = SHARED_TOOL_SPECS.editPageText;
assert.equal(spec.mcpName, "edit_page_text");
assert.equal(spec.mcpName, "editPageText");
const shape = spec.buildShape(z);
assert.deepEqual(Object.keys(shape).sort(), ["edits", "pageId"]);
// A valid edits batch parses.
@@ -78,49 +81,68 @@ test("editPageText builder produces { pageId, edits } and drops the stale strip-
assert.match(spec.description, /REFUSED into\s+failed\[\]/);
});
test("getNode builder produces exactly { pageId, nodeId }", () => {
const shape = SHARED_TOOL_SPECS.getNode.buildShape(z);
assert.deepEqual(Object.keys(shape).sort(), ["nodeId", "pageId"]);
// #413: getNode gained an optional `format` (markdown default / json opt-in).
test("getNode builder produces { pageId, nodeId, format? } with format optional", () => {
const spec = SHARED_TOOL_SPECS.getNode;
const shape = spec.buildShape(z);
assert.deepEqual(Object.keys(shape).sort(), ["format", "nodeId", "pageId"]);
const schema = z.object(shape);
// format is optional (markdown default lives in the client).
assert.doesNotThrow(() => schema.parse({ pageId: "p1", nodeId: "n1" }));
assert.doesNotThrow(() =>
schema.parse({ pageId: "p1", nodeId: "n1", format: "json" }),
);
assert.throws(() =>
schema.parse({ pageId: "p1", nodeId: "n1", format: "yaml" }),
);
// The description advertises the markdown default and the json opt-in.
assert.match(spec.description, /markdown/i);
assert.match(spec.description, /json/i);
});
test("patchNode spec exists, merges BOTH descriptions, builds { pageId, nodeId, node }", () => {
// #413: patchNode takes XOR { markdown | node } (both schema-optional).
test("patchNode spec exists, describes markdown+node XOR, builds { pageId, nodeId, markdown?, node? }", () => {
const spec = SHARED_TOOL_SPECS.patchNode;
assert.ok(spec, "patchNode spec missing");
assert.equal(spec.mcpName, "patch_node");
assert.equal(spec.mcpName, "patchNode");
assert.equal(spec.inAppKey, "patchNode");
// The canonical description must carry the key guidance from BOTH originals:
// - MCP-only: "WITHOUT resending the whole document" + the cheaper/safer note.
// - in-app-only: "keeps the same node id" + the "Reversible ... page history"
// framing the MCP copy lacked.
assert.match(spec.description, /WITHOUT resending the whole document/);
assert.match(spec.description, /Cheaper and safer/);
assert.match(spec.description, /keeps the same node id/i);
// The canonical description must carry the #413 guidance.
assert.match(spec.description, /WITHOUT/i);
assert.match(spec.description, /EXACTLY ONE of `markdown` or `node`/);
assert.match(spec.description, /RECOMMENDED/);
assert.match(spec.description, /keeps the same block id/i);
assert.match(spec.description, /Reversible/i);
assert.match(spec.description, /page history/i);
const shape = spec.buildShape(z);
assert.deepEqual(Object.keys(shape).sort(), ["node", "nodeId", "pageId"]);
// A minimal valid input parses (node accepts an arbitrary object via z.any()).
const parsed = z.object(shape).parse({
assert.deepEqual(
Object.keys(shape).sort(),
["markdown", "node", "nodeId", "pageId"],
);
// markdown and node are BOTH optional in the schema (XOR enforced at runtime).
const schema = z.object(shape);
const parsedMd = schema.parse({ pageId: "p1", nodeId: "n1", markdown: "hi" });
assert.equal(parsedMd.markdown, "hi");
const parsedNode = schema.parse({
pageId: "p1",
nodeId: "n1",
node: { type: "paragraph" },
});
assert.equal(parsed.pageId, "p1");
assert.equal(parsed.nodeId, "n1");
assert.equal(parsedNode.pageId, "p1");
// Neither given parses at the schema level (the client throws the XOR error).
assert.doesNotThrow(() => schema.parse({ pageId: "p1", nodeId: "n1" }));
});
test("insertNode spec exists, merges BOTH descriptions, builds the full anchor shape", () => {
// #413: insertNode also takes XOR { markdown | node } plus the anchor shape.
test("insertNode spec exists, describes markdown+node XOR, builds the full anchor+content shape", () => {
const spec = SHARED_TOOL_SPECS.insertNode;
assert.ok(spec, "insertNode spec missing");
assert.equal(spec.mcpName, "insert_node");
assert.equal(spec.mcpName, "insertNode");
assert.equal(spec.inAppKey, "insertNode");
// Canonical description must keep BOTH sides' nuance:
// - in-app-only: "EXACTLY ONE of anchorNodeId or anchorText" + "Reversible".
// - MCP-only: the table-structure (tableRow/tableCell) insertion guidance.
assert.match(spec.description, /EXACTLY ONE of anchorNodeId or anchorText/);
assert.match(spec.description, /EXACTLY ONE of `markdown` or `node`/);
assert.match(spec.description, /tableRow/);
assert.match(spec.description, /append is top-level only/);
assert.match(spec.description, /Reversible via page history/);
@@ -128,15 +150,18 @@ test("insertNode spec exists, merges BOTH descriptions, builds the full anchor s
const shape = spec.buildShape(z);
assert.deepEqual(
Object.keys(shape).sort(),
["anchorNodeId", "anchorText", "node", "pageId", "position"],
["anchorNodeId", "anchorText", "markdown", "node", "pageId", "position"],
);
// before/after/append are the only accepted positions; anchors are optional.
// before/after/append are the only accepted positions; markdown/node/anchors optional.
const schema = z.object(shape);
assert.doesNotThrow(() =>
schema.parse({ pageId: "p1", markdown: "hi", position: "append" }),
);
assert.doesNotThrow(() =>
schema.parse({ pageId: "p1", node: { type: "paragraph" }, position: "append" }),
);
assert.throws(() =>
schema.parse({ pageId: "p1", node: {}, position: "sideways" }),
schema.parse({ pageId: "p1", markdown: "x", position: "sideways" }),
);
});
@@ -145,3 +170,36 @@ test("no-arg specs (getWorkspace/listSpaces/listShares) omit buildShape", () =>
assert.equal(SHARED_TOOL_SPECS[key].buildShape, undefined, `${key} should be no-arg`);
}
});
// #411: plain-Markdown full-body replace tool, paired with updatePageJson.
test("updatePageMarkdown spec exists, pairs with updatePageJson, builds { pageId, content, title }", () => {
const spec = SHARED_TOOL_SPECS.updatePageMarkdown;
assert.ok(spec, "updatePageMarkdown spec missing");
assert.equal(spec.mcpName, "updatePageMarkdown");
assert.equal(spec.inAppKey, "updatePageMarkdown");
// Registered on BOTH hosts (a shared spec, no inAppOnly/mcpOnly flag).
assert.notEqual(spec.inAppOnly, true);
assert.notEqual(spec.mcpOnly, true);
// Same tier as its JSON sibling.
assert.equal(spec.tier, SHARED_TOOL_SPECS.updatePageJson.tier);
const shape = spec.buildShape(z);
assert.deepEqual(Object.keys(shape).sort(), ["content", "pageId", "title"]);
// pageId + content required, title optional.
const schema = z.object(shape);
assert.doesNotThrow(() => schema.parse({ pageId: "p1", content: "# Hi" }));
assert.throws(() => schema.parse({ pageId: "p1" }));
// The description must flag the `^[...]` inline-footnote parse path so the
// markdown->footnote canonicalization guarantee stays documented (#411).
assert.match(spec.description, /\^\[/);
});
// #411: importPageMarkdown is dropped from the EXTERNAL MCP surface but stays
// available to the in-app agent — encoded as inAppOnly on the shared spec.
test("importPageMarkdown spec is inAppOnly (removed from the external MCP surface, kept in-app)", () => {
const spec = SHARED_TOOL_SPECS.importPageMarkdown;
assert.ok(spec, "importPageMarkdown spec missing");
assert.equal(spec.inAppOnly, true);
// The spec + its client method are NOT deleted — only hidden from the MCP host.
assert.equal(spec.mcpName, "importPageMarkdown");
assert.equal(spec.inAppKey, "importPageMarkdown");
});
+2 -2
View File
@@ -13,7 +13,7 @@ test("times a tool and preserves the handler's return value", async () => {
const onMetric = (name, value, labels) => calls.push({ name, value, labels });
const handler = async (arg) => ({ ok: true, echo: arg });
const wrapped = timeToolHandler("get_page", handler, onMetric);
const wrapped = timeToolHandler("getPage", handler, onMetric);
const result = await wrapped("hello");
// Return value passes through untouched.
@@ -22,7 +22,7 @@ test("times a tool and preserves the handler's return value", async () => {
// Exactly one sample, correct name/labels, numeric non-negative duration.
assert.equal(calls.length, 1);
assert.equal(calls[0].name, "mcp_tool_duration_seconds");
assert.deepEqual(calls[0].labels, { tool: "get_page" });
assert.deepEqual(calls[0].labels, { tool: "getPage" });
assert.equal(typeof calls[0].value, "number");
assert.ok(calls[0].value >= 0, "duration must be non-negative seconds");
});
@@ -0,0 +1,82 @@
// #409: unstorableYjsError diagnostics PRECEDENCE. The opaque Yjs encode failure
// (`Unknown node type: undefined`) is a node-SHAPE problem, so the shared
// findInvalidNode is consulted FIRST and yields a path-anchored node message;
// only a shape-sound doc falls back to findUnstorableAttr (undefined/function/
// etc. attr values). Exercised through `assertYjsEncodable`, which runs the same
// encode + error-wrapping the live write path uses.
import { test } from "node:test";
import assert from "node:assert/strict";
import { assertYjsEncodable } from "../../build/lib/collaboration.js";
import {
findInvalidNode,
findUnstorableAttr,
} from "@docmost/prosemirror-markdown";
const doc = (...content) => ({ type: "doc", content });
test("a nested typeless node yields the rich node-shape message (not an attr hint)", () => {
const bad = doc({
type: "paragraph",
content: [{ text: "oops", marks: [] }], // missing "type":"text"
});
assert.throws(
() => assertYjsEncodable(bad),
(err) => {
assert.match(err.message, /Invalid node:/);
assert.match(err.message, /missing "type"/);
assert.match(err.message, /content\[0\]/);
// It must NOT fall through to the generic attribute sentence.
assert.doesNotMatch(err.message, /Offending attribute/);
assert.doesNotMatch(
err.message,
/attribute likely holds a value Yjs cannot store/,
);
return true;
},
);
});
test("PRECEDENCE: a genuine undefined-attr case is a node-SHAPE-clean case, so the attr fallback fires", () => {
// A doc whose node shapes are ALL valid but that carries a Yjs-unstorable
// undefined attribute. unstorableYjsError checks findInvalidNode FIRST (must
// miss here) and only then findUnstorableAttr (must hit) — this is exactly the
// division of labor that keeps a real attr problem from being mislabelled as a
// node-shape problem, and vice versa. We assert the two helpers directly (the
// wrapper is not exported) because sanitizeForYjs strips undefined attrs before
// the live encoder ever sees them, so this branch cannot be reached through
// assertYjsEncodable without also failing the clone.
const attrProblem = doc({
type: "paragraph",
attrs: { id: "p1", indent: undefined },
content: [{ type: "text", text: "hi" }],
});
// findInvalidNode: shape is clean -> null (so the wrapper does NOT emit
// "Invalid node").
assert.equal(findInvalidNode(attrProblem), null);
// findUnstorableAttr: pinpoints the undefined attr -> the fallback message.
assert.match(findUnstorableAttr(attrProblem) ?? "", /indent \(undefined\)/);
});
test("PRECEDENCE: a node-shape problem is caught by findInvalidNode even when an attr is also unstorable", () => {
// Both a shape problem (typeless nested leaf) AND an unstorable attr exist;
// findInvalidNode wins, so the model is pointed at the node shape (the real
// root cause of `Unknown node type: undefined`), not the attribute.
const both = doc({
type: "paragraph",
attrs: { id: "p1", indent: undefined },
content: [{ text: "oops" }],
});
const shape = findInvalidNode(both);
assert.notEqual(shape, null);
assert.match(shape.summary, /missing "type"/);
});
test("a fully valid document encodes without throwing", () => {
const good = doc({
type: "paragraph",
attrs: { id: "p1" },
content: [{ type: "text", text: "hi" }],
});
assert.doesNotThrow(() => assertYjsEncodable(good));
});
@@ -334,7 +334,7 @@ const DocmostAttributes = Extension.create({
* Docmost inline comment mark. Anchors a comment thread to a text range via
* `commentId`. Without it, any document containing comment highlights fails to
* round-trip through the schema ("There is no mark type comment in this schema"),
* which breaks update_page_json and edit_page_text on every commented page.
* which breaks updatePageJson and editPageText on every commented page.
* Mirrors Docmost's @docmost/editor-ext comment mark (commentId / resolved).
*/
const Comment = Mark.create({
@@ -53,10 +53,14 @@ export {
buildOutline,
getNodeByRef,
replaceNodeById,
replaceNodeByIdWithMany,
reassignCollidingBlockIds,
deleteNodeById,
sanitizeForYjs,
findUnstorableAttr,
findInvalidNode,
insertNodeRelative,
insertNodesRelative,
readTable,
insertTableRow,
deleteTableRow,
@@ -14,7 +14,9 @@
* `content`, non-object nodes, and absent `attrs` are tolerated.
*/
import { getSchema } from "@tiptap/core";
import { stripInlineMarkdown } from "./text-normalize.js";
import { docmostExtensions } from "./docmost-schema.js";
/** Deep-clone a JSON-serializable value without mutating the original. */
function clone<T>(value: T): T {
@@ -215,6 +217,54 @@ export function replaceNodeById(
return { doc: out, replaced };
}
/**
* Splice a SINGLE node whose `attrs.id === nodeId` with an ORDERED ARRAY of new
* nodes (a "1 -> N" replacement), anywhere in the tree. Used by the markdown
* patch path, where importing a markdown fragment can yield several blocks that
* must replace one existing block in place ("rewrite a section" in one call).
*
* Unlike `replaceNodeById` (which substitutes EVERY match), this walks to the
* FIRST match only and splices `newNodes` in its position, so ordering and the
* neighbouring blocks are preserved byte-for-byte. It deliberately does NOT
* touch further duplicates: the caller (#159 semantics) must have already
* verified the id is unambiguous via a `replaceNodeById` dry pass, so a single
* splice here is safe and every other block is untouched.
*
* Each entry of `newNodes` is deep-cloned so they never share references with
* each other or with the caller\'s array. Operates on a clone of `doc`; returns
* `{ doc, replaced }` where `replaced` is 1 when a match was spliced, else 0.
*/
export function replaceNodeByIdWithMany(
doc: any,
nodeId: string,
newNodes: any[],
): { doc: any; replaced: number } {
const out = clone(doc);
const fresh = Array.isArray(newNodes) ? newNodes.map((n) => clone(n)) : [];
let replaced = 0;
// Walk to the FIRST match and splice the array in its place; stop afterwards.
const walkContent = (content: any[]): boolean => {
for (let i = 0; i < content.length; i++) {
const child = content[i];
if (matchesId(child, nodeId)) {
content.splice(i, 1, ...fresh);
replaced = 1;
return true;
}
if (isObject(child) && Array.isArray(child.content)) {
if (walkContent(child.content)) return true;
}
}
return false;
};
if (isObject(out) && Array.isArray(out.content)) {
walkContent(out.content);
}
return { doc: out, replaced };
}
/**
* Remove EVERY node whose `attrs.id === nodeId` from its parent `content`
* array, anywhere in the tree (recursive, including callouts and tables).
@@ -261,7 +311,7 @@ export function deleteNodeById(
* changed. No-op for the unambiguous single-match case.
*/
export function assertUnambiguousMatch(
op: "patch_node" | "delete_node",
op: "patchNode" | "deleteNode",
verb: "replace" | "delete",
count: number,
nodeId: string,
@@ -383,6 +433,119 @@ export function findUnstorableAttr(doc: any): string | null {
return null;
}
/**
* The Docmost schema's known node and mark NAME sets, derived ONCE from the very
* same `docmostExtensions` the Yjs encode path builds its schema from
* (`getSchema(docmostExtensions)` mirrored in mcp's `docmostSchema`). Deriving
* both from the same extension list guarantees `findInvalidNode`'s "known type"
* set matches exactly what `PMNode.fromJSON`/`toYdoc` will actually accept, so
* the walker never flags a node the encoder would have stored (or vice versa).
* Lazy + cached: the schema is only built on first use.
*/
let schemaNames: { nodes: Set<string>; marks: Set<string> } | null = null;
function getSchemaNames(): { nodes: Set<string>; marks: Set<string> } {
if (schemaNames == null) {
const schema = getSchema(docmostExtensions);
schemaNames = {
nodes: new Set(Object.keys(schema.nodes)),
marks: new Set(Object.keys(schema.marks)),
};
}
return schemaNames;
}
/**
* Depth-first walk of the JSON `content` tree looking for the FIRST node whose
* SHAPE the Yjs encode path will reject with an opaque
* `Unknown node type: undefined` (issue #409). Returns `{ path, summary }` for
* the offending node, or `null` when every node (and every mark) is a known
* Docmost schema type.
*
* Two failure modes are detected, in order, per node:
* 1. `type` is missing or not a string the dominant `undefined` case, e.g.
* a text leaf written as `{"text":"foo"}` with no `"type":"text"`.
* 2. `type` is a string but NOT a known Docmost node name (a typo / unknown
* block), OR one of the node's marks carries an unknown mark name.
*
* The returned `summary` is a model-actionable, path-anchored message such as:
* `node.content[2].content[0]: missing "type" (keys: text, marks) — did you
* mean {"type": "text", ...}?`
* or for an unknown type:
* `node.content[1]: unknown node type "paragraf" — not in the Docmost schema`
*
* `path` is the same dotted JSON path used in the summary (e.g.
* `node.content[2].content[0]`) so callers can surface it separately. Null-safe:
* a non-object doc returns `null`.
*
* NOTE: This is a SHAPE check, not a full ProseMirror content-model validation
* (it does not verify that a paragraph may legally contain a table, etc.). Its
* job is to turn the specific "unknown/absent node type" Yjs crash into a clear,
* pre-write diagnostic; the schema's own `.check()` still catches deeper
* content-model violations at encode time.
*/
export function findInvalidNode(
doc: any,
): { path: string; summary: string } | null {
if (!isObject(doc)) return null;
const { nodes, marks } = getSchemaNames();
// Build the "did you mean" hint for a typeless node from its own keys, so the
// model sees WHICH object is malformed and the canonical text-leaf fix.
const keyHint = (node: Record<string, any>): string => {
const keys = Object.keys(node);
const looksLikeText =
typeof node.text === "string" && node.type === undefined;
const suffix = looksLikeText
? ` — did you mean {"type": "text", ...}?`
: ` — every node needs a string "type" from the Docmost schema`;
return `missing "type" (keys: ${keys.join(", ") || "none"})${suffix}`;
};
const walk = (
node: any,
path: string,
): { path: string; summary: string } | null => {
if (!isObject(node)) return null;
// (1) missing / non-string type.
if (typeof node.type !== "string") {
return { path, summary: `${path}: ${keyHint(node)}` };
}
// (2) string type that is not a known Docmost node.
if (!nodes.has(node.type)) {
return {
path,
summary: `${path}: unknown node type "${node.type}" — not in the Docmost schema`,
};
}
// (2b) unknown mark on an otherwise-valid node.
if (Array.isArray(node.marks)) {
for (let i = 0; i < node.marks.length; i++) {
const mark = node.marks[i];
if (isObject(mark) && typeof mark.type === "string" && !marks.has(mark.type)) {
return {
path: `${path}.marks[${i}]`,
summary: `${path}.marks[${i}]: unknown mark type "${mark.type}" — not in the Docmost schema`,
};
}
}
}
if (Array.isArray(node.content)) {
for (let i = 0; i < node.content.length; i++) {
const hit = walk(node.content[i], `${path}.content[${i}]`);
if (hit != null) return hit;
}
}
return null;
};
// The root doc node is addressed as "node" (matching the mcp arg name); its
// children are node.content[i]. The root itself is checked too so a typeless
// root is reported rather than silently skipped.
return walk(doc, "node");
}
/**
* Table structural node types and the container each must live directly inside.
* Used by `insertNodeRelative` to splice rows/cells into the correct ancestor
@@ -516,7 +679,7 @@ export function insertNodeRelative(
// top level — appending one would produce invalid nesting.
if (isStructural) {
throw new Error(
`insert_node: cannot append a ${node.type} at the top level; use ` +
`insertNode: cannot append a ${node.type} at the top level; use ` +
`position before/after with an anchor inside the target table`,
);
}
@@ -551,7 +714,7 @@ export function insertNodeRelative(
if (containerIdx === -1) {
throw new Error(
`insert_node: cannot insert a ${node.type} here — the anchor is not ` +
`insertNode: cannot insert a ${node.type} here — the anchor is not ` +
`inside a ${containerType}. Anchor on a cell's text or a block id ` +
`that lives inside the target table.`,
);
@@ -610,6 +773,88 @@ export function insertNodeRelative(
return { doc: out, inserted: false };
}
/**
* Insert an ORDERED ARRAY of nodes relative to an anchor, preserving their
* order. This is the multi-node twin of `insertNodeRelative`, used by the
* markdown insert path where importing a markdown fragment can yield several
* blocks that must land, in order, at one anchor.
*
* Semantics mirror `insertNodeRelative` exactly:
* - position "append": push every node onto the top-level `doc.content`.
* - position "before"/"after": splice every node into the anchor\'s parent
* `content` array immediately before / after it, keeping array order.
*
* The structural-table branch of `insertNodeRelative` is intentionally NOT
* duplicated here: a markdown fragment can never produce a bare tableRow/
* tableCell/tableHeader (those are not expressible in markdown), so the markdown
* insert path only ever hands whole top-level blocks. Structural inserts stay on
* the single-node JSON path. An empty `nodes` array is a no-op that still
* reports `inserted:false` (nothing to place).
*
* Operates on a clone of `doc`; returns `{ doc, inserted }`. `inserted` is false
* when the anchor could not be resolved (doc returned unchanged apart from the
* clone) or when `nodes` is empty.
*/
export function insertNodesRelative(
doc: any,
nodes: any[],
opts: InsertOptions,
): { doc: any; inserted: boolean } {
const out = clone(doc);
const fresh = Array.isArray(nodes) ? nodes.map((n) => clone(n)) : [];
if (!isObject(opts) || fresh.length === 0) {
return { doc: out, inserted: false };
}
// "append": push every node at the top level, in order.
if (opts.position === "append") {
if (isObject(out)) {
if (!Array.isArray(out.content)) out.content = [];
out.content.push(...fresh);
return { doc: out, inserted: true };
}
return { doc: out, inserted: false };
}
const offset = opts.position === "after" ? 1 : 0;
// Resolve by id anywhere in the tree: splice the whole array into the parent.
if (opts.anchorNodeId != null) {
let inserted = false;
const walkContent = (content: any[]): void => {
for (let i = 0; i < content.length; i++) {
const child = content[i];
if (matchesId(child, opts.anchorNodeId as string)) {
content.splice(i + offset, 0, ...fresh);
inserted = true;
return;
}
if (isObject(child) && Array.isArray(child.content)) {
walkContent(child.content);
if (inserted) return;
}
}
};
if (isObject(out) && Array.isArray(out.content)) {
walkContent(out.content);
}
return { doc: out, inserted };
}
// Resolve by text: only top-level doc.content blocks are scanned. Exact match
// wins; a markdown-stripped fallback is tried only on a miss.
if (opts.anchorText != null && isObject(out) && Array.isArray(out.content)) {
const i = findAnchorTextIndex(out.content, opts.anchorText);
if (i !== -1) {
out.content.splice(i + offset, 0, ...fresh);
return { doc: out, inserted: true };
}
}
return { doc: out, inserted: false };
}
// ===========================================================================
// Table editing helpers
//
@@ -658,6 +903,27 @@ function makeFreshId(used: Set<string>): string {
return id;
}
/**
* Re-mint any top-level block id in `blocks` that already exists in `liveDoc`,
* so a 1 -> N splice cannot introduce a duplicate id. `skipIndex` (optional) is a
* block whose id is intentionally set (the patch path's first block inherits the
* target node's id) and must not be re-minted. Mutates `blocks` in place.
*/
export function reassignCollidingBlockIds(
liveDoc: any,
blocks: any[],
skipIndex?: number,
): void {
const used = new Set<string>();
collectIds(liveDoc, used);
blocks.forEach((b, i) => {
if (i === skipIndex || !isObject(b)) return;
if (!isObject(b.attrs)) b.attrs = {};
if (b.attrs.id != null && used.has(b.attrs.id)) b.attrs.id = makeFreshId(used);
if (b.attrs.id != null) used.add(b.attrs.id);
});
}
/**
* Resolve a table reference against an ALREADY-CLONED doc and return the LIVE
* table node (a reference inside `rootClone`, so the caller may mutate it) plus
@@ -739,7 +1005,7 @@ function makeCellParagraph(id: string, text: string): any {
* width.
* - `cells`: `string[][]` of each cell's `blockPlainText`.
* - `cellIds`: `(string|null)[][]` of each cell's FIRST paragraph id (or null),
* so callers can `patch_node` a cell for rich-formatted edits.
* so callers can `patchNode` a cell for rich-formatted edits.
* - `path`: index path of the table within the doc.
*/
export function readTable(
@@ -769,7 +1035,7 @@ export function readTable(
const rowIds: (string | null)[] = [];
for (const cellNode of cellNodes) {
rowText.push(blockPlainText(cellNode));
// The cell's first paragraph carries the id used for patch_node.
// The cell's first paragraph carries the id used for patchNode.
const firstPara = Array.isArray(cellNode?.content)
? cellNode.content[0]
: undefined;
@@ -825,7 +1091,7 @@ export function insertTableRow(
if (Array.isArray(cells) && cells.length > colCount) {
throw new Error(
`table_insert_row: got ${cells.length} cell(s) but the table has ${colCount} column(s)`,
`tableInsertRow: got ${cells.length} cell(s) but the table has ${colCount} column(s)`,
);
}
@@ -891,12 +1157,12 @@ export function deleteTableRow(
if (!Number.isInteger(index) || index < 0 || index >= rows) {
throw new Error(
`table_delete_row: row index ${index} out of range (table has ${rows} row(s))`,
`tableDeleteRow: row index ${index} out of range (table has ${rows} row(s))`,
);
}
if (rows <= 1) {
throw new Error(
"table_delete_row: refusing to delete the only row of the table",
"tableDeleteRow: refusing to delete the only row of the table",
);
}
@@ -940,7 +1206,7 @@ export function updateTableCell(
col < 0 ||
col >= cols
) {
throw new Error(`table_update_cell: cell [${row},${col}] out of range`);
throw new Error(`tableUpdateCell: cell [${row},${col}] out of range`);
}
const cellNode = rowNode.content[col];
@@ -1,7 +1,7 @@
// The model sometimes serializes a ProseMirror node arg as a JSON string
// instead of an object. Normalize: parse a string to an object (throwing on
// invalid JSON), pass an object through unchanged. Shared by patch_node /
// insert_node (and the analogous update_page_json content parsing).
// invalid JSON), pass an object through unchanged. Shared by patchNode /
// insertNode (and the analogous updatePageJson content parsing).
//
// This lives in the converter package (#414) so BOTH consumers import the ONE
// copy: `@docmost/mcp` (ESM) and the CommonJS server app. The server cannot
@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest';
import { findInvalidNode } from '../src/lib/node-ops.js';
// findInvalidNode (#409): a depth-first SHAPE gate that turns the encoder's
// opaque `Unknown node type: undefined` into a path-anchored, pre-write
// diagnostic. It flags the FIRST node whose `type` is absent/non-string or not
// a known Docmost schema node, or that carries an unknown mark; returns null for
// a well-formed doc.
const doc = (...content: any[]) => ({ type: 'doc', content });
const para = (...content: any[]) => ({
type: 'paragraph',
attrs: { id: 'p1' },
content,
});
const text = (value: string, marks?: any[]) => {
const node: any = { type: 'text', text: value };
if (marks) node.marks = marks;
return node;
};
describe('findInvalidNode', () => {
it('returns null for a fully valid document', () => {
const good = doc(
para(text('hello ', [{ type: 'bold' }]), text('world')),
{
type: 'heading',
attrs: { id: 'h1', level: 2 },
content: [text('Title')],
},
);
expect(findInvalidNode(good)).toBeNull();
});
it('flags a NESTED typeless text leaf with a path-precise summary', () => {
// A text leaf written as {"text":"foo"} with no "type":"text" — the dominant
// `Unknown node type: undefined` cause.
const bad = doc(
para(text('ok')),
para({ text: 'foo', marks: [] } as any),
);
const hit = findInvalidNode(bad);
expect(hit).not.toBeNull();
// Second paragraph (index 1), first child (index 0).
expect(hit!.path).toBe('node.content[1].content[0]');
expect(hit!.summary).toContain('node.content[1].content[0]');
expect(hit!.summary).toContain('missing "type"');
expect(hit!.summary).toContain('keys: text, marks');
expect(hit!.summary).toContain('did you mean {"type": "text", ...}');
});
it('flags a non-string type (e.g. numeric)', () => {
const bad = doc(para({ type: 123, content: [] } as any));
const hit = findInvalidNode(bad);
expect(hit).not.toBeNull();
expect(hit!.path).toBe('node.content[0].content[0]');
expect(hit!.summary).toContain('missing "type"');
});
it('flags an UNKNOWN node type name that is not in the Docmost schema', () => {
const bad = doc({
type: 'paragraf', // typo — not a real node
attrs: { id: 'x' },
content: [text('hi')],
});
const hit = findInvalidNode(bad);
expect(hit).not.toBeNull();
expect(hit!.path).toBe('node.content[0]');
expect(hit!.summary).toContain('unknown node type "paragraf"');
expect(hit!.summary).toContain('not in the Docmost schema');
});
it('flags an UNKNOWN mark type on an otherwise-valid node', () => {
const bad = doc(para(text('hi', [{ type: 'blink' }])));
const hit = findInvalidNode(bad);
expect(hit).not.toBeNull();
expect(hit!.path).toBe('node.content[0].content[0].marks[0]');
expect(hit!.summary).toContain('unknown mark type "blink"');
});
it('accepts every real Docmost node/mark type it is asked about', () => {
// Known node (callout) and known marks (italic, code) must NOT be flagged.
const good = doc({
type: 'callout',
attrs: { id: 'c1', type: 'info' },
content: [para(text('x', [{ type: 'italic' }, { type: 'code' }]))],
});
expect(findInvalidNode(good)).toBeNull();
});
it('reports the root itself when the root is typeless', () => {
const hit = findInvalidNode({ content: [] } as any);
expect(hit).not.toBeNull();
expect(hit!.path).toBe('node');
});
it('is null-safe for non-object input', () => {
expect(findInvalidNode(null)).toBeNull();
expect(findInvalidNode(undefined)).toBeNull();
expect(findInvalidNode('nope' as any)).toBeNull();
});
});
@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import {
replaceNodeByIdWithMany,
insertNodesRelative,
} from "../src/lib/node-ops.js";
// #413: the array-splice helpers used by the markdown patch/insert paths.
const p = (id: string, t: string): any => ({
type: "paragraph",
attrs: { id },
content: [{ type: "text", text: t }],
});
describe("replaceNodeByIdWithMany", () => {
it("splices N nodes in place of the first match, keeping neighbours byte-identical", () => {
const before = { type: "doc", content: [p("a", "A"), p("b", "B"), p("c", "C")] };
const snap = JSON.parse(JSON.stringify(before));
const { doc, replaced } = replaceNodeByIdWithMany(before, "b", [
p("b1", "B1"),
p("b2", "B2"),
]);
expect(replaced).toBe(1);
expect(doc.content.map((n: any) => n.attrs.id)).toEqual(["a", "b1", "b2", "c"]);
// Input never mutated.
expect(before).toEqual(snap);
// Neighbours byte-identical.
expect(doc.content[0]).toEqual(snap.content[0]);
expect(doc.content[3]).toEqual(snap.content[2]);
});
it("reaches a nested match (inside a callout) and splices there", () => {
const before = {
type: "doc",
content: [
{ type: "callout", attrs: { id: "co" }, content: [p("x", "X")] },
],
};
const { doc, replaced } = replaceNodeByIdWithMany(before, "x", [
p("x1", "X1"),
p("x2", "X2"),
]);
expect(replaced).toBe(1);
expect(doc.content[0].content.map((n: any) => n.attrs.id)).toEqual(["x1", "x2"]);
});
it("only touches the FIRST duplicate (caller guards ambiguity)", () => {
const before = { type: "doc", content: [p("d", "1"), p("d", "2")] };
const { doc, replaced } = replaceNodeByIdWithMany(before, "d", [p("n", "N")]);
expect(replaced).toBe(1);
// First replaced; the second duplicate survives untouched.
expect(doc.content.map((n: any) => n.attrs.id)).toEqual(["n", "d"]);
});
it("reports replaced:0 for no match, doc unchanged", () => {
const before = { type: "doc", content: [p("a", "A")] };
const { doc, replaced } = replaceNodeByIdWithMany(before, "zzz", [p("n", "N")]);
expect(replaced).toBe(0);
expect(doc).toEqual(before);
});
});
describe("insertNodesRelative", () => {
it("inserts an ordered array after an id anchor", () => {
const before = { type: "doc", content: [p("a", "A"), p("b", "B")] };
const { doc, inserted } = insertNodesRelative(
before,
[p("n1", "N1"), p("n2", "N2")],
{ position: "after", anchorNodeId: "a" },
);
expect(inserted).toBe(true);
expect(doc.content.map((n: any) => n.attrs.id)).toEqual(["a", "n1", "n2", "b"]);
});
it("inserts before an id anchor", () => {
const before = { type: "doc", content: [p("a", "A"), p("b", "B")] };
const { doc } = insertNodesRelative(before, [p("n", "N")], {
position: "before",
anchorNodeId: "b",
});
expect(doc.content.map((n: any) => n.attrs.id)).toEqual(["a", "n", "b"]);
});
it("appends an ordered array at the top level", () => {
const before = { type: "doc", content: [p("a", "A")] };
const { doc } = insertNodesRelative(before, [p("n1", "N1"), p("n2", "N2")], {
position: "append",
});
expect(doc.content.map((n: any) => n.attrs.id)).toEqual(["a", "n1", "n2"]);
});
it("resolves an anchor by top-level text", () => {
const before = { type: "doc", content: [p("a", "hello there"), p("b", "B")] };
const { doc, inserted } = insertNodesRelative(before, [p("n", "N")], {
position: "after",
anchorText: "hello",
});
expect(inserted).toBe(true);
expect(doc.content.map((n: any) => n.attrs.id)).toEqual(["a", "n", "b"]);
});
it("reports inserted:false when the anchor is missing", () => {
const before = { type: "doc", content: [p("a", "A")] };
const { doc, inserted } = insertNodesRelative(before, [p("n", "N")], {
position: "after",
anchorNodeId: "missing",
});
expect(inserted).toBe(false);
expect(doc).toEqual(before);
});
it("is a no-op for an empty node array", () => {
const before = { type: "doc", content: [p("a", "A")] };
const { doc, inserted } = insertNodesRelative(before, [], {
position: "append",
});
expect(inserted).toBe(false);
expect(doc).toEqual(before);
});
});
+8
View File
@@ -1044,6 +1044,9 @@ importers:
axios:
specifier: 1.16.0
version: 1.16.0
elkjs:
specifier: ^0.11.1
version: 0.11.1
form-data:
specifier: ^4.0.0
version: 4.0.5
@@ -6876,6 +6879,9 @@ packages:
electron-to-chromium@1.5.286:
resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==}
elkjs@0.11.1:
resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==}
emittery@0.13.1:
resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==}
engines: {node: '>=12'}
@@ -17642,6 +17648,8 @@ snapshots:
electron-to-chromium@1.5.286: {}
elkjs@0.11.1: {}
emittery@0.13.1: {}
emoji-regex@8.0.0: {}