Перейти к содержанию

Чек-лист приёмки

Пошаговая проверка интеграции. Каждый пункт: как воспроизвести (wscat или код), что должно быть в ответе, как выглядит типичная ошибка. Токен и device.id — подставьте свои; в примерах реальных токенов нет.

Инструменты: npm i -g wscat, адрес пилота — wss://hermes-go.com/mobile-ws. Для локальной разработки — ws://127.0.0.1:8787/ws. Токен удобнее передавать в самом кадре hello (не в URL), чтобы он не осел в логах прокси.


1. Подключение и ready с capabilities

Как получить

wscat -c "wss://hermes-go.com/mobile-ws"
# затем отправьте:
{"t":"hello","token":"<ТОКЕН>","device":{"id":"qa-1","name":"QA","platform":"cli","app_version":"0.0.1"},"user":{"id":"u-qa","name":"QA"}}

Ожидаемый результат

{"t":"ready","v":1,"session":"mobile:qa-1","device":{"id":"qa-1","name":"QA"},
 "avatar_url":"…","capabilities":{"text":true,"voice":true,"typing":true,
 "avatar_states":["idle","thinking","speaking"],"media_in":["image","video","document","audio"],
 "media_out":["image","voice","video","document","animation"],
 "prompts":["exec_approval","slash_confirm","clarify"],"edit":true,
 "max_media_bytes":8388608,"max_text_chars":12000}}

Проверьте: v == 1, session == "mobile:<ваш device.id>", capabilities.edit == true.

Типичная ошибка: {"t":"error","code":"auth_required",…} — кадр hello не первый (или не отправлен). Первым кадром обязан идти только hello.


2. Отправка текста и получение ответа

Как получить

{"t":"text","id":"q-1","text":"Привет, ответь одним словом"}

Ожидаемый результат: серия message с одним id и растущим chunk.index, последний — final:true; перед этим может прийти avatar/typing. Агенту уходит event.message_id == "q-1".

Типичная ошибка: текст ответа не приходит вовсе — устройство ещё не одобрено владельцем агента. На машине агента: hermes pairing list → approve mobile qa-1. Либо отправлен пустой текст — тогда прилетит {"t":"error","code":"bad_frame","message":"Пустое сообщение"}.


3. Повтор с тем же id → нет дубля

Как получить

{"t":"text","id":"dup-1","text":"запомни число 42"}
{"t":"text","id":"dup-1","text":"запомни число 42"}
{"t":"text","id":"dup-1","text":"запомни число 42"}

Ожидаемый результат: агенту доходит одно сообщение (адаптер игнорирует повторы с тем же id). Спросите агента «сколько раз я просил запомнить 42?» — ответ «один раз».

Типичная ошибка: используете новый id при каждой переотправке — агент видит три сообщения. id обязателен для идемпотентности и должен повторяться при retry после обрыва.


4. Карточка одобрения и ответ на неё

Как получить: нужен агент, который запрашивает одобрение опасной команды (в Hermes это exec_approval). Попросите выполнить команду, требующую подтверждения — придёт кадр:

{"t":"prompt","id":"<prompt_id>","kind":"exec_approval","text":"…rm -rf…",
 "command":"rm -rf /tmp/build","description":"Удаляет временный каталог сборки",
 "options":[{"id":"once","label":"✅ Одобрить один раз","style":"primary"},
            {"id":"deny","label":"❌ Отклонить","style":"danger"}],
 "session":"mobile:qa-1","timeout_seconds":300}

Проверьте поля command/description: приложение обязано показать точную команду рядом с кнопками.

Ответьте кнопкой:

{"t":"prompt","prompt_id":"<prompt_id>","option_id":"once"}

Ожидаемый результат: {"t":"prompt_done","id":"<prompt_id>","option_id":"once"} и выполнение команды. Ответ на карточку не уходит в чат как обычное сообщение.

Типичная ошибка: отправляете {"t":"text","text":"once"} вместо кадра prompt — выбор не применяется; либо неверный prompt_id — придёт prompt_done{expired:true}. Проверка безопасности: отправьте option_id:"разрешить_всё" — сервер заменит на deny и вернёт его в prompt_done.


5. Медиа туда и обратно

Туда (клиент → агент). Сначала подготовьте base64 маленькой картинки и отправьте:

{"t":"media","id":"mq-1","kind":"image","mime":"image/png","filename":"dot.png","caption":"точка","data":"<base64>","part":0,"parts":1}

Ожидаемый результат: агент получает изображение как вложение (в логе — MessageType.PHOTO). Попросите «что на картинке?» — агент опишет.

Обратно (агент → клиент). Попросите агента прислать картинку/файл. Придёт кадр media:

{"t":"media","id":"a1","kind":"image","mime":"image/png","filename":"chart.png","caption":"График",
 "part":0,"parts":1,"final":true,"size":20480,"data":"<base64>"}

Если файл крупный — несколько кадров с part 0..parts-1, final:true на последнем.

Типичная ошибка: {"t":"error","code":"too_big",…} — файл > 8 МБ отправлен одним кадром. Режьте по ~3 МБ (part/parts), досылая все куски. Либо unsupported_frame — kind вне image|video|document|audio.


