Files
llm-proxy/TESTING.md
T
subochev 558b2f925b
Build LLM Proxy / Build and push (release) Successful in 38s
feat: выравнивание контракта конца SSE — дописывать data: [DONE] при штатном закрытии
Проблема (Vikunja #71): opencode показывал «модель ещё думает» ~45 с после конца
генерации на апстриме MiniMax (модель codding-big), который НЕ присылает
терминатор data: [DONE] и просто закрывает сокет. Bifrost для custom-провайдеров
считает маркер обязательным, поэтому завершал стрим клиенту только на закрытии
keep-alive-сокета llm-proxy (Ktor CIO connectionIdleTimeoutSeconds = 45).

Правка: стрим клиенту завершается маркером data: [DONE] всегда, когда апстрим
закрыл поток штатно (в потоке был непустой finish_reason), но сам маркера не
прислал. Обрыв БЕЗ finish_reason маркер НЕ дописывает — иначе усечённый стрим
выглядит как успешный (детект Bifrost SSEStreamEndedOnMarker).

- Main.kt: SSE_DONE_MARKER, StreamEndDetector (скользящее окно 128 байт — маркер
  может разрезаться границей чтения), hasNonNullFinishReasonText (экранированные
  вхождения не считаются), hasFinishReason, streamRawWithDoneContract для ветки
  think_tags: off; в streamSseWithThinkTags — учёт sawDone/sawFinishReason и
  дописывание маркера после сброса хвостов сплиттеров.
- StreamDoneContractTest.kt: 13 тестов (детектор, разрез маркера границей чтения,
  byte-exact passthrough, отсутствие дублирования, обрыв без finish_reason).
- TESTING.md: строка про новый тест-файл + 3 пункта мутационной приёмки;
- docs/sse-done-contract.md: ТЗ и разбор замеров.

Проверено: ./gradlew clean jvmTest fatJar — 79 тестов (было 66), 0 падений;
мутационная приёмка — ослабление охраны до `if (!sawDone)` роняет
thinkStreamTruncatedNeedsNoMarker.
2026-09-11 18:26:52 +03:00

4.7 KiB

Тестирование llm-proxy

Все тесты — обычные модульные, живут в src/commonTest/kotlin/pw/binom/llmproxy/.

Запуск

  • ./gradlew jvmTest — прогнать все тесты;
  • ./gradlew clean jvmTest fatJar — полная сборка с нуля;
  • результат смотреть в build/test-results/jvmTest/*.xml (атрибуты tests/failures/errors), потому что строки вида «N tests completed» печатаются только при падениях.

Что покрыто

Файл Что проверяет
ThinkTagSplitterTest Автомат рассечения think-тегов: passthrough при off, вырезание рассуждений при split, отбрасывание при strip, удержание разрезанного тега, несколько блоков, незакрытый блок;
ConfigLogicTest Разбор конфига, приоритет источников (апстрим важнее провайдера), слияние патчей, выбор апстрима и лимиты конкурентности, заголовки, сессии;
ThinkTagTransformTest Non-stream путь transformThinkMessage: перенос рассуждений в reasoning_content, дописывание к уже имеющемуся, strip, незакрытый блок, отсутствие изменений → null;
ThinkTagChunkTest SSE-чанки transformThinkChunk: удержание хвоста тега между чанками, независимые сплиттеры по index, удаление пустого content;
ThinkTagStreamTest Обвязка стрима streamSseWithThinkTags: разрез тега между data-событиями, сброс удержанного хвоста в финиш-чанке, прохождение служебных строк и [DONE], битый JSON, чанк без choices, strip.
StreamDoneContractTest Контракт конца SSE: детектор finish_reason/[DONE] (в т.ч. разрезанных границей чтения), дописывание data: [DONE]\n\n в сыром passthrough и в think-обвязке при штатном закрытии без маркера, отсутствие маркера при обрыве без finish_reason, отсутствие дублирования.

Проверка качества тестов (мутационная приёмка)

Приём по шагам:

  1. Забэкапить файл.
  2. Внести РОВНО одну поломку в боевой код.
  3. Прогнать ./gradlew cleanJvmTest jvmTest.
  4. Посмотреть XML — тест, который не упал, считается пустым.
  5. Откатить (git checkout -- <файл>).

Обязательно: cleanJvmTest обязателен, иначе прогон не перезапустится.

Проверенные мутации, каждая из которых ДОЛЖНА ронять тесты:

  • transformThinkMessage возвращает null → падают тесты non-stream;
  • transformThinkChunk возвращает null → падают тесты чанков и стрима;
  • блок финиш-чанка в streamSseWithThinkTags не выполняется → падает тест про удержанный хвост;
  • holdableSuffix всегда 0 (хвост тега не удерживается) → падают тесты автомата, чанков и стрима;
  • strip начинает отдавать рассуждения → падает тест автомата;
  • missingDoneMarker всегда false → падают тесты сырого пути (rawStreamIsByteExactAndAppendsDone) и тест разрезанного маркера;
  • if (sawFinishReason && !sawDone) → if (!sawDone) в streamSseWithThinkTags → падает thinkStreamTruncatedNeedsNoMarker;
  • if (sawFinishReason && !sawDone) → if (false) в streamSseWithThinkTags → падает thinkStreamAppendsDoneWhenUpstreamClosedWithoutMarker.

Правило: боевой код нельзя подгонять под тест; если тест не проходит, неверен тест.

Известное ограничение

Живой стрим в реальном апстриме модульными тестами не проверяется: обвязка испытывается на синтетическом SSE через каналы ktor. Реальный апстрим проверяется только после деплоя.