| 1 | # ADR 0134: Intercom — единый prepare-pipeline сообщений (wire, presentation, commit) |
| 2 | |
| 3 | **Статус:** Accepted · Implemented |
| 4 | **Дата:** 2026-05-20 |
| 5 | |
| 6 | ## Связанные ADR |
| 7 | |
| 8 | | ADR | Роль | |
| 9 | |-----|------| |
| 10 | | [0128](0128-intercom-attachment-anchors-and-code-references.md) | `AttachmentAnchor`, маркеры `⟦a:id⟧`, reveal, degraded outcomes | |
| 11 | | [0129](0129-intercom-message-body-markdown-and-fenced-code.md) | Fenced code в `content` — отдельно от attach | |
| 12 | | [0119](0119-chat-slash-commands-intercom-surface.md) | `IntercomOutboundSendOrchestrator`, фазы send | |
| 13 | | [0123](0123-intercom-full-skia-surface-evolution.md) | Flat feed, Skia-лента | |
| 14 | | [0045](0045-agent-chat-persistence-event-log-and-projections.md) | Event log: `content` + `attachments[]` | |
| 15 | | [0135](0135-intercom-attach-symbol-cache-and-hci-sidecar.md) | L1/L2 кэш member→lines; HCI sidecar после reindex | |
| 16 | |
| 17 | ## Резюме |
| 18 | |
| 19 | Зафиксировать **один пайплайн подготовки** исходящего/входящего в ленту сообщения Intercom: |
| 20 | |
| 21 | 1. **Prepare** (фон, Roslyn) — parse → resolve по attach → **wire** `content` с `⟦a:id⟧` → **presentation** для Skia. |
| 22 | 2. **Commit policy** — единые правила для composer send и `send_chat` (MCP): не коммитить «сырой» текст без маркеров при failed prepare. |
| 23 | 3. **Degraded resolve** — один неудачный member не роняет всё сообщение: anchor + marker + `resolveOutcome` (ADR 0128 §9.1). |
| 24 | 4. **Тупой рендер** — Skia читает `IntercomFeedPresentation`, hit только на `AttachLink`-сегментах; выделение сообщения не перекрывает ссылки. |
| 25 | |
| 26 | **Не цель:** отдельный WPF `BackgroundWorker`; уже достаточно `Task.Run` / `IntercomAttachmentResolveAtSendWorker`. |
| 27 | |
| 28 | --- |
| 29 | |
| 30 | ## Проблема (as-is) |
| 31 | |
| 32 | | Симптом | Причина | |
| 33 | |---------|---------| |
| 34 | | MCP-сообщение с `[F:… M:…]` без клика | `AppendMessageFromMcpAsync` при `!TryBuild` всё равно пишет **сырой** `content`, `attachments = []` | |
| 35 | | Клик выделяет сообщение | Нет attach-hit: нет маркеров → нет attach-сегмента → только `CreateHit` на всё сообщение | |
| 36 | | Нет подчёркивания | Attach-сегмент не строится; prose идёт через RTK/markdown без `BodyTone.Link` | |
| 37 | | Зависание UI (исправлено ранее) | Синхронный build на UI; вынесено в worker | |
| 38 | |
| 39 | Composer send при ошибке build **не** коммитит (orchestrator). MCP — **другая политика**. Skia **повторно** парсит `content`+`attachments` с правилами, отличными от builder. |
| 40 | |
| 41 | --- |
| 42 | |
| 43 | ## Решение |
| 44 | |
| 45 | ### 1. Слои |
| 46 | |
| 47 | ```text |
| 48 | RawInput |
| 49 | → Prepare (background) |
| 50 | Parsed body segments (prose | code) |
| 51 | Per-attach resolve (strict → degraded) |
| 52 | Wire: Content + Attachments[] |
| 53 | Presentation: IntercomFeedPresentation |
| 54 | → Commit (UI, policy) |
| 55 | → Project / Render (UI thread, dumb) |
| 56 | → Reveal @ click (re-resolve получателя, ADR 0128 §8) |
| 57 | ``` |
| 58 | |
| 59 | | Слой | Владелец | Хранится в event log | |
| 60 | |------|----------|----------------------| |
| 61 | | Wire | `ChatMessageViewModel.Content` | да | |
| 62 | | Attachments | `ChatMessageViewModel.Attachments` | да | |
| 63 | | Presentation | вычисляется `IntercomFeedProjector` @ commit/load | нет (детерминирован от wire) | |
| 64 | | Agent envelope | `IntercomAttachmentPromptFormatter` | только prompt, не лента | |
| 65 | |
| 66 | ### 2. Типы |
| 67 | |
| 68 | - `IntercomMessagePrepareStatus`: `Success`, `PartialSuccess`, `Failed`. |
| 69 | - `IntercomMessageCommitPolicy`: `Strict` (composer), `Strict` (MCP inject) — v1 одинаково. |
| 70 | - `PreparedIntercomMessage`: status, warnings, error, `Outbound`, `FeedPresentation`. |
| 71 | - `IntercomFeedSegmentKind`: `Prose`, `Code`, `AttachLink` (+ `AnchorId`, `Stale`). |
| 72 | |
| 73 | ### 3. Prepare API |
| 74 | |
| 75 | ```csharp |
| 76 | IntercomOutboundMessagePreparer.PrepareAsync( |
| 77 | raw, pending, editor, workspace, solution, ct) |
| 78 | → PreparedIntercomMessage |
| 79 | ``` |
| 80 | |
| 81 | Вызывают: |
| 82 | |
| 83 | - `IntercomOutboundSendOrchestrator` (user send), |
| 84 | - `AppendMessageFromMcpAsync` (`send_chat` assistant), |
| 85 | - `IntercomAttachmentResolveAtSendWorker` (тонкая обёртка над preparer). |
| 86 | |
| 87 | ### 4. Degraded resolve @ send |
| 88 | |
| 89 | При неудаче Roslyn для `member` / `syntaxScope`: |
| 90 | |
| 91 | - всё равно вставить `⟦a:id⟧` и `AttachmentAnchor` с `file`, `memberKey`, `displayLabel`, `excerpt` (если есть); |
| 92 | - `resolveOutcome` = `member_not_found` (или аналог); |
| 93 | - `lineStart`/`lineEnd` опционально пусты; |
| 94 | - статус prepare = `PartialSuccess`, предупреждение в UI. |
| 95 | |
| 96 | Reveal у получателя делает re-resolve (уже `IntercomAttachmentRevealPlanner`). |
| 97 | |
| 98 | ### 5. Commit policy |
| 99 | |
| 100 | | Prepare status | Composer send | MCP `send_chat` assistant | |
| 101 | |----------------|---------------|---------------------------| |
| 102 | | `Failed` | не коммитить, статус ошибки | вернуть ошибку, **не** добавлять в ленту | |
| 103 | | `PartialSuccess` | коммит + предупреждение | коммит + предупреждение | |
| 104 | | `Success` | коммит | коммит | |
| 105 | |
| 106 | ### 6. Skia / hit-test |
| 107 | |
| 108 | - `IntercomFeedProjector.Project(content, attachments)` — единая логика сегментов. |
| 109 | - **`SkiaChatHitRegistry`** — единственный реестр pointer hit на кадр (`_chatHits.Clear()` в начале `DrawSkiaScene`). |
| 110 | - **Приоритет `FindIndex`:** `RevealAttachment` (attach chip, markdown link) → `SkiaChatPointerAction` (chrome: overview, composer, slash) → z-order. |
| 111 | - **`SkiaChatDrawContext.RegisterHit`** → `RegisterContentRect` (content + `scrollOffset`); chrome — `RegisterControlRect` (`SkiaChatHitGeometry`). |
| 112 | - **Диспетчер:** `SkiaChatSurfaceControl.PointerDispatch.cs` — `TryDispatchPointerPress` / `TryDispatchPointerWheel` (не разрозненные `Contains` по полям). |
| 113 | - **Лента:** `SkiaChatMessageFeedEntity.CreateHit` → **null**; узкие hits только в `Draw` (attach chip, markdown link, row select). |
| 114 | - **Markdown link:** `RegisterFeedMarkdownLinkHits` (markdown-lines) или `RegisterFeedMarkdownLinkHitsFromText` (RTK/plain prose). |
| 115 | - **Composer / overview:** `RegisterComposerPointerHits`, `registerChromePointerHits` — `SkiaChatPointerAction`. |
| 116 | - **`CreateHit` на entity** — только цельные плитки (topic card, slash block), не feed-сообщения. |
| 117 | - `ParseInline`: при `[` без code-ref — продвинуть `i`, не зацикливаться. |
| 118 | |
| 119 | ### 7. Reveal |
| 120 | |
| 121 | Клик из ленты → `IntercomAttachmentNavigator.Apply` (без MCP roundtrip с UI-потока) — ADR 0130 / уже в коде. |
| 122 | |
| 123 | --- |
| 124 | |
| 125 | ## Фазы внедрения (этот ADR) |
| 126 | |
| 127 | | Шаг | Содержание | |
| 128 | |-----|------------| |
| 129 | | 1 | ADR 0134 + типы + `IntercomFeedProjector` | |
| 130 | | 2 | `TryPrepare` + degraded resolve | |
| 131 | | 3 | `IntercomOutboundMessagePreparer`, единый commit | |
| 132 | | 4 | Orchestrator + MCP append | |
| 133 | | 5 | Skia presentation + hit (chip: рамка/статус, без underline) | |
| 134 | |
| 135 | --- |
| 136 | |
| 137 | ## Последствия |
| 138 | |
| 139 | - Старые сообщения в логе без маркеров: projector fallback только если есть `attachments[]`; иначе prose как текст (без клика). |
| 140 | - Persistence schema не меняется (presentation не в event log). |
| 141 | - Тесты: builder partial/degraded, projector attach segment, MCP append fail-closed. |
| 142 | |
| 143 | ## Критерии приёмки |
| 144 | |
| 145 | 1. `send_chat` с `[F:path M:Member]` при открытом solution → в ленте `[Member]` с подчёркиванием, клик → reveal. |
| 146 | 2. При полном fail prepare MCP возвращает ошибку, в ленте нет сырой bracket-строки без маркера. |
| 147 | 3. Composer send при fail prepare не добавляет сообщение (как раньше). |
| 148 | 4. Shift+клик по attach → select в редакторе. |
| 149 | |