package pw.binom.agentik.desktop.ui import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeoutOrNull import org.junit.jupiter.api.Assumptions.assumeTrue import pw.binom.agentik.desktop.model.UiMessage import pw.binom.agentik.desktop.session.AgentConnection import pw.binom.agentik.desktop.session.AgentRegistry import pw.binom.agentik.desktop.session.AgentStatus import pw.binom.agentik.desktop.settings.AgentConfig import pw.binom.agentik.desktop.settings.SettingsRepository import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertNotNull import kotlin.test.assertTrue import java.net.HttpURLConnection import java.net.URI import java.nio.file.Files /** * Приложение целиком (без пикселей) против ЖИВОГО `:standalone`-агента: * настройки → реестр → [AppState] → диалог с настоящей LLM. * * Это замена GUI-прогону, когда vision-сервис недоступен: UI у нас — чистая * функция от [AppState], а сам рендер уже проверен `ThemeRenderTest`. * Проверяем то, что ломается только в жизни: создание диалога на сервере, * отправку, живой ответ модели, попадание реплик в локальный кэш (историю), * снятие «непрочитанного» после открытия. * * Адрес — `AGENTIK_E2E_URL` или `http://127.0.0.1:8080/agentik`; если сервер * не поднят, тест **пропускается** (`assumeTrue`), как [RealServerIT]. */ class RealAppStateIT { private var registry: AgentRegistry? = null private var state: AppState? = null // Свой scope, а не coroutineContext из runBlocking: у реестра/сессии живут // вечные коллекторы (SSE, listFlow), и runBlocking ждал бы их вечно. private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @AfterTest fun tearDown() { runCatching { state?.shutdown() } runCatching { registry?.close() } scope.cancel() } private fun url(): String = System.getenv("AGENTIK_E2E_URL")?.takeIf { it.isNotBlank() } ?: "http://127.0.0.1:8080/agentik" private fun reachable(url: String): Boolean = runCatching { (URI("$url").toURL().openConnection() as HttpURLConnection).run { connectTimeout = 1_500 readTimeout = 1_500 requestMethod = "GET" responseCode in 200..499 } }.getOrDefault(false) private suspend fun await( what: String, timeoutMs: Long = 10_000, condition: suspend () -> Boolean, ) { val ok = withTimeoutOrNull(timeoutMs) { while (!condition()) delay(20) true } check(ok == true) { "не дождались: $what (${timeoutMs}мс)" } } @Test fun `app state creates a dialog and gets a live answer from the real agent`() = runBlocking { val base = url() assumeTrue(reachable(base), "standalone-агент недоступен на $base — тест пропущен") val tmp = Files.createTempDirectory("agentik-appstate") val reg = AgentRegistry.create( settingsRepo = SettingsRepository(tmp.resolve("settings.json")), scope = scope, ) registry = reg val st = AppState(registry = reg, scope = scope) state = st st.start() st.addAgent(baseUrl = base, nameOverride = "Живой агент") await("агент появился в настройках") { st.agents.isNotEmpty() } val agentId = st.agents.first().id await("агент подключился", timeoutMs = 20_000) { st.statusesState[agentId] is AgentStatus.Online } st.selectAgent(agentId) await("список диалогов подтянулся с сервера") { st.conversations.isNotEmpty() } st.newConversation(agentId) await("диалог открылся") { st.activeConversationId != null } val convId = st.activeConversationId!! st.send("Ответь ровно одним словом: два плюс два?") await("пришёл ответ ассистента", timeoutMs = 240_000) { st.history.any { it is UiMessage.Assistant } } val assistant = st.history.filterIsInstance().last() val assistantText = assistant.content.filterIsInstance() .joinToString(" ") { it.body } assertTrue(assistantText.isNotBlank(), "ответ ассистента пустой") assertTrue( st.history.any { it is UiMessage.User }, "реплика пользователя должна быть в истории (из локального кэша)", ) // Превью денормализовано в локальную мету (для строки списка). val preview = reg.connection(agentId)!!.meta.meta(convId)?.preview assertNotNull(preview, "preview диалога должен записаться локально") // Открытие диалога помечает его просмотренным: непрочитанных нет. val lastSeen = reg.connection(agentId)!!.meta.meta(convId)?.lastSeen assertNotNull(lastSeen, "lastSeen должен выставиться при открытии") // История диалога переживает повторное открытие (тот же локальный кэш). st.openConversation(convId) await("история перезагрузилась из кэша") { st.history.isNotEmpty() } assertTrue( st.history.filterIsInstance().isNotEmpty(), "после повторного открытия ответ должен остаться в истории", ) } @Test fun `live dialog list arrives by itself right after connect`() = runBlocking { val base = url() assumeTrue(reachable(base), "standalone-агент недоступен на $base — тест пропущен") val tmp = Files.createTempDirectory("agentik-convflow") val cfg = AgentConfig.create(baseUrl = base, nameOverride = "Живой агент") val conn = AgentConnection.open( config = cfg, clientId = "agentik-desktop-it", scope = scope, dbPath = tmp.resolve("agentik.db").toString(), ) // Seed внутри `AgentikAgent` асинхронный: список обязан приехать сам, // без единого `refresh()` со стороны UI. Именно этого не хватало — // приложение показывало пустой список диалогов. await("живой список диалогов доехал сам", timeoutMs = 15_000) { conn.conversationsFlow.value.isNotEmpty() } conn.close() } }