Перейти к основному содержимому

ADR 0124 — Группировка состояния канала хода, извлечённая в useTurnoDoAgente

  • Статус: принят
  • Дата: 2026-08-30
  • Ссылки (без правки): ADR 0122 (этот ADR — тот «собственный ADR, позже, с отдельным номером», который раздел Последствий 0122 назвал и отложил); PR механической дедупликации, на который опирается это извлечение (refactor(web): termina a deduplicação do arme de turno no SessionPage.tsx, слит до этого ADR, без собственного ADR — упомянут здесь как предпосылка, по тому же образцу, по которому 0122 ссылался на ADR 0121, не редактируя его).

Контекст​

ADR 0122 намеренно оставил группировку состояния канала хода вне своей механической декомпозиции SessionPage.tsx на пять PR, назвав её «переплетённым потоком управления, [...] проектным решением о том, где должно жить состояние жизненного цикла хода, а не механическим перемещением» и назвав turnoViaCanal, statusAgent, pensandoVisivel, atividadeDoTurno «читаемыми И записываемыми четырьмя разными обработчиками входа хода (handleSend, handleReadiness, handleArchitectureReadiness, handleAcceptHandoff)». Это отложенная работа.

Перечитывание файла построчно для очерчивания рамок показало, что собственный подсчёт ADR 0122 оказался ниже реального: он называет четыре обработчика входа хода, но реальных точек входа записи в этой группировке семь. Три названных плюс handleAcceptHandoff — это четыре; остальные три — это сам эффект канала (обработчики onAgentDelta/onToolCall/onAgentDone/ onAgentStatus/onEvent из connectSessionHeartbeat — один useEffect, но поверхность записи не менее реальная, чем любой обработчик), callback StructuredQuestionCard (onTurnoIniciado/onTurnoTerminado, связанный с AnswerStructuredQuestionUseCase) и handleReturnStory (RN-174, ReturnStoryUseCase → reviseStory в po_server) — который ADR 0122 ни разу не упомянул, хотя он уже тогда корректно вызывал iniciarTurnoDoAgente/finalizarTurnoDoAgente.

PR механической дедупликации, предшествующий этому (уже слит), закрыл второй пробел, найденный в том же проходе: iniciarTurnoDoAgente/ finalizarTurnoDoAgente уже существовали как полная пара useCallback, но их вызывали только две из семи точек входа — handleSend, handleReadiness и handleArchitectureReadiness каждый дублировали одно и то же пятистрочное взведение inline вместо вызова существующей функции. Тот PR заменил три дубликата вызовами iniciarTurnoDoAgente (которая получила там параметр { comStatus } — см. Решение ниже) и ввёл cancelarTurnoOtimista. PR этого ADR — вторая половина: перенести уже дедуплицированное состояние, эффект канала и три функции жизненного цикла из SessionPage.tsx в хук.

Решение​

Новый модуль apps/web/src/lib/session-turno.ts, экспортирующий хук:

function useTurnoDoAgente(
projectId: string,
sessionId: string,
sessionStatus: string | undefined,
queryClient: QueryClient,
) {
return {
// reads
streaming, streamingText, streamingAgent, turnoViaCanal, statusAgent,
pensandoVisivel, atividadeDoTurno, optimisticUser,
// imperative API
iniciarTurnoDoAgente, finalizarTurnoDoAgente, cancelarTurnoOtimista,
// raw setters/ref — two call sites, and only two
setStreaming, setStreamingText, setOptimisticUser,
setTurnoViaCanal, turnoAgentRef,
};
}

Объект, а не кортеж — 13 полей; позиционная деструктуризация была бы нечитаемой и хрупкой к переупорядочиванию. SessionPage.tsx вызывает его один раз, сразу после запроса session, от которого он зависит ради sessionStatus, и деструктурирует каждое поле под тем же именем, что уже было, — каждая точка чтения в JSX, каждое тело обработчика и затвор refetchInterval у useSessionEvents (который читает streaming) продолжают работать без изменений.

До этого в коде не было прецедента хука, который одновременно владеет состоянием и императивным API: useAutoCollapseSidebar (lib/sidebar-state.ts) возвращает void — это только зарегистрированный эффект. useSessionReadiness (lib/session-readiness.ts, ADR 0122 PR 5) — чистая функция двух параметров, без собственных useState/эффекта. Ни один из них не является образцом, которому следует этот хук; это сказано здесь явно, а не намёком на «существующий образец».

Почему эффект канала переносится целиком. useEffect, вызывающий connectSessionHeartbeat, — седьмая точка входа записи, названная выше, и он построчно проверен как на 100% механизм жизненного цикла хода — даже опережающий refetch в onEvent читает streamingRef (часть этой же группировки), чтобы решить, запускаться ли. Ничто в эффекте не выходит за пределы группировки, поэтому он переносится с полным массивом зависимостей, без изменений.

