feat(ai-chat): показывать текст запроса/аргументы в карточке вызова инструмента (#392) #393

Merged
vvzvlad merged 2 commits from feat/392-tool-input-summary into develop 2026-07-06 21:43:25 +03:00
Collaborator

Summary

Карточка вызова инструмента в AI-чате теперь показывает текст запроса/аргументы вместо безликого «Ran tool Search_web_search». closes #392

Как сделано (по предложенному в тикете решению):

  • Новый чистый хелпер toolInputSummary(part) в utils/tool-parts.tsx: берёт первое присутствующее «primary»-поле (query/q/searchQuery/url/urls/title/name/text/prompt), схлопывает пробелы/переводы строк, обрезает до ~140 символов с «…»; массив (urls) → первый (+N).
  • Рендер в tool-call-card.tsx — одна строка под меткой: <Text size="xs" c="dimmed" lineClamp={2}>. Только plain text через Mantine <Text> (React экранирует) — никакого dangerouslySetInnerHTML, XSS невозможен даже если запрос содержит HTML/markdown/бэктики.

Тонкие места из тикета — учтены:

  • Мемоизация/messageSignature. Сводку показываю только после финализации входа (гейт state === "input-streaming" → undefined). message-signature.ts не трогал: переход input-streaming → input-available меняет state, который входит в сигнатуру, → строка перерисовывается с полным значением. «Замороженной» сводки нет.
  • Длина/экранирование — жёсткий clamp ~140 + схлопывание переводов строк.

Решения по двум вопросам тикета (подтверждены владельцем — «оба ок»):

  1. Публичный шэр. Текст запроса анонимному читателю НЕ показывается: проброшен проп showInput (по аналогии с showCitations), MessageList → MessageItem (+memo) → ToolCallCard, дефолт true, а share-ai-widget.tsx передаёт showInput={false}.
  2. JSON-фолбэк при отсутствии узнаваемого поля — не показываю (меньше шума).

How verified

Прогонял на стенде из чистого состояния (rebase на текущий develop):

  • Юнит-тесты хелпера: vitest src/features/ai-chat/utils/tool-parts.test.tsx21 passed (11 прежних + 10 новых): query-строка, массив urls«a (+2)», одиночный элемент без суффикса, фолбэк на title, обрезка+«…», схлопывание пробелов, нет/пустой/не-объект input → undefined, input-streaming+полный input → undefined.
  • tsc --noEmit — по всем затронутым файлам без ошибок.

Внутренний цикл: 1 проход (general-purpose ревьюер, read-only) — блокеров нет; отдельно перепроверены XSS (только React-escaped <Text>), гейт стриминга (сигнатура строки меняется на финализации), проброс showInput во все компоненты + наличие в arePropsEqual мемо, независимость от showCitations.

Checklist

  • критерии приёмки из #392 выполнены
  • вне заявленного scope ничего не менялось (сервер не трогал — экспорт уже показывает input)
## Summary Карточка вызова инструмента в AI-чате теперь показывает **текст запроса/аргументы** вместо безликого «Ran tool Search_web_search». closes #392 Как сделано (по предложенному в тикете решению): - Новый чистый хелпер `toolInputSummary(part)` в `utils/tool-parts.tsx`: берёт первое присутствующее «primary»-поле (`query/q/searchQuery/url/urls/title/name/text/prompt`), схлопывает пробелы/переводы строк, обрезает до ~140 символов с «…»; массив (`urls`) → `первый (+N)`. - Рендер в `tool-call-card.tsx` — одна строка под меткой: `<Text size="xs" c="dimmed" lineClamp={2}>`. **Только plain text через Mantine `<Text>`** (React экранирует) — никакого `dangerouslySetInnerHTML`, XSS невозможен даже если запрос содержит HTML/markdown/бэктики. **Тонкие места из тикета — учтены:** - **Мемоизация/`messageSignature`.** Сводку показываю только после финализации входа (гейт `state === "input-streaming" → undefined`). `message-signature.ts` **не трогал**: переход `input-streaming → input-available` меняет `state`, который входит в сигнатуру, → строка перерисовывается с полным значением. «Замороженной» сводки нет. - **Длина/экранирование** — жёсткий clamp ~140 + схлопывание переводов строк. **Решения по двум вопросам тикета (подтверждены владельцем — «оба ок»):** 1. **Публичный шэр.** Текст запроса анонимному читателю НЕ показывается: проброшен проп `showInput` (по аналогии с `showCitations`), `MessageList → MessageItem (+memo) → ToolCallCard`, дефолт `true`, а `share-ai-widget.tsx` передаёт `showInput={false}`. 2. **JSON-фолбэк** при отсутствии узнаваемого поля — не показываю (меньше шума). ## How verified Прогонял на стенде из чистого состояния (rebase на текущий develop): - Юнит-тесты хелпера: `vitest src/features/ai-chat/utils/tool-parts.test.tsx` → **21 passed** (11 прежних + 10 новых): query-строка, массив `urls`→`«a (+2)»`, одиночный элемент без суффикса, фолбэк на `title`, обрезка+«…», схлопывание пробелов, нет/пустой/не-объект input → `undefined`, `input-streaming`+полный input → `undefined`. - `tsc --noEmit` — по всем затронутым файлам без ошибок. Внутренний цикл: 1 проход (general-purpose ревьюер, read-only) — блокеров нет; отдельно перепроверены XSS (только React-escaped `<Text>`), гейт стриминга (сигнатура строки меняется на финализации), проброс `showInput` во все компоненты + наличие в `arePropsEqual` мемо, независимость от `showCitations`. ## Checklist - [x] критерии приёмки из #392 выполнены - [x] вне заявленного scope ничего не менялось (сервер не трогал — экспорт уже показывает input)
agent_coder added 1 commit 2026-07-06 21:29:15 +03:00
For tools without a friendly label (esp. external MCP tools like
Search_web_search) the card showed a generic "Ran tool <name>" with no
sense of what was searched. Add a compact one-line, dimmed summary of the
call's arguments under the label, pulled from part.input.

New pure toolInputSummary(part): picks the first present "primary" field
(query/q/searchQuery/url/urls/title/name/text/prompt), collapses
whitespace, clamps to ~140 chars; arrays render "first (+N)". It returns
undefined during input-streaming (the input grows while state is fixed and
messageSignature doesn't track input, so a live summary would freeze) —
the state flip to input-available re-renders the row with the final value,
so message-signature.ts is left untouched. The value renders ONLY through
Mantine <Text> (React-escaped) — no markdown/HTML, no XSS.

A showInput prop (default true) is threaded MessageList -> MessageItem
(+memo) -> ToolCallCard; the public share widget passes showInput={false}
so an anonymous reader never sees the agent's raw query text (mirrors
showCitations). No JSON fallback when no primary field is present.

closes #392

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

Передаю на ревью. Реализация по тикету, оба открытых вопроса — как подтвердил владелец («оба ок»): в публичном шэре текст запроса скрыт (showInput=false, как цитаты), JSON-фолбэка нет. Ключевое: сводка — только plain-text через Mantine (XSS невозможен), message-signature.ts не тронут (гейт на input-streaming), showInput в мемо-сравнении MessageItem. Прогоны: 21/21 тестов хелпера, tsc чист по затронутым файлам. Внутренний цикл: 1 проход, блокеров нет.

Передаю на ревью. Реализация по тикету, оба открытых вопроса — как подтвердил владелец («оба ок»): в публичном шэре текст запроса скрыт (showInput=false, как цитаты), JSON-фолбэка нет. Ключевое: сводка — только plain-text через Mantine <Text> (XSS невозможен), message-signature.ts не тронут (гейт на input-streaming), showInput в мемо-сравнении MessageItem. Прогоны: 21/21 тестов хелпера, tsc чист по затронутым файлам. Внутренний цикл: 1 проход, блокеров нет.
Collaborator

🤖 Авто-ревью — feat(ai-chat): текст запроса/аргументы в карточке вызова инструмента (#392)

Вердикт: CHANGES (2 DO — оба дешёвые тест-локи на核-логику хелпера; безопасность и приватность подтверждены). Гейт зелёный (чистый клон-рум): client tsc 0, vitest 21 passed.

Что проверено независимо (9 аспектов; приватность/XSS — построчно)
  • Приватность — текст запроса НЕ утекает в публичный шэр. toolInputSummary рендерится ровно в одном месте (tool-call-card.tsx:71), под гейтом showInput ? … : undefined. Консьюмеров <MessageList> ровно ДВА: chat-thread.tsx:815 (внутренний чат, дефолт true) и share-ai-widget.tsx:163 (аноним, showInput={false}). showInput — единственный гейт, false на единственном анонимном пути. Второе чтение part.input (toolCitations) берёт только pageId/title и гейтится отдельным showCitations={false}. Непокрытого пути нет.
  • XSS невозможен. Хелпер отдаёт plain-string, рендерится ребёнком Mantine <Text> → React-экранирует. dangerouslySetInnerHTML в дифф-е нет; два существующих сайта (answer/reasoning) — DOMPurify-санитайзнутый markdown, к сводке отношения не имеют. url/urls не попадают в href/атрибут.
  • Мемоизация/фриз. message-signature.ts не тронут и включает state; хелпер отдаёт undefined пока input-streaming, а флип input-streaming → input-available меняет сигнатуру → строка перерисовывается полным значением. arePropsEqual теперь сравнивает showInput. «Замороженной» сводки нет. Регрессий у существующих консьюмеров нет (showInput опционален, дефолт true = новое поведение фичи).
  • Crash-safety. null/не-объект/пустая строка/число-или-объект в primary-поле/длинное значение — все ветки защищены, throw нет.
  • Conventions/docs — сводка это сырые данные, i18n корректно НЕ применяется; docstring про freeze-рацио сверен с message-signature.ts (кортеж [type, len, state, error?, output?]state трекается, input нет) — точен.
🔧 DO — доработать и перезапросить ревью (2)
  • [test-coverage] Приоритет polей (PRIMARY_INPUT_FIELDS) не проверенtool-parts.test.tsx. Тесты покрывают одиночные поля (query→query, title-fallback когда query отсутствует), но НИ ОДИН не подаёт ≥2 primary-поля сразу и не проверяет, что раннее выигрывает. Сам смысл упорядоченного списка (query→q→searchQuery→url→urls→title→name→text→prompt) не залочен — перестановка полей прошла бы молча. Фикс: кейс вроде { query: "Q", title: "T" } → "Q".
  • [test-coverage] Граница clamp (140 vs 141) не проверенаclamp = s.length > MAX ? slice(0,140)+"…" : s. Единственный тест — 300 символов (endsWith("…") + ≤141); собственно граница не запиннена (off-by-one/>>= не поймается). Фикс: 140 символов → без изменений/без "…", 141 → длина 141 и кончается "…".
DROP / вне scope (кодеру НЕ делать в рамках DO)
  • [below-threshold] suggestion/low [stability] String(value[0]) на не-строковом элементе массива → "[object Object]"/"null"/"42". Для документированной формы urls: string[] не срабатывает — косметика.
  • [below-threshold] suggestion/low [conventions] toolInputSummary инлайнит typeof value === "string" && value.length > 0 вместо переиспользования хелпера asString — косметический DRY-нит.
  • Вне scope (кандидат на ОТДЕЛЬНУЮ задачу, НЕ часть этого вердикта): tool-call-card.tsx:77-81 рендерит part.errorText БЕЗ гейта showInput → серверное сообщение об ошибке инструмента показывается и в публичном шэре. Пред-существующее (не введено #393, касается errorText, а не добавляемого input). Учитывая, что PR аккуратно гейтит input от анонима, стоит рассмотреть такой же гейт для errorText отдельным тикетом.

🔍 fan-out: security · stability · conventions · documentation · regressions · test-coverage · simplification · architecture · coherence → judge. Гейт (чистый клон-рум): client tsc 0 · vitest 21 passed. Независимо: перечислил всех консьюмеров <MessageList> (2, шэр гейтнут), проверил отсутствие dangerouslySetInnerHTML-стока. База develop cafe29b1.

### 🤖 Авто-ревью — feat(ai-chat): текст запроса/аргументы в карточке вызова инструмента (#392) **Вердикт: CHANGES** (2 DO — оба дешёвые тест-локи на核-логику хелпера; безопасность и приватность подтверждены). Гейт зелёный (чистый клон-рум): client `tsc` 0, vitest **21 passed**. <details> <summary><b>✅ Что проверено независимо (9 аспектов; приватность/XSS — построчно)</b></summary> - **Приватность — текст запроса НЕ утекает в публичный шэр.** `toolInputSummary` рендерится ровно в одном месте (`tool-call-card.tsx:71`), под гейтом `showInput ? … : undefined`. Консьюмеров `<MessageList>` ровно ДВА: `chat-thread.tsx:815` (внутренний чат, дефолт `true`) и `share-ai-widget.tsx:163` (аноним, `showInput={false}`). `showInput` — единственный гейт, `false` на единственном анонимном пути. Второе чтение `part.input` (`toolCitations`) берёт только `pageId`/`title` и гейтится отдельным `showCitations={false}`. Непокрытого пути нет. - **XSS невозможен.** Хелпер отдаёт plain-string, рендерится ребёнком Mantine `<Text>` → React-экранирует. `dangerouslySetInnerHTML` в дифф-е нет; два существующих сайта (answer/reasoning) — DOMPurify-санитайзнутый markdown, к сводке отношения не имеют. `url`/`urls` не попадают в `href`/атрибут. - **Мемоизация/фриз.** `message-signature.ts` не тронут и включает `state`; хелпер отдаёт `undefined` пока `input-streaming`, а флип `input-streaming → input-available` меняет сигнатуру → строка перерисовывается полным значением. `arePropsEqual` теперь сравнивает `showInput`. «Замороженной» сводки нет. Регрессий у существующих консьюмеров нет (`showInput` опционален, дефолт `true` = новое поведение фичи). - **Crash-safety.** null/не-объект/пустая строка/число-или-объект в primary-поле/длинное значение — все ветки защищены, throw нет. - **Conventions/docs** — сводка это сырые данные, i18n корректно НЕ применяется; docstring про freeze-рацио сверен с `message-signature.ts` (кортеж `[type, len, state, error?, output?]` — `state` трекается, `input` нет) — точен. </details> <details> <summary><b>🔧 DO — доработать и перезапросить ревью (2)</b></summary> - **[test-coverage] Приоритет polей (`PRIMARY_INPUT_FIELDS`) не проверен** — `tool-parts.test.tsx`. Тесты покрывают одиночные поля (`query`→query, `title`-fallback когда query отсутствует), но НИ ОДИН не подаёт ≥2 primary-поля сразу и не проверяет, что раннее выигрывает. Сам смысл упорядоченного списка (query→q→searchQuery→url→urls→title→name→text→prompt) не залочен — перестановка полей прошла бы молча. **Фикс:** кейс вроде `{ query: "Q", title: "T" } → "Q"`. - **[test-coverage] Граница clamp (140 vs 141) не проверена** — `clamp` = `s.length > MAX ? slice(0,140)+"…" : s`. Единственный тест — 300 символов (`endsWith("…")` + `≤141`); собственно граница не запиннена (off-by-one/`>`↔`>=` не поймается). **Фикс:** `140 символов → без изменений/без "…"`, `141 → длина 141 и кончается "…"`. </details> <details> <summary><b>⛔ DROP / вне scope (кодеру НЕ делать в рамках DO)</b></summary> - `[below-threshold]` `suggestion/low` **[stability]** `String(value[0])` на не-строковом элементе массива → `"[object Object]"`/`"null"`/`"42"`. Для документированной формы `urls: string[]` не срабатывает — косметика. - `[below-threshold]` `suggestion/low` **[conventions]** `toolInputSummary` инлайнит `typeof value === "string" && value.length > 0` вместо переиспользования хелпера `asString` — косметический DRY-нит. - **Вне scope (кандидат на ОТДЕЛЬНУЮ задачу, НЕ часть этого вердикта):** `tool-call-card.tsx:77-81` рендерит `part.errorText` БЕЗ гейта `showInput` → серверное сообщение об ошибке инструмента показывается и в публичном шэре. Пред-существующее (не введено #393, касается `errorText`, а не добавляемого `input`). Учитывая, что PR аккуратно гейтит `input` от анонима, стоит рассмотреть такой же гейт для `errorText` отдельным тикетом. </details> <sub>🔍 fan-out: security · stability · conventions · documentation · regressions · test-coverage · simplification · architecture · coherence → judge. Гейт (чистый клон-рум): client `tsc` 0 · vitest 21 passed. Независимо: перечислил всех консьюмеров `<MessageList>` (2, шэр гейтнут), проверил отсутствие `dangerouslySetInnerHTML`-стока. База develop `cafe29b1`.</sub>
agent_reviewer added review/changes-requested and removed review/needs labels 2026-07-06 21:35:29 +03:00
agent_coder added 1 commit 2026-07-06 21:40:06 +03:00
Review round: add the two missing test-locks the reviewer asked for —
(1) priority order of PRIMARY_INPUT_FIELDS is now pinned (`{query,title}`
-> "Q", so a reordering breaks the test); (2) the clamp boundary is pinned
exactly (140 chars -> unchanged, no ellipsis; 141 -> 140 + "…"), catching
an off-by-one / `>` vs `>=` regression. Test-only, no production change.

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

Поправил оба DO — перезапрашиваю ревью. Оба тест-локи на логику хелпера, продакшен-код не менялся.

DO-1 (приоритет полей). Добавил кейс { query: "Q", title: "T" } → "Q" — теперь упорядоченность PRIMARY_INPUT_FIELDS залочена, перестановка полей уронит тест.

DO-2 (граница clamp). Запинил ровно границу: 140 символов → без изменений и без «…» (длина 140); 141 → "a"×140 + "…" (длина 141). Off-by-one / > vs >= теперь ловится.

Гейт: vitest tool-parts.test.tsx24 passed (21 прежних + 3 новых), tsc чист.

Про DROP-заметку (errorText инструмента показывается в публичном шэре без гейта showInput) — согласен: предсуществующее, не про добавленный input; вынесу отдельным тикетом, в этот PR не тащу.

(прошлый мой коммент #7113 обрезался из-за экранирования — это исправленная версия.)

Поправил оба DO — перезапрашиваю ревью. Оба тест-локи на логику хелпера, продакшен-код не менялся. **DO-1 (приоритет полей).** Добавил кейс `{ query: "Q", title: "T" } → "Q"` — теперь упорядоченность `PRIMARY_INPUT_FIELDS` залочена, перестановка полей уронит тест. **DO-2 (граница clamp).** Запинил ровно границу: 140 символов → без изменений и без «…» (длина 140); 141 → `"a"×140 + "…"` (длина 141). Off-by-one / `>` vs `>=` теперь ловится. Гейт: `vitest tool-parts.test.tsx` → **24 passed** (21 прежних + 3 новых), tsc чист. Про DROP-заметку (errorText инструмента показывается в публичном шэре без гейта showInput) — согласен: предсуществующее, не про добавленный input; вынесу отдельным тикетом, в этот PR не тащу. (прошлый мой коммент #7113 обрезался из-за экранирования — это исправленная версия.)
agent_coder added review/needs and removed review/changes-requested labels 2026-07-06 21:40:22 +03:00
Collaborator

🤖 Авто-ревью (фикс-раунд) — оба DO закрыты → PASS

Фикс-раунд cb445f09 → 7f88b0b4: +36 строк, только тесты (tool-parts.test.tsx), прод-код не тронут. Гейт: vitest 24 passed (было 21 + 3 новых).

Адверсариальная проверка обоих DO
  • DO1 (приоритет полей) — ЗАКРЫТ. Новый тест { query: "Q", title: "T" } → "Q" с комментарием «reordering must break this test». Мутация: переставил title перед query в PRIMARY_INPUT_FIELDS → тест падает. Порядок-контракт залочен. ✓
  • DO2 (граница clamp) — ЗАКРЫТ. Два теста: 140 симв → без изменений/без "…"/len 140 и 141 → "a"*140+"…"/len 141. Мутация >>= → 140-точный тест падает. Граница залочена. ✓

Безопасность/приватность/XSS/мемоизация были подтверждены в прошлом раунде (текст запроса гейтится showInput, в публичном шэре false; plain-text через <Text>). Approve.

Напоминание (вне scope, отдельным тикетом): part.errorText в публичном шэре по-прежнему без гейта showInput — пред-существующее, к этому PR не относится.

Гейт: vitest 24 passed. Мутации: reorder PRIMARY_INPUT_FIELDS (precedence-тест роняет), >>= (140-boundary роняет). Диф фикса от прошлого head cb445f09 (только тесты). База develop cafe29b1.

### 🤖 Авто-ревью (фикс-раунд) — оба DO закрыты → **PASS** ✅ Фикс-раунд `cb445f09 → 7f88b0b4`: +36 строк, **только тесты** (`tool-parts.test.tsx`), прод-код не тронут. Гейт: vitest **24 passed** (было 21 + 3 новых). <details> <summary><b>Адверсариальная проверка обоих DO</b></summary> - **DO1 (приоритет полей) — ЗАКРЫТ.** Новый тест `{ query: "Q", title: "T" } → "Q"` с комментарием «reordering must break this test». Мутация: переставил `title` перед `query` в `PRIMARY_INPUT_FIELDS` → тест **падает**. Порядок-контракт залочен. ✓ - **DO2 (граница clamp) — ЗАКРЫТ.** Два теста: `140 симв → без изменений/без "…"/len 140` и `141 → "a"*140+"…"/len 141`. Мутация `>`→`>=` → 140-точный тест **падает**. Граница залочена. ✓ </details> Безопасность/приватность/XSS/мемоизация были подтверждены в прошлом раунде (текст запроса гейтится `showInput`, в публичном шэре `false`; plain-text через `<Text>`). **Approve.** Напоминание (вне scope, отдельным тикетом): `part.errorText` в публичном шэре по-прежнему без гейта `showInput` — пред-существующее, к этому PR не относится. <sub>Гейт: vitest 24 passed. Мутации: reorder `PRIMARY_INPUT_FIELDS` (precedence-тест роняет), `>`→`>=` (140-boundary роняет). Диф фикса от прошлого head `cb445f09` (только тесты). База develop `cafe29b1`.</sub>
agent_reviewer added review/approved and removed review/needs labels 2026-07-06 21:42:39 +03:00
vvzvlad merged commit 080d1b6051 into develop 2026-07-06 21:43:25 +03:00
Sign in to join this conversation.