558b2f925b
Build LLM Proxy / Build and push (release) Successful in 38s
Проблема (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.
4.7 KiB
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, отсутствие дублирования. |
Проверка качества тестов (мутационная приёмка)
Приём по шагам:
- Забэкапить файл.
- Внести РОВНО одну поломку в боевой код.
- Прогнать
./gradlew cleanJvmTest jvmTest. - Посмотреть XML — тест, который не упал, считается пустым.
- Откатить (
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. Реальный апстрим проверяется только после деплоя.