Files
agentik-desktop/src/jvmTest/kotlin/pw/binom/agentik/desktop/session/RealServerIT.kt
T
subochev 2c71542b13 Десктоп-клиент: Compose-UI, сессии, SQLite-кэш, голос, живые тесты
- settings/: settings.json (агенты, тема, clientId), атомарная запись
- persistence/: ConversationMetaRepository — группы, lastSeen, превью,
  watermark синхронизации (synced_at через PRAGMA table_info)
- session/: AgentRegistry (параллельное подключение, деградация в офлайн),
  AgentConnection (журнал + мета + живой список диалогов conversationsFlow,
  refresh по SSE-потоку), ChatSession (history + backfill + live + preview,
  дедуп по id, 1 мс шаг watermark, syncMutex), LiveTurn
- UI: рейка агентов, список диалогов (живое обновление), чат со стримингом,
  композер (Enter отправляет, Shift+Enter переносит, Ctrl+M — голос),
  окна «Агенты»/«Группы»/«Новый диалог», тёмная и светлая темы
- voice/: mic-api → Silero VAD → Vosk на одном выделенном потоке;
  VoiceNative.warmUp() до skia (иначе SIGSEGV), модель в артефакте
- markdown/: вендоренный рендер из ai/assistent
- ошибки в баннер через AppState.reportError (CancellationException игнорируется)
- тесты 103 (реальная SQLite + фейковый транспорт); против живого агента —
  RealServerIT, RealAppStateIT, RealSpeechIT
- LIVE-RUN-NOTES.md: живой прогон в GUI и найденные/починенные баги
2026-09-28 10:08:10 +03:00