Почему cancelarTurnoOtimista покрывает только две из пяти форм, которыми этот файл отменяет взведение. Существует пять разных блоков сброса-при-отказе, разбросанных по обработчикам, в трёх разных формах, а не одна форма, повторённая пять раз:

  1. Предпроверка handleSend (startAgent('criativo') падает ещё до того, как агент вообще известен): 2 поля (streaming, optimisticUser) — остальное так и не взводилось. Остаётся двумя сырыми вызовами setState в SessionPage.tsx, использующими setStreaming/setOptimisticUser, которые хук выставляет ровно для этого.
  2. Ветка агента в handleSend: 4 поля полного взведения плюс optimisticUser, которого само взведение никогда не касается (он задаётся безусловно в начале handleSend, ещё до того, как агент разрешён). Становится cancelarTurnoOtimista(); setOptimisticUser(null); — два вызова, а не одна большая функция, потому что жизненный цикл optimisticUser принадлежит handleSend, а не паре взвести/снять.
  3. handleReadiness: 4 поля (streaming, turnoAgentRef, statusAgent, turnoViaCanal) — ровно то, что покрывает cancelarTurnoOtimista.
  4. handleArchitectureReadiness: блок, ИДЕНТИЧНЫЙ блоку handleReadiness, — то же покрытие.
  5. handleAcceptHandoff: 2 поля (turnoAgentRef, turnoViaCanal) — он вообще никогда не взводит streaming/statusAgent, потому что его kickoff — это асинхронный GenServer.cast в engine (находка B): обработчик в момент клика ещё не знает, что начнётся реальный ход, знает только, кто будет отвечать. Вызов здесь cancelarTurnoOtimista молча связал бы обработчик, который никогда не трогал streaming/statusAgent, с функцией, чьё имя обещает отменить оба, — та же ловушка, на которую ADR 0122 уже указал для группировки в целом. Этот блок остаётся inline в handleAcceptHandoff, ровно как всегда был, используя setTurnoViaCanal/turnoAgentRef, которые хук выставляет для этой единственной точки вызова.

cancelarTurnoOtimista сделана, чтобы покрывать ровно №3 и №4 — два идентичных блока — и ничего больше. Шестая функция, покрывающая также №5, рассматривалась и была отвергнута: она существовала бы ради одного вызывающего, дублируя то, что две сырые операции уже говорят напрямую.

Почему два PR, а не один. Механическая дедупликация (три дублированных взведения заменены вызовами уже существовавшей iniciarTurnoDoAgente, два идентичных блока отказа заменены новой cancelarTurnoOtimista) и извлечение модуля (состояние, эффект канала и три функции перемещены в lib/session-turno.ts) — это разные виды риска. Первый — «это, вызов за вызовом, то же самое, что пять строк, которые он заменяет», ревьюится по diff за минуты. Второй — «замыкания на queryClient/projectId/sessionId и массивы зависимостей эффектов переживают смену области модуля», и это нужно было проверить отдельно, не конкурируя за внимание с «эта версия взведения тонко отличается от той». Первый PR был слит до начала второго.

Последствия​

  • SessionPage.tsx уходит с 2 681 до 2 479 строк (wc -l) — объявления состояния, документированные JSDoc iniciarTurnoDoAgente/ finalizarTurnoDoAgente/cancelarTurnoOtimista, эффект канала и эффект взведения/снятия pensandoVisivel уходят в lib/session-turno.ts (341 строка, включая комментарии — та же плотность, что у заменяемого, потому что вместе с кодом переместилось рассуждение, обосновывавшее каждое поле и каждую ветку, а не только код).
  • Это закрывает пункт группировки канала хода, который раздел «Явно вне рамок» ADR 0122 отложил, именно его. ProjectSettingsTab.tsx — другой файл, названный в той же строке техдолга, что и SessionPage.tsx, — этим ADR не затронут и остаётся полностью отдельным решением о рамках, по-прежнему открытым, без даты; ничто здесь не меняет его состояния.
  • Девять тестов, названных покрытием регрессии этой группировки (SessionPage.pista-e-status, .cancelar-turno, .turno-preso, .readiness-turno-preso, .handoff-devlead-e-colapso, .ideacao-automatica, .arquiteto-modelo-icone, .agente-mais-recente, .promocao-inline-e-volta — последний единственный файл, покрывающий handleReturnStory/RN-174, который ADR 0122 ни разу не назвал), все проходят без правки, вместе со всем набором SessionPage.*.test.tsx, доказывая, что изменение не повлияло ни на какое наблюдаемое поведение.
  • Находка о пяти формах выше (Решение, «почему cancelarTurnoOtimista покрывает только две из пяти») теперь записана один раз, здесь, а не живёт только в комментариях кода — будущее изменение любого из пяти блоков сброса имеет этот ADR как ссылку на то, почему сегодня это не одна и та же функция.

Отвергнутые альтернативы​

  • Шестая функция, покрывающая сброс 2 полей в handleAcceptHandoff (блок №5 выше), чтобы каждый обработчик вызывал именованную функцию вместо двух сырых операций. Отвергнуто: у неё был бы ровно один вызывающий, а две строки, которые она обернула бы (turnoAgentRef.current = null; setTurnoViaCanal(false);), уже говорят, что делают, без промежуточного имени, которое нужно искать.
  • Расширить cancelarTurnoOtimista, чтобы она также сбрасывала atividadeDoTurno, по теории, что неудавшийся ход всегда должен очищать полосу активности. Отвергнуто: ни один из трёх путей отказа, зависящих от await (handleReadiness, handleArchitectureReadiness, ветка агента в handleSend), сегодня не сбрасывает atividadeDoTurno, хотя вызов может длиться до 120 с, и deltas/tool-calls канала могли уже за это время наполнить reducer. Это пробел, принятый в коде таким, каким он был до этого ADR, а не то, что это извлечение было призвано исправить, — добавление нового dispatchAtividade({ tipo: 'reset' }) здесь было бы молчаливым изменением поведения, едущим попутно в изменении, у которого его не должно быть.
  • Один PR вместо двух. Отвергнуто по причине, изложенной выше в Решении: дедупликация и смена модуля — разные виды риска, и их смешивание затруднило бы на ревью изоляцию регрессии в любой из них.