The four MCP markdown-write tools (updatePageMarkdown/createPage/patchNode/
insertNode) still described the old parse-everything behavior after #502 turned
TeX math and fuzzy autolink OFF on the write paths. Add a concise contract hint
to each: $...$ / $$...$$ stay literal (not a math formula) and schemeless
www.host / bare emails are not auto-linked (explicit https:// still links); for
a real formula use updatePageJson (or a mathInline/mathBlock node via `node` on
patch/insert). Also note getPage format:"text" in README.md/README.ru.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F1: the server model-replay loaded history via findAllByChat().map(rowToUiMessage)
WITHOUT hydrating parts from ai_chat_run_steps. A HARD crash mid-run (SIGKILL/OOM)
fires no terminal callback, so the assistant row stays parts:[] and its partial
tool-calls/results/text (durable in the steps table) dropped out of the model's
next-turn context. Hydrate needy assistant rows (role==='assistant' &&
!rowHasInlineParts) via findByMessageIds + hydrateAssistantParts before the replay
map — mirroring the controller's withReconstructedParts exactly — guarded on the
optional repo. Fix the now-false interrupt-resume comment.
F2: add a service int-spec that drives the REAL onStep append-persist WRITE branch
through AiChatService.stream with a real AiChatRunStepRepo injected, asserting the
per-step rows' stepIndex + parts slice and the step-marker metadata match a
single-row flush (catches an stepsPersisted-1 off-by-one).
F3: add a controller int-spec that drives withReconstructedParts through getMessages
WITH the repo present (a mid-run marker-only row + its step rows), asserting the
reconstructed metadata.parts and workspace-scoping.
F4: remove the dead countByMessage (zero prod callers; reconstructRunParts derives
stepsPersisted inline) + its now-unused sql import and the redundant test assertion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Internal review found the two layered-extension flags landed in the SHARED
markdownToProseMirrorCanonical wrapper, which mis-covered two tools. Move the
decision to each caller by the tool's semantics.
BLOCKER 1 — createPage was NOT covered. createPage POSTs to the server
/pages/import endpoint, which imported with DEFAULTS (math + fuzzy autolink ON),
so an agent's `$x=1$` still became a formula and `www.host` still autolinked.
Add an optional `disableMarkdownExtensions` multipart field to /pages/import
(default OFF, so human file uploads keep math ON); import.controller reads it,
import.service.importPage/processMarkdown thread it into markdownToProseMirror.
MCP createPage sends it true.
BLOCKER 2 — import_page_markdown was wrongly disabled. It goes through the same
wrapper, so hardcoding parseMath:false degraded an exported `$x^2$` to literal
text on re-import, breaking the #328 lossless export→import pair. The wrapper no
longer hardcodes the flags: it takes them from the caller and DEFAULTS to the
package importer defaults (extensions ON). Callers now set them explicitly:
- updatePageMarkdown (updatePageContentRealtime) -> OFF
- patch_node/insert_node (importMarkdownFragment) -> OFF (unchanged)
- import_page_markdown -> DEFAULTS (math ON) — #328 round-trip restored
Tests: rewrite the mcp write test around the corrected per-caller semantics
(agent-write OFF, import_page_markdown DEFAULTS incl. the REAL #328 round-trip);
add a collab-backed wiring test driving client.updatePage (OFF) and
client.importPageMarkdown (DEFAULTS) end-to-end; add a createPage multipart-flag
test; add a server import.service.processMarkdown spec (flag OFF -> literal / no
autolink, default -> math ON for human uploads); reword the prosemirror-markdown
round-trip test to its package-default scope. Mutation-verified both caller
wirings (flip updatePageMarkdown -> defaults reddens the OFF wiring test; make
the wrapper default OFF reddens the #328 round-trip).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the inline ProseMirror comment-mark clear out of the Undo toast's
onClick and into the reopen mutation's onSuccess branch. Clearing it
eagerly flipped the collab mark to unresolved before the reopen result
was known: on a non-404 failure the RQ cache rolls back to resolved but
the doc kept an active highlight the panel treats as resolved, and a
comment refetch can't heal a divergence that lives in the collab doc.
Mirrors the 404 branch's editor-liveness guard/try-catch and the safe
await-then-mark pattern in comment-list-item. The button-triggered
reopen already sets the mark, so the onSuccess call is an idempotent
no-op there.
Tests: reopen failure (500) leaves the mark untouched; null editorRef
on the success path degrades gracefully.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Модель органически вставляет `<!-- ... -->` в mxGraph-XML вопреки запрету
в описании тула (природа LLM, промптингом не лечится). Жёсткая ошибка
линтера [no-comments] заставляла её ПОЛНОСТЬЮ перегенерировать диаграмму —
впустую потраченный tool-call на почти каждой первой генерации.
Теперь в общем prepare-пути (`prepareModel`, через который идут
drawioCreate/drawioUpdate/drawioEditCells) комментарии срезаются ДО линта:
`xml.replace(/<!--[\s\S]*?-->/g, "")`. Комментарии не несут семантики, так
что стрип безопасен. Число срезанных уходит в `warnings[]` как
`stripped N XML comment(s)` (только при N > 0) — модель это видит, но НЕ
ретраит.
Стрип ГЕЙТИТСЯ двумя условиями, чтобы регэксп бил только по настоящим
comment-нодам:
1. well-formedness — сначала парсим модель; на malformed-входе (сырой
неэкранированный `<!--` внутри значения атрибута на пути create/update,
где normalizeInput отдаёт строку без парсинга) стрип НЕ выполняется,
иначе он молча вырезал бы текст автора. Вход отклоняют правила
value-escaping / well-formed-xml, автор видит реальную ошибку.
2. отсутствие CDATA — CDATA-секция well-formed, но её текст может содержать
литеральный `<!-- ... -->`, который НЕ является comment-нодой; стрип
молча удалил бы контент автора. Настоящий drawio держит подписи в
атрибутах `value=` (CDATA невозможен), так что исключение CDATA бесплатно
на легитимных моделях; CDATA-модель с реальным комментарием падает на
сохранённый backstop no-comments (явная ошибка), а не тихо портится.
Правило `no-comments` в линтере оставлено как defense-in-depth backstop.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WRITE: parameterize the canonical importer markdownToProseMirror with
{parseMath, fuzzyLinkify} (defaults true — editor/file-import/git-sync unchanged);
MCP write paths (createPage/updatePageMarkdown/patchNode/insertNode) call with both
false so $...$ stays literal text and bare domains don't autolink (explicit
https:// still links). READ: getPage format:'text' reuses the server jsonToText path
(deterministic flag) for flat machine-diffable text, [image]/[table RxC] placeholders.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Issue #503 reported a hardBreak sent via updatePageJson being «silently
cut» — no error, no line break — forcing the agent to split lines into
separate paragraphs.
A faithful repro shows the bug does NOT reproduce against develop:
hardBreak is fully representable in the markdown canon in BOTH directions
and survives the real Yjs write path.
- pm -> md: the serializer (now in @docmost/prosemirror-markdown after the
#293 STEP 5 consolidation) emits the CommonMark hard break ` \n`, not a
bare `\n`.
- md -> pm: marked tokenizes ` \n` to `<br>`, which generateJSON parses
back to a hardBreak node in ONE paragraph (not split, not dropped).
- updatePageJson (PM JSON -> applyDocToFragment = PMNode.fromJSON +
updateYFragment, the real collab-session write encoder -> read back)
keeps text + hardBreak + text in one paragraph.
The issue's diagnosis points at packages/mcp/.../markdown-converter.ts as
the serializer, but that file is a 15-line re-export shim since #293 STEP 5
— the diagnosis predates the converter consolidation that already closed
both sides. P1 (semantic round-trip) + P2 (byte-fixpoint) hold, and the
node already has broad property/corpus/golden coverage in the package.
No converter change is warranted (switching the break form to `\`+newline
would churn the whole #351 byte-fixpoint corpus against the established
` \n` repo convention for zero functional gain). This adds one
integration guard at the MCP canon seam covering all three seams of the
reported updatePageJson path. Mutation-verified: neutering the serializer
arm or the importer `<br>` handling reddens seams 1-2; neutering the real
applyDocToFragment write path reddens seam 3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
getPageBreadCrumbs returns the full ancestor chain filtered only by
deletedAt; the /breadcrumbs endpoint validates validateCanView on the
target page only. Document why this is safe rather than an accepted leak:
page restrictions inherit down the tree, so viewing a page implies the
right to view every ancestor (canUserAccessPage checks the full ancestor
chain). A hidden ancestor would already hide the target itself, making
per-ancestor filtering redundant. Owner decision: document, no logic change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The list-only cap assumed a well-formed prefix; a pathologically long
agent-supplied spaceId (unvalidated for length) lives in the prefix and bypassed
the cap. Add the same unconditional final slice formatDocmostAxiosError uses, so
the WHOLE message is bounded regardless of spaceId length. No-op for normal
inputs (36-char UUID) where the list cap already keeps it <= 300.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review follow-up (#536).
F1 (test coverage): add two tests that pin the wrapper's fail-open guards, which
previously survived mutation:
- a non-404 error (500) on a wrapped call with a spaceId propagates unchanged and
triggers NO /spaces sweep (a real 5xx/403 must never be swallowed/reformatted);
- a 404 when the spaceId IS in the accessible index fails open (the 404 is about
another resource), so it is not falsely rewritten to "not found among spaces".
Both mutation-verified: forcing the non-404 condition to false reddens the first;
removing the id-present guard reddens the second.
F2 (conventions): formatSpaceNotAccessible now honours ERROR_MESSAGE_CAP (300),
the same budget formatDocmostAxiosError enforces. Only the interpolated space
list is truncated (with an ellipsis); the fixed prefix (bad spaceId) and suffix
(listSpaces pointer) are always kept, so the actionable parts survive. Test: 10
long space names -> message <= 300 and still contains the spaceId + listSpaces.
Adjusted the existing list-cap test to short ids/names so it exercises the 10-item
cap + "(+N ещё)" tail below the length cap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Part 1 (#542): add an inline Undo (reopen) button to the resolve success
toast in useResolveCommentMutation. Undo re-invokes the same mutation via a
self-ref (declared before useMutation, read at click time — no cycle) and
clears the inline comment mark through an editor ref (survives the originating
CommentListItem unmounting). A closured `done` flag guards a fast double-click
(notifications.hide is async). Reopen keeps the plain toast (no Undo).
onError gains a terminal 404 branch: drop the comment from the cache and
unsetComment its orphaned mark (no rollback → no phantom in Resolved, neutral
"Comment no longer exists"). The generic-failure copy is now directional
(resolve vs. re-open). Autoclose policy const RESOLVE_UNDO_AUTOCLOSE_MS=10000.
Part 2: sort the Resolved tab by resolvedAt DESC via an exported
sortResolvedByResolvedAt helper; coerce with new Date(...) because resolvedAt
is an ISO string at runtime (Date only in the optimistic window).
i18n: add "Failed to re-open comment" and "Comment no longer exists" to
en-US/ru-RU. Tests: 6 resolve-undo cases + 4 sort cases.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The #332 integration test (ai-chat-stream.int-spec.ts) asserted a deferred
tool activated in one turn does NOT leak into the next ("cold start per
turn"). #490 (f5bbfdb2) deliberately reversed that: it persists the
activation set into chat metadata.activatedTools and seeds the next turn
from it, so the model need not re-run loadTools. The test only surfaced now
because the token-estimate CI fix let the integration step run again.
Adopt #490 persistence as the intended behavior:
- flip the turn-2 assertion to expect createPage IS active on the fresh
turn's first step (seeded from metadata); keep turn-1 cold-start assertions.
- rewrite the test docstring, describe/it titles and comments accordingly.
- fix two stale "not persisted / per-turn" comments in ai-chat.service.ts
(prepareAgentStep + the streaming-loop activation block); no logic change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reviewer follow-up on the #522 internal-link import fix. The fix itself is
confirmed correct; these three changes harden its load-bearing subset invariant
and fix a stale doc.
Finding 1 (drift guard): the in-package test only compared isInternalPagePath
against a HAND-COPIED server regex, so a later narrowing of the real server
INTERNAL_LINK_REGEX would leave the test green while the client silently became
WIDER than the server. Add apps/server/.../export/internal-link-parity.spec.ts:
the top layer already depends on @docmost/prosemirror-markdown and exports
isInternalPagePath, so this spec imports the LIVE server INTERNAL_LINK_REGEX AND
the LIVE isInternalPagePath and asserts subset over an accept-corpus — a server
narrowing now reddens CI. The in-package manual copy is demoted from its
"drift guard" role (comment updated to say so; it stays as local documentation).
Finding 2 (charset non-vacuity): the REJECT list was purely structural, so the
mutation widening the slug charset [a-zA-Z0-9-] -> [a-zA-Z0-9-.] survived the
whole suite. Add REJECT cases whose ONLY defect is a forbidden slug character
(abc.def / abc_def / abc%20 / abc~x); assert both isInternalPagePath and the
server regex reject them. The mutation now reddens the new reject test.
Finding 3 (stale JSDoc): canonicalize.ts KNOWN_DEFAULTS module blurb claimed
every entry was read from docmost-schema and import-materialized. The new
link.internal default breaks both claims (source is editor-ext/src/lib/link.ts;
external links leave internal absent/null, never false). Add a bullet documenting
the one editor-sourced, non-materialized default (false ≡ absent/null).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
При импорте markdown ссылка вида `[t](/s/<space>/p/<slug>)` сохранялась как
внешняя (link-mark получал `internal:null`, `target:"_blank"`,
`rel:"noopener noreferrer nofollow"`): открывалась новой вкладкой, без
hover-превью и без участия в backlinks. Единственный путь к нативной внутренней
ссылке был ручной JSON-патч.
Чиню в общем конвертере пакета, через который проходят ВСЕ markdown-пути (MCP
updatePageMarkdown/importPageMarkdown, patch/insertNode, тела комментариев,
серверный REST create/update, single/zip-импорт, ai-chat, git-sync pull,
вставка markdown в редактор) — все они получают исправление разом.
- Новый модуль `internal-links.ts`: чистый `isInternalPagePath(href)` —
якорный `^/s/<space>/p/<slug>/?$`, СТРОГОЕ подмножество серверного
`INTERNAL_LINK_REGEX` (apps/server/.../export/utils.ts), поэтому всё
помеченное гарантированно бэклинкуется и переписывается при экспорте.
Fail-toward-external: любая неоднозначность (scheme/host, `#`, `?`,
бесспейсовый `/p/<slug>`, лишний сегмент, не-строка) остаётся внешней.
- `markInternalLinks(doc)`: пост-обход готового ProseMirror-документа,
помечает КАЖДУЮ text-ноду, покрытую внутренней ссылкой (включая случай
вложенных bold/italic внутри ссылки) → `{internal:true, target:null,
rel:null}`. Чистая и идемпотентная.
- Разводка в `markdownToProseMirrorSync` после stripEmptyParagraphs.
- §11 (обязательная сопутствующая правка): `internal:false` добавлен в
`KNOWN_DEFAULTS.link` канонизатора — редакторные внешние ссылки хранят
`internal:false`, теперь он канонизируется как absent/null/external, а
load-bearing `internal:true` (не дефолт) переживает канонизацию.
Тесты: accept/reject-пиннинг матчера (edge: trailing slash, `#`, `?`,
`/p/x` без спейса, `https://h/p/x`, `/api/...`, uppercase, empty-space),
subset-инвариант против серверного регэкспа, мультинодовая ссылка со вложенными
марками, идемпотентность, полный конвертер (internal помечен / external нетронут /
бэклинк-извлекаемость), комментарий-тело через общий путь, canonicalize §11.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review follow-up on the list-seam coalescing:
1. The three-way merge only checked each PRE-EXISTING list against the
inserted one. A default-typed inserted orderedList between two lists with
explicit DIFFERENT numbering styles passed both pairwise checks through the
null-typed middle, collapsing all three and silently losing the right
list's style. Add a `listsMergeable(left, right)` guard to the three-way
condition. On failure fall through to the single-seam path: a single
inserted block can be absorbed by at most one neighbour, so prefer the LEFT
seam (consistent with the three-way survivor choice) and leave the
incompatible right list separate with its own style.
2. Lock the footnotesList exclusion with a test — inserting a footnotesList
next to a footnotesList must leave TWO separate blocks (a refactor of the
allow-list to endsWith("List") would otherwise silently merge them and
corrupt footnotes).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review follow-up to the base64→entity-XML content= switch. The whole mxfile XML
now lives in one content="..." attribute; XML attribute-value normalization
collapses a LITERAL tab/newline/CR to a single space on DOM read (jsdom and the
real draw.io editor alike), silently flattening multi-line labels and
tab-bearing values that the old base64 form stored verbatim.
Finding 1 (data-loss): both encode paths — buildDrawioSvg's xmlEscape (mcp) and
the import service's escape — now append 	/
/
 after the four
&<>" replaces (numeric char-refs survive normalization, as draw.io's own export
does). The extractContentAttr regex fallback now decodes those char-refs (hex
case-insensitive plus decimal 	/ / ) so it agrees with the DOM path;
& stays decoded last so an escaped &#x9; reads back as literal text.
Finding 2 (dedup): the server's private xmlEscapeAttr is replaced by the shared
htmlEscape helper (& < > " ' — a strict superset, the extra ' is harmless in a
"-delimited value) wrapped in xmlEscapeContent, which adds the three control-char
char-refs on top (htmlEscape does not escape them).
Finding 3 (docs): narrow the CHANGELOG healing claim — only a diagram still
holding its original correct-UTF-8 base64 (not yet opened/autosaved) is
recoverable; one already opened in the editor persisted mojibake at rest and its
text is lost.
Tests: new mcp round-trip test with literal tab/newline/CR in a value (DOM path,
byte-stable) plus a fallback-branch test forcing a malformed wrapper so both
decode paths are proven to agree; new server spec asserting char-ref encoding.
Mutation-checked: dropping the encode replaces reddens both new mcp tests;
dropping only the fallback decode reddens just the fallback test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Реviewer-filed баг: draw.io-редактор декодит base64 content= как Latin-1
(atob-семантика) → кириллица разваливается в мойбейк, автосейв редактора
персистит порчу и убивает превью. Нативная форма draw.io — entity-encoded
mxfile-XML (content="<mxfile…"), DOM-декодится как UTF-8.
Фикс двух write-путей: buildDrawioSvg (mcp, все create/update) и
createDrawioSvg (server Confluence-импорт) теперь XML-эскейпят content=
вместо base64. Декодер уже различает startsWith("<") vs base64 — обе формы
читаются, старые base64-файлы открываются (back-compat), byte-stable
round-trip. CHANGELOG + заметка про лечение старых диаграмм (drawioGet→Update).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Раньше каждый onStepFinish переписывал ВСЮ строку ассистента (растущий
metadata.parts jsonb со всеми выводами инструментов) → O(n²) объёма записи
на прогон: под MVCC/TOAST апдейт jsonb переписывает всю версию строки, так
что шаг k пишет ~k×вывод. Прогон из 50 шагов по ~100 КБ = сотни МБ WAL и
мёртвых кортежей за ход, что молотит autovacuum. (#490 убрал только ВТОРУЮ
копию в tool_calls; сам metadata.parts всё ещё рос и переписывался.)
Теперь каждый завершённый шаг ДОПИСЫВАЕТСЯ отдельной строкой в лёгкую
таблицу ai_chat_run_steps (только парты этого шага), а строка сообщения
получает дешёвый маркер (stepsPersisted + toolTraceVersion, без растущего
блоба parts). Полный metadata.parts собирается ОДИН раз на финализации.
НЕ jsonb-append (||): апдейт всё равно переписывает всю TOAST-версию —
экономится только сетевой payload, а WAL/мёртвые кортежи остаются; поэтому
именно ОТДЕЛЬНАЯ таблица + INSERT.
Три обязательные интеграции:
- reconstructRunParts(row, stepRows) → { parts, stepsPersisted }: единый
шов переключения бэкенда. Читает парты из СТРОКИ, если она уже несёт
inline-parts (старые записи + ЛЮБАЯ финализированная), иначе из ТАБЛИЦЫ
ШАГОВ (mid-run запись #492). Дискриминатор — наличие непустого
metadata.parts (флаг схемы не нужен). Потребители (attach-seed,
delta-poll, export, reconnect) прогоняют строки через hydrateAssistantParts
на границе чтения — их контракт/вывод не меняется, старые и новые записи
восстанавливаются идентично.
- сигнал ротации кольца реестра #491 (confirmPersistedStep) теперь стреляет
на подтверждённый INSERT шага, под тем же контрактом (updateStreaming
возвращает stepsPersisted / null).
- era-marker toolTraceVersion (#490) больше не ставится полной переписью —
ставится в маркере шага и на финализации (flushAssistant), остаётся
консистентным.
Полная обратная совместимость: прогон, записанный по-старому (полная строка,
без строк шагов), восстанавливается/attach/export идентично. При отсутствии
репозитория шагов (позиционные тест-конструкции) — фолбэк на прежний
полнострочный flush (без регрессии, только без выигрыша WAL).
Тесты (реальный pg, int-lane):
- WAL-дельта (pg_current_wal_lsn) на прогоне 40×100КБ: new=4.3МБ vs
old=90.3МБ (20.8x) — O(Σ шагов) против O(n²); старый путь в тесте И есть
ревертнутое поведение (мутация-проверка).
- reconstruct-контракт: new-style (таблица шагов) и old-style (inline) прогоны
восстанавливаются в идентичные parts; hydrate заполняет строку.
- миграция up/down roundtrip.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The shared @docmost/token-estimate package (main: ./dist/index.js, dist/
gitignored) was never built in the CI jobs that bypass nx, so develop CI
went red:
- test.yml `test` (pnpm -r test): client vitest failed to resolve the import.
- develop.yml `e2e-server` (direct jest e2e): server history-budget.ts hit
TS2307 Cannot find module '@docmost/token-estimate'.
The nx-driven jobs (build, e2e-mcp) stayed green via dependsOn: ^build.
- test.yml: add "Build token-estimate" step to the `test` job.
- develop.yml: add "Build token-estimate" step to the `e2e-server` job.
- Dockerfile: ship packages/token-estimate/dist + package.json into the
installer stage — apps/server depends on it (workspace:*) and imports it
at runtime, so the prod image would otherwise crash with
ERR_MODULE_NOT_FOUND (masked so far because publish/smoke never ran).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Путь ответа ассистента теперь рендерится инкрементально, по образцу
StreamingPlainText из ветки reasoning (#492, волна C эпика #497).
Раньше MarkdownPart прогонял ВЕСЬ накопленный ответ через канонический
конвейер (markdownToProseMirrorSync → PMNode.fromJSON → DOMSerializer →
DOMPurify) на КАЖДОМ throttled-тике (~20 Гц). На синтетическом потоке
~100 КБ это 394 вызова renderChatMarkdown — O(числа тиков), причём каждый
вызов заново парсит всю растущую строку.
Теперь:
- StreamingMarkdownText делит текст на блоки по безопасному срезу
(splitPlainChunks — та же append-only-инвариантность, что у reasoning):
СТАБИЛИЗИРОВАННЫЕ блоки идут через канонический конвейер и мемоизируются
(каждый блок парсится РОВНО ОДИН раз), живой ХВОСТ — дешёвый plain-text
(React-escaped, без парсера/санитайзера/innerHTML) до стабилизации.
- На финализации (флип state → done или конец хода) — ОДИН полный
канонический рендер всего текста: побайтовая визуальная паритетность с
прежним выводом (включая <li><p>-обёртки схемы и scoped-CSS из #498).
- Гейт liveness тот же, что у ReasoningBlock: streaming =
turnStreaming && part.state === "streaming".
Также цикл рендера частей переведён на ИСЧЕРПЫВАЮЩИЙ switch по видам частей
с never-проверкой в default (вместо прежнего WARNING-комментария): новый
закрытый вид части в UIMessagePart теперь ошибка компиляции.
Тесты:
- perf-smoke: на ~100 КБ потоке число вызовов renderChatMarkdown ≤ blocks+2
и ≪ ticks (O(блоков), не O(тиков)); мутация (снять memo с MarkdownChunk)
краснит ассерт (78631 вызовов).
- visual-regression: финальный рендер побайтово равен renderChatMarkdown
всего текста (в т.ч. <li><p>), инкрементальный вид сходится к нему на
финализации; учтён neutralizeInternalLinks.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
При живом обрыве SSE вход в лестницу reconnect происходил только
после резолва getRun(cid) (ре-сид из персиста). Путь REJECT
обрабатывался через .catch, но ЗАВИСШИЙ getRun (соединение есть,
ответа нет) был не ограничен: axios-клиент (lib/api-client.ts) не
имеет timeout, а stalled-idle-cap взводится только ПОСЛЕ входа в
reconnecting/polling. Итог — FSM залипала в `streaming` без баннера
и без поллинга до сокет-таймаута браузера (минуты). Это тот самый
класс тихого зависания, который устраняет эпик #497.
Оборачиваю ожидание getRun гонкой с таймаутом
(RECONNECT_RESEED_TIMEOUT_MS = 4s): по таймауту берётся ТОТ ЖЕ
фолбэк, что и на reject — dropLivePartialAndReplayFromStart() +
enterReconnect(runId), так что лестница/поллинг стартуют и
stalled-idle-cap взводится. Локальный флаг `settled` делает ветви
resolve/reject/timeout взаимоисключающими: поздний резолв getRun
после сработавшего таймаута полностью игнорируется (не входит в
reconnect повторно, не перетирает replay-from-start устаревшим
ре-сидом, ничего не перевзводит). Таймер живёт в reseedTimerRef и
очищается при размонтировании (никаких висящих setTimeout).
Тесты: hang-кейс (getRun не резолвится -> по таймауту FSM в
reconnecting, live-partial сброшен, replay-from-start) и
late-resolve safety (поздний резолв — no-op). Мутация: замена гонки
на голый getRun краснит оба #541-теста.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hardening follow-ups from PR #526 review (no defect fixes):
- DO1: add auth.controller.spec tests for the collab-token handler, the
arm-seam of the anti-laundering defense. Assert getCollabToken is invoked
with { apiKeyId } only when the SIGNED req.raw marks an api_key principal,
and undefined otherwise (session, missing apiKeyId, or a spoofed body).
Verified non-vacuous: nulling the ternary reddens the ARMED test.
- DO2: document the API_KEYS_ENABLED kill-switch in .env.example next to the
other feature flags (default ON; strict true/false; OFF denies api-key auth
and 404s the management endpoints).
- DO3: remove dead bindAccessJwtVerifier (+ its now-orphaned AccessJwtVerifier
interface and its dedicated spec block); prod switched to
bindMcpBearerVerifier. verifyBearerAccess is retained (still used).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The comment falsely listed a 'DnD move guard' consumer; no DnD path routes
through the predicate (local DnD/create-page use the raw index-based insert).
List the real consumers (handleToggle + realtime insertByPosition/placeByPosition)
and note the local raw-insert path as a #525 follow-up. Comment-only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ре-ревью нашло регрессию класса #137/#161 на пути отказа getRun при tail-only
re-seed:
- НАХОДКА 1 (MEDIUM/HIGH): в onFinish-disconnect ветка .catch (отказ getRun) на
локальном дропе входила в reconnect-ladder БЕЗ ре-сида и БЕЗ фильтра «живой»
частичной строки. anchorRef оставался устаревшим (mount-инициализатор), живая
частичная строка со шагами N..M-1 — последней; через ~1с реконнект строил
?anchor=&n=N_mount, и при живом ране с покрытием от N_mount (flaky-сеть: SSE и
getRun упали, сеть поднялась за 1с) сервер отдавал кадры ≥N_mount → SDK дописывал
их к строке, где они УЖЕ есть → дублирование (клиентского дедупа реплея против
parts нет). Фикс: восстановлена структурная гарантия удалённого resumeStream-
фильтра — на отказе getRun (и на no-persisted-row, и на no-cid) живая частичная
строка удаляется из стора по id + anchorRef=null → реконнект реплеит со start в
ЧИСТЫЙ стор (полная пересборка) либо 204→poll. Нет пути, где attach tail-applies
на строку с уже присутствующими шагами. Тест: getRun-reject на локальном
дисконнекте → живая строка отфильтрована + URL без параметров (mutation-verify:
без фикса тест краснеет — фильтр не срабатывает).
- НАХОДКА 2 (LOW): RUN_FACT в enterReconnect теперь epoch-штампуется (epoch:
stampEpoch), как везде (postRun): getRun-rtt расширяет окно onFinish→dispatch,
конкурентный SEND_LOCAL во время rtt теперь дропает устаревший RUN_FACT по I1,
а не перетирает runFact.runId нового хода.
- НАХОДКА 3 (LOW, doc): run-fsm.spec.md обновлён — stripRef/strippedRowRef →
anchorRef {id, stepsPersisted}, tail-only + re-seed-from-persist.
FSM run-fsm.ts не тронут; инварианты #488 (epoch/honor-in-stopping/ownership-reset/
disconnect-first/render-gate) сохранены. Клиент ai-chat vitest 399 зелёный, tsc 0
ai-chat-ошибок; сервер delta(6, реально исполняется)/registry/step-marker/attach +
integration attach — зелёное.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>