196 lines
8.7 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package pw.binom.agentik.desktop.session
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assumptions.assumeTrue
import pw.binom.agentik.desktop.model.UiContent
import pw.binom.agentik.desktop.model.UiMessage
import pw.binom.agentik.desktop.settings.AgentConfig
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.time.Instant
import java.nio.file.Files
/**
* Реальная интеграция с ЖИВЫМ `:standalone`-сервером (Qwen через vLLM),
* а не с in-memory-эхо-агентом из [AgentConnectionE2ETest].
*
* Адрес берётся из env `AGENTIK_E2E_URL`, по умолчанию
* `http://127.0.0.1:8080/agentik`. Если сервер не поднят — тест
* **пропускается** (`assumeTrue`), поэтому `./gradlew jvmTest` остаётся
* зелёным без запущенного агента, а с ним — проверяет настоящий контракт:
* создать диалог → отправить → дождаться живого ответа LLM → история в
* локальном SQLite → повторное открытие не плодит дублей.
*
* БД уводится в temp-папку, пользовательский `~/.agentik-desktop` не
* трогается.
*/
class RealServerIT {
private val connection = mutableListOf<AgentConnection>()
@AfterTest
fun tearDown() {
connection.forEach { runCatching { it.close() } }
connection.clear()
}
private fun url(): String =
System.getenv("AGENTIK_E2E_URL")?.takeIf { it.isNotBlank() }
?: "http://127.0.0.1:8080/agentik"
private fun connect(scope: kotlinx.coroutines.CoroutineScope, db: String): AgentConnection? =
runCatching {
AgentConnection.open(
config = AgentConfig.create(baseUrl = url()),
clientId = "agentik-desktop-real-e2e",
scope = scope,
dbPath = db,
)
}.getOrNull()
@Test
fun `live server answers and the turn lands in the local cache`() = runBlocking {
val dir = Files.createTempDirectory("agentik-real-e2e")
val db = dir.resolve("agentik.db").toString()
val conn = connect(this, db)
assumeTrue(conn != null, "standalone-сервер недоступен на ${url()} — тест пропущен")
conn!!
connection += conn
val conv = conn.createConversation(temp = false)
assertNotNull(conv, "createConversation")
val session = assertNotNull(conn.openSession(conv.id), "openSession")
try {
session.start()
session.send("Ответь ровно одним словом: привет")
await(timeoutMs = 120_000) {
session.history.value.size >= 2 && !session.isStreaming
}
val history = session.history.value
assertEquals(2, history.size, "user + assistant: $history")
val user = history[0] as UiMessage.User
assertEquals("Ответь ровно одним словом: привет", (user.content.single() as UiContent.Text).body)
val bot = history[1] as UiMessage.Assistant
val answer = (bot.content.single() as UiContent.Text).body
assertTrue(answer.isNotBlank(), "LLM вернул пустой ответ")
// Локальный кэш (offline-история) — на диске.
val cached = conn.journalCache.list(conv.id, Instant.DISTANT_PAST, 0, Int.MAX_VALUE)
assertEquals(2, cached.size, "cache records")
// Превью + watermark денормализованы из ответа.
assertNotNull(conn.meta.meta(conv.id)?.preview)
assertNotNull(conn.meta.meta(conv.id)?.syncedAt)
// Список диалогов от сервера содержит созданный.
await(timeoutMs = 20_000) {
conn.uiConversations().any { it.id == conv.id }
}
// Переоткрытие читает из кэша и НЕ дублирует бэкфиллом.
val again = assertNotNull(conn.openSession(conv.id))
try {
again.start()
assertEquals(2, again.history.value.size, "reopen must not duplicate")
} finally {
again.close()
}
} finally {
session.close()
}
}
/**
* Сценарий «перезапуск клиента»: закрываем соединение и открываем
* заново на той же БД — история должна читаться из локального кэша,
* а не теряться.
*/
@Test
fun `history survives a reconnect on the same database`() = runBlocking {
val dir = Files.createTempDirectory("agentik-real-e2e-reconnect")
val db = dir.resolve("agentik.db").toString()
val convId: String
run {
val conn = connect(this, db)
assumeTrue(conn != null, "standalone-сервер недоступен на ${url()} — тест пропущен")
conn!!
connection += conn
val conv = assertNotNull(conn.createConversation(temp = false))
val session = assertNotNull(conn.openSession(conv.id))
session.start()
session.send("Скажи 'ок'")
await(timeoutMs = 120_000) { session.history.value.size >= 2 && !session.isStreaming }
convId = conv.id
session.close()
conn.close()
connection -= conn
}
val conn2 = assertNotNull(connect(this, db), "повторное подключение")
connection += conn2
val session2 = assertNotNull(conn2.openSession(convId), "openSession после перезапуска")
try {
session2.start()
await(timeoutMs = 30_000) { session2.history.value.size >= 2 }
assertEquals(2, session2.history.value.size, "история из локального кэша после реконнекта")
} finally {
session2.close()
}
}
/**
* Регрессия на реальный баг: `POST /conversations/{id}/messages` на
* сервере отвечает не сразу, а по завершении всего хода агента. Длинный
* ответ LLM (десятки секунд) обрывался дефолтным request-timeout'ом
* клиента (15000 мс) — ход умирал на клиенте, продолжая жить на сервере.
* Клиент обязан ставить на этот запрос infinite-timeout
* (`ConversationClient.send` → `noReadTimeout()`).
*/
@Test
fun `long llm answer outlives the default client request timeout`() = runBlocking {
val dir = Files.createTempDirectory("agentik-real-e2e-long")
val conn = connect(this, dir.resolve("agentik.db").toString())
assumeTrue(conn != null, "standalone-сервер недоступен на ${url()} — тест пропущен")
conn!!
connection += conn
val conv = assertNotNull(conn.createConversation(temp = false))
val session = assertNotNull(conn.openSession(conv.id))
try {
session.start()
session.send("Напиши связный текст примерно на 300 слов про историю SQLite. Без списков и заголовков.")
await(timeoutMs = 300_000) {
session.history.value.size >= 2 && !session.isStreaming
}
val answer = (session.history.value[1] as UiMessage.Assistant)
.content.filterIsInstance<UiContent.Text>().joinToString("\n") { it.body }
assertTrue(
answer.length > 500,
"ожидался длинный ответ (иначе тест не проверяет таймаут), а пришло ${answer.length} символов: $answer",
)
} finally {
session.close()
}
}
private suspend fun await(timeoutMs: Long = 5_000, condition: suspend () -> Boolean) {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
if (condition()) return
delay(50)
}
assertTrue(condition(), "condition not met within ${timeoutMs}ms")
}
}