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() @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().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") } }