Skip to Content
ДизайнDaemon Acp HTTPDaemon ACP-over-HTTP → Официальный транспорт ACP Streamable HTTP

Daemon ACP-over-HTTP → Официальный транспорт ACP Streamable HTTP

Целевая ветка: daemon_mode_b_main. Ветка: feat/daemon-acp-http-streamable. Автор: arnoo.gao. Дата: 2026-05-24. Статус: Design v1 → implementation. Работа по принципу «сначала дизайн»: этот документ появляется до или вместе с PR реализации, чтобы контракт по протоколу был проверяем.


0. TL;DR

В настоящее время демон (qwen serve) общается с веб/SDK-клиентами на собственном диалекте REST + SSE, а с порождённым процессом qwen --acp — на настоящем ACP JSON-RPC через stdio. Это предложение добавляет второй северный транспорт, реализующий официальный транспорт ACP Streamable HTTP (RFD #721) на единой конечной точке /acp, так что любой ACP-нативный клиент (Zed, Goose, будущие SDK) может управлять демоном напрямую по стандартному протоколу — без знания qwen-специфичного REST.

Решение: два транспорта одновременно, аддитивно. Новая конечная точка /acp монтируется рядом с существующим REST-интерфейсом, используя под капотом тот же HttpAcpBridge + EventBus. REST API не удаляется. Обоснование в §6.

Решение: пространство имён расширений = _qwen/… (префикс с одним подчёркиванием, зарезервированная форма для кастомных методов по спецификации ACP) для функций демона, у которых нет стандартного метода ACP (переключение модели, интроспекция рабочей области, heartbeat, политика разрешений для нескольких клиентов, настройка противодавления SSE). Обоснование в §5.

В этот PR входит полная, локально запускаемая эталонная реализация (packages/cli/src/serve/acp-http/) и проверочный харнесс (scripts/acp-http-smoke.mjs).


1. Предыстория — что значит «ACP через HTTP» сегодня

Три уровня (проверено на коммите 0c0430939):

┌──────────────┐ собственный REST + SSE (HTTP/1.1) ┌────────────┐ ACP JSON-RPC ┌──────────────┐ │ веб / SDK │ ───────────────────────────────► │ qwen │ (stdio NDJSON) │ qwen --acp │ │ клиент │ ◄─── GET /session/:id/events ──── │ serve │ ◄─────────────► │ дочерний │ │ (ACP-клиент) │ (text/event-stream) │ (демон) │ ndJsonStream │ (Agent) │ └──────────────┘ └────────────┘ └──────────────┘ северный: НЕ протокол ACP мост южный: настоящий ACP

1.1 Северный (клиент ↔ демон) — собственный, сегодня

  • Express 5 приложение в packages/cli/src/serve/server.ts (~30 маршрутов).
  • Отдельные REST-глаголы, не JSON-RPC:
    • POST /session (создать), POST /session/:id/prompt, POST /session/:id/cancel, POST /session/:id/load|resume, POST /session/:id/model, POST /session/:id/permission/:requestId, POST /session/:id/heartbeat, DELETE /session/:id, а также /workspace/*, /capabilities, /health.
  • Потоковая передача сервер→клиент: GET /session/:id/eventstext/event-stream.
    • Фреймы: id: <n>\nevent: <type>\ndata: <json>\n\n (server.ts:formatSseFrame, ~2626).
    • Монотонный id на сессию + возобновление через Last-Event-ID с поддержкой кольцевого буфера EventBus (acp-bridge/src/eventBus.ts).
    • Типы событий: session_update, client_evicted, slow_client_warning, state_resync_required, stream_error, …
  • Аутентификация: Authorization: Bearer <token> (serve/auth.ts), CORS deny + белый список хостов.
  • Противодавление: последовательная цепочка записи на соединение + комментарии о heartbeat каждые 15 с.

1.2 Южный (демон ↔ дочерний) — уже ACP

  • acp-bridge/src/spawnChannel.ts запускает qwen --acp, оборачивает stdin/stdout с помощью ndJsonStream из @agentclientprotocol/sdk (^0.14.1).
  • acp-bridge/src/bridge.ts:729 new ClientSideConnection(() => client, channel.stream) — демон является ACP клиентом, дочерний — ACP агентом.
  • На этом плече уже используются методы расширения: unstable_setSessionModel, unstable_resumeSession, unstable_listSessions (acp-integration/acpAgent.ts).

1.3 Зачем мигрировать северный

  • Каждый клиент (webui, TS SDK, Java SDK, Python SDK, VSCode companion) перереализует собственное REST-отображение. Стандартная конечная точка ACP позволит ACP-нативным редакторам подключаться без qwen-специфичной склейки.
  • Приводит внешнюю поверхность демона в соответствие с протоколом, который он уже использует внутри.

2. Цель: ACP Streamable HTTP (RFD #721)

Слитый черновик RFD (agentclientprotocol/agent-client-protocol#721, слит 2026-04-22). Ещё не нормативный; ещё не включён ни в один SDK. Мы реализуем по дизайну протокола из RFD.

2.1 Конечная точка и глаголы (единый /acp)

ГлаголПоведение
POST /acpОтправить JSON-RPC. initialize200 + тело JSON (возможности) и устанавливает Acp-Connection-Id. Все остальные запросы/уведомления → 202 Accepted, пустое тело; ответ (если есть) доставляется по соответствующему долгоживущему SSE-потоку.
GET /acpОткрыть долгоживущий SSE-поток. (Upgrade: websocket → WebSocket; отложено, см. §7.)
DELETE /acpЗавершить соединение → 202.

2.2 Двухуровневые долгоживущие потоки

  • Поток уровня соединения: GET /acp с заголовком Acp-Connection-Id, без заголовка сессии. Передаёт ответы уровня соединения (session/new, session/load, authenticate) и уведомления уровня соединения.
  • Поток уровня сессии: GET /acp с Acp-Connection-Id и Acp-Session-Id. Передаёт уведомления session/update, запросы от агента к клиенту (session/request_permission, fs/read_text_file, …) и ответы на POST сессии (session/prompt, session/cancel).

2.3 Идентичность (3 уровня)

  • Acp-Connection-Id (HTTP-заголовок) — привязка к транспорту, создаётся при initialize.
  • Acp-Session-Id (HTTP-заголовок) — обязателен для GET уровня сессии и POST сессии.
  • sessionId (параметр JSON-RPC) — внутри параметров метода (должен совпадать с заголовком).

2.4 Отличия от MCP StreamableHTTP

ACP использует долгоживущие потоки (не SSE на запрос), два ID-заголовка (соединение vs сессия), 202-для-не-initialize, обязательный HTTP/2, обязательный WebSocket-со стороны клиента. Мы заимствуем скелет с единой конечной точкой + POST/GET-SSE + заголовок сессии, но адаптируем под долгоживущую двух-ID модель. Мы не используем @modelcontextprotocol/sdk’s StreamableHTTPServerTransport (его модель потока на запрос и единый Mcp-Session-Id не подходят).

2.5 Стандартные методы (подтверждены текущей схемой)

  • Запросы клиент→агент: initialize, authenticate, session/new, session/load, session/prompt, session/resume, session/close, session/list, session/set_mode, session/set_config_option, logout.
  • Уведомление клиент→агент: session/cancel.
  • Запросы агент→клиент: fs/read_text_file, fs/write_text_file, session/request_permission, terminal/create|output|wait_for_exit|kill|release.
  • Уведомление агент→клиент: session/update.

3. Архитектура нового транспорта

Демон должен предоставлять поверхность ACP-агента через HTTP на север, оставаясь ACP клиентом по отношению к дочернему процессу на юге. Слой /acp поэтому является маршрутизатором JSON-RPC, который завершает HTTP-транспорт и мостится в существующий HttpAcpBridge.

POST /acp (JSON-RPC запросы/ответы/уведомления) клиент ──────────────────────────────────────────────► ┌───────────────────────────┐ (редактор) │ AcpHttpTransport │ ◄── GET /acp (SSE уровня соединения) ───────── │ - реестр соединений │ ◄── GET /acp (SSE уровня сессии) ───────────── │ - корреляция ID JSON-RPC│ │ - диспетчеризация методов│ └────────────┬──────────────┘ │ использует ┌────────────▼──────────────┐ │ HttpAcpBridge + EventBus │ (без изменений) └────────────┬──────────────┘ │ ACP stdio (без изменений) qwen --acp дочерний процесс

3.1 Новая структура модуля (packages/cli/src/serve/acp-http/)

ФайлОтветственность
index.tsmountAcpHttp(app, bridge, opts) — регистрирует маршруты /acp на существующем Express-приложении.
connection-registry.tsAcp-Connection-IdAcpConnection (писатель SSE соединения, Map<sessionId, SessionStream>, ожидающие запросы агент→клиент по JSON-RPC id, распределитель монотонных id). TTL + очистка при DELETE.
json-rpc.tsХелперы для разбора/проверки/сериализации JSON-RPC 2.0; коды ошибок (-32600 и т.д.); защита пространства имён _qwen/.
dispatch.tsСопоставляет входящие методы JSON-RPC → вызовы HttpAcpBridge. Сопоставляет BridgeEvent’ы → исходящие JSON-RPC фреймы. Таблица трансляции (§4).
sse-stream.tsПисатель долгоживущего SSE (переиспользует паттерн противодавления/heartbeat из server.ts). Отличается от REST /events (другая структура фреймов: полные JSON-RPC объекты, а не конверты событий qwen).

Без изменений в bridge.ts / eventBus.ts (только аддитивный потребитель).

3.2 Жизненный цикл соединения и сессии

  1. POST /acp {initialize} → создать connectionId, создать AcpConnection, ответить 200 с {protocolVersion, agentCapabilities, _meta:{qwen:{…}}} + заголовок Acp-Connection-Id.
  2. Клиент открывает GET /acp (уровня соединения) с Acp-Connection-Id.
  3. POST /acp {session/new}202; демон вызывает bridge.createSession(...); отправляет JSON-RPC ответ (с sessionId) по потоку соединения.
  4. Клиент открывает GET /acp (уровня сессии) с Acp-Connection-Id+Acp-Session-Id; демон вызывает bridge.subscribeEvents(sessionId) и передаёт трансформированные фреймы.
  5. POST /acp {session/prompt}202; bridge.sendPrompt(...); уведомления session/update передаются в реальном времени по потоку сессии; финальный ответ на prompt ({id, result:{stopReason}}) отправляется по потоку сессии, когда он завершится.
  6. Запрос агент→клиент (например, session/request_permission) выдаётся как JSON-RPC запрос по потоку сессии с id, выделенным демоном; клиент отвечает через POST /acp {id, result}; dispatch разрешает его через API разрешений моста.
  7. DELETE /acp (или закрытие потока соединения + TTL) уничтожает сессии/подписки.

4. Таблица трансляции (мост ⇄ ACP/HTTP)

4.1 Входящие (клиент POST → мост)

ACP методВызов мостаОтвет направляется на
initialize(нет; возможности из capabilities.ts)inline 200
authenticateсуществующий провайдер аутентификации (serve/auth/*)поток соединения
session/newbridge.createSessionпоток соединения
session/load / session/resume`bridge.restoreSession(‘load''resume’)`
session/promptbridge.sendPromptпоток сессии (отложен до завершения)
session/cancel (уведомление)bridge.cancel
session/listbridge.listSessions (unstable_listSessions)поток соединения
session/set_modeлогика маршрута режима утвержденияпоток сессии
JSON-RPC ответ (на запрос агент→клиент)разрешить ожидающий (§4.3)
_qwen/session/set_modelbridge.setSessionModel (unstable_setSessionModel)поток сессии
_qwen/workspace/list и т.д.маршруты интроспекции рабочей областипоток соединения
_qwen/session/heartbeatbridge.heartbeatпоток соединения

4.2 Исходящие (BridgeEvent → JSON-RPC по потоку сессии)

BridgeEvent.typeОтправляется как
session_updateуведомление {method:"session/update", params:<data>}
запрос разрешениязапрос {id:<n>, method:"session/request_permission", params}
client_evicted / slow_client_warning / state_resync_requiredуведомление {method:"_qwen/notify", params:{kind,…}}
stream_errorJSON-RPC ответ об ошибке на активный id prompt (или _qwen/notify)
завершение prompt{id:<promptId>, result:{stopReason}}

4.3 Ожидающие запросы агент→клиент

AcpConnection хранит Map<jsonRpcId, {sessionId, kind, bridgeRequestId, resolve}>. Когда клиент отправляет POST с JSON-RPC объектом ответа, dispatch сопоставляет id, затем вызывает путь разрешения моста (например, внутренний эквивалент POST /session/:id/permission/:requestId).

Статус v1: реализован только цикл запрос-ответ session/request_permission агент→клиент. Пересылка fs/* и terminal/* от агента к клиенту отложена (§7) — демон ещё не объявляет согласование клиентских возможностей fs/terminal на /acp, поэтому в v1 ACP-клиенты не должны рассчитывать на семантику файловой системы/терминала через этот транспорт. Целевое конечное состояние (пересылать fs/* клиенту; при отсутствии возможности fs у клиента — использовать файловую систему рабочей области демона) — это последующее улучшение, описанное в §7.


5. Стратегия расширений (требование #2)

ACP резервирует любые методы, начинающиеся с _, для кастомных расширений, и предоставляет _meta для каждого типа. На южном плече кодовой базы уже используются имена методов unstable_*.

Выбор для северного: имена методов с вендорным пространством имён _qwen/<область>/<глагол> (соответствующий спецификации префикс _). Возможности объявляются в agentCapabilities._meta.qwen при initialize, чтобы клиенты могли определить функции до использования.

ПотребностьСтандартный метод ACP отсутствует?Расширение
Переключение моделида_qwen/session/set_model
Интроспекция рабочей области (MCP/скиллы/провайдеры/окружение)да_qwen/workspace/list, _qwen/workspace/<область>
Heartbeat / время последней активностида_qwen/session/heartbeat
Политика разрешений для нескольких клиентов (консенсус/назначение)частичноsession/request_permission + _meta.qwen.policy
Настройка противодавления SSE (maxQueued)даЗаголовок Acp-Qwen-Max-Queued на GET сессии
Курсор возобновления (кольцевой Last-Event-ID)RFD Фаза 4Заголовок Last-Event-ID + _meta.qwen.eventId на фреймах

Стандартные методы никогда не переименовываются; расширения строго аддитивны и игнорируемы.


6. Два транспорта или замена (требование #4)

Решение: два транспорта (аддитивно).

  • Официальный транспорт — это черновик RFD, ещё не нормативный и отсутствующий во всех SDK — полная замена привязала бы нас к неутверждённому дизайну и сломала бы webui + 3 SDK + VSCode companion одновременно.
  • REST-поверхность содержит функции, для которых пока нет чистого отображения на ACP (интроспекция рабочей области, посредничество разрешений для нескольких клиентов, возобновление с кольцевым буфером, реестр возможностей). Они деградируют до расширений _qwen/* на /acp, но REST-поверхность остаётся авторитетной до ратификации RFD.
  • Оба транспорта используют один экземпляр HttpAcpBridge + EventBus, поэтому нет дублирования состояния — /acp и /session/* могут даже управлять одной и той же активной сессией одновременно (множественные клиенты уже поддерживаются мостом).
  • Переключатель (v1, поставляется): включён по умолчанию; QWEN_SERVE_ACP_HTTP=0 отключает монтирование. Флаг CLI --no-acp-http и тег acp_http в /capabilities для определения клиентом отложены до последующего обновления (не в v1) — до тех пор клиенты определяют транспорт, пробуя POST /acp {initialize}.

Путь миграции: после ратификации RFD и выхода SDK REST-маршруты могут быть переосмыслены как тонкая прослойка совместимости поверх /acp (отдельный, более поздний PR).


7. Объём PR реализации

Входит в объём (работоспособно + проверено локально):

  • Диспетчеризация POST /acp для initialize, session/new, session/prompt, session/cancel, session/load, обработка JSON-RPC ответов.
  • SSE потоки уровня соединения и уровня сессии через GET /acp с фреймингом JSON-RPC.
  • Потоковая передача session/update + корреляция финального ответа на prompt.
  • Цикл запрос-ответ session/request_permission агент→клиент.
  • Расширение _qwen/session/set_model как рабочий пример требования #2.
  • Переиспользование Bearer-аутентификации + белого списка хостов (те же middleware, что и REST).
  • Модульные тесты (acp-http/*.test.ts) + скрипт чёрного ящика, управляющий реальным демоном.

Отложено (задокументировано, не реализовано сейчас):

  • Путь обновления до WebSocket (требование клиента по RFD; SSE достаточно для локальной проверки).
  • Мультиплексирование HTTP/2 (мы работаем на HTTP/1.1; POST и долгоживущие GET используют отдельные сокеты, что работает для CLI/Node клиентов и браузеров с ≤6 соединениями). Задокументированное отличие.
  • Полная пересылка fs/* + terminal/* от агента к клиенту (путь разрешения доказывает механизм; остальное — механическое последующее улучшение).
  • Доведение устойчивости возобновления SSE до паритета с кольцевым буфером (Фаза 4 в RFD).

8. План локальной проверки

  1. npm run build (или сборка воркспейса cli + acp-bridge).
  2. Запустите демон: qwen serve --listen 127.0.0.1:0 --token <t> (или токен из переменной окружения).
  3. Запустите node scripts/acp-http-smoke.mjs:
    • POST /acp {initialize} → проверить 200 + Acp-Connection-Id.
    • Открыть SSE-соединение; POST {session/new} → проверить ответ в потоке.
    • Открыть SSE сессии; POST {session/prompt:"say hi"} → проверить ≥1 session/update, затем финальный {result:{stopReason}}.
    • Вызвать инструмент, требующий разрешения → проверить запрос session/request_permission, отправить POST с разрешением → проверить завершение промпта.
    • POST {_qwen/session/set_model} → проверить смену модели + session/update.
  4. Vitest: acp-http/*.test.ts зелёные.

9. Риски

РискСмягчение
Изменения RFD до ратификацииЗа флагом возможности + пространством имён _qwen; изолированный модуль; легко пересмотреть.
HTTP/1.1 vs. требуемый HTTP/2Локальные/CLI-клиенты не затронуты; задокументировано; h2 можно заменить транспортом позже.
Гонка двух транспортов на одном мостуМост уже поддерживает нескольких клиентов; используем его блокировку повторно.
Проброс fs/* vs. локальная ФС демонаОграничено возможностью: пробрасывать, когда клиент объявляет fs, иначе локально.

10. Журнал реализации и проверки (v1)

Реализовано в packages/cli/src/serve/acp-http/ (json-rpc.ts, sse-stream.ts, connection-registry.ts, dispatch.ts, index.ts), смонтировано из server.ts через mountAcpHttp(app, bridge, { boundWorkspace }).

Автоматизированные тесты (packages/cli/src/serve/acp-http/*.test.ts)

transport.test.ts запускает реальный Express-сервер + реальный mountAcpHttp над контролируемым фейковым мостом и управляет им через fetch + ручной разбор SSE. 15 тестов зелёные, покрывают: initialize 200 + Acp-Connection-Id; неизвестное соединение 400; ответ session/new в потоке соединения; промпт → поток session/update + корреляция финального результата; круговорот session/request_permission агент→клиент→агент; _qwen/session/set_model; метод не найден; DELETE для завершения.

Живой демон (реальная модель)

Запущен qwen serve --port 8767 --token … --workspace … (точка входа в бандл, чтобы запущенный дочерний процесс qwen --acp был самодостаточным) и выполнен scripts/acp-http-smoke.mjs:

✓ initialize: connectionId=… protocolVersion=1 ✓ session/new: sessionId=… → prompt: "Reply with the single word: pong" pong ✓ prompt complete: 10 session/update frames, stopReason=end_turn ✓ DELETE /acp — connection closed ALL CHECKS PASSED ✅

Путь ошибки также подтверждён вживую: когда дочерний процесс не запускался, тайм-аут моста отображался для клиента как JSON-RPC-кадр ошибки в потоке соединения ({"id":2,"error":{"code":-32603,…}}), что доказывает корреляцию id + разделение 202/SSE при сбое.

Исправление по результатам ревью — clientId, выданный мостом (обнаружено при живой проверке)

Первый живой запуск упал на session/prompt с “client id … is not registered for session”. Коренная причина: spawnOrAttach/loadSession игнорируют clientId, предоставленный вызывающей стороной, который мост никогда не выдавал, и ставят новый (возвращается в BridgeSession.clientId); диспетчер эхом возвращал собственный (незарегистрированный) id соединения на sendPrompt. Исправление: сохранять выданный мостом id в SessionBinding и возвращать его при каждом вызове для данной сессии (sessionCtx). Повторно проверено — зелёный.


11. Ревью раунд 2 — исправления

Два независимых ревью (корректность/конкурентность + соответствие протоколу/безопасность) плюс самопроверка. Все исправления проверены расширенным набором vitest (18 тестов) + свежий живой прогон (21 кадр session/updatestopReason=end_turn).

#ВажностьНаходкаИсправление
R1P0Переподключение потока сессии было навсегда мёртвым: SessionBinding.abort создавался один раз и переиспользовался; при закрытии потока он навсегда прерывался, поэтому при повторном подключении subscribeEvents(signal) получал уже прерванный сигнал и не получал никаких событий.attachSessionStream теперь устанавливает новый AbortController на каждый поток (и закрывает предыдущий поток); index.ts работает с этим новым сигналом.
R2P0await dispatcher.handle() выполнялся после res.end(202); выбрасывающий вызов моста (в частности, не обработанный try/catch путь isResponse) приводил к отклонению промиса и мог вызвать падение демона.Обернул путь isResponse в try/catch; добавил .catch() на ожидаемый handle(...) и на pumpSessionEvents(...).
R3P1Нет привязки соединения к сессии: любой аутентифицированный клиент мог открыть SSE сессии или отправить промпт для любого sessionId в воркспейсе (чтение/подслушивание; промпт блокировался лишь случайно из-за ошибки незарегистрированного clientId).AcpConnection.ownedSessions заполняется при session/new/load/resume; поток сессии возвращает 403, а POST для сессии возвращают INVALID_PARAMS для не принадлежащих id (requireOwned).
R4P1mountAcpHttp handle не сохранялся → утечка таймера TTL-очистки и живых SSE-потоков при завершении.Handle сохранён в app.locals; хук закрытия runQwenServe вызывает dispose() перед bridge.shutdown() (аналогично регистру device-flow).
R5P1Утечка ожидающих разрешений: закрытие сессии/соединения с ожидающим разрешением оставляло мост заблокированным в ожидании голоса.closeSessionStream/destroy отменяют соответствующие ожидающие запросы через внедрённый onAbandonPendingcancelAbandonedPermission.
R6P1Буферы кадров перед присоединением (connBuffer/binding.buffer) были неограниченны.Ограничены 256 кадрами (удаление самых старых), аналогично maxQueued в EventBus.
R7P2initialize игнорировал запрошенный клиентом protocolVersion.Выполняется согласование min(requested, 1).
R8P2Нет перекрёстной проверки Acp-Session-Idparams.sessionId (RFD §2.3).POST проверяет их совпадение; несовпадение → INVALID_PARAMS.
R9P2session/cancel в форме запроса (с id) никогда не получал ответа; дублирующийся _meta.qwen на верхнем уровне.Ответ отправляется, когда id присутствует; один agentCapabilities._meta.qwen.

Принято / задокументировано (не исправлено в v1)

  • Порядок результат-промпт vs. завершающий session/update (P2): handlePrompt ожидает sendPrompt, затем записывает кадр результата, в то время как обновления приходят параллельно. На практике мост публикует все session/update в шину до того, как sendPrompt завершится, и оба используют одну упорядоченную цепочку записи SSE, поэтому результат оказывается последним (подтверждено: 21 обновление, затем результат). Строгий барьер — возможное ужесточение позже, если редьюсер клиента окажется чувствительным.
  • Браузерный EventSource не может установить Authorization — GET-потоки /acp требуют bearer-заголовок, поэтому браузерам нужен отложенный путь WebSocket (§7); CLI/Node-клиенты не затронуты.
  • Реальная граница доверия демона остаётся bearer-токен + привязка к одному воркспейсу (как и у REST-поверхности); проверка владения из R3 — защита в глубину + корректность контракта, а не граница арендатора.

12. Ревью раунд 3 — исправления от PR-ботов (#4472)

Два автоматических ревьюера PR плюс бот-резюме. Все исправления проверены набором тестов (теперь 22 теста) + свежий живой прогон (16 session/updateend_turn).

#ВажностьНаходкаИсправление
B1P0AbortController в handlePrompt никогда не прерывался — отключающийся/отменяющий клиент оставлял агента работающим (сжигалась квота модели, блокировалась FIFO сессий). Обнаружено обоими ботами + 5 под-агентами.promptAbort сохранён в SessionBinding; прерывается по session/cancel и при разрушении сессии/соединения (closeSessionStream/destroy).
B2P0В sessionCtx отсутствовал fromLoopback → каждый голос разрешения ACP считался удалённым; политика local-only отклоняла бы loopback-клиентов.Захват loopback при initialize (ядровый remoteAddress, не подделываемые заголовки) → AcpConnection.fromLoopback → пробрасывается через sessionCtx.
B3P0Ошибки записи в SSE молча проглатывались → потоки-зомби (heartbeat срабатывают, события не доставляются, нет логов).Первая ошибка записи логируется + закрывает поток.
B4P0Очистка по таймеру уничтожала соединения без логирования и без ограничения на количество соединений (флуд инициализациями).Очистка логирует каждое удаление; pumpSessionEvents вызывает touch() (долгие молчащие промпты не удаляются); ограничение maxConnections (64) → 503.
B5P1sessionCtx молча возвращался к незарегистрированному clientId соединения, когда у привязки его не было (не тестировалось, в FakeBridge всегда срабатывало).Бросок при отсутствии выданного clientId (нарушение инварианта); FakeBridge теперь выдаёт один.
B6P1`session/newload
B7P1session/prompt передавал непроверенный prompt мосту.validatePrompt (непустой массив объектов), аналогично REST.
B8P1Сырые сообщения об ошибках моста передавались клиенту.toRpcError сопоставляет известные ошибки моста кодированным, безопасным для клиента формам; неизвестные → общий Internal error (полные данные всё равно в stderr).
B9P1nextId использовал последовательные отрицательные числа — клиент, легально использующий отрицательные id, мог вызвать коллизию в pending.Id, исходящие от демона, теперь строки (_qwen_perm_N), не пересекающиеся с любыми клиентскими id.
B10P2Тип параметра resolveClientResponse исключал JsonRpcError; SSE-поток, ограниченный соединением, не имел onClose; DELETE без заголовка был молчаливым 202; SseStream.close вызывал onClose вне try/catch; session/load·resume·close не тестировались.Расширен тип до JsonRpcResponse; поток соединения логирует закрытие; DELETE без заголовка → 400; onClose обёрнут в try/catch; добавлены тесты load/resume/close + DELETE-400.

Вне области видимости (базовая ветка daemon_mode_b_main, не этот дифф) — второй ревьюер отметил ошибки типов в acpAgent.ts (entryCount/entrySummary/sessionClose) и другие ранее существовавшие пункты, которые он явно отнёс к базовой ветке (введены #4353). Отслеживаются отдельно; здесь не затронуты.

Всё ещё отложено (задокументировано): секрет для каждого соединения для DELETE/владение соединением (токен остаётся границей); WebSocket + HTTP/2 (§7); строгий барьер результат-промпт vs. завершающий update (§11).


13. Ревью раунд 4 — исправления PR (перебазировано на #4469)

Ветка перебазирована на daemon_mode_b_main (#4353 + #4469) — чисто, без конфликтов. Два ревьюера PR (GPT-5 + qwen3.7-max). Набор теперь 25 тестов; живая перепроверка (125 session/updateend_turn).

#ВажностьНаходкаИсправление
C1P0Обработка ошибок записи SSE из раунда 3 была задокументирована, но НЕ реализована — SseStream всё ещё оставлял это игнорирующим вызывающим (потоки-зомби).writeRaw теперь берёт это на себя: первое отклонение записи логируется один раз + вызывает close(); doWrite также слушает 'error' (отклоняет сразу, а не ждёт 'close'); onClose обёрнут в try/catch.
C2P1fromLoopback захватывался только при initialize + хелпер уже REST → голоса local-only от последующего POST неправильно оценивались.Loopback на каждый запрос пробрасывается через handlesessionCtx/resolveClientResponse; isLoopbackReq расширен до 127.0.0.0/8 + ::ffff:127.* + ::1 (совпадает с REST).
C3P1Маршрутизация ошибок определяла поток из params.sessionId → ошибки методов, ограниченных соединением (session/load/resume/close/heartbeat), направлялись в несуществующий поток сессии (молчаливая потеря).Введён набор CONN_ROUTED_METHODS; ошибки направляются так же, как и путь успеха.
C4P1bridge.detachClient никогда не вызывался при разрушении → устаревшие clientId, выданные мостом, оставались в knownClientIds()/наборах голосующих.Реестр принимает DetachSessionFn; closeSessionStream/destroy открепляют каждую принадлежащую сессию (best-effort).
C5P1session/close пропускал локальную очистку, если bridge.closeSession выбрасывал исключение.closeSessionStream перенесён в finally.
C6P2Windows-путь cwd (C:\…) отвергался startsWith('/').path.isAbsolute (учитывает платформу), совпадает с REST.
C7P2protocolVersion мог согласовать 0/отрицательное значение.Ограничение Math.max(1, Math.min(requested, 1)); тесты для 0/отр/огромное/невалидное.
C8P2session/load/resume принимали пустой sessionId.Отклонять пустой с INVALID_PARAMS.
C9P2Ошибки session/prompt в форме уведомления исчезали молча.Логирование на пути без id.
C10P2SSE сессии сбрасывал буферизованные кадры до заголовков/retry:.open() до attachSessionStream.
C11P2Дублирование локального logStderr.Общий writeStderrLine из utils/stdioHelpers.
C12P2Документация рекламировала флаги --no-acp-http, тег возможности acp_http и проброс fs/*, отсутствующие в v1.Документация приведена в соответствие с поставляемой поверхностью (только переключатель env-var; fs/*+terminal/* + флаг + тег помечены как отложенные).

Всё ещё отложено (без изменений): WebSocket + HTTP/2; секрет на соединение для DELETE/владения (токен + одна рабочая область остаётся границей); строгий барьер упорядочения подсказка→результат; приведения на границе моста as never (целенаправленные, отмечены для последующего дополнения по типам адаптера).


14. Обзорный раунд 5 — слияния PR

Ещё один проход рецензента (qwen3.7-max). Набор 26 тестов, повторно проверены вживую.

#СерьёзностьНаходкаИсправление
D1P0resolveClientResponse удалял ожидающую запись ДО вызова respondToSessionPermission. Некорректный голос (result: {}) вызывает ошибку в медиаторе моста — а поскольку ожидающая запись уже удалена, abandonPendingForSession в teardown не может её отменить, и подсказка агента зависает на голосе, который никогда не разрешится (владелец токена может заблокировать сессию одним неправильным POST).Обернуть голос в try/catch; при любой ошибке откатываться к cancelAbandonedPermission, чтобы медиатор всегда освобождался. Добавлен новый тест для пути с некорректным голосом.
D2P1onClose потока сессии прерывал только насос событий, а не binding.promptAbort — отключение клиента (закрытие вкладки / потеря сети) оставляло выполняющуюся подсказку (квота + FIFO) до окончания idle TTL.onClose теперь также прерывает promptAbort сессии.
D3P1Когда pumpSessionEvents отклонялся, .catch только логировал — SSE-поток оставался открытым, отправляя heartbeat’ы, но не доставляя ничего (зомби, без сигнала переподключения)..catch теперь также вызывает closeSessionStream(sessionId).

15. Обзорный раунд 6 — слияния PR

Ещё один проход рецензента (qwen3.7-max). Набор 28 тестов, повторно проверены вживую.

#СерьёзностьНаходкаИсправление
E1P0handlePrompt перезаписывал binding.promptAbort без прерывания предыдущего контроллера — два одновременных session/prompt для одной сессии приводили к сиротству первого (выполняется до конца в FIFO моста, не может быть прерван через session/cancel).Прервать предыдущий promptAbort перед установкой нового. Добавлен тест.
E2P0Путь, где subscribeEvents выбрасывает исключение, отправлял уведомление stream_error, затем return (разрешался) — .catch вызывающего никогда не срабатывал, оставляя зомби-поток SSE (heartbeat’ы, без событий, без сигнала переподключения).Повторно выбросить исключение после уведомления, чтобы .catch вызывающего закрыл поток. Тест проверяет закрытие подсказки.
E3P1Heartbeat SSE не отмечал соединение как активное — длинная подсказка без промежуточных событий >30 мин приводила к сборке по idle (потоки и подсказки уничтожались).SseStream принимает хук onHeartbeat; оба GET-обработчика передают () => conn.touch().
E4P2.catch в pumpSessionEvents закрывал по sessionId — переподключение между выбросом и микрозадачей могло убить НОВЫЙ поток.Защита по идентичности: закрывать только если binding.stream всё ещё этот поток.
E6P2sendSession автоматически создавал привязку — поздний фрейм pump/reply после closeSessionStream воскрешал призрачную привязку, которая бесконечно буферизировала до 256 фреймов.sendSession теперь работает только на поиск: отбрасывает фреймы, когда у сессии нет активной привязки.
E5принятоsession/load/resume не отклоняются, когда другое активное соединение владеет сессией (“перехват”).Принято, не изменено: граница доверия демона — токен-носитель + привязка к одной рабочей области, а мультиклиентское подключение является намеренным (мост спроектирован как мультиклиентский; REST обладает тем же свойством). Владелец токена не получает возможностей, которых у него нет через REST. Отслеживается вместе с остальными элементами границы токена (владение DELETE, §13).

16. Обзорный раунд 7 — слияния PR

Ещё один проход рецензента (qwen3.7-max). Набор 30 тестов, повторно проверены вживую.

#СерьёзностьНаходкаИсправление
F1P0Гонка параллельных session/close TOCTOU: ownedSessions.delete выполнялся только в finally (после await), поэтому два одновременных закрытия оба проходили requireOwned → ошибочное сообщение для второго + избыточное закрытие моста.Удалить шлюз владения СИНХРОННО перед await; закрытие моста выполняется один раз. Добавлен тест.
F2P1Жизненный цикл насоса: чистое завершение итератора (подпроцесс завершён, done) разрешалось → .catch никогда не срабатывал → зомби-поток; а ошибка итератора В СЕРЕДИНЕ ПОТОКА не отправляла stream_error.pumpSessionEvents оборачивает весь цикл (синхронные ошибки + ошибки в середине потока отправляют stream_error, затем повторный выброс); потребитель .then(onDone, onErr) закрывает поток на ОБОИХ путях (с защитой по идентичности). Добавлены тесты.
F3P2Отклонение соединения при достижении лимита (503) не имело лога в stderr.writeStderrLine со значением лимита.
F4P2Spread в _qwen/notify stream_error позволял event.data.kind затенять дискриминатор.Сначала spread, затем kind: 'stream_error'.
F5P2MAX_WORKSPACE_PATH_LENGTH повторно объявлен (= 4096) вместо канонического из fs/paths.js.Импорт из ../fs/paths.js (без расхождений).
F6P2isObjectParams дублирует json-rpc.isObject.Импортировать isObject.
F7P2Использование сырого process.stderr.write в index.ts/sse-stream.ts вместо writeStderrLine в других местах.Унифицировано на writeStderrLine во всём модуле.

17. REST эквивалентность + внедрение аудита расширений (раунд 8)

Цель: сделать /acp эквивалентной альтернативой REST+SSE. В этой серии на основе выводов аудита переработаны схемы расширений и дополнены все возможности, уже предоставляемые мостом; возможности, которых у моста пока нет (файловый ввод/вывод, потоки устройств, agents/memory CRUD), согласно архитектурным требованиям сначала дополняются в acp-bridge (см. §17.3).

17.1 Аудит схем расширения → внедрение (замена старой схемы из §5)

Сверка по реализованному SDK @agentclientprotocol/sdk@0.14.1 (не только по сайту):

  • session/set_config_optionосновной (не unstable_) метод, запрос {sessionId, configId, value}, category включает model/mode/thought_level; при этом set_model по-прежнему идёт через unstable_setSessionModel.
  • Спецификация оставляет префикс _ для расширений, пример — доменный стиль _zed.dev/…; данные производителя помещаются в _meta с разбивкой по доменам.

Внедрение:

  • Пространство имён _qwen/ → обратный домен _qwen/; _meta унифицирован как _meta:{ "qwen": … } (содержит анонс возможностей initialize и requestId для session/request_permission).
  • Модель + режим утверждения → стандартный session/set_config_option (configId:"model"|"mode"), маршрутизируется в существующие bridge.setSessionModel/setSessionApprovalMode; результат session/new рекламирует configOptions (берётся из состояния сессии подпроцесса getSessionContextStatus().state.configOptions, уже в форме ACP). Удалён производительский _qwen/session/set_model.
  • REST(http+sse) не требует синхронных изменений: оба транспорта используют один и тот же мост, состояние согласовано автоматически.

17.2 Новые методы /acp, добавленные в этой серии (мост уже поддерживает, 1:1 соответствие REST)

REST/acpbridge
POST /session/:id/model / approval-modeстандартный session/set_config_option (model/mode)setSessionModel / setSessionApprovalMode
GET /session/:id/context_qwen/session/contextgetSessionContextStatus
GET /session/:id/supported-commands_qwen/session/supported_commandsgetSessionSupportedCommandsStatus
PATCH /session/:id/metadata_qwen/session/update_metadataupdateSessionMetadata
GET /workspace/{mcp,skills,providers,env,preflight}_qwen/workspace/{…}getWorkspace*Status
POST /workspace/init_qwen/workspace/initinitWorkspace
POST /workspace/tools/:name/enable_qwen/workspace/set_tool_enabledsetWorkspaceToolEnabled
POST /workspace/mcp/:server/restart_qwen/workspace/restart_mcp_serverrestartMcpServer

(Уже выровнены: session/new·load·resume·close·list·prompt·cancel, heartbeat, permission, events.)

17.3 Оставшиеся пробелы → требуется сначала дополнить acp-bridge (архитектурная корректность)

Файловый ввод/вывод REST (/file /glob /list /stat /file/write /file/edit), потоковая аутентификация устройств (/workspace/auth/*), agents CRUD (/workspace/agents), memory CRUD (/workspace/memory) в настоящее время не находятся на HttpAcpBridge — маршруты REST напрямую вызывают сервисы уровня маршрутов (WorkspaceFileSystemFactory, DeviceFlowRegistry, SubagentManager, writeWorkspaceContextFile), минуя мост.

Решение (принято по результатам рецензирования / мнения владельца): не заставлять транспорт /acp подключаться напрямую к этим сервисам уровня маршрутов (это воспроизвело бы архитектурный дрейф REST и удвоило бы связанность транспорта). Правильный подход — сначала дополнить HttpAcpBridge в @qwen-code/acp-bridge этими возможностями (например, readWorkspaceFile/writeWorkspaceFile/globWorkspace, startDeviceFlow/pollDeviceFlow, listAgents/upsertAgent/deleteAgent, readMemory/writeMemory), чтобы и REST, и /acp проходили через мост. После этого в /acp будут добавлены _qwen/fs/*, _qwen/auth/*, _qwen/workspace/agent*, _qwen/workspace/memory* (чтение файлов, так как нет стандартного метода ACP клиент→агент, является легальным расширением производителя).

Полная эквивалентность = текущая серия (возможности, уже имеющиеся в мосту) + последующая серия после восполнения пробелов acp-bridge.


18. Обзорный раунд 9 — слияния PR

#СерьёзностьНаходкаИсправление
G1P1 (регрессия)Переподключение потока сессии прерывало выполняющуюся подсказку: attachSessionStream закрывал СТАРЫЙ поток перед установкой нового, а onClose старого потока безусловно прерывал promptAbort — таким образом, переподключающийся клиент (сбои сети / роуминг) терял свою запущенную подсказку.Установить новый поток ДО закрытия старого; защитить прерывание promptAbort в onClose проверкой идентичности (прерывать только если ЭТО всё ещё активный поток сессии). Добавлен тест (подсказка переживает переподключение).
G2P2session/cancel передавал undefined в качестве тела CancelNotification, отбрасывая поля отмены (причина/контекст), предоставленные клиентом, которые REST передаёт.Передавать { ...params, sessionId } (аналог REST).

Перебазировано на последнюю версию daemon_mode_b_main (#4473/#4483/#4484/#4500), конфликтов нет. Набор 33 теста, повторно проверены вживую.


19. Дорожная карта / последующие PR (чтобы не забыть)

Текущий PR (#4472) = ACP Streamable HTTP transport + полное выравнивание возможностей, поддерживаемых мостом + официальная схема расширений. Переведён в готов. Чтобы достичь «/acp полностью эквивалентен REST+SSE», необходимы:

  1. Follow-up PR 1 — дополнение возможностей acp-bridge (предварительно / bridge-first): HttpAcpBridge получает новые методы: файловый ввод/вывод, потоки устройств, agents CRUD, memory CRUD; маршруты REST переходят на мост (устранение дрейфа прямых вызовов сервисов уровня маршрутов).
  2. Follow-up PR 2 — оставшееся выравнивание /acp (зависит от PR 1): _qwen/fs/*, _qwen/auth/*, _qwen/workspace/agent*, _qwen/workspace/memory* → полная эквивалентность REST.

Отслеживание: #3803 (открытые решения), #4175 (дорожная карта Mode B) — в обоих оставлены комментарии. Отложенные пункты по hardening указаны в описании PR («Известные отложенные»).


20. Переименование пространства имён расширений + анализ транспорта SDK (раунд 11)

  • Пространство имён _qwen.ai/_qwen/: Единственное жёсткое правило ACP — ведущий символ _; сегмент домена _zed.dev/ — это соглашение по примеру, а не обязательное требование. Поскольку qwen является различимым, мы используем более короткую базовую форму. Ключ _meta также "qwen". (Обзор реальных агентов: Zed/gemini-cli в основном используют _meta в стандартных методах + собственные unstable_* ACP; пользовательские методы с _ редки — наши _qwen/* представляют собой действительно новые операции с рабочей областью/сессией, не имеющие стандартного эквивалента, поэтому метод с _ — правильный инструмент.)
  • Почему самодельный транспорт (не на основе SDK): TS SDK поставляется только с ndJsonStream (stdio); RFD #721 HTTP находится в фазе 3 SDK (не реализован). Connection в SDK — это одно-дуплексный поток; наш транспорт — многопоточный (POSTs + соединение-SSE + SSE на сессию) и требует демультиплексирования исходящих сообщений по sessionId — что наш диспетчер уже знает на этапе маршрутизации. Полная переработка SDK противоречит этой модели и не устранит основную часть (трансляция моста, жизненный цикл SSE, владение, EventBus→JSON-RPC). Прагматичное улучшение (кандидат на follow-up): использовать валидаторы схем Zod из SDK + типы для проверки параметров, сохраняя самодельный транспорт. Клиенты SDK, использующие extMethod('_qwen/…'), будут взаимодействовать с нашими обработчиками (идентичная форма сообщения на проводе).
Last updated on