6. Стриминг: приходят правки с edit=true

Как получить: включите стриминг у агента и попросите длинный ответ. Наблюдайте кадры:

{"t":"message","id":"r1","text":"Идёт","edit":true,"chunk":{"index":0,"total":1},"final":false}
{"t":"message","id":"r1","text":"Идёт ответ…","edit":true,"chunk":{"index":0,"total":1},"final":true}

Ожидаемый результат: приходят кадры message с тем же id и edit:true; ваш клиент перерисовывает один пузырь, а не добавляет новые.

Типичная ошибка: клиент показывает каждый edit-кадр как новое сообщение — видны дубли растущего текста. Перерисовывайте по id. Помните: если правка не удалась, шлюз дошлёт хвост обычным message (без edit) — не считайте это ошибкой.


7. Состояния аватара: thinking / speaking / idle

Как получить: отправьте {"t":"text","id":"a2","text":"поздоровайся"} и наблюдайте avatar.

Ожидаемый результат (типичный порядок):

{"t":"avatar","state":"thinking"}   // + возможно {"t":"typing"}
…message…
{"t":"avatar","state":"speaking"}
{"t":"avatar","state":"idle"}       // через паузу после ответа

Типичная ошибка: жёстко ожидаете строго три кадра — speaking может отсутствовать (аватар озвучивает клиент), а thinking/typing приходят в разном порядке. Обрабатывайте каждое состояние независимо, значения строго idle|thinking|speaking.


8. typing.stop гасит индикатор

Как получить: во время ответа агента дождитесь кадра {"t":"typing","stop":true}.

Ожидаемый результат: индикатор «агент печатает» гасится немедленно, не дожидаясь таймаута аватара; затем приходит {"t":"avatar","state":"idle"}.

Типичная ошибка: клиент гасит индикатор только по таймауту и игнорирует stop:true — индикатор «зависает» на пару секунд дольше, чем нужно. stop — явный сигнал, обрабатывайте его как финал.


9. Отказ с кодом 1008 на неверном токене

Как получить

wscat -c "wss://hermes-go.com/mobile-ws"
{"t":"hello","token":"неверный-токен","device":{"id":"qa-bad"}}

Ожидаемый результат: кадр error, затем закрытие соединения кодом 1008:

{"t":"error","code":"auth_failed","message":"…","fatal":true}

wscat напечатает Disconnected (code: 1008).

Типичная ошибка: приложение запускает цикл переподключений на 1008 — сервер долбится впустую. Повторять бессмысленно: покажите «нет доступа» и не переподключайтесь. То же касается device.id, отсутствующего в MOBILE_ALLOWED_DEVICES.


10. Незнакомый кадр не ломает соединение

Как получить: отправьте серверу кадр с неизвестным типом, затем нормальный текст:

{"t":"какой-то-новый-кадр","поле":1}
{"t":"text","id":"q-9","text":"работает?"}

Ожидаемый результат: сервер игнорирует незнакомый кадр и продолжает работать — вы получаете ответ на текст. Соединение не рвётся.

Типичная ошибка: клиент сам рвёт соединение или падает с исключением на неизвестном кадре от сервера. Правило протокола: неизвестные типы и необязательные поля игнорируются. Так же обрабатывайте новые кадры, которые пришлёт обновлённый шлюз.


Дополнительно

Проверка Как Ожидание
ping/pong {"t":"ping","ts":42} {"t":"pong","ts":42}
пустой текст {"t":"text","id":"z","text":" "} error{code:"bad_frame"}
текст > 12 000 символов один text длинной строкой error{code:"too_big"}
аудио не wav/ogg {"t":"audio","format":"mp3",…} error{code:"unsupported_frame"}
медиа-kind неверный {"t":"media","kind":"sticker",…} error{code:"unsupported_frame"}
ответ на чужой prompt_id {"t":"prompt","prompt_id":"нет-такого","option_id":"once"} prompt_done{expired:true}, без ошибки
ответ на карточку чужого устройства подключиться под своим device.id и ответить на карточку, показанную другому устройству error{code:"forbidden"}
занятый device.id другим токеном MOBILE_EXTRA_TOKENS=qa-1=<токен>; подключиться с другим токеном под id:"qa-1" error{code:"forbidden"} + закрытие 1008
deliver не от cron обычное (не loopback) соединение шлёт {"t":"deliver","id":"x","target":"qa-1","text":"hi"} error{code:"forbidden"}
медиа-загрузка сверх лимита памяти заявить parts > 2048 либо держать открытыми > 8 серий / > 8 МиБ суммарно error{code:"bad_frame"} (parts) / too_big (объём)
cron-доставка офлайн hermes send --to <device.id> "текст" при закрытом приложении результат device_offline на машине агента

Как читать отказы: fatal:true в кадре error — соединение будет закрыто, повторять не нужно (auth_failed). fatal:false — можно продолжать (например, bad_frame на конкретном кадре). Код закрытия 1008 — всегда отказ в доступе.