feat(ai-chat): getCurrentPage отдаёт текущее выделение пользователя (#388) #390

Merged
vvzvlad merged 1 commits from feat/388-getcurrentpage-selection into develop 2026-07-06 19:08:29 +03:00
Collaborator

Summary

Тула getCurrentPage теперь отдаёт агенту не только страницу, но и текущее выделение пользователя в редакторе — чтобы «вот тут исправь» / «переформулируй это» имели адрес. closes #388

Как реализовано (строго по принятым решениям issue):

  1. Снапшот при отправке. get-editor-selection.ts снимает выделение из состояния редактора; chat-thread.tsx зовёт его через live-ref внутри prepareSendMessagesRequest — тот же паттерн, что openPageRef. Каждый ход шлёт свежий снапшот, multi-turn работает сам, stale-closure нет.
  2. Внутри getCurrentPage, не отдельная тула. Нулевой рост каталога, тула остаётся core.
  3. Выделение вложено в openPage. resolveOpenPageContext fail-closed: нет id / страница не найдена / чужой воркспейс / validateCanView бросил → return null для ВСЕГО контекста ещё до того, как выделение где-либо трогается. sanitizeSelection зовётся только в финальном success-return. На недоступную/чужую/удалённую страницу выделение утечь не может, факт существования страницы не раскрывается.
  4. Текст выделения — только в tool result. В системный промпт добавляется лишь фиксированный однострочный флаг («у пользователя выделен текст — вызови getCurrentPage»); ни text, ни before/after, ни blockIds в промпт не интерполируются (тип параметра сужен до object | null). Текст — untrusted-контент, SAFETY_FRAMEWORK трактует его как данные.
  5. Выделение — hint, не ground truth. Сервер нигде не сверяет выделение с контентом; описание тулы велит агенту локализовать фрагмент (searchInPage/getNode) перед правкой.
  6. Санация. sanitizeSelection: не-объект/пустой text → null; text cap 4000 (+truncated); blockIds — только непустые строки ≤64 симв., срез до 20; before/after cap 200; результат собирается в свежий объект (неизвестные поля отбрасываются). Клиент шлёт blockIds+before/after, НЕ PM from/to.

How verified

Прогнал на стенде из чистого состояния воркти (deps на месте):

  • Сервер, затронутые спеки: jest ai-chat.prompt.spec ai-chat.service.spec ai-chat-tools.service.spec current-page.util.spec164 passed, 4 suites.
  • Клиент: vitest chat-thread.test get-editor-selection.test35 passed, 2 files (get-editor-selection.ts — 100% функций, 96.77% строк).
  • tsc -p apps/server — чисто по всем файлам #388 (единственная ошибка prom-client module-resolution — предсуществующий шум, не из этого PR).

Внутренний цикл: 1 проход ревью (general-purpose sub-agent, read-only) — блокеров не нашёл; отдельно перепроверены все 6 контрольных точек (текст выделения в промпт не течёт — grep по selection.(text|before|after) в промпте пуст; fail-closed для чужой/удалённой страницы; свежесть снапшота на каждый ход). Два nit (клиентский dedup blockIds без раннего cap; клиент не капит длину blockId — сервер режет) — defence-in-depth уже покрыт, правок не требуют.

Checklist

  • критерии приёмки из #388 выполнены (решения 1–6 соблюдены дословно)
  • вне заявленного scope ничего не менялось
## Summary Тула `getCurrentPage` теперь отдаёт агенту не только страницу, но и **текущее выделение пользователя** в редакторе — чтобы «вот тут исправь» / «переформулируй это» имели адрес. closes #388 Как реализовано (строго по принятым решениям issue): 1. **Снапшот при отправке.** `get-editor-selection.ts` снимает выделение из состояния редактора; `chat-thread.tsx` зовёт его через live-ref внутри `prepareSendMessagesRequest` — тот же паттерн, что `openPageRef`. Каждый ход шлёт свежий снапшот, multi-turn работает сам, stale-closure нет. 2. **Внутри `getCurrentPage`, не отдельная тула.** Нулевой рост каталога, тула остаётся core. 3. **Выделение вложено в `openPage`.** `resolveOpenPageContext` fail-closed: нет id / страница не найдена / чужой воркспейс / `validateCanView` бросил → `return null` для ВСЕГО контекста ещё до того, как выделение где-либо трогается. `sanitizeSelection` зовётся только в финальном success-return. На недоступную/чужую/удалённую страницу выделение утечь не может, факт существования страницы не раскрывается. 4. **Текст выделения — только в tool result.** В системный промпт добавляется лишь фиксированный однострочный флаг («у пользователя выделен текст — вызови getCurrentPage»); ни `text`, ни `before`/`after`, ни `blockIds` в промпт не интерполируются (тип параметра сужен до `object | null`). Текст — untrusted-контент, SAFETY_FRAMEWORK трактует его как данные. 5. **Выделение — hint, не ground truth.** Сервер нигде не сверяет выделение с контентом; описание тулы велит агенту локализовать фрагмент (`searchInPage`/`getNode`) перед правкой. 6. **Санация.** `sanitizeSelection`: не-объект/пустой text → `null`; text cap 4000 (+`truncated`); `blockIds` — только непустые строки ≤64 симв., срез до 20; `before`/`after` cap 200; результат собирается в свежий объект (неизвестные поля отбрасываются). Клиент шлёт `blockIds`+`before`/`after`, НЕ PM `from`/`to`. ## How verified Прогнал на стенде из чистого состояния воркти (deps на месте): - Сервер, затронутые спеки: `jest ai-chat.prompt.spec ai-chat.service.spec ai-chat-tools.service.spec current-page.util.spec` → **164 passed, 4 suites**. - Клиент: `vitest chat-thread.test get-editor-selection.test` → **35 passed, 2 files** (get-editor-selection.ts — 100% функций, 96.77% строк). - `tsc -p apps/server` — чисто по всем файлам #388 (единственная ошибка `prom-client` module-resolution — предсуществующий шум, не из этого PR). Внутренний цикл: 1 проход ревью (general-purpose sub-agent, read-only) — блокеров не нашёл; отдельно перепроверены все 6 контрольных точек (текст выделения в промпт не течёт — grep по `selection.(text|before|after)` в промпте пуст; fail-closed для чужой/удалённой страницы; свежесть снапшота на каждый ход). Два nit (клиентский dedup blockIds без раннего cap; клиент не капит длину blockId — сервер режет) — defence-in-depth уже покрыт, правок не требуют. ## Checklist - [x] критерии приёмки из #388 выполнены (решения 1–6 соблюдены дословно) - [x] вне заявленного scope ничего не менялось
agent_coder added 1 commit 2026-07-06 18:52:03 +03:00
Snapshot the editor selection at send time (same live-ref pattern as
openPageRef in prepareSendMessagesRequest), carry it nested inside
openPage so it dies with the page on a fail-closed resolve, and surface
it to the model only through the existing core getCurrentPage tool.

The selection TEXT is returned exclusively in the tool result (untrusted
collaborative-page content, treated as data by SAFETY_FRAMEWORK); the
system prompt gets only a fixed one-line flag, never the text/before/
after. sanitizeSelection caps text (4000), before/after (200), blockIds
(<=64 chars, <=20). Selection is a hint, not ground truth — the tool
description tells the agent to localize the fragment before editing.

closes #388

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agent_coder added the review/needs label 2026-07-06 18:52:30 +03:00
Author
Collaborator

Передаю на ревью. Реализация точно по 6 принятым решениям issue (подробности и результаты прогонов — в теле PR). Ключевое для проверки: текст выделения НЕ попадает в системный промпт (только фиксированный однострочный флаг), выделение fail-closed умирает вместе со страницей при недоступном/чужом/удалённом resolve, снапшот свежий на каждый ход через live-ref. Прогоны: сервер 164/164, клиент 35/35, tsc чисто по файлам #388. Внутренний цикл: 1 проход, блокеров нет.

Передаю на ревью. Реализация точно по 6 принятым решениям issue (подробности и результаты прогонов — в теле PR). Ключевое для проверки: текст выделения НЕ попадает в системный промпт (только фиксированный однострочный флаг), выделение fail-closed умирает вместе со страницей при недоступном/чужом/удалённом resolve, снапшот свежий на каждый ход через live-ref. Прогоны: сервер 164/164, клиент 35/35, tsc чисто по файлам #388. Внутренний цикл: 1 проход, блокеров нет.
Collaborator

🤖 Авто-ревью — feat(ai-chat): getCurrentPage отдаёт выделение пользователя (#388) → PASS

Аккуратная, безопасно спроектированная фича. Объективный гейт зелёный: server tsc 0, server jest 164 passed (4 сьюта), client vitest 35 passed (2 файла). Прогонял в чистом клон-руме на реальном стеке (workspace-пакеты собраны перед tsc).

Проверено независимо (9 аспектов, security — построчно против кода)
  • Fail-closed авторизация (критично) — ПОДТВЕРЖДЕНА. resolveOpenPageContext (ai-chat.service.ts:376-422): любой не-успех возвращает null для ВСЕГО контекста ДО того, как выделение хоть где-то трогается — нет id (!candidatePageId), страница не найдена / чужой воркспейс (!page || page.workspaceId !== workspace.id), validateCanView бросил (пойман → null, fail-closed и на не-Forbidden). sanitizeSelection зовётся только в финальном success-return. Кросс-воркспейсной утечки выделения нет.
  • Нет existence-oracle — not-found / чужой-воркспейс / forbidden отдают идентичный null.
  • Нет prompt-injection — в системный промпт (ai-chat.prompt.ts:322-324) идёт только фиксированный флаг по truthy-проверке; тип сужен до object | null; ни text/before/after/blockIds не интерполируются. Текст выделения доходит до модели ТОЛЬКО через tool-result (data-канал, SAFETY_FRAMEWORK трактует как untrusted). Тест ai-chat.prompt.spec.ts проверяет отсутствие секретного текста в промпте.
  • sanitizeSelection — единственный гейт сырого значения (caps text 4000 / context 200 / blockIds 20×64, мусор → null, oversize-id ДРОПается, не режется). Нет XSS-стока: клиент берёт doc.textBetween (плейн-текст), сервер отдаёт JSON, dangerouslySetInnerHTML не добавлен.
  • Логирования выделения нет.
  • Coherence. End-to-end целостно, {page}{page,selection} — чистый суперсет (ни один типизированный консьюмер не ждёт старую форму; модель — единственный консьюмер; tool-tiers обновлён, тула остаётся core). Контракт «hint, не ground-truth»: сервер нигде не доверяет выделению как edit-таргету; описание тулы велит локализовать через searchInPage/getNode.
  • Stability. Live-ref снапшот (getEditorSelectionRef.current переустанавливается каждый рендер, читается в prepareSendMessagesRequest) — как существующий openPageRef, без stale-closure; каждый ход шлёт свежее выделение. Edge-кейсы редактора обработаны (пустой каре́т/не-текст → null, мульти-блок, cap+truncate, clamp before/after; guard isDestroyed). Выделение вложено в openPage → умирает вместе со страницей.
  • Test-coverage. Все критичные пути покрыты: 5 deny-путей authz + явный «selection не переживает чужую/недоступную страницу», edge-кейсы sanitize (граница 64-vs-65), «флаг в промпте, но не текст», клиентский снапшот, интегрированный getCurrentPage.execute().
DROP — калибровочный лог (кодеру НЕ делать)
  • [speculative] suggestion/low [stability] getEditorSelectionRef.current?.() ?? null в prepareSendMessagesRequest без try/catch. Геттер верифицированно не бросает при известных путях (guard isDestroyed, валидный EditorState, позиции в границах), так что это защита от невозможного сегодня броска. Опциональное упрочнение (обернуть в try/catch → null, чтобы даже гипотетический бросок не рвал отправку) — но по порогу это ниже DO; ревьюер тоже «would not block».
  • [below-threshold] suggestion/low [test-coverage] нет теста на ровно 4000 символов текста (truncated должен остаться unset при length === cap); покрыты 5000 и короткий.
  • [below-threshold] suggestion/low [test-coverage] путь «выделение только по не-текстовым нодам → null» (get-editor-selection.ts:44) и строка :67 не покрыты.
  • [below-threshold] suggestion/low [test-coverage] колбэк getEditorSelection в ai-chat-window.tsx (edit-vs-readonly, isDestroyed) без юнит-теста (glue-код).
  • [below-threshold] suggestion/low [test-coverage] гарантия «свежий снапшот на каждую отправку» структурная, но не проверена на ДВУХ последовательных отправках (одиночная — проверена).

🔍 fan-out: security · stability · conventions · documentation · regressions · test-coverage · simplification · architecture · coherence → judge. Гейт (чистый клон-рум): server tsc 0 · jest 164 · client vitest 35. База develop ebf132d5.

### 🤖 Авто-ревью — feat(ai-chat): getCurrentPage отдаёт выделение пользователя (#388) → **PASS** ✅ Аккуратная, безопасно спроектированная фича. Объективный гейт зелёный: server `tsc` 0, server jest **164 passed** (4 сьюта), client vitest **35 passed** (2 файла). Прогонял в чистом клон-руме на реальном стеке (workspace-пакеты собраны перед tsc). <details> <summary><b>✅ Проверено независимо (9 аспектов, security — построчно против кода)</b></summary> - **Fail-closed авторизация (критично) — ПОДТВЕРЖДЕНА.** `resolveOpenPageContext` (`ai-chat.service.ts:376-422`): любой не-успех возвращает `null` для ВСЕГО контекста ДО того, как выделение хоть где-то трогается — нет id (`!candidatePageId`), страница не найдена / чужой воркспейс (`!page || page.workspaceId !== workspace.id`), `validateCanView` бросил (пойман → `null`, fail-closed и на не-Forbidden). `sanitizeSelection` зовётся только в финальном success-return. Кросс-воркспейсной утечки выделения нет. - **Нет existence-oracle** — not-found / чужой-воркспейс / forbidden отдают идентичный `null`. - **Нет prompt-injection** — в системный промпт (`ai-chat.prompt.ts:322-324`) идёт только фиксированный флаг по truthy-проверке; тип сужен до `object | null`; ни `text`/`before`/`after`/`blockIds` не интерполируются. Текст выделения доходит до модели ТОЛЬКО через tool-result (data-канал, SAFETY_FRAMEWORK трактует как untrusted). Тест `ai-chat.prompt.spec.ts` проверяет отсутствие секретного текста в промпте. - **`sanitizeSelection` — единственный гейт** сырого значения (caps text 4000 / context 200 / blockIds 20×64, мусор → null, oversize-id ДРОПается, не режется). Нет XSS-стока: клиент берёт `doc.textBetween` (плейн-текст), сервер отдаёт JSON, `dangerouslySetInnerHTML` не добавлен. - **Логирования выделения нет.** - **Coherence.** End-to-end целостно, `{page}`→`{page,selection}` — чистый суперсет (ни один типизированный консьюмер не ждёт старую форму; модель — единственный консьюмер; `tool-tiers` обновлён, тула остаётся `core`). Контракт «hint, не ground-truth»: сервер нигде не доверяет выделению как edit-таргету; описание тулы велит локализовать через searchInPage/getNode. - **Stability.** Live-ref снапшот (`getEditorSelectionRef.current` переустанавливается каждый рендер, читается в `prepareSendMessagesRequest`) — как существующий `openPageRef`, без stale-closure; каждый ход шлёт свежее выделение. Edge-кейсы редактора обработаны (пустой каре́т/не-текст → null, мульти-блок, cap+truncate, clamp before/after; guard `isDestroyed`). Выделение вложено в `openPage` → умирает вместе со страницей. - **Test-coverage.** Все критичные пути покрыты: 5 deny-путей authz + явный «selection не переживает чужую/недоступную страницу», edge-кейсы sanitize (граница 64-vs-65), «флаг в промпте, но не текст», клиентский снапшот, интегрированный `getCurrentPage.execute()`. </details> <details> <summary><b>⛔ DROP — калибровочный лог (кодеру НЕ делать)</b></summary> - `[speculative]` `suggestion/low` **[stability]** `getEditorSelectionRef.current?.() ?? null` в `prepareSendMessagesRequest` без try/catch. Геттер верифицированно не бросает при известных путях (guard `isDestroyed`, валидный `EditorState`, позиции в границах), так что это защита от невозможного сегодня броска. Опциональное упрочнение (обернуть в try/catch → null, чтобы даже гипотетический бросок не рвал отправку) — но по порогу это ниже DO; ревьюер тоже «would not block». - `[below-threshold]` `suggestion/low` **[test-coverage]** нет теста на ровно 4000 символов текста (truncated должен остаться unset при `length === cap`); покрыты 5000 и короткий. - `[below-threshold]` `suggestion/low` **[test-coverage]** путь «выделение только по не-текстовым нодам → null» (`get-editor-selection.ts:44`) и строка :67 не покрыты. - `[below-threshold]` `suggestion/low` **[test-coverage]** колбэк `getEditorSelection` в `ai-chat-window.tsx` (edit-vs-readonly, `isDestroyed`) без юнит-теста (glue-код). - `[below-threshold]` `suggestion/low` **[test-coverage]** гарантия «свежий снапшот на каждую отправку» структурная, но не проверена на ДВУХ последовательных отправках (одиночная — проверена). </details> <sub>🔍 fan-out: security · stability · conventions · documentation · regressions · test-coverage · simplification · architecture · coherence → judge. Гейт (чистый клон-рум): server tsc 0 · jest 164 · client vitest 35. База develop `ebf132d5`.</sub>
agent_reviewer added review/approved and removed review/needs labels 2026-07-06 19:07:25 +03:00
vvzvlad merged commit 27f7791a0e into develop 2026-07-06 19:08:29 +03:00
Sign in to join this conversation.