feat(ai-chat): agent avatar stack — agent front, launcher behind (#300) #304
Open
agent_coder
wants to merge 2 commits from
feat/300-agent-avatar-stack into develop
pull from: feat/300-agent-avatar-stack
merge into: vvzvlad:develop
vvzvlad:main
vvzvlad:feat/git-sync
vvzvlad:refactor/294-tool-spec-registry
vvzvlad:feat/scroll-restore-ux
vvzvlad:fix/302-reasoning-parse-when-open
vvzvlad:develop
vvzvlad:feat/184-autonomous-agent-runs
vvzvlad:fix/responsive-tablet-sidebar
vvzvlad:feature/ai-chat-page-change-observability
vvzvlad:feature/offline-sync
vvzvlad:image-inline-center
vvzvlad:fix/283-short-remap-title
vvzvlad:fix/283-slash-layout
vvzvlad:image-inline-row
vvzvlad:feat/276-ai-chat-dock
vvzvlad:fix/269-table-menu-refocus
vvzvlad:docs/dev-stand-guide
vvzvlad:feat/266-scroll-position
vvzvlad:fix/260-collab-docname-slugid
vvzvlad:test/244-phase2-tail
vvzvlad:fix/262-reindex-progress-realtime
vvzvlad:fix/258-changelog-compare-links
vvzvlad:fix/244-dataloss-bugs
vvzvlad:feat/246-spoiler
vvzvlad:feat/221-image-captions
vvzvlad:test/244-part-b
vvzvlad:feat/251-intentional-clear
vvzvlad:fix/embeddings-reindex-progress
vvzvlad:refactor/193-tool-spec-registry
vvzvlad:fix/255-ws-redis-adapter-leak
vvzvlad:fix/252-e2e-open-handles
vvzvlad:feat/229-catalog-yaml
vvzvlad:feat/243-blob-sandbox
vvzvlad:feat/228-inline-footnotes
vvzvlad:fix/qa-ui-bugs-216-218
vvzvlad:feature/agent-roles-catalog
vvzvlad:fix/share-alias-rename
vvzvlad:fix/ai-chat-empty-render
vvzvlad:feat/191-chat-doc-binding
vvzvlad:feat/201-temporary-notes
vvzvlad:feat/198-interrupt-agent
vvzvlad:feat/ai-chat-full-history
vvzvlad:feat/199-ai-generate-title
vvzvlad:feat/205-share-aliases
vvzvlad:batch/issues-189-187-170
vvzvlad:feat/170-mcp-test-button
vvzvlad:feat/189-context-badge
vvzvlad:feat/198-interrupt-agent-send-now
vvzvlad:fix/issues-190-159
vvzvlad:fix/ai-chat-new-chat-during-stream
vvzvlad:fix/ai-chat-stream-perf
vvzvlad:batch/issues-2026-06-25
vvzvlad:feat/ai-chat-persistent-history
vvzvlad:fix/ai-chat-copy-chat-wysiwyg
vvzvlad:fix/ai-stream-reset-resilience
vvzvlad:fix/ai-stream-undici-timeout
vvzvlad:fix/footnote-review-1227-followup
vvzvlad:fix/ai-chat-token-counter-realtime
vvzvlad:docs/manual-qa-test-plan
No Reviewers
Labels
Clear labels
epic
needs-human
review/approved
review/changes-requested
review/needs
Large multi-phase effort spanning many changes
эскалация: нужно решение человека
в последнем ревью нет открытых blocking-находок
последнее ревью оставило открытые blocking-находки
head не ревьюился (head != reviewed_head)
No Label
review/approved
Milestone
No items
No Milestone
Projects
Clear projects
No project
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: vvzvlad/gitmost#304
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "feat/300-agent-avatar-stack"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Реализует #300: для контента, написанного AI-агентом (комментарии + история страницы), меняет иерархию аватарок — спереди аватар агента, за ним меньше аккаунт человека, который его запустил (вместо текстового бейджа
AI-AGENT). closes #300.Backend
Единый server-authoritative резолвер
resolveAgentProvenanceнормализует провенанс в{ agent, launcher }ТОЛЬКО из серверных колонок (createdSource/lastUpdatedSource,aiChatId,creator, роль чата) — ничего из тела запроса, так что личность агента не подделать. Кейсы: внутр. чат → agent = роль чата (name/emoji), launcher = человек; внешний MCP (aiChatId==null) → agent = агент-аккаунт, launcher = null; не-агент → оба поля опущены. Join роли (aiChatId → ai_chats.role_id → ai_agent_roles) НАМЕРЕННО без фильтраenabled/deleted_at— подпись исторического контента переживает выключение/удаление роли (какfindById, неfindLiveEnabled). Обогащение применено И вfindPageComments(список), И вfindById(путь broadcast create/resolve/update) — стек показывается на live-событиях и не пропадает при resolve/edit. Аналогично для истории (page-history.repo.ts).db.d.tsне тронут.Frontend
Новый
AgentAvatarStack+AgentGlyph(приоритет картинки:avatarUrl→ эмодзи роли на фиолетовом →IconSparklesна фиолетовом), интеграция вcomment-list-itemиhistory-itemна место бейджа; клик-переход в чат поaiChatIdперенесён на стек.ai-agent-badgeудалён. i18n-ключи (en/ru).How verified
AgentAvatarStack(роль/без-роли/MCP/клик/не-кликабельно); резолвер провенанса + recorder-тесты, доказывающие что join роли НИКОГДА не фильтруетenabled/deleted_at; обогащениеfindById(охраняет live-broadcast регрессию).tsc0,vitest7 passed; servertsc0,jest25 passed (провенанс+comment.repo+comment.service).commentCreated/resolve/edit; добавил обогащение вfindById, все три broadcast-события уже передаютincludeCreator:true.Checklist (DoD из #300)
aiChatIdcreatedSource !== 'agent'→ прежний одиночный аватар человекаagent/launcherиз подписанного провенанса (без спуфинга)Ревью — #304 (feat ai-chat: agent avatar stack — agent front, launcher behind, closes #300), round 1, head
0968ea97d, base develop (merge-basee648771ab)Вердикт: CHANGES — фича сделана хорошо, анти-спуфинг подтверждён по коду, объективка зелёная. Три DO: случайный симлинк-мусор + два реальных пробела покрытия на side-effecting путях (live-broadcast стек + page-history mapping).
Полный 9-аспектный веер. Объективка запущена мной (детач
0968ea97): clienttsc0 +vitest agent-avatar-stack5 passed; servertsc0 +jest agent-provenance/comment.repo/comment.service26 passed (4 suites).Подтверждено по коду (LGTM-аспекты)
createdSource='agent'/aiChatIdштампуются СЕРВЕРОМ через@AuthProvenance()-декоратор (читаетreq.raw.actor/aiChatId), которые ставятся ТОЛЬКО вjwt.strategyиз подписанного JWT (actor='agent'требует DB-колонкуuser.isAgent); agent-provenance-токены минтятся лишь внутри настоящего internal-agent execution. Клиентские DTO не содержат source-полей, сервис не спредит DTO в строку.resolveAgentProvenanceпотребляет ТОЛЬКО server-authoritative колонки. Подделать «написано агентом X» нельзя. Утечки нет (launcher = уже-отдаваемый creator). Роль-join без enabled/deleted_at безопасен (role_idиммутабелен после создания чата → read-join == snapshot; hard-delete → graceful fallbackAGENT_FALLBACK_NAME).{agent,launcher}течёт в список (findPageComments) И во все 3 broadcast (create/resolve/update); shape консистентен comment.repo↔page-history.repo (общий resolver); non-agent контент рендерит прежний одиночный аватар БАЙТ-идентично; удаление ai-agent-badge без висящих импортов; click-переход по aiChatId сохранён; join — scalar-subquery (нет N+1/не множит строки); null-safe все комбо; резолвер задокументирован ровно там, где опасная будущая правка (анти-спуфинг + no-filter-join). Резолвер и no-filter-join — правильные структурные решения, эскалации нет.Do — apply these, then re-review
packages/mcp/node_modules/node_modules. Закоммичен ЭТИМ PR (new file mode 120000,+1): self-referential симлинк на АБСОЛЮТНЫЙ build-machine путь/home/claude/gitmost/packages/mcp/node_modules, НЕ gitignored (git check-ignore— пусто). Артефакт pnpm, бесполезен в репо, течёт домашний путь разработчика. Fix:git rm packages/mcp/node_modules/node_modules+ добавить ignore-правило чтобы не рекоммитился. (Остальной trackedpackages/mcp/node_modules/*— пред-существующий, вне scope.)comment.service.ts:123/278/214. Регрессия «стек агента пропадает на live-событии» УЖЕ случалась раз (internal review поймал, поэтому добавили findById-обогащение). Сейчас это запиннено на repo-уровне (bare findById НЕ обогащает;includeCreator— обогащает), но КОНТРАКТ вызывающих не покрыт:commentCreated/commentResolvedре-фетчат enriched findById (ок по чтению), аcommentUpdated(:214-218) НЕ ре-фетчит — ре-эмитит контроллер-загруженный объект, мутируя in-place; обогащение выживает ТОЛЬКО потому чтоcomment.controller.ts:132загрузил сincludeCreator:true. Если будущий вызывающий уронитincludeCreatorили update перестанет сохранять enriched-поля — стек молча исчезнет на edit, ни один тест не поймает. Fix: сервис/caller-level тест, что ВСЕ ТРИ broadcast несутagent/launcher(спотлайтcommentUpdated-путь load-enriched-then-mutate). Альтернатива/дополнение: ре-загрузить enriched вupdate()для симметрии с create/resolve (тогда не зависит от caller pre-load).attachPageHistoryAgentmapping —page-history.repo.ts:190. Новая enrichment-функция проводит ДРУГОЙ набор колонок (lastUpdatedSource/lastUpdatedAiChatId/lastUpdatedBy) vs comment (createdSource/aiChatId/creator), но покрыта лишь транзитивно через общий resolver. Copy-paste-ошибка в проводке (напр. чтениеcreatedSourceвместоlastUpdatedSource) дала бы неверную identity-атрибуцию И прошла бы ВСЕ текущие тесты. Fix: маленький тест, ассертящий что page-history mapping читаетlastUpdated*-колонки.⛔ DROP — кодеру НЕ делать · калибровочный лог (для оператора)
[below-threshold]low[security] роль-join без явногоworkspaceId-предиката (comment.repo.ts:26,page-history.repo.ts:25) — cross-workspace утечка имени/эмодзи роли требовала бы ПРЕД-существующего integrity-нарушения (chat в WS-A ссылается на роль WS-B), которое PR не создаёт (aiChatIdserver-stamped,roleIdвыбран в пределах WS). Не эксплойт этого PR; опц.-харден добавить WS-scoped предикат.[out-of-scope]info[test-coverage] write-side анти-спуф (штамп колонок из подписанногоAuthProvenanceData, не из тела) не тестируется — но весь write-path ПРЕД-существующий, не в диффе #304.[style/linter]info[conventions/arch]comment.types/page.typesимпортятAgentInfo/LauncherInfoИЗ UI-компонента (инвертированная зависимость, type-only/erased); + дублированы в serveragent-provenance.ts(держать в синке руками); findByIdas Comment-каст скрывает enriched-shape (type-honesty); redundantdefault export; inline-styles vs CSS-module; i18n interp-var{{role}}/{{person}}vs{{name}};agent-provenance.tsflat-file вrepos/(не в subfolder). Всё — polish, пред-существующие паттерны/type-only.F1: remove an accidentally-committed self-referential symlink packages/mcp/node_modules/node_modules -> an absolute build-machine path (leaked a dev home path, a pnpm artifact useless in the repo), and add a targeted ignore so it can't recommit. F2: the commentUpdated broadcast re-emitted the caller's pre-loaded comment mutated in place, so the {agent,launcher} stack survived only because the controller happened to load it with includeCreator:true — the fragile coupling that let the stack vanish on edit once already. update() now RE-FETCHES the enriched comment before broadcasting, symmetric with create()/resolveComment() (the row is already persisted), so all three broadcasts carry the stack regardless of any caller's pre-load. Adds a caller-contract test asserting all three broadcasts emit agent/launcher for an agent comment and neither for a non-agent one, spotlighting the update path (non-vacuous vs the old re-emit). F3: add a direct test of the page-history attachPageHistoryAgent mapping (its distinct lastUpdatedSource/lastUpdatedAiChatId/lastUpdatedBy column set): role / no-role / MCP / non-agent, and that the internal agentRole join column is stripped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>F1/F2/F3 закрыты, коммит
86c1307e.F1: fixed — снёс случайно закоммиченный self-referential симлинк
packages/mcp/node_modules/node_modules→ абсолютный build-путь (git rm --cached+ удалил файл), добавил таргетный ignore (packages/mcp/.gitignore:node_modules/node_modules), чтобы не рекоммитился. Остальной trackedpackages/mcp/node_modules/*не трогал (пред-существующий).F2: fixed — по твоему замечанию сделал РОБАСТНО, а не только тест:
commentUpdatedтеперь РЕ-ФЕТЧИТ обогащённый комментарий перед broadcast (findById(id, {includeCreator, includeResolvedBy})), симметричноcreate()/resolveComment().updateComment()уже персистнут выше, так что ре-рид несёт и новый контент, и{agent,launcher}. Убрал хрупкую связку «стек выживает только потому что контроллер загрузил с includeCreator:true» — теперь broadcast не зависит от пре-лоада вызывающего. + caller-contract тест (comment.service.provenance-broadcast.spec.ts): все ТРИ broadcast (commentCreated/commentUpdated/commentResolved) несутagent/launcherдля agent-коммента и ни одного для не-agent; спотлайт на update-путь (ему подаётся НЕобогащённый вход, ассертит что ре-фетч случился). Не-вакуозно: откат update() на re-emit мутированного входа роняет update-тест.F3: fixed — прямой тест
attachPageHistoryAgent(page-history.repo.spec.ts) на его ДРУГОЙ набор колонок (lastUpdatedSource/lastUpdatedAiChatId/lastUpdatedBy): роль/без-роли/MCP/не-agent + что внутренняяagentRole-колонка срезается. Не-вакуозно (identity-map роняет все 4).Проверка (apps/server):
tsc0 по затронутым;jest(comment + repos/comment + agent-provenance + page-history) — 36/36. review/needs.Ре-ревью — #304 (agent avatar / provenance stack), round 2, head
86c1307e, base developДельта с round-1 head
0968ea97: 408 строк, 5 файлов (comment.service.ts +20, comment.service.provenance-broadcast.spec.ts +237 НОВЫЙ, page-history.repo.spec.ts +107, packages/mcp/.gitignore +1, снят симлинк packages/mcp/node_modules/node_modules). Цель round-2 — закрыть три round-1 находки F1/F2/F3.Вердикт: PASS — все три находки закрыты по-настоящему (сверено по коду), объективка зелёная. Ничего для кодера.
Целевой веер (5 аспектов: stability, test-coverage, coherence, regressions, security+conventions) — все LGTM. Объективка запущена мной (детач
86c1307e, editor-ext пересобран): server jestcomment.service.provenance-broadcast+page-history.repo→ 2 suites, 10 tests passed.Закрыто (сверено по коду + прогоны)
packages/mcp/node_modules/node_modulesреально снят из дерева (lsфейлит,git ls-filesпусто) иgit rm'нут в дельте.packages/mcp/.gitignore: node_modules/node_modules— паттерн слэш-заякорен наpackages/mcp/, матчит ровно этот self-referential путь и блокирует рекоммит; ничего реального npm так не называет, остальные 60 vendored-node_modules (пред-существующие) не тронуты. Per-package.gitignore— принятая конвенция монорепо (apps/server, apps/client, editor-ext уже имеют свои). Скоуп верный, не band-aid.update()(comment.service.ts:210-230) теперь РЕ-ФЕТЧИТfindById(comment.id, {includeCreator:true, includeResolvedBy:true})ПОСЛЕ персиста (updateCommentawait'нут на:190) и броадкастит/возвращаетupdatedComment. Байт-в-байт симметричноcreate()(:123) иresolveComment()(:288). Хрупкая связка «стек выживает только потому что контроллер пред-загрузил с includeCreator» РЕАЛЬНО убрана — броадкаст читает собственную обогащённую загрузку, не caller-объект. Один лишний single-row PK-findById на правку (та же цена, что create/resolve уже платят) — не hot-path (правка коммента — человеческая), не N+1, один броадкаст. Return-контракт не изменён (контроллер:132-135и раньше грузил с теми же флагами → тот же shape в HTTP-ответе и в ws-payload, бэк-совместимо). Тестcomment.service.provenance-broadcast.spec.tsНЕ-ВАКУОЗЕН: update-спотлайт подаёт НЕобогащённый вход и жёстко ассертитfindById.toHaveBeenCalledWith('comment-new', {includeCreator,includeResolvedBy})+{agent,launcher}на событии — откат к старому re-emit-in-place роняет оба ассерта. Все 3 броадкаста (created/updated/resolved) покрыты agent- и non-agent-кейсами.page-history.repo.spec.tsгоняет маппинг через publicfindPageHistoryByPageId(метод module-private — верный подход), ассертит ДРУГОЙ набор колонокlastUpdatedSource/lastUpdatedAiChatId/lastUpdatedByна всех 4 provenance-формах + чтоagentRoleсрезается. Мис-wire наcreatedSource/createdByили на дискриминаторaiChatId— провабельно упал бы (WITH-role кейс ловит подмену дискриминатора). Не-вакуозен.comment.creatorId !== authUser.id→ Forbidden,:182) +validateCanComment(controller:145) отрабатывают ДО персиста/ре-фетча;comment.id— server-загруженный авторизованный id. Provenance по-прежнему только из server-authoritative подписанных колонок (createdSource/aiChatId + agentRole-join), никогда из request-body. Опции ре-фетча байт-идентичны тому, что контроллер уже грузил → та же экспозиция, не больше.⛔ DROP — кодеру НЕ делать · калибровочный лог (для оператора)
[out-of-scope]low/medium[stability] TOCTOU: ре-фетчupdatedCommentможет вернутьnull, если строку удалят между guard-load контроллера и ре-фетчем → броадкаст{comment:null}—comment.service.ts:219-230. НО тот же непроверенный паттерн уже вcreate()(:123) иresolveComment()(:288), принят в round-1; emit-аргументы (space/pageId) берутся из non-null caller-объекта → NPE нет, только payload null в узком гоночном окне. Не новый класс риска, симметричен соседям. Не блокер; при желании — отдельный follow-up на null-guard во всех трёх.View